팀 프로젝트 다듬기 1 - 내가 맡은 부분 다시 짜기
이 장에서 배우는 것
팀 프로젝트를 마쳤다는 것은 여러 사람이 정해진 시간 안에 하나의 서비스를 움직이게 했다는 뜻이다. 기능 구현뿐 아니라 일정 조정, 의견 교환, 오류 추적까지 경험했다는 점이 강점이다. 다만 제출 당시의 코드는 그때의 마감과 역할 분담을 반영한다. 시간이 지난 뒤 다시 읽으면 이름만으로 의도를 알기 어렵거나, 한 함수에 여러 판단이 모여 있는 부분을 발견할 수 있다.
이번에는 자신이 맡았던 기능 하나를 골라 다시 정리한다. 외부에서 관찰하는 동작을 유지하면서 내부 구조를 바꾸는 리팩터링(refactoring)이 중심이다. Python으로 만드는 도구는 Java나 JavaScript 프로젝트를 자동으로 고치는 프로그램이 아니다. 짧은 Python 예제를 대상으로 함수 길이와 중첩 깊이를 재면서, 실제 프로젝트에서 어떤 지점을 살펴볼지 익히는 점검기다.
- 자신의 기여를 설명할 수 있는 기능 하나를 선택하고 변경 범위를 정한다.
- 입력, 판단, 계산의 역할을 구분해 이름과 함수 경계를 정리한다.
- 예외의 종류와 발생 조건을 유지하며 변경 전후 동작을 비교한다.
- 함수 길이와 중첩 깊이를 측정하고 수치의 한계를 설명한다.
- 팀 결과물을 개인 포트폴리오에 소개할 때 합의와 기여 표기를 남긴다.
문제 상황
수료 프로젝트에서 공동 주문 서비스의 금액 계산을 맡았다고 하자. 주문 목록을 돌며 활성 항목만 고르고, 수량과 가격을 확인하고, 합계를 구하는 기능이다. 시연에서는 정상 주문이 잘 처리됐다. 그런데 프로젝트를 다시 설명하려고 코드를 열자 여러 겹의 조건문 안에 계산과 오류 처리가 섞여 있다. “제가 금액 계산을 구현했습니다”라는 말은 할 수 있지만, 어떤 입력을 거절하며 어떤 판단을 먼저 하는지 바로 설명하기 어렵다.
이때 주문 기능 전체를 새로 만드는 것부터 시작하면 범위가 커진다. 화면, 요청 처리, 저장소 접근까지 함께 바꾸면 결과가 달라졌을 때 원인을 찾기도 어렵다. 이번 주의 대상은 이미 받은 주문 항목에서 합계를 계산하는 부분으로 제한한다. 화면과 저장 방식은 그대로 두고, 입력 목록을 받아 정수 합계를 돌려주거나 정해진 예외를 발생시키는 동작만 비교한다.
앞 장에서 실패한 풀이의 원인을 기록했다면 같은 습관을 프로젝트에 적용할 수 있다. 이번에는 “못 풀었다”가 아니라 “어떤 입력에서 어떤 동작을 보존해야 하는가”를 기록한다. 코드를 더 짧게 만들었다는 사실보다, 바꾼 이유와 확인한 범위를 남기는 편이 자신의 판단을 설명하기에 좋다.
기능 하나를 고르고 관찰할 동작을 적는다
내가 설명할 수 있는 경계부터 정한다
첫 대상으로는 자신이 구현했거나 수정 과정을 충분히 기억하는 기능이 알맞다. 다른 팀원이 만든 기능을 처음부터 해석하는 작업도 배움이 되지만, 개인 기여를 정리하는 이번 작업과는 목적이 다르다. 입력과 결과가 분명하고, 외부 서비스 없이도 핵심 판단을 확인할 수 있는 부분을 고른다. 주문 금액 계산, 게시글 수정 권한 판단, 검색 조건 정리처럼 한 문장으로 역할을 말할 수 있으면 범위를 잡기 쉽다.
선택한 뒤에는 변경 계약을 짧게 적는다. 예제에서는 각 항목이 active, quantity, price 키를 가지며 활성 여부는 불리언, 수량과 가격은 정수라고 가정한다. 활성 항목의 수량은 양수이고 가격은 0 이상이어야 한다. 비활성 항목은 계산과 값 검증에서 제외한다. 이 입력 형식 자체를 확인하는 일은 이번 함수 밖의 책임으로 둔다. 계약을 적어 두면 구조 정리 도중 입력 검증 정책까지 슬쩍 바꾸는 일을 줄일 수 있다.
| 관찰 지점 | 입력 조건 | 기존 동작 | 변경 후 기준 |
|---|---|---|---|
| 정상 계산 | 활성 항목, 수량 2, 가격 10 | 20 반환 | 같은 정수 반환 |
| 제외 규칙 | 비활성 항목, 수량 0, 가격 -1 | 검증 없이 제외 | 같은 항목 제외 |
| 수량 오류 | 활성 항목, 수량 0 | ValueError 발생 | 종류와 메시지 유지 |
| 검증 순서 | 활성 항목, 수량 0, 가격 -1 | 수량 오류 먼저 발생 | 첫 오류 유지 |
반환값만 관찰하면 예외의 차이를 놓친다. 수량과 가격이 모두 잘못됐을 때 어느 오류를 먼저 보여 주는지도 호출하는 쪽에서는 동작의 일부다. 실제 서비스에서 이를 바꾸고 싶다면 별도 요구사항으로 다룬다. 구조 정리와 정책 변경을 나누면 어떤 의도로 결과가 달라졌는지 설명하기 쉽다.
팀 결과물을 가져오기 전에 합의한다
팀 저장소에 접근할 수 있다는 사실과 내용을 개인 계정에 공개할 수 있다는 사실은 다르다. 먼저 저장소의 이용 조건, 팀 합의, 사용한 자료의 공개 범위를 확인한다. 개인 저장소로 복사하거나 공개 범위를 바꾸려면 팀원에게 목적과 범위를 설명하고 합의를 남긴다. 팀원이 작성한 코드를 자신의 단독 결과물처럼 표시하지 않는다.
개인 작업 공간에는 원본 프로젝트 이름, 원본 위치, 팀 작업 기간, 공동 작업 사실, 자신이 맡은 범위, 이후 개인 수정 범위를 구분해 적는다. 원본이 비공개라면 접근할 수 없는 링크를 공개 증거처럼 제시하기보다 공개 가능한 설명과 예제의 범위를 합의한다. 허용되지 않은 코드는 가져오지 않고, 같은 문제를 다루는 작은 예제를 새로 작성하는 방법도 있다.
커밋 작성자와 기존 이용 조건도 유지한다. 팀 전체의 구현을 개인 성과로 바꾸기 위해 이력을 지우지 않는다. 설정 파일의 접근 정보, 실제 사용자 자료, 팀 내부 논의가 섞여 있는지도 확인한다. 이는 보기 좋은 소개 문장을 만드는 작업에 앞서, 함께 만든 결과물을 정확하게 다루는 과정이다. 이번 주에는 문서 전체를 꾸미기보다 합의 내용과 기여 구분을 짧게 남긴다.
이름과 함수 경계로 판단의 순서를 드러낸다
이름은 역할을 설명하는 문장에 맞춘다
계산 결과를 담는 변수에 data나 result라는 이름을 붙이면 읽는 사람이 매번 오른쪽 식을 확인해야 한다. total은 합계라는 역할을 드러내고, quantity와 price는 계산에 쓰이는 두 값의 의미를 드러낸다. 다만 이름을 길게 만드는 것이 목적은 아니다. 같은 범위 안에서 무엇의 합계인지 분명하면 total로 충분하다. 여러 합계가 함께 있다면 order_total, discount_total처럼 차이를 표현한다.
함수 이름도 호출하는 곳에서 읽어 본다. validate_row는 항목의 조건을 확인하고, line_total은 한 항목의 금액을 구한다. order_total은 여러 항목을 순회하며 전체 금액을 누적한다. 세 이름은 각각 검사, 단일 계산, 집계라는 역할을 나타낸다. 함수를 나눈 뒤에도 호출 순서를 따라가야만 뜻을 알 수 있다면 경계나 이름을 다시 살펴볼 필요가 있다.
중첩을 줄일 때 검증 대상까지 바꾸지 않는다
기존 함수는 활성 여부, 수량, 가격을 차례로 확인한 뒤 계산한다. 활성 항목만 처리하는 반복문에서는 비활성 항목을 먼저 건너뛰면 본 계산이 안쪽으로 밀리지 않는다. 잘못된 수량과 가격은 별도 검사 함수에서 즉시 예외로 알린다. 그 결과 집계 함수에는 “제외할 항목을 건너뛰고 금액을 더한다”라는 흐름이 남는다.
여기서 검사를 반복문 맨 앞으로 옮기면 의미가 달라진다. 원래 무시하던 비활성 항목의 잘못된 값 때문에 주문 전체가 실패할 수 있기 때문이다. 들여쓰기가 줄었다는 이유만으로 좋은 변경이라고 판단할 수 없다. 조건문의 위치는 어떤 입력에 검사를 적용하는지도 결정한다.
예외는 감추지 말고 책임이 있는 곳으로 보낸다
이 예제의 검사 함수는 잘못된 값에 ValueError를 발생시킨다. 합계를 구하는 함수는 이 예외를 잡아 0으로 바꾸지 않는다. 0은 빈 주문이나 가격이 0인 정상 주문에서도 나올 수 있으므로 오류와 성공을 구분할 수 없게 되기 때문이다. 화면에 어떤 안내를 보여 줄지는 호출하는 쪽의 책임이다.
확인용 프로그램은 반환값과 예외를 비교하기 위해 ValueError를 잡는다. 이는 업무 함수가 오류를 숨기는 것과 목적이 다르다. KeyError나 TypeError까지 한꺼번에 잡지 않으므로, 준비한 입력 형식이 틀렸거나 코드에 다른 문제가 생기면 그대로 드러난다. 실제 프로젝트에서도 예상한 실패와 예상하지 못한 결함을 같은 값으로 덮지 않는 편이 원인을 찾기 쉽다.
숫자는 읽어 볼 위치를 알려 준다
완성 코드에서는 추상 구문 트리(AST)를 이용해 함수를 찾는다. 소스를 문자열 줄바꿈만으로 쪼개면 주석 속 def나 여러 줄 문자열을 함수로 오해할 수 있다. ast 모듈은 Python 문법에 따라 코드를 분석하므로 함수와 조건문을 구분해 방문할 수 있다.
함수 길이는 def가 있는 줄부터 함수의 마지막 구문이 끝나는 줄까지의 물리적 줄 수로 정한다. 그 사이의 빈 줄과 주석은 포함되지만, 함수 뒤에 붙인 빈 줄이나 주석은 마지막 구문의 위치에 포함되지 않는다. 장식자는 def보다 앞에 있으므로 이 계산에서 빠진다. 따라서 이 수치는 실행문 개수나 이해하는 데 드는 시간을 직접 나타내지 않는다.
중첩 깊이는 if, for, while, try, with, match와 비동기 반복·문맥 구문의 겹수를 센다. 함수 본문 자체는 0이다. else와 except를 별도 단계로 추가하지 않으며, elif는 문법 트리에서 else 안의 if로 표현되므로 한 단계 더해진다. 중첩 함수나 클래스의 내부는 바깥 함수의 깊이에 더하지 않는다. 내부 함수는 별도 함수로 측정한다. 조건식이나 논리 연산까지 세는 복잡도 지표와는 범위가 다르다.
예제의 검토 기준은 10줄 초과 또는 깊이 3 초과다. 이는 작은 예제를 읽기 위한 임시 기준이며 채용 평가나 코드 품질 점수가 아니다. 함수를 억지로 쪼개 호출 경로만 늘리거나, 여러 문장을 한 줄에 붙여 숫자를 낮추면 도구의 목적에서 멀어진다. 수치를 확인한 뒤에는 반드시 함수 이름, 입력 계약, 예외의 흐름을 함께 읽는다.
완성 코드
다음 내용을 main.py에 저장한다. 파일이나 네트워크 입력 없이 프로그램 안의 고정 예제만 분석하므로 실행 결과가 일정하다. ast와 copy는 Python 표준 라이브러리다. 실행에 사용하는 소스는 아래에 직접 작성한 BEFORE와 AFTER 두 문자열로 한정한다.
import ast
from copy import deepcopy
BEFORE = '''def order_total(rows):
total = 0
for row in rows:
if row["active"]:
if row["quantity"] > 0:
if row["price"] >= 0:
total += row["quantity"] * row["price"]
else:
raise ValueError("가격은 0 이상이어야 한다")
else:
raise ValueError("수량은 양수여야 한다")
return total
'''
AFTER = '''def validate_row(row):
if row["quantity"] <= 0:
raise ValueError("수량은 양수여야 한다")
if row["price"] < 0:
raise ValueError("가격은 0 이상이어야 한다")
def line_total(row):
validate_row(row)
return row["quantity"] * row["price"]
def order_total(rows):
total = 0
for row in rows:
if not row["active"]:
continue
total += line_total(row)
return total
'''
BLOCKS = (
ast.If, ast.For, ast.AsyncFor, ast.While,
ast.Try, ast.TryStar, ast.With, ast.AsyncWith, ast.Match,
)
MAX_LINES = 10
MAX_DEPTH = 3
class DepthCounter(ast.NodeVisitor):
def __init__(self):
self.depth = 0
self.maximum = 0
def visit_FunctionDef(self, node):
return
def visit_AsyncFunctionDef(self, node):
return
def visit_ClassDef(self, node):
return
def visit_Lambda(self, node):
return
def generic_visit(self, node):
is_block = isinstance(node, BLOCKS)
if is_block:
self.depth += 1
self.maximum = max(self.maximum, self.depth)
super().generic_visit(node)
if is_block:
self.depth -= 1
def measure(source):
tree = ast.parse(source)
functions = [
node for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
]
functions.sort(key=lambda node: node.lineno)
records = []
for function in functions:
counter = DepthCounter()
for statement in function.body:
counter.visit(statement)
length = function.end_lineno - function.lineno + 1
records.append((function.name, length, counter.maximum))
return records
def show_metrics(label, source):
print(f"[{label}]")
for name, length, depth in measure(source):
review = length > MAX_LINES or depth > MAX_DEPTH
status = "검토" if review else "기준 이내"
print(f"{name}: 길이={length}, 중첩={depth}, {status}")
def load_order_total(source):
namespace = {}
exec(compile(source, "<고정 예제>", "exec"), namespace)
return namespace["order_total"]
def observe(function, rows):
try:
return ("반환", function(deepcopy(rows)))
except ValueError as error:
return ("예외", type(error).__name__, str(error))
def main():
show_metrics("변경 전", BEFORE)
show_metrics("변경 후", AFTER)
before = load_order_total(BEFORE)
after = load_order_total(AFTER)
cases = [
("빈 목록", [], ("반환", 0)),
("정상", [
{"active": True, "quantity": 2, "price": 10},
], ("반환", 20)),
("비활성 제외", [
{"active": False, "quantity": 0, "price": -1},
], ("반환", 0)),
("수량 오류", [
{"active": True, "quantity": 0, "price": 10},
], ("예외", "ValueError", "수량은 양수여야 한다")),
("가격 오류", [
{"active": True, "quantity": 2, "price": -1},
], ("예외", "ValueError", "가격은 0 이상이어야 한다")),
("검증 순서", [
{"active": True, "quantity": 0, "price": -1},
], ("예외", "ValueError", "수량은 양수여야 한다")),
]
print("[동작 비교]")
failures = 0
for name, rows, expected in cases:
old = observe(before, rows)
new = observe(after, rows)
passed = old == new == expected
if not passed:
failures += 1
status = "통과" if passed else "실패"
print(f"{name}: {status}")
print(f"확인: {len(cases) - failures}/{len(cases)}")
if failures:
raise SystemExit(1)
if __name__ == "__main__":
main()
줄별 해설
import ast와 from copy import deepcopy는 서로 다른 확인을 준비한다. ast는 구조를 읽는 데 사용하고, deepcopy는 동작 비교 때 각 함수에 독립된 입력을 전달하는 데 사용한다. 현재 예제는 입력을 수정하지 않지만 같은 목록 객체를 순서대로 넘기면, 나중에 수정 코드가 입력을 바꿨을 때 두 번째 실행의 출발점이 달라질 수 있다.
BEFORE는 변경 전 계산을 담는다. order_total의 길이는 12줄이다. 반복문 안에 활성 여부, 수량, 가격 확인이 차례로 들어가므로 가장 깊은 지점의 중첩은 4다. 이 코드는 계약에 맞는 동작을 수행한다. 이번 수정의 이유는 오답이어서가 아니라 판단과 계산의 역할을 더 쉽게 읽기 위해서다.
AFTER의 validate_row는 수량을 먼저 검사하고 가격을 나중에 검사한다. 두 if는 서로 안쪽에 있지 않으므로 최대 깊이는 1이다. 수량 오류가 있으면 첫 번째 raise에서 실행이 끝나므로 기존 오류 우선순위도 유지된다. 함수가 값을 반환하지 않는 것은 검사를 통과했다는 사실만 호출자에게 전달하면 되기 때문이다.
line_total은 검사와 한 항목의 곱셈을 묶는다. 이 함수에 전달되는 항목은 집계 함수가 이미 활성 여부를 확인한 항목이다. 따라서 활성 여부 검사는 반복하지 않는다. 작은 함수라도 호출 조건을 설명할 수 있어야 한다. 이를 잊고 다른 곳에서 모든 항목에 호출하면 비활성 항목을 제외한다는 집계 규칙과 다른 사용이 된다.
변경 후 order_total은 비활성 항목에서 continue로 다음 반복으로 넘어간다. 활성 항목만 line_total에 도달한다. 함수 길이는 7줄이고 최대 깊이는 반복문과 그 안의 if를 합친 2다. 전체 코드의 함수 수는 늘었지만 집계와 검증을 따로 읽을 수 있게 됐다.
BLOCKS와 두 상수는 측정 규칙과 검토 기준을 한곳에 모은다. ast.TryStar는 예외 그룹을 다루는 구문을 표현한다. 이를 포함해도 예제 출력에는 영향이 없다. 기준을 수정하고 싶다면 MAX_LINES와 MAX_DEPTH를 바꾼다. 조건이 초과 여부를 검사하므로 길이가 정확히 10이거나 깊이가 정확히 3이면 기준 이내다.
DepthCounter의 네 방문 메서드는 함수, 비동기 함수, 클래스, 람다 내부로 들어가지 않고 돌아온다. 바깥 함수의 깊이에 별도 실행 단위의 내부를 섞지 않기 위해서다. 람다는 def로 정의한 함수 목록에도 포함하지 않으므로 이번 도구의 별도 측정 대상은 아니다.
generic_visit는 현재 노드가 측정 대상 구문이면 깊이를 하나 올리고 최대값을 갱신한다. 이어 자식 노드를 방문한 뒤 깊이를 하나 내린다. 내려오는 처리가 있어야 나란히 놓인 두 if를 중첩된 것으로 세지 않는다. 들여쓰기 공백 수를 세는 방식이 아니므로 문자열 안의 공백이나 주석은 깊이에 영향을 주지 않는다.
measure는 ast.walk로 함수 노드를 모으고 소스 줄 순서로 정렬한다. 각 함수의 본문부터 방문하기 때문에 함수 자신을 건너뛰는 방문 규칙과 충돌하지 않는다. end_lineno에서 lineno를 빼고 1을 더하면 양 끝 줄을 포함한 길이가 된다. 중첩 함수가 있다면 ast.walk가 그 함수도 찾아 별도로 기록한다. 같은 이름이 반복되면 현재 출력만으로 구별하기 어려우므로 실제 파일로 확장할 때는 시작 줄도 표시하는 편이 좋다.
show_metrics는 측정과 출력의 책임을 나눈다. measure가 계산한 자료를 받아 두 기준 중 하나라도 초과하면 검토라고 표시한다. 기준 이내라는 표시는 읽을 필요가 없다는 뜻이 아니다. 단지 이번에 정한 두 수치로는 검토 표시가 생기지 않았다는 뜻이다.
load_order_total은 고정 문자열을 컴파일한 뒤 별도 이름 공간에서 실행하고 함수를 꺼낸다. compile은 이 문자열의 문법을 확인하며, ast.parse만 수행할 때와 달리 exec는 실제 코드를 실행한다. 빈 사전을 넘기는 것은 격리된 보안 실행 환경을 만든다는 뜻이 아니다. 이 부분에 다운로드한 코드나 사용자가 입력한 소스를 연결하지 않는다. 외부 파일의 길이와 깊이만 잴 때는 measure만 호출하면 된다.
observe와 cases는 결과를 비교 가능한 튜플로 바꾼다. 정상 결과는 반환이라는 표식과 값으로, 예상한 값 오류는 예외라는 표식과 종류 및 메시지로 기록한다. cases에는 변경 전후의 일치뿐 아니라 기대 결과도 들어 있다. 두 구현이 같은 실수를 하더라도 기대 결과와 다르면 통과하지 않는다.
main의 마지막 반복은 각 사례에 대해 두 관찰값과 기대값이 모두 같은지 확인한다. 실패가 있으면 요약을 출력한 뒤 종료 상태 1로 끝난다. assert를 사용하지 않으므로 Python 최적화 실행 여부에 따라 비교가 빠지지 않는다. 이 비교는 여섯 입력에 대한 확인이며, 가능한 모든 입력에서 두 구현이 같다는 증명은 아니다.
실행 결과
main.py가 있는 디렉터리에서 실행한다. 아래 두 명령 중 첫 번째는 경고를 오류로 취급하며 문법 컴파일을 확인하고, 두 번째는 프로그램을 실행한다. 컴파일 명령은 성공하면 아무것도 출력하지 않는다.
python3 -W error -m py_compile main.py
python3 main.py
프로그램의 예상 출력은 다음과 같다.
[변경 전]
order_total: 길이=12, 중첩=4, 검토
[변경 후]
validate_row: 길이=5, 중첩=1, 기준 이내
line_total: 길이=3, 중첩=0, 기준 이내
order_total: 길이=7, 중첩=2, 기준 이내
[동작 비교]
빈 목록: 통과
정상: 통과
비활성 제외: 통과
수량 오류: 통과
가격 오류: 통과
검증 순서: 통과
확인: 6/6
변경 전에는 계산이 한 함수에 모여 있고 변경 후에는 세 함수에 나뉜다. 그러므로 변경 후 각 함수가 짧아졌다는 사실과 전체 코드가 줄었다는 주장은 구별해야 한다. 이 결과에서 확인할 수 있는 것은 함수별 길이와 깊이, 그리고 지정한 여섯 사례의 반환값 및 예외가 유지됐다는 사실이다. 처리 속도나 다른 언어의 코드 품질은 이 출력으로 판단할 수 없다.
실무에서 자주 틀리는 것
검사를 앞으로 옮기면서 제외 규칙을 바꾼다
다음 코드는 비활성 항목도 먼저 검사한다. 기존에 무시하던 자료 때문에 예외가 생기므로 구조만 바꾼 것이 아니다.
for row in rows:
validate_row(row)
if not row["active"]:
continue
total += row["quantity"] * row["price"]
제외 조건을 먼저 적용한 다음 활성 항목의 금액을 구한다. 검증의 위치는 처리 대상과 함께 결정한다.
for row in rows:
if not row["active"]:
continue
total += line_total(row)
예외를 잡고 정상 값처럼 돌려준다
넓은 예외 처리는 잘못된 수량뿐 아니라 오타나 누락된 키까지 덮는다. 호출자는 계산 성공 여부를 알 수 없게 된다.
def line_total(row):
try:
validate_row(row)
return row["quantity"] * row["price"]
except Exception:
return 0
계산 함수는 예상한 값 오류를 그대로 전달한다. 이를 기록하거나 사용자에게 안내할 책임이 있는 호출 지점에서 구체적인 예외를 처리한다.
def line_total(row):
validate_row(row)
return row["quantity"] * row["price"]
측정값을 낮추려고 문장을 한 줄에 붙인다
다음 코드는 줄 수가 적지만 검사와 계산이 한 줄에 몰린다. 측정 기준에 맞추는 일이 읽는 사람을 돕는 일보다 앞선 경우다.
def line_total(row):
validate_row(row); return row["quantity"] * row["price"]
서로 다른 실행 단계를 줄로 나누면 오류 위치를 살피고 변경 내용을 비교하기 쉽다. 함수 길이는 이렇게 읽기 좋게 쓴 결과를 살피는 보조 정보로 사용한다.
def line_total(row):
validate_row(row)
return row["quantity"] * row["price"]
같은 입력 객체를 재사용하고 반환값만 비교한다
다음 비교는 첫 함수가 입력을 바꾸면 두 번째 함수에 다른 상태를 전달한다. 또한 예외가 발생하면 비교 자체가 중단된다.
same = before(rows) == after(rows)
독립된 입력에서 반환값 또는 예상 예외를 관찰하고, 미리 적은 기대 결과도 함께 비교한다. 입력 변경 자체가 계약의 일부라면 변경 후 입력 상태를 관찰값에 추가해야 한다. 현재 예제의 관찰값에는 그 상태가 들어 있지 않다.
old = observe(before, rows)
new = observe(after, rows)
same = old == new == expected
한눈에 보기
| 대상 | 판단 기준 | 남길 기록 |
|---|---|---|
| 변경 범위 | 입력과 결과를 한 문장으로 설명하는가 | 선택한 함수와 제외 범위 |
| 이름과 분리 | 검사·계산·집계의 역할이 보이는가 | 이름을 바꾼 이유와 호출 관계 |
| 예외 | 종류·메시지·발생 순서를 유지했는가 | 오류 입력과 기대 결과 |
| 측정값 | 정한 계산 규칙으로 비교했는가 | 변경 전후 출력과 기준의 한계 |
| 개인 활용 | 공개 범위와 기여 구분에 합의했는가 | 합의 내용과 원본·개인 수정 표기 |
서재의 《클린 코드 노하우》는 이름과 함수 경계를 검토할 때, 《코드 리뷰 실무》는 변경 이유와 확인 범위를 다른 사람이 검토할 수 있게 정리할 때 연결한다. 이번에는 책의 예제를 옮기기보다 자신의 함수에 “이 이름으로 역할을 알 수 있는가”, “이 변경에서 관찰할 동작은 무엇인가”라는 질문을 적용한다.
조건과 예외의 흐름이 헷갈리면 《논리로 푸는 알고리즘》과 코드패스에서 작은 입력을 손으로 추적하는 연습으로 연결한다. 자료구조나 요청 처리의 책임 경계가 막히면 《개발자를 위한 컴퓨터 사이언스》와 《비전공자를 위한 컴퓨터공학 지도》에서 해당 주제를 찾아 개념을 보완한다. 대상 기능에 조회나 갱신이 포함돼 있다면 《데이터베이스 개론》과 SQL 연구소를 연결하되, 이번 구조 정리와 조회 조건·트랜잭션 정책 변경은 구분한다. 《개발자 실무 면접》은 나중에 선택 이유와 변경 근거를 말로 정리할 때 활용한다.
| 시점 | 이번 주에 할 일 | 완료 증거 |
|---|---|---|
| 주 초 | □ 내가 맡은 기능 하나와 공개 가능 범위를 정한다 | 범위 메모, 필요한 팀 합의 기록 |
| 주 중 첫 작업 | □ 정상·제외·오류 입력의 기존 동작을 적는다 | 입력과 기대 결과를 짝지은 표 |
| 주 중 다음 작업 | □ 이름과 함수 경계를 바꾸고 차이를 읽는다 | 변경 전후 코드 차이와 이유 세 문장 |
| 주 후반 | □ 동작과 측정값을 비교한다 | 실행 출력, 확인하지 못한 범위 메모 |
| 주말 | □ 원본·팀 기여·개인 수정 범위를 구분한다 | 기여 표기와 다음 작업 목록 |
완료 증거는 코드가 짧아졌다는 화면 한 장으로 끝나지 않는다. 기능 선택 이유, 보존할 동작, 실제 변경, 비교 결과, 공개와 기여 표기가 서로 이어져 있어야 한다. 개인 작업 전후를 구분하는 커밋을 남기면 수정 범위를 찾기 쉽다. 다음 장에서는 이 변경을 바탕으로 테스트, README, 배포 기록을 정리한다. 이번 주에는 그 작업에 필요한 근거까지 준비한다.
연습 문제
- 활성 항목의 수량이 3이고 가격이 0인 사례를 cases에 추가한다. 기대 결과를 먼저 적고, 기존 검증 조건을 바꾸지 않아도 되는 이유를 설명한다.
- 중첩 if가 없는 함수 안에 두 if를 나란히 작성한다. 각 if의 본문에는 값 대입 한 줄만 둔다. measure로 최대 깊이를 확인하고, DepthCounter에서 깊이를 내리는 처리가 없으면 어떤 오측정이 생기는지 설명한다.
- AFTER의 order_total에서 line_total 호출을 활성 여부 확인보다 앞으로 옮긴다고 가정한다. 기존 사례 중 실패할 사례와 그 이유를 적는다. 이를 단순한 구조 변경으로 볼 수 있는지도 설명한다.
- 자신의 팀 프로젝트에서 기능 하나를 고르고, 원본 출처·팀 기여·자신의 기존 기여·개인 수정 계획·공개 합의 상태를 각각 한 문장으로 적는다. 이어 변경 전에 관찰할 정상 사례와 오류 사례를 하나씩 정한다.
정답과 해설
기대 결과는 ("반환", 0)이다. 가격 조건은 0 이상이므로 0을 허용한다. 수량 3은 양수이므로 검사도 통과한다. 수량과 가격을 곱한 결과가 0이라는 이유로 오류라고 판단하면 기존 계약을 바꾸게 된다. 사례를 하나만 추가했다면 마지막 요약은 확인: 7/7이 된다.
최대 깊이는 1이다. 첫 if 방문이 끝나면 현재 깊이는 0으로 돌아오고, 두 번째 if에 들어갈 때 다시 1이 된다. 깊이를 내리지 않으면 나란히 놓인 구문이 누적되어 두 번째 if에서 2로 측정된다. 방문 중의 현재 깊이와 지금까지 본 최대 깊이를 따로 보관하는 이유다.
비활성 제외 사례가 실패한다. 변경 전에는 활성 여부를 먼저 확인해 해당 항목을 무시하지만, 호출을 앞으로 옮기면 수량 0을 검사하면서 ValueError가 발생한다. 검증 순서 사례는 활성 항목이므로 수량 오류를 먼저 내는 동작이 유지된다. 제외하던 입력에서 새로운 오류가 발생하므로 단순한 구조 변경으로 볼 수 없다.
예를 들어 “팀 주문 서비스의 합계 계산을 대상으로 한다”, “주문 흐름은 팀이 함께 설계했다”, “나는 합계 계산과 입력 오류 처리를 구현했다”, “개인 작업에서는 검사와 집계를 분리한다”, “공개 가능한 코드 범위를 팀과 확인한 뒤 게시한다”처럼 구분한다. 마지막 문장이 계획이라면 공개 합의가 완료된 것으로 표시하지 않는다. 정상 사례는 유효한 두 항목의 합계, 오류 사례는 활성 항목의 수량 0처럼 관찰 가능한 결과까지 정한다. 실제 기여와 합의 상태가 다르면 문장도 그 사실에 맞게 바꾼다.