리팩터링 - 테스트로 지키며 구조 바꾸기
이 장에서 배우는 것
공간 예약 서비스에 기능을 보태다 보면 처음에는 읽기 쉬웠던 main.py가 길어진다. 예약 시간을 검사하는 코드, 기존 예약과 비교하는 코드, 결과 메시지를 만드는 코드가 한 함수에 모인다. 실행 결과는 맞지만 다음 수정이 부담스러워지는 시점이다. 이때 필요한 작업이 리팩터링(refactoring)이다. 사용자가 관찰하는 동작을 유지하면서 코드의 구조를 바꾸는 작업을 뜻한다.
앞 장에서 만든 문서 검색 도움말도 서비스의 일부이지만, 이번에는 예약을 저장하는 함수에 집중한다. 검색 방식이나 화면을 확장하지 않는다. 독립적으로 실행할 수 있는 작은 예약 예제를 통해 테스트를 고정하고, 책임을 나누고, 변경 내용을 검토하는 순서를 익힌다. 완성 코드는 이전 함수와 바꾼 함수를 함께 실행해 반환값과 저장 결과를 비교한다.
- 긴 함수, 중복, 섞인 책임을 찾아 구조 변경의 이유를 설명한다.
- 현재 지켜야 할 동작을 테스트로 고정하고 경계 조건을 확인한다.
- 한 번에 한 가지 구조 변경을 적용하고 변경 차이를 검토한다.
- AI가 구조와 함께 동작까지 바꾼 흔적을 알아본다.
- 리팩터링 전후의 반환값과 저장 상태가 같은지 검사한다.
문제 상황
동아리와 스터디 모임이 함께 쓰는 공간에는 A실과 B실이 있다. 예약 담당자는 공간 이름, 시작 시각, 종료 시각, 신청자 이름을 입력한다. 같은 공간에서 시간이 겹치면 예약할 수 없다. 다만 기존 예약이 11:00에 끝났다면 다음 예약은 11:00에 시작할 수 있다.
처음 작성한 예약 함수는 입력을 검사한 뒤 시간을 분으로 바꾸고, 기존 예약을 순회하고, 목록에 새 예약을 넣는다. 시작 시각과 종료 시각을 변환하는 코드가 거의 같다. 함수 중간에 오류를 반환하는 곳도 많다. 시간 표현을 바꾸려면 두 군데를 고쳐야 하고, 충돌 규칙을 찾으려면 입력 검사부터 지나가야 한다.
작업 기록에는 “시간 변환을 별도 함수로 옮기기”라고 적었는데, AI가 돌려준 코드에는 이름의 앞뒤 공백 제거와 충돌 조건 변경도 들어 있다고 가정해 보자. 보기에는 짧고 정돈됐지만, 이전에는 허용하던 연속 예약을 거절한다. 입력한 이름을 그대로 보관하던 방식도 달라진다. 함수가 짧아졌다는 사실만으로 작업이 끝났다고 판단할 수 없는 이유다.
이번 예제의 예약 목록은 메모리에 둔다. 프로그램을 다시 실행하면 같은 초기 예약에서 시작한다. 파일이나 데이터베이스의 차이를 확인하는 작업을 끼워 넣지 않고, 함수 구조와 동작의 관계를 관찰하기 위한 선택이다. 입력 시각은 같은 날의 시각이며 자정을 넘는 예약은 다루지 않는다.
코드 냄새를 구조 변경의 근거로 삼는다
코드 냄새(code smell)는 수정하기 어려워질 가능성을 보여 주는 징후다. 곧바로 오류라는 뜻은 아니다. 긴 함수도 정확하게 실행될 수 있고, 짧은 함수도 잘못된 결과를 낼 수 있다. 중요한 것은 어느 부분을 바꾸기 어렵고, 어떤 책임을 따로 이름 붙이면 읽기 쉬워지는지 설명하는 일이다.
| 징후 | 예약 코드의 모습 | 구조 변경 |
|---|---|---|
| 긴 함수 | 입력 검사부터 저장까지 이어진다. | 시간 변환 부분을 함수로 추출한다. |
| 중복 | 시작과 종료 시각을 같은 방식으로 검사한다. | 두 입력에 같은 변환 함수를 호출한다. |
| 섞인 책임 | 시간 해석과 충돌 판단이 한곳에 있다. | 충돌 판단에 이름을 붙인다. |
함수 추출은 기존 코드의 일부를 새 함수로 옮기고 원래 위치에서 호출하는 작업이다. 새 함수에는 입력과 반환값이 필요하다. 이 예제에서는 시각 문자열을 받아 분 단위 정수 또는 None을 반환한다. None은 “유효한 시각으로 바꿀 수 없다”는 뜻으로 사용한다. 충돌 판단 함수는 두 예약 구간을 받아 겹치는지를 나타내는 참 또는 거짓을 반환한다.
예약을 저장하는 책임까지 잘게 나누지는 않는다. 목록에 튜플 하나를 추가하는 동작은 짧고, 예약 함수의 마지막 부분에서 쉽게 찾을 수 있다. 튜플은 여러 값을 순서대로 묶은 자료다. 여기서는 공간, 시작 분, 종료 분, 신청자 이름을 묶는다. 함수의 개수를 늘리는 것보다 읽기와 수정에 도움이 되는 경계를 찾는 편이 중요하다.
그림의 오른쪽에서도 입력을 검사하고 예약을 저장하는 전체 순서는 유지된다. 바뀌는 것은 시간을 해석하는 코드와 겹침을 판단하는 코드의 위치다. 새 도우미 함수가 화면 메시지를 출력하거나 목록을 수정하기 시작하면 책임이 다시 섞인다. 도우미 함수가 무엇을 받아 무엇을 돌려주는지 먼저 적어 두면 이런 확장을 발견하기 쉽다.
테스트로 동작을 고정하고 한 가지씩 바꾼다
구조를 바꾸기 전에는 현재 지켜야 할 동작을 확인한다. 기존 함수가 언제나 옳다고 가정하는 것은 아니다. 현재 명세와 맞는 동작을 골라 예상 결과를 적고 기존 함수로 검사한다. 이미 명세와 다른 동작을 발견했다면 오류 수정으로 따로 기록한다. 오류 수정과 구조 변경을 한꺼번에 진행하면 어떤 수정 때문에 결과가 달라졌는지 찾기 어렵다.
이번 예약 규칙에서는 시간 구간의 시작을 포함하고 끝을 제외한다. 기존 예약이 10:00부터 11:00까지라면 11:00에 시작하는 예약은 겹치지 않는다. 두 구간이 겹치는 조건은 “새 시작이 기존 종료보다 이르고, 기존 시작이 새 종료보다 이르다”다. 두 비교를 모두 만족해야 한다. 끝 시각이 맞닿는 경우에 등호를 넣으면 규칙이 달라진다.
테스트에는 보통 입력, 예상 반환값, 예상 저장 상태가 필요하다. 성공 메시지가 같아도 목록에 예약이 두 번 들어갔다면 같은 동작이 아니다. 반대로 예약을 저장하지 않았지만 성공을 반환하는 경우도 반환값만 확인하면 놓친다. 이 예제는 세 요소를 함께 검사한다.
| 사례 | 예상 결과 | 확인할 규칙 |
|---|---|---|
| 10:30부터 같은 공간 예약 | 충돌로 거절 | 시간이 일부 겹친다. |
| 09:00부터 10:00까지 예약 | 저장 | 기존 시작에 맞닿아도 허용한다. |
| 11:00부터 12:00까지 예약 | 저장 | 기존 종료에 맞닿아도 허용한다. |
| 같은 시각에 B실 예약 | 저장 | 공간이 다르면 충돌하지 않는다. |
| 10:60 또는 9:00 입력 | 형식 오류 | 범위와 다섯 글자 형식을 지킨다. |
| 실패한 예약 | 목록 유지 | 거절 과정에서 저장하지 않는다. |
작업은 작은 단계로 진행한다. 먼저 기존 함수에 대한 검사를 통과시킨다. 다음으로 시간 변환만 추출하고 다시 검사한다. 그다음 충돌 판단만 추출하고 다시 검사한다. 완성 코드에는 이 두 단계를 마친 결과가 들어 있지만, 실제 작업에서는 단계마다 실행하고 변경 내용을 읽는다. 실패했을 때 살펴볼 범위를 좁히기 위해서다.
각 사례는 같은 초기 예약 목록의 새 복사본으로 실행한다. 앞 사례에서 성공한 예약이 다음 사례의 조건을 바꾸지 않게 한다. 한 번 만든 목록을 계속 재사용하면 연속 예약 검사에서 추가한 예약 때문에 다른 검사가 실패할 수 있다. 순서에 따라 결과가 달라지는 테스트는 구조 변경의 안전망으로 쓰기 어렵다.
예제는 assert 대신 비교 함수를 사용한다. 값이 다르면 AssertionError를 일으키는 함수다. Python의 최적화 실행에서는 assert 문이 생략될 수 있지만, 명시적으로 호출한 비교 함수는 그대로 실행된다. 검사가 실패하면 프로그램이 멈추므로 마지막 성공 출력이 나타나지 않는다.
AI의 변경은 실행 결과와 변경 차이로 확인한다
AI에 요청할 때 “정리해 달라”만 적으면 변경 범위가 넓어진다. 유지할 규칙과 이번에 옮길 책임을 함께 적는다. 다음 요청은 시간 변환을 추출하는 첫 단계에 사용할 수 있다. 요청문은 실행할 코드가 아니라 작업 범위를 정하는 글이다.
main.py의 예약 함수에서 시작 시각과 종료 시각의 변환 코드만 to_minutes 함수로 추출하라. 입력은 문자열이며 HH:MM 형식과 현재 범위 검사를 유지하라. 오류 메시지, 검사 순서, 신청자 이름 저장 방식, 충돌 조건, 목록 변경 방식은 유지하라. 새로운 기능을 추가하지 말고 변경한 부분과 유지한 규칙을 설명하라.
AI의 답에 “이름을 정리하고 시간 충돌도 더 엄격하게 처리했다”는 설명이 있다면 요청 범위를 벗어났다는 신호다. 설명이 없더라도 코드를 확인해야 한다. 특히 비교 연산자의 등호, 문자열에 적용한 strip(), 목록을 바꾸는 위치, 오류를 반환하는 순서를 살펴본다. 모두 몇 글자의 변경으로 사용자에게 보이는 결과를 바꿀 수 있는 지점이다.
변경 차이(diff)는 수정 전후의 파일에서 추가되거나 삭제된 부분을 보여 준다. 편집기의 변경 비교 화면을 사용해도 된다. 시간 변환 추출 단계라면 중복 변환 코드가 사라지고 새 함수와 호출이 생겨야 한다. 충돌식, 메시지, 저장할 값까지 바뀌었다면 그 이유를 따로 확인한다. 검사가 통과해도 변경 차이를 읽는 이유는 테스트에 없는 입력이 있기 때문이다.
전후 비교 검사도 유용하지만 단독으로는 부족하다. 기존 함수와 새 함수가 같은 오류를 내면 서로 같다는 검사는 통과한다. 그래서 완성 코드는 먼저 두 함수가 각각 예상 결과와 맞는지 검사하고, 이어서 두 함수의 결과를 서로 비교한다. 명세에 대한 검사와 동등성 검사를 함께 두는 구성이다.
이 검사는 준비한 사례에 대해 같은 결과를 냈다는 근거다. 모든 문자열과 모든 예약 목록에서 같다는 증명은 아니다. 다만 경계 시각, 다른 공간, 잘못된 입력, 저장 상태처럼 이번 변경이 건드릴 부분을 포함하면 확인의 밀도를 높일 수 있다. AI의 완료 선언 대신 직접 실행한 결과와 읽어 본 변경 차이를 작업 기록에 남긴다.
완성 코드
다음 내용을 main.py에 저장한다. 외부 패키지, 네트워크, 별도 데이터 파일을 사용하지 않는다. 초기 예약은 A실의 10:00부터 11:00까지 한 건이다. 시간은 하루 안의 분 단위 정수로 보관한다. 예약 함수의 입력은 문자열로 한정하며, 시각의 각 숫자는 0부터 9까지의 문자만 허용한다.
reserve_before는 비교 기준으로 남겨 둔 기존 함수다. reserve_after는 시간 변환과 충돌 판단을 추출한 함수다. 실제 서비스에서 두 버전을 계속 유지할 필요는 없지만, 여기서는 독자가 동작 보존을 직접 확인하도록 함께 넣었다.
def reserve_before(bookings, room, start_text, end_text, member):
if not member.strip():
return False, "신청자 이름이 필요하다."
if room not in ("A", "B"):
return False, "알 수 없는 공간이다."
if (len(start_text) != 5 or start_text[2] != ":"
or any(ch not in "0123456789"
for ch in start_text[:2] + start_text[3:])):
return False, "시각은 HH:MM 형식이어야 한다."
start_hour = int(start_text[:2])
start_minute = int(start_text[3:])
if not (0 <= start_hour < 24 and 0 <= start_minute < 60):
return False, "시각은 HH:MM 형식이어야 한다."
start = start_hour * 60 + start_minute
if (len(end_text) != 5 or end_text[2] != ":"
or any(ch not in "0123456789"
for ch in end_text[:2] + end_text[3:])):
return False, "시각은 HH:MM 형식이어야 한다."
end_hour = int(end_text[:2])
end_minute = int(end_text[3:])
if not (0 <= end_hour < 24 and 0 <= end_minute < 60):
return False, "시각은 HH:MM 형식이어야 한다."
end = end_hour * 60 + end_minute
if start >= end:
return False, "종료는 시작보다 늦어야 한다."
for saved_room, saved_start, saved_end, _ in bookings:
if (room == saved_room
and start < saved_end and saved_start < end):
return False, "이미 예약된 시간이다."
bookings.append((room, start, end, member))
return True, "예약을 저장했다."
def to_minutes(text):
if (len(text) != 5 or text[2] != ":"
or any(ch not in "0123456789"
for ch in text[:2] + text[3:])):
return None
hour = int(text[:2])
minute = int(text[3:])
if not (0 <= hour < 24 and 0 <= minute < 60):
return None
return hour * 60 + minute
def overlaps(start, end, saved_start, saved_end):
return start < saved_end and saved_start < end
def reserve_after(bookings, room, start_text, end_text, member):
if not member.strip():
return False, "신청자 이름이 필요하다."
if room not in ("A", "B"):
return False, "알 수 없는 공간이다."
start = to_minutes(start_text)
if start is None:
return False, "시각은 HH:MM 형식이어야 한다."
end = to_minutes(end_text)
if end is None:
return False, "시각은 HH:MM 형식이어야 한다."
if start >= end:
return False, "종료는 시작보다 늦어야 한다."
for saved_room, saved_start, saved_end, _ in bookings:
if room == saved_room and overlaps(
start, end, saved_start, saved_end):
return False, "이미 예약된 시간이다."
bookings.append((room, start, end, member))
return True, "예약을 저장했다."
BASE = [("A", 600, 660, "독서 모임")]
CASES = [
("일부 겹침", ("A", "10:30", "11:30", "수학 모임"),
(False, "이미 예약된 시간이다."), None),
("앞 경계", ("A", "09:00", "10:00", "수학 모임"),
(True, "예약을 저장했다."), ("A", 540, 600, "수학 모임")),
("뒤 경계", ("A", "11:00", "12:00", " 수학 모임 "),
(True, "예약을 저장했다."), ("A", 660, 720, " 수학 모임 ")),
("다른 공간", ("B", "10:30", "11:30", "수학 모임"),
(True, "예약을 저장했다."), ("B", 630, 690, "수학 모임")),
("같은 구간", ("A", "10:00", "11:00", "수학 모임"),
(False, "이미 예약된 시간이다."), None),
("기존 구간 포함", ("A", "09:00", "12:00", "수학 모임"),
(False, "이미 예약된 시간이다."), None),
("분 범위 오류", ("A", "10:60", "12:00", "수학 모임"),
(False, "시각은 HH:MM 형식이어야 한다."), None),
("시간 순서 오류", ("A", "12:00", "11:00", "수학 모임"),
(False, "종료는 시작보다 늦어야 한다."), None),
("빈 이름", ("A", "11:00", "12:00", " "),
(False, "신청자 이름이 필요하다."), None),
("없는 공간", ("C", "11:00", "12:00", "수학 모임"),
(False, "알 수 없는 공간이다."), None),
("시각 형식 오류", ("A", "9:00", "10:00", "수학 모임"),
(False, "시각은 HH:MM 형식이어야 한다."), None),
]
def check_equal(actual, expected, label):
if actual != expected:
raise AssertionError(
f"{label}: 실제 {actual!r}, 예상 {expected!r}"
)
def check_contract(reserve):
for label, arguments, expected_result, extra in CASES:
bookings = BASE.copy()
result = reserve(bookings, *arguments)
expected_bookings = BASE.copy()
if extra is not None:
expected_bookings.append(extra)
check_equal(result, expected_result, label + " 반환값")
check_equal(bookings, expected_bookings, label + " 저장 상태")
return len(CASES)
def compare_versions():
for label, arguments, _, _ in CASES:
before_bookings = BASE.copy()
after_bookings = BASE.copy()
before_result = reserve_before(before_bookings, *arguments)
after_result = reserve_after(after_bookings, *arguments)
check_equal(after_result, before_result, label + " 전후 반환값")
check_equal(
after_bookings, before_bookings, label + " 전후 저장 상태"
)
print(f"전후 비교: {label} - 일치")
return len(CASES)
def main():
before_count = check_contract(reserve_before)
print(f"기준 동작 검사: {before_count}개 통과")
after_count = check_contract(reserve_after)
print(f"변경 후 동작 검사: {after_count}개 통과")
compared_count = compare_versions()
print(f"비교 완료: {compared_count}개 사례의 반환값과 저장 상태가 같다.")
if __name__ == "__main__":
main()
줄별 해설
reserve_before의 첫 두 검사. 신청자 이름에 공백 이외의 문자가 있는지 검사하고, 공간이 A 또는 B인지 확인한다. strip()은 검사에만 사용한다. 실제로 저장할 member는 바꾸지 않는다. 입력한 이름의 공백을 제거해 저장하는 기능은 이번 변경 범위에 포함되지 않는다.
시각 형식 검사. 문자열 길이가 다섯인지 먼저 확인하고 세 번째 문자가 콜론인지 검사한다. or는 앞 조건이 참이면 뒤 조건을 평가하지 않는다. 따라서 짧은 문자열에서 세 번째 문자를 읽다가 오류가 나지 않는다. any()는 반복해서 확인한 조건 중 하나라도 참이면 참을 반환한다. 여기서는 숫자 자리에 허용하지 않은 문자가 있는지 찾는다.
정수 변환과 범위 검사. 숫자 문자만 남았음을 확인한 뒤 int()로 바꾼다. 시는 0 이상 24 미만, 분은 0 이상 60 미만이어야 한다. 시간에 60을 곱하고 분을 더하면 10:00은 600이 된다. 문자열을 비교하는 대신 같은 단위의 정수를 비교할 준비다. 시작과 종료에 이 과정이 반복되는 부분이 추출 대상이다.
to_minutes. 중복된 시간 변환을 옮긴 함수다. 입력이 잘못되면 None을, 올바르면 분 단위 정수를 돌려준다. 오류 메시지는 이 함수에 넣지 않는다. 어떤 메시지를 반환할지는 예약 함수가 결정한다. 반환값 0은 00:00이라는 유효한 시각이므로 실패를 0으로 표시해서는 안 된다.
overlaps. 두 구간의 겹침을 판단한다. 이 함수는 공간 이름을 알지 못한다. 같은 공간인지 확인하는 책임은 reserve_after에 남겨 둔다. 이름만 보고도 시간 비교의 목적을 읽을 수 있으며, 목록을 바꾸지 않으므로 같은 입력에는 같은 결과를 낸다.
reserve_after. 검사 순서와 메시지, 저장 위치는 기존 함수와 같다. 달라진 부분은 to_minutes를 두 번 호출하고 overlaps를 호출하는 부분이다. 시작 변환이 실패하면 종료 변환을 시도하기 전에 반환한다. 구조를 옮기면서 실패 처리의 순서를 바꾸지 않았다는 점도 확인한다.
BASE와 CASES. BASE는 기준 예약 한 건이다. CASES의 각 항목에는 사례 이름, 함수에 넘길 네 입력, 예상 반환값, 추가될 예약이 들어 있다. 실패 사례의 마지막 값은 None이다. 성공 사례에는 예상 저장 값을 직접 적는다. 예상 값을 새 변환 함수로 계산하지 않아 검사 대상의 실수를 그대로 따라가지 않게 한다.
check_contract. 매 사례마다 BASE.copy()로 별도 목록을 만든다. 예약 항목은 문자열과 정수로 이루어진 튜플이므로 이번 예제에서는 바깥 목록을 복사하는 것으로 충분하다. reserve(bookings, *arguments)의 별표는 튜플의 네 값을 각각의 인자로 펼쳐 전달한다. 기존 함수와 새 함수 모두 같은 검사 함수를 사용할 수 있다.
compare_versions와 main. 두 버전에 서로 다른 목록을 전달한 뒤 반환값과 목록을 비교한다. 같은 목록을 넘기면 먼저 실행한 함수가 추가한 예약이 뒤 함수의 입력을 바꾼다. main은 기존 동작 검사, 변경 후 동작 검사, 전후 비교 순서로 실행한다. 실행 시간이 아니라 고정된 사례 수만 출력하므로 실행 결과가 일정하다.
실행 결과
먼저 컴파일 검사로 문법 오류를 확인하고 프로그램을 실행한다. 컴파일 검사는 예약 규칙의 정확성을 확인하지 않는다. 이어지는 실행에서 실제 동작을 검사한다. 아래 명령은 main.py가 있는 쓰기 가능한 작업 폴더에서 사용한다.
python3 -W error -m py_compile main.py
python3 main.py
첫 명령은 성공하면 아무것도 출력하지 않는다. 두 번째 명령의 출력은 다음과 같다.
기준 동작 검사: 11개 통과
변경 후 동작 검사: 11개 통과
전후 비교: 일부 겹침 - 일치
전후 비교: 앞 경계 - 일치
전후 비교: 뒤 경계 - 일치
전후 비교: 다른 공간 - 일치
전후 비교: 같은 구간 - 일치
전후 비교: 기존 구간 포함 - 일치
전후 비교: 분 범위 오류 - 일치
전후 비교: 시간 순서 오류 - 일치
전후 비교: 빈 이름 - 일치
전후 비교: 없는 공간 - 일치
전후 비교: 시각 형식 오류 - 일치
비교 완료: 11개 사례의 반환값과 저장 상태가 같다.
마지막 줄만 보고 변경을 확정하지 않는다. 변경 비교 화면에서 시간 변환과 충돌 판단 이외의 수정이 없는지도 확인한다. 이 원고의 실행 결과는 코드에 대응하는 예상 출력이다. 자신의 작업 환경에서 직접 실행해 같은 결과가 나오는지 확인한 뒤 작업 기록에 실제 결과를 남긴다.
실무에서 자주 틀리는 것
맞닿는 시각까지 충돌로 바꾼다
AI가 “겹침 검사를 강화했다”며 등호를 추가할 수 있다. 다음 함수는 이번 예약 규칙에 맞지 않는다. 11:00에 끝나는 예약과 11:00에 시작하는 예약도 충돌한다고 판단한다.
def overlaps_wrong(start, end, saved_start, saved_end):
return start <= saved_end and saved_start <= end
끝 시각을 제외하는 규칙에 맞게 엄격한 비교를 유지한다. 앞 경계와 뒤 경계 사례가 이 변경을 잡아낸다.
def overlaps_fixed(start, end, saved_start, saved_end):
return start < saved_end and saved_start < end
0분을 변환 실패로 취급한다
시간 변환을 추출한 뒤 반환값을 참과 거짓으로만 검사하면 00:00을 잘못 거절한다. 다음 함수는 값 0과 실패 표시 None을 모두 오류로 취급한다.
def conversion_failed_wrong(value):
return not value
실패 표시로 정한 값만 검사한다. 00:00 사례는 현재 완성 코드의 검사 목록에 없으므로 연습 문제에서 보강한다. 변경 차이 검토가 테스트의 빈틈을 찾는 데도 쓰인다는 예다.
def conversion_failed_fixed(value):
return value is None
전후 비교에 같은 목록을 전달한다
다음 비교 함수는 입력 상태를 공유한다. 기존 함수가 예약을 저장하면 새 함수는 그 예약까지 포함한 목록을 받는다. 두 함수가 같은 구현이어도 성공 예약을 비교할 때 결과가 달라질 수 있다.
def compare_wrong(before, after, base, arguments):
bookings = base.copy()
first = before(bookings, *arguments)
second = after(bookings, *arguments)
return first == second
입력을 별도로 만들고 반환값과 저장 상태를 모두 비교한다. 실패 사례에서도 목록이 그대로인지 확인할 수 있다.
def compare_fixed(before, after, base, arguments):
first_bookings = base.copy()
second_bookings = base.copy()
first = before(first_bookings, *arguments)
second = after(second_bookings, *arguments)
return first == second and first_bookings == second_bookings
정리라는 이름으로 저장 값을 바꾼다
공백을 제거해 이름을 저장하는 것이 별도 요구로는 유용할 수 있다. 그러나 이번 작업에서는 기존에 보관하던 값을 바꾸는 동작 변경이다. 다음 함수는 입력 그대로 저장한다는 현재 규칙을 깨뜨린다.
def stored_name_wrong(member):
return member.strip()
저장할 값은 그대로 유지한다. 공백뿐인 이름인지 검사하는 것과 저장 값을 바꾸는 것은 서로 다른 작업이다. 완성 코드의 뒤 경계 사례는 이름 양쪽에 공백을 넣어 이 차이를 확인한다.
def stored_name_fixed(member):
return member
한눈에 보기
| 순서 | 할 일 | 완료 근거 |
|---|---|---|
| 기준 확인 | 명세에 맞는 예상 결과를 적는다. | 기존 함수의 반환값과 저장 상태가 맞는다. |
| 시간 변환 추출 | 중복 코드를 to_minutes로 옮긴다. | 검사가 통과하고 다른 규칙은 유지된다. |
| 충돌 판단 추출 | 비교식에 overlaps라는 이름을 붙인다. | 겹침과 경계 사례가 통과한다. |
| 전후 확인 | 같은 입력을 별도 목록으로 실행한다. | 두 버전의 반환값과 저장 상태가 같다. |
| 변경 검토 | 등호, 메시지, 저장 값, 검사 순서를 읽는다. | 요청한 구조 변경의 범위를 벗어나지 않는다. |
이 장의 완료 조건은 함수가 짧아졌다는 인상이 아니다. 정한 동작 검사를 통과하고, 전후 결과가 같고, 변경 차이가 의도한 범위에 머무르는 것이다. 작업 기록에는 추출한 책임과 실제 검사 결과를 남긴다. 다음 장에서는 이 확인을 바탕으로 배포 전에 필요한 설정과 되돌리기 계획을 점검한다.
연습 문제
- CASES에 A실의 00:00부터 01:00까지 예약하는 성공 사례를 추가하라. 예상 저장 값을 직접 적고 검사 수가 어떻게 달라지는지 확인하라.
- 시작과 종료가 모두 12:00인 예약을 추가하라. 반환 메시지와 저장 상태를 예상하고 기존 함수와 새 함수가 모두 거절하는지 확인하라.
- 신청자 이름은 공백뿐이고 공간은 C인 사례를 추가하라. 현재 검사 순서에 맞는 예상 메시지를 적어라. AI가 공간 검사를 앞으로 옮기면 무엇이 달라지는지 설명하라.
- AI가 기존 함수와 새 함수 양쪽에 같은 잘못된 충돌식을 넣었다고 가정하라. 전후 비교만으로 발견할 수 있는지 설명하고, 어떤 검사가 문제를 드러내는지 적어라.
정답과 해설
-
00:00은 0분, 01:00은 60분이다. 초기 예약과 겹치지 않으므로 성공하며 다음 항목을 CASES에 추가한다. 이 문제만 추가하면 두 동작 검사와 전후 비교의 사례 수가 각각 12개가 된다.
CASES.append(( "자정 시작", ("A", "00:00", "01:00", "새벽 모임"), (True, "예약을 저장했다."), ("A", 0, 60, "새벽 모임"), ))이 문장은 CASES 정의 뒤, main이 호출되기 전에 둔다. 값 0을 실패로 취급하는 변경이 들어가면 이 사례가 실패한다.
-
길이가 0인 예약은 허용하지 않는다. start가 end 이상인지 검사하므로 “종료는 시작보다 늦어야 한다.”를 반환하고 목록을 유지한다.
CASES.append(( "같은 시작과 종료", ("A", "12:00", "12:00", "수학 모임"), (False, "종료는 시작보다 늦어야 한다."), None, ))앞 문제와 함께 추가했다면 검사 수는 각각 13개다. 충돌 여부를 확인하기 전에 시간 순서 오류로 반환한다.
-
현재는 이름을 먼저 검사하므로 “신청자 이름이 필요하다.”가 예상 메시지다. 공간을 먼저 검사하면 “알 수 없는 공간이다.”로 바뀐다. 성공과 실패 여부가 같아도 사용자에게 표시하는 오류가 달라지므로 동작 변경이다.
CASES.append(( "여러 입력 오류", ("C", "11:00", "12:00", " "), (False, "신청자 이름이 필요하다."), None, ))세 문제의 사례를 모두 추가했다면 검사 수는 각각 14개다. 이 사례는 어떤 오류를 먼저 안내하는지도 계약의 일부로 고정한다.
-
두 함수가 같은 잘못된 결과를 내면 전후 비교는 통과할 수 있다. 그러나 예상 결과를 직접 적은 동작 검사는 앞 경계와 뒤 경계에서 실패한다. 두 사례는 저장을 기대하지만 등호가 들어간 잘못된 식은 충돌을 반환한다. 기준 함수를 손대지 않는 습관과 독립적으로 적은 예상 결과가 서로 다른 방향에서 변경을 확인한다.