기능 추가 요청 다루기 - 명세 변경과 회귀 테스트
이 장에서 배우는 것
공간 예약 서비스에 새 요구가 들어왔다. 스터디 모임이 매주 같은 시간에 모이므로, 날짜를 바꾸어 가며 예약하는 일을 줄이고 싶다는 요청이다. 화면에 반복 횟수 입력란을 붙이면 끝날 것처럼 보인다. 그러나 예약을 여러 건 만드는 순간, 중간 날짜의 충돌과 저장 실패를 어떻게 처리할지 정해야 한다. 기능의 이름보다 실패했을 때 남는 데이터가 더 중요한 변경이다.
앞 장에서 잘못된 입력을 걸러 내고 사용자에게 오류를 알려 주는 흐름을 만들었다. 이 장에서는 반복 예약의 묶음 저장을 설명하기 위한 별도 예제를 사용한다. 날짜·시간 형식과 시간 순서, 같은 공간의 충돌 및 맞닿은 시간 허용 규칙을 다루지만, 앞 장의 공간 존재·회원 권한·과거 시각·최대 두 시간 검사는 포함하지 않는다. 함수 인터페이스와 스키마도 앞 장과 다르므로, 여기서 말하는 기존 테스트는 이 장의 단일 예약 예제에 대한 테스트다. 필요한 기존 코드를 한 파일에 포함하므로 앞 장의 파일 없이도 실행할 수 있다. 데이터베이스는 실행 중에만 존재하는 메모리 데이터베이스를 사용한다.
- 새 요구를 성공 조건과 실패 조건이 있는 명세로 바꾼다.
- 변경할 함수와 유지할 규칙을 나누어 영향 범위를 확인한다.
- 기존 테스트로 이전 동작을 확인하고 새 요구의 테스트를 추가한다.
- 반복 예약의 일부만 저장되는 일을 막는다.
- AI에게 작은 변경을 요청하고 실행 결과와 변경 내용을 직접 검토한다.
문제 상황
독서 모임 운영자가 목요일 저녁의 작은 방을 예약한다. 지금은 날짜 하나, 시작 시각 하나, 종료 시각 하나를 입력하여 예약 한 건을 저장한다. 앞으로는 첫 날짜와 반복 횟수를 입력하면 같은 요일과 시간의 예약을 함께 만들고 싶다. 예를 들어 2026년 10월 8일부터 세 번 예약하면 날짜는 10월 8일, 15일, 22일이다.
여기서 마지막 날짜인 10월 22일에 이미 다른 예약이 있다고 가정한다. 앞의 두 날짜만 저장하고 오류를 반환하면 운영자는 실패 메시지를 보고도 두 건의 예약을 갖게 된다. 같은 요청을 다시 보내면 이번에는 자신이 만든 앞의 예약과 충돌한다. 저장된 상태와 사용자가 이해한 결과가 달라지는 셈이다.
“매주 반복 예약을 추가해 줘”라는 말에는 이 문제의 답이 없다. 가능한 날짜만 예약할 수도 있고, 충돌이 하나라도 있으면 모두 취소할 수도 있다. 어느 쪽이든 서비스의 약속으로 정해야 한다. 이 장에서는 요청에 포함된 모든 날짜를 예약할 수 있을 때만 저장한다는 방식을 선택한다.
반복 예약을 묶어 수정하거나 취소하는 기능은 이번 변경에 포함하지 않는다. 저장되는 것은 지금까지와 같은 개별 예약이다. 반복 요청은 개별 예약 여러 건을 한 번에 생성하는 편의 기능으로 한정한다. 이 범위를 정해 두어야 AI가 예약 묶음 번호, 반복 종료일, 달력 화면까지 함께 만들지 않도록 요청을 구체화할 수 있다.
새 요구를 명세로 바꾸기
명세는 서비스가 무엇을 받아 어떤 결과를 돌려주는지 적은 약속이다. 구현 방법만 적으면 사용자에게 보이는 차이를 판단하기 어렵다. “반복문을 쓴다”보다 “세 번째 날짜가 충돌하면 새 예약은 한 건도 남지 않는다”가 테스트로 확인하기 좋은 명세다.
먼저 기존 규칙을 적는다. 날짜는 연도 네 자리와 월·일 두 자리로 받는다. 시각은 시와 분을 두 자리씩 받는다. 시작 시각은 종료 시각보다 빨라야 하고, 자정을 넘기는 예약은 받지 않는다. 같은 공간에서 시간이 겹치는 예약은 거절한다. 한 예약의 종료 시각과 다음 예약의 시작 시각이 같으면 허용한다.
기존 규칙이 적혀 있어야 새 요구를 넣으며 무엇을 유지해야 하는지도 보인다. 반복 예약이라고 해서 잘못된 시간을 허용하거나 다른 공간의 예약까지 충돌로 판단해서는 안 된다. 반복 요청의 각 날짜에도 단일 예약과 같은 규칙을 적용한다.
| 항목 | 기존 약속 | 추가할 약속 |
|---|---|---|
| 날짜와 시간 | 날짜 하나와 당일 시간 구간을 받는다. | 첫 날짜에서 7일씩 더한 날짜를 만든다. |
| 반복 횟수 | 단일 예약에는 횟수가 없다. | 첫 날짜를 포함하여 정수 1부터 4까지 받는다. |
| 충돌 | 같은 공간·날짜의 겹치는 시간을 거절한다. | 어느 날짜든 충돌하면 요청 전체를 거절한다. |
| 저장과 반환 | 예약 한 건을 저장하고 번호를 반환한다. | 모두 저장한 뒤 날짜 순서의 번호 목록을 반환한다. |
반복 횟수의 상한은 이 예제에서 정한 서비스 규칙이다. 데이터베이스가 네 건만 처리할 수 있다는 뜻이 아니다. 첫 구현에서 입력과 실패 조건을 작게 유지하기 위한 선택이다. 나중에 상한을 바꾸려면 이 명세와 경계값 테스트를 함께 바꾼다.
횟수는 첫 날짜를 포함한다. 따라서 횟수가 1이면 첫 날짜 한 건만 만든다. “추가로 반복할 횟수”라고 해석하면 같은 입력으로 두 건이 만들어질 수 있다. 이런 작은 표현 차이도 데이터의 개수를 바꾸므로 요청문과 함수 설명에서 같은 의미로 사용해야 한다.
참과 거짓을 나타내는 값도 횟수로 받지 않는다. Python에서 참인 값은 정수와 관련된 성질을 갖지만, 예약 화면의 횟수로는 의미가 다르다. 완성 코드에서는 값의 자료형이 정확히 정수인지 확인한다. 날짜를 더한 결과가 Python이 표현할 수 있는 범위를 벗어나는 경우도 입력 오류로 돌려준다.
명세를 고쳤다면 AI에게 구현을 요청하기 전에 검토만 맡길 수 있다. 다음 요청은 저장 실패와 범위 누락을 찾아내는 데 집중한다. AI의 답은 검토할 후보이며 서비스의 약속을 대신 결정하는 문서가 아니다.
공간 예약 서비스에 매주 반복 예약을 추가하려 한다. 첫 날짜를 포함하여 1~4회이며, 어느 날짜든 충돌하면 아무것도 저장하지 않는다. 기존 단일 예약의 입력 검증과 시간 경계 규칙을 유지한다. 구현하지 말고, 이 명세에서 빠진 실패 조건과 모호한 표현을 찾아라. 묶음 수정·취소와 화면 변경은 이번 범위에 없다.
답에서 “첫 날짜를 포함하는가”나 “중간 충돌 때 일부 저장하는가”를 지적했다면 명세와 비교해 이미 해결된 항목인지 확인한다. 새로운 지적이라면 실제 서비스에서 필요한 조건인지 판단하여 명세에 반영한다. 지적을 전부 기능으로 추가하면 요청의 범위가 계속 커진다.
영향 범위를 나누고 회귀를 확인하기
영향 범위는 변경 때문에 동작이 달라질 수 있는 부분이다. 이 예제에서는 날짜를 여러 개 만드는 함수, 입력 검증, 충돌 확인, 저장 순서가 직접 영향을 받는다. 예약 목록을 읽는 함수는 새로 저장된 여러 행을 그대로 읽으면 된다. 단일 예약 함수의 외부 사용법을 바꿀 이유도 없다.
데이터베이스 표도 그대로 둔다. 공간, 날짜, 시작 분, 종료 분을 가진 기존 예약 행을 여러 개 저장하면 이번 요구를 표현할 수 있다. 반복 예약을 한 묶음으로 관리해야 한다면 추가 모델이 필요하지만, 이번 명세에는 그 요구가 없다. 필요한 데이터와 미래에 쓸지도 모르는 데이터를 구분하는 판단이다.
| 부분 | 작업 | 확인 방법 |
|---|---|---|
| 입력 검증 | 기존 검증을 재사용하고 횟수 검증을 추가한다. | 잘못된 입력 뒤 예약 수가 0인지 확인한다. |
| 충돌 확인 | 모든 후보 날짜에 기존 식을 적용한다. | 시간 경계와 마지막 날짜 충돌을 확인한다. |
| 단일 예약 | 기존 함수의 인자와 반환값을 유지한다. | 기존 네 테스트를 다시 실행한다. |
| 반복 저장 | 여러 행을 한 저장 단위로 처리한다. | 성공 시 날짜 목록, 실패 시 기존 목록을 비교한다. |
회귀 테스트(regression test)는 변경 후에도 이전에 되던 동작이 유지되는지 확인하는 테스트다. 새 기능만 성공한다고 변경이 끝난 것은 아니다. 반복 예약 구현을 위해 충돌 식을 바꾸었다가 연속된 두 예약을 거절할 수도 있다. 기존 테스트가 그 차이를 드러낸다.
작업 순서는 기존 코드의 테스트 실행, 명세 변경, 새 테스트 추가, 기능 구현, 전체 테스트 실행으로 잡는다. 새 테스트를 추가한 직후에는 새 함수가 없거나 요구를 만족하지 않아 실패해야 한다. 그 실패가 요청한 차이를 가리키는지 읽은 뒤 구현한다. 기존 테스트가 변경 전부터 실패했다면 먼저 그 원인을 구분해야 한다.
기존 테스트를 통과시키려고 기대값을 새 구현에 맞추어 바꾸어서는 안 된다. 기대값은 명세에서 나와야 한다. 특히 “실패하면 저장하지 않는다”는 조건을 “앞의 두 건이 저장된다”로 바꾸면 오류를 해결한 것이 아니라 서비스의 약속을 바꾼 것이다.
AI에게는 먼저 기존 테스트의 의미를 설명하게 하고, 다음 요청에서 새 테스트만 추가하게 할 수 있다. 그다음 구현을 요청한다. 요청을 나누는 이유는 메시지 수를 늘리기 위해서가 아니다. 각 변경이 어느 약속을 구현하는지 확인하기 쉽게 만들기 위해서다.
기존 단일 예약 함수의 인자와 반환값을 유지하라. 반복 예약 명세에 맞는 테스트를 먼저 추가하라. 정상 생성, 마지막 날짜 충돌 시 전체 취소, 잘못된 횟수, 횟수 1을 확인하라. 기존 테스트의 기대값은 수정하지 말고, 아직 구현하지 않은 함수 때문에 발생한 실패를 설명하라.
추가한 테스트를 만족하는 반복 예약 함수만 구현하라. 같은 표에 개별 예약을 저장하고 기존 검증과 충돌 확인을 재사용하라. 모든 날짜를 검사한 뒤 한 저장 단위로 삽입하라. 외부 패키지, 화면, 취소 기능을 추가하지 말라. 실행한 테스트와 변경한 함수 목록을 답에 적어라.
AI가 “모든 테스트가 통과했다”고 답해도 직접 실행한다. 답변의 기록은 실제 실행 증거와 다를 수 있다. 변경 전후의 차이를 보여 주는 diff도 읽는다. 기존 테스트 삭제, 충돌 비교 기호 변경, 함수 인자 변경, 새 의존성 추가가 없는지 확인한다. 실행은 결과를 확인하고, diff 검토는 요청 밖의 변경을 찾는다.
여러 예약을 하나의 저장 단위로 다루기
반복 예약 함수는 검증, 날짜 생성, 전체 충돌 확인, 저장의 순서로 움직인다. 충돌 확인이 끝나기 전에는 예약을 삽입하지 않는다. 이 순서만 지켜도 뒤 날짜의 충돌 때문에 앞 날짜의 예약이 남는 문제를 피할 수 있다.
삽입 도중의 오류에도 대비해야 한다. 트랜잭션(transaction)은 여러 데이터 변경을 하나의 저장 단위로 묶는 방법이다. 묶음의 작업이 정상적으로 끝나면 반영하고, 작업 중 예외가 발생하면 그 묶음에서 한 변경을 되돌린다. 완성 코드에서는 연결 객체를 사용하는 with 문으로 삽입 묶음을 처리한다.
예외는 함수가 작업을 계속할 수 없음을 호출한 쪽에 알리는 방식이다. 충돌이나 잘못된 입력에는 ValueError를 사용한다. 이 예외를 테스트가 받아 메시지와 저장 상태를 확인한다. 저장 블록 안에서 오류를 잡아 숨기면 정상 종료로 보일 수 있으므로, 데이터베이스 오류를 그 안에서 삼키지 않는다.
이 장의 실행은 한 연결에서 순서대로 이루어진다. 동시에 여러 사용자가 예약하는 상황까지 해결했다고 해석해서는 안 된다. 여러 연결이 함께 쓰는 서비스에서는 충돌 조회와 저장 사이에 다른 요청이 들어오는 경우도 설계해야 한다. 여기서는 반복 요청의 성공과 실패 상태를 결정적으로 확인하는 데 범위를 둔다.
완성 코드의 테스트는 각각 새 메모리 데이터베이스를 만든다. 테스트끼리 예약을 공유하면 실행 순서에 따라 결과가 달라질 수 있기 때문이다. 고정된 날짜와 고정된 순서로 실행하고, 마지막 시연도 별도의 데이터베이스에서 진행한다.
완성 코드
다음 코드를 main.py로 저장한다. 표준 라이브러리의 sqlite3와 datetime만 사용한다. 테스트 함수는 검사가 맞지 않으면 AssertionError를 발생시킨다. 모든 검사를 통과한 경우에만 성공 메시지가 출력된다. 실행 뒤 데이터베이스 연결을 닫으므로 파일이나 서버가 남지 않는다.
import sqlite3
from datetime import date, timedelta
def open_db():
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE reservations (
id INTEGER PRIMARY KEY,
room TEXT NOT NULL,
day TEXT NOT NULL,
start_min INTEGER NOT NULL,
end_min INTEGER NOT NULL,
CHECK (start_min >= 0),
CHECK (end_min <= 1439),
CHECK (start_min < end_min)
)
""")
conn.commit()
return conn
def parse_day(text):
if not isinstance(text, str):
raise ValueError("날짜는 YYYY-MM-DD 형식이어야 한다.")
try:
value = date.fromisoformat(text)
except ValueError:
raise ValueError("날짜는 YYYY-MM-DD 형식이어야 한다.") from None
if value.isoformat() != text:
raise ValueError("날짜는 YYYY-MM-DD 형식이어야 한다.")
return value
def parse_time(text):
message = "시각은 HH:MM 형식이어야 한다."
if not isinstance(text, str):
raise ValueError(message)
if len(text) != 5 or text[2] != ":":
raise ValueError(message)
digits = text[:2] + text[3:]
if any(char not in "0123456789" for char in digits):
raise ValueError(message)
hour = int(text[:2])
minute = int(text[3:])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError(message)
return hour * 60 + minute
def validate_request(room, day, start, end):
if not isinstance(room, str) or not room.strip():
raise ValueError("공간 이름이 필요하다.")
first_day = parse_day(day)
start_min = parse_time(start)
end_min = parse_time(end)
if start_min >= end_min:
raise ValueError("시작 시각은 종료 시각보다 빨라야 한다.")
return room.strip(), first_day, start_min, end_min
def ensure_available(conn, room, day, start_min, end_min):
row = conn.execute(
"""
SELECT id FROM reservations
WHERE room = ? AND day = ?
AND start_min < ? AND end_min > ?
LIMIT 1
""",
(room, day, end_min, start_min),
).fetchone()
if row is not None:
raise ValueError(f"예약 충돌: {room} {day}")
def insert_row(conn, room, day, start_min, end_min):
cursor = conn.execute(
"""
INSERT INTO reservations (room, day, start_min, end_min)
VALUES (?, ?, ?, ?)
""",
(room, day, start_min, end_min),
)
return cursor.lastrowid
def reserve_one(conn, room, day, start, end):
room, first_day, start_min, end_min = validate_request(
room, day, start, end
)
day_text = first_day.isoformat()
ensure_available(conn, room, day_text, start_min, end_min)
with conn:
booking_id = insert_row(
conn, room, day_text, start_min, end_min
)
return booking_id
def reserve_weekly(conn, room, day, start, end, count):
if type(count) is not int or not 1 <= count <= 4:
raise ValueError("반복 횟수는 정수 1~4여야 한다.")
room, first_day, start_min, end_min = validate_request(
room, day, start, end
)
try:
days = [
(first_day + timedelta(days=7 * index)).isoformat()
for index in range(count)
]
except OverflowError:
raise ValueError("반복 날짜가 지원 범위를 벗어난다.") from None
for day_text in days:
ensure_available(conn, room, day_text, start_min, end_min)
booking_ids = []
with conn:
for day_text in days:
booking_ids.append(
insert_row(conn, room, day_text, start_min, end_min)
)
return booking_ids
def list_rows(conn):
return conn.execute(
"""
SELECT id, room, day, start_min, end_min
FROM reservations
ORDER BY day, start_min, room, id
"""
).fetchall()
def check(condition, message):
if not condition:
raise AssertionError(message)
def expect_value_error(action, expected):
try:
action()
except ValueError as error:
check(str(error) == expected, "오류 메시지가 다르다.")
else:
raise AssertionError("ValueError가 발생해야 한다.")
def test_single(conn):
booking_id = reserve_one(
conn, "작은방", "2026-10-08", "18:00", "19:00"
)
check(booking_id == 1, "첫 예약 번호가 다르다.")
check(
list_rows(conn) == [(1, "작은방", "2026-10-08", 1080, 1140)],
"단일 예약 내용이 다르다.",
)
def test_overlap(conn):
reserve_one(conn, "작은방", "2026-10-08", "18:00", "19:00")
expect_value_error(
lambda: reserve_one(
conn, "작은방", "2026-10-08", "18:30", "19:30"
),
"예약 충돌: 작은방 2026-10-08",
)
check(len(list_rows(conn)) == 1, "충돌한 예약이 저장되었다.")
def test_boundary_and_room(conn):
reserve_one(conn, "작은방", "2026-10-08", "18:00", "19:00")
reserve_one(conn, "작은방", "2026-10-08", "19:00", "20:00")
reserve_one(conn, "큰방", "2026-10-08", "18:00", "19:00")
check(len(list_rows(conn)) == 3, "허용할 예약이 거절되었다.")
def test_invalid_input(conn):
cases = [
(("", "2026-10-08", "18:00", "19:00"), "공간 이름이 필요하다."),
(
("작은방", "2026-02-30", "18:00", "19:00"),
"날짜는 YYYY-MM-DD 형식이어야 한다.",
),
(
("작은방", "2026-10-08", "8:00", "19:00"),
"시각은 HH:MM 형식이어야 한다.",
),
(
("작은방", "2026-10-08", "19:00", "18:00"),
"시작 시각은 종료 시각보다 빨라야 한다.",
),
]
for args, message in cases:
expect_value_error(
lambda args=args: reserve_one(conn, *args), message
)
check(list_rows(conn) == [], "잘못된 입력이 저장되었다.")
def test_weekly_success(conn):
booking_ids = reserve_weekly(
conn, "작은방", "2026-10-08", "18:00", "19:00", 4
)
check(booking_ids == [1, 2, 3, 4], "반환 번호가 다르다.")
check(
list_rows(conn) == [
(1, "작은방", "2026-10-08", 1080, 1140),
(2, "작은방", "2026-10-15", 1080, 1140),
(3, "작은방", "2026-10-22", 1080, 1140),
(4, "작은방", "2026-10-29", 1080, 1140),
],
"반복 예약 내용이 다르다.",
)
def test_weekly_conflict(conn):
reserve_one(conn, "작은방", "2026-10-22", "18:30", "19:30")
before = list_rows(conn)
expect_value_error(
lambda: reserve_weekly(
conn, "작은방", "2026-10-08", "18:00", "19:00", 3
),
"예약 충돌: 작은방 2026-10-22",
)
check(list_rows(conn) == before, "실패 뒤 예약 목록이 달라졌다.")
def test_weekly_invalid(conn):
for count in (0, 5, True, 2.0, "2"):
expect_value_error(
lambda count=count: reserve_weekly(
conn, "작은방", "2026-10-08", "18:00", "19:00", count
),
"반복 횟수는 정수 1~4여야 한다.",
)
expect_value_error(
lambda: reserve_weekly(
conn, "작은방", "2026-10-08", "19:00", "18:00", 2
),
"시작 시각은 종료 시각보다 빨라야 한다.",
)
expect_value_error(
lambda: reserve_weekly(
conn, "작은방", "9999-12-31", "18:00", "19:00", 2
),
"반복 날짜가 지원 범위를 벗어난다.",
)
check(list_rows(conn) == [], "실패한 반복 예약이 저장되었다.")
def test_weekly_once(conn):
booking_ids = reserve_weekly(
conn, "작은방", "2026-10-08", "18:00", "19:00", 1
)
check(booking_ids == [1], "횟수 1의 반환값이 다르다.")
check(
list_rows(conn) == [(1, "작은방", "2026-10-08", 1080, 1140)],
"횟수 1의 저장 내용이 다르다.",
)
def run_group(label, cases):
for name, test in cases:
conn = open_db()
try:
test(conn)
finally:
conn.close()
print(f"[{label}] {name}: 통과")
print(f"{label} 테스트: {len(cases)}/{len(cases)} 통과")
def format_time(minutes):
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def demo():
conn = open_db()
try:
reserve_one(conn, "작은방", "2026-10-22", "18:30", "19:30")
try:
reserve_weekly(
conn, "작은방", "2026-10-08", "18:00", "19:00", 3
)
except ValueError as error:
print(f"거절: {error}")
print(f"거절 뒤 예약 수: {len(list_rows(conn))}")
booking_ids = reserve_weekly(
conn, "큰방", "2026-10-08", "18:00", "19:00", 3
)
print(f"반복 예약 번호: {booking_ids}")
print("최종 예약 목록:")
for booking_id, room, day, start, end in list_rows(conn):
print(
f"{booking_id} | {room} | {day} | "
f"{format_time(start)}~{format_time(end)}"
)
finally:
conn.close()
def main():
existing = [
("단일 예약 저장", test_single),
("겹치는 시간 거절", test_overlap),
("시간 경계와 다른 공간 허용", test_boundary_and_room),
("잘못된 입력 거절", test_invalid_input),
]
added = [
("매주 네 건 생성", test_weekly_success),
("마지막 날짜 충돌 시 전체 취소", test_weekly_conflict),
("반복 입력과 날짜 범위 검증", test_weekly_invalid),
("횟수 1은 한 건 생성", test_weekly_once),
]
run_group("기존", existing)
run_group("신규", added)
print("전체 테스트: 8/8 통과")
print()
demo()
if __name__ == "__main__":
main()
줄별 해설
연결과 입력 검증
첫 두 줄은 데이터베이스 연결과 날짜 계산에 필요한 도구를 가져온다. open_db의 connect 호출은 메모리 데이터베이스를 만든다. 연결을 새로 열 때마다 빈 데이터베이스가 생긴다. CREATE TABLE은 앞 장의 단일 예약을 담을 수 있는 표를 만든다. CHECK는 잘못된 시간 구간이 표에 들어오는 것을 제한하는 조건이다.
conn.commit은 표 준비를 마친 상태를 확정한다. 이후 예약 함수는 준비된 연결을 받는다. 함수 안에서 연결을 새로 만들지 않으므로 충돌 조회와 삽입이 같은 데이터베이스를 대상으로 이루어진다.
parse_day는 문자열인지 확인한 뒤 실제 날짜로 해석한다. 2월 30일처럼 존재하지 않는 날짜는 여기서 거절된다. 다시 만든 표준 날짜 문자열과 입력을 비교하는 줄은 날짜 해석 함수가 받아들일 수 있는 다른 표기까지 서비스가 허용하지 않도록 막는다. from None은 사용자에게 보여 줄 예외 뒤에 내부 변환 오류가 이어지는 것을 줄인다.
parse_time은 길이, 콜론 위치, 숫자 문자, 시와 분의 범위를 순서대로 확인한다. 마지막 줄은 시간을 자정부터 지난 분으로 바꾼다. 18시는 1080분이다. 같은 날짜 안의 시간 구간을 숫자로 비교하므로 문자열 표기 차이가 충돌 계산에 끼어들지 않는다.
validate_request는 공간 이름의 앞뒤 공백을 없애고 기존 날짜·시간 검증을 묶는다. 이 함수를 두 예약 함수가 함께 호출한다. 반복 예약에서 시작과 종료의 순서를 다시 따로 구현하지 않아도 기존 규칙이 적용된다. 반환값에는 정리한 공간 이름, 날짜 객체, 시작 분, 종료 분이 들어 있다.
충돌 조회와 단일 예약
ensure_available의 질문표는 값을 넣을 자리다. 공간 이름이나 날짜를 SQL 문자열에 이어 붙이지 않고 별도 값으로 전달한다. 충돌 조건은 기존 시작이 새 종료보다 빠르고, 기존 종료가 새 시작보다 늦은 경우다. 두 조건이 모두 참이면 시간 구간이 겹친다.
비교 기호에 등호가 없는 이유는 끝과 시작이 맞닿은 두 예약을 허용하기 위해서다. 기존 예약이 19시에 끝나고 새 예약이 19시에 시작하면 기존 종료가 새 시작보다 늦다는 조건은 거짓이다. room과 day 조건도 함께 사용하므로 다른 공간이나 다른 날짜는 이 조회의 충돌 대상이 아니다.
insert_row는 실제 삽입 한 건만 수행한다. 여기에는 commit이 없다. 이 함수가 매번 저장을 확정하면 반복 예약 함수가 여러 삽입을 하나의 단위로 묶기 어려워진다. 반환하는 lastrowid는 이번 삽입에 부여된 예약 번호다.
reserve_one은 검증, 충돌 확인, 저장이라는 기존 흐름을 유지한다. with conn 안에서 삽입하고 블록이 정상 종료하면 반영한다. 예약 번호 하나를 반환하는 사용법도 유지한다. 연결 객체의 with 문이 연결을 닫아 주는 것은 아니므로, 연결을 만든 테스트와 시연 함수가 따로 닫는다.
반복 날짜 생성과 묶음 저장
reserve_weekly의 첫 조건은 횟수 검증이다. type(count) is not int는 True, 실수 2.0, 문자열 "2"를 정수 횟수와 구분한다. 이어지는 범위 조건은 1과 4를 포함한다. 조건을 통과한 뒤 공통 입력 검증을 호출한다.
days를 만드는 부분은 반복문으로 만든 값을 목록에 모으는 표현이다. range(count)가 만드는 번호는 0부터 시작하므로 첫 날짜에는 0일을 더한다. 다음 날짜에는 7일, 그다음에는 14일을 더한다. timedelta는 날짜에 더할 기간을 나타낸다. 각 결과는 저장에 쓸 날짜 문자열로 바뀐다.
날짜 계산에서 OverflowError가 발생하면 예약용 입력 오류로 바꾼다. 이 시점에는 삽입이 없으므로 정리할 예약도 없다. 이어지는 첫 for 문은 모든 날짜의 충돌을 조회한다. 후보 날짜는 서로 다르고 같은 날짜의 시간을 반복 생성하지 않으므로, 이번 후보끼리 시간 충돌을 검사할 필요는 없다.
두 번째 for 문은 with conn 안에 있다. 모든 후보가 검사를 통과했을 때만 들어오며, 한 건씩 삽입한 번호를 목록에 모은다. 저장 도중 예외가 바깥으로 전달되면 이 삽입 묶음의 변경은 되돌려진다. 반환문을 저장 블록 뒤에 둔 이유는 저장이 정상 종료된 번호만 호출한 쪽에 돌려주기 위해서다.
list_rows는 날짜, 시작 시각, 공간, 번호 순서로 정렬한다. 데이터베이스가 우연히 돌려준 순서에 기대지 않기 위한 줄이다. 저장 순서와 출력 순서는 다를 수 있다. 이 차이는 최종 시연에서 번호 1이 목록의 마지막에 나타나는 것으로 확인할 수 있다.
테스트와 실행 진입점
check는 조건이 거짓이면 테스트를 중단한다. assert 문 대신 직접 예외를 발생시키므로 Python 실행 옵션으로 검사가 생략되지 않는다. expect_value_error는 전달받은 작업을 실행하고 예외 종류와 메시지를 확인한다. 예외가 없을 때도 검사 실패로 처리한다.
테스트에서 쓰는 lambda는 나중에 실행할 짧은 함수를 만든다. 오류가 날 작업을 즉시 실행하지 않고 검사 함수에 전달하기 위해 사용한다. 반복문 안의 lambda args=args와 lambda count=count는 현재 반복의 값을 함수에 고정하는 표기다.
기존 네 테스트는 저장 내용, 겹침 거절, 시간 경계와 공간 구분, 잘못된 입력을 확인한다. 신규 성공 테스트는 개수만 세지 않고 네 행의 공간·날짜·시간과 반환 번호를 비교한다. 날짜를 하루씩 더하는 잘못된 구현도 개수 검사만으로는 통과할 수 있기 때문이다.
충돌 테스트는 마지막 날짜에 기존 예약을 먼저 넣고 목록 전체를 before에 보관한다. 실패 뒤에는 개수뿐 아니라 목록이 그대로인지 비교한다. 기존 예약의 시간을 바꾸거나 지워 버리는 실수도 이 검사에서 발견된다. 입력 테스트는 횟수 경계, 자료형, 역순 시간, 날짜 범위 초과를 함께 확인한다.
run_group은 테스트마다 연결을 열고 finally에서 닫는다. 성공 출력은 테스트 실행과 연결 정리가 끝난 뒤에 나온다. 검사 실패가 발생하면 전체 성공 문장까지 도달하지 않는다. main의 마지막 성공 문장이 실제로 실행된 검사와 떨어져 무조건 출력되는 구조를 피한 것이다.
format_time은 분을 시와 분으로 나누어 출력한다. demo는 충돌 요청 뒤 한 건만 남았음을 보여 준 다음, 다른 공간에 반복 예약을 성공시킨다. 마지막 조건문은 이 파일을 직접 실행했을 때 main을 호출한다. 다른 파일에서 가져와도 시연이 자동 실행되지 않는 형태다.
실행 결과
macOS 또는 Linux의 터미널에서 main.py가 있는 디렉터리로 이동한 뒤 실행한다. 컴파일 확인은 코드를 실행하기 전에 문법을 검사한다. 정상이라면 첫 명령에는 출력이 없다. 여기 제시한 결과는 코드의 고정 입력에 따른 예상 출력이며, 자신의 환경에서 직접 실행하여 비교한다.
python3 -W error -m py_compile main.py
python3 main.py
[기존] 단일 예약 저장: 통과
[기존] 겹치는 시간 거절: 통과
[기존] 시간 경계와 다른 공간 허용: 통과
[기존] 잘못된 입력 거절: 통과
기존 테스트: 4/4 통과
[신규] 매주 네 건 생성: 통과
[신규] 마지막 날짜 충돌 시 전체 취소: 통과
[신규] 반복 입력과 날짜 범위 검증: 통과
[신규] 횟수 1은 한 건 생성: 통과
신규 테스트: 4/4 통과
전체 테스트: 8/8 통과
거절: 예약 충돌: 작은방 2026-10-22
거절 뒤 예약 수: 1
반복 예약 번호: [2, 3, 4]
최종 예약 목록:
2 | 큰방 | 2026-10-08 | 18:00~19:00
3 | 큰방 | 2026-10-15 | 18:00~19:00
4 | 큰방 | 2026-10-22 | 18:00~19:00
1 | 작은방 | 2026-10-22 | 18:30~19:30
거절 뒤 예약 수가 1이라는 출력은 원래 있던 작은방 예약만 남았다는 뜻이다. 앞의 두 날짜에 새 예약이 남지 않았다. 성공한 큰방 예약의 번호는 2부터 시작한다. 충돌 요청에서는 삽입 자체를 시작하지 않았으므로 시연의 예약 번호를 소비하지 않았다.
여덟 테스트의 통과는 여기 적은 사례를 만족한다는 근거다. 저장 도중 발생할 수 있는 모든 데이터베이스 오류나 여러 사용자의 동시 요청을 시험한 결과는 아니다. 작업 기록에는 확인한 사례와 아직 확인하지 않은 조건을 구분해 남긴다. 그래야 다음 변경에서 이 결과를 과하게 해석하지 않는다.
실무에서 자주 틀리는 것
날짜를 문자열처럼 증가시키기
날짜 문자열을 잘라 일자에 7을 더하면 월말에서 존재하지 않는 날짜가 나온다. 다음 잘못된 코드는 2026-10-36을 출력한다. 실행은 되지만 서비스가 받을 수 있는 날짜는 아니다.
first = "2026-10-29"
next_day = first[:8] + str(int(first[8:]) + 7)
print(next_day)
고친 코드는 날짜 객체에 기간을 더한다. 월과 연도가 바뀌는 계산을 날짜 도구에 맡기므로 2026-11-05가 나온다. 문자열은 입력과 출력의 형식으로 사용하고, 계산에는 날짜 객체를 사용한다.
from datetime import date, timedelta
first = date.fromisoformat("2026-10-29")
next_day = first + timedelta(days=7)
print(next_day.isoformat())
반복문의 매 단계에서 저장을 확정하기
예약마다 저장을 확정하면 다음 단계에서 오류가 나도 앞의 변경을 되돌릴 수 없다. 다음 독립 예제는 두 번째 값이 중복되어 실패하지만 첫 값은 남는다. 예약의 각 날짜에 commit을 넣었을 때 생기는 문제와 같다.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE sample (value INTEGER UNIQUE)")
try:
for value in (1, 1):
conn.execute("INSERT INTO sample VALUES (?)", (value,))
conn.commit()
except sqlite3.IntegrityError:
print("저장 실패")
print(conn.execute("SELECT value FROM sample").fetchall())
conn.close()
고친 예제는 반복문 전체를 with conn으로 감싼다. 두 번째 삽입에서 오류가 나면 첫 번째 삽입도 되돌려져 빈 목록을 출력한다. except를 저장 블록 바깥에 두었으므로 연결 객체가 실패를 확인한 뒤 호출한 쪽에서 메시지를 처리한다.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE sample (value INTEGER UNIQUE)")
try:
with conn:
for value in (1, 1):
conn.execute("INSERT INTO sample VALUES (?)", (value,))
except sqlite3.IntegrityError:
print("저장 실패")
print(conn.execute("SELECT value FROM sample").fetchall())
conn.close()
생성 개수만 확인하기
예약이 세 건 있다는 사실만으로 매주 예약이 맞다고 판단할 수 없다. 다음 검사는 하루 간격의 잘못된 목록도 통과시킨다. 테스트가 구현의 겉모습만 확인하면 요구와 다른 결과가 남을 수 있다.
actual = ["2026-10-08", "2026-10-09", "2026-10-10"]
if len(actual) != 3:
raise AssertionError("예약 개수가 다르다.")
print("개수 검사 통과")
고친 검사는 명세에서 정한 날짜 목록을 직접 비교한다. 완성 코드에서는 여기에 공간과 시간까지 포함한다. 구현과 똑같은 날짜 생성 식으로 기대값을 만들면 같은 계산 실수를 공유할 수 있으므로, 대표 사례의 기대 날짜는 명시하는 편이 읽기 쉽다.
actual = ["2026-10-08", "2026-10-15", "2026-10-22"]
expected = ["2026-10-08", "2026-10-15", "2026-10-22"]
if actual != expected:
raise AssertionError("반복 날짜가 다르다.")
print("날짜 검사 통과")
한눈에 보기
| 순서 | 할 일 | 남길 근거 |
|---|---|---|
| 기존 상태 확인 | 변경 전에 기존 테스트를 실행한다. | 통과·실패 결과와 실행 명령 |
| 명세 변경 | 반복 간격, 횟수, 전체 실패 조건을 적는다. | 입력·저장·반환의 약속 |
| 영향 확인 | 공통 규칙과 추가할 부분을 나눈다. | 변경할 함수와 유지할 사용법 |
| 구현과 검증 | 새 테스트를 추가하고 작은 범위로 구현한다. | 기존·신규 결과와 diff 검토 기록 |
작업 기록에는 “반복 예약 완료”만 적지 않는다. 첫 날짜 포함, 1~4회, 한 날짜 충돌 시 전체 거절이라는 변경을 적고, 기존 네 테스트와 신규 네 테스트의 실행 결과를 붙인다. 표 구조와 단일 예약 사용법을 유지했다는 검토 내용도 남긴다. 다음 사람이 코드와 요청의 관계를 따라갈 수 있는 기록이다.
연습 문제
- 2026년 10월 29일부터 두 번 반복 예약하는 테스트를 추가한다. 예상 날짜를 직접 적고, 월이 바뀌어도 같은 요일을 유지하는지 확인한다.
- 기존 예약이 2026년 10월 15일에 있을 때, 10월 8일부터 세 번 반복하는 요청을 거절하는 테스트를 추가한다. 실패 뒤 예약 목록 전체가 그대로인지 확인한다.
- 최대 반복 횟수를 6으로 바꾼다고 가정한다. 먼저 고칠 명세와 테스트를 적는다. 범위 밖 값을 확인하는 기존 테스트에서 어떤 입력을 바꿔야 하는지도 설명한다.
- 반복 예약과 함께 달력 화면, 묶음 취소, 알림까지 추가해 달라는 요청을 받았다. 이번 반복 예약 변경을 먼저 검증할 수 있도록 AI에게 보낼 요청을 둘로 나눈다.
정답과 해설
첫 문제의 예상 날짜는 2026-10-29와 2026-11-05다. 신규 테스트 함수에서 두 번 반복 예약하고 list_rows의 날짜를 이 목록과 비교한다. test_weekly_success가 한 달 안의 간격을 확인했다면, 이 테스트는 월 경계에서도 날짜 계산이 맞는지 확인한다. 구현 함수는 날짜 도구를 사용하므로 이 요구 때문에 바꿀 필요가 없다.
두 번째 문제에서는 10월 15일의 작은방 예약을 먼저 저장한 뒤 before에 목록을 보관한다. 반복 요청의 오류 메시지는 “예약 충돌: 작은방 2026-10-15”여야 한다. 실패 뒤 목록은 before와 같아야 한다. 마지막 날짜 충돌 테스트에 더해 중간 날짜에서 검사 흐름이 멈추어도 저장 상태가 유지되는지 확인하는 사례다.
세 번째 문제에서는 명세의 상한, 함수의 범위 조건, 오류 메시지를 함께 6으로 바꾼다. 실패 입력에 있던 5는 이제 허용되는 값이므로 범위 밖 값인 7로 바꾼다. 정상 테스트에는 6회 성공 사례를 추가하고 여섯 날짜와 반환 번호를 확인한다. 기존 4회 성공과 1회 성공은 여전히 유효하므로 유지한다. 상한을 바꾸었다는 이유로 기존 성공 사례를 지울 필요는 없다.
네 번째 문제의 첫 요청은 명세와 영향 범위만 다룬다. 두 번째 요청은 확정한 반복 예약 명세의 테스트와 구현만 다룬다. 다른 기능은 별도 작업으로 기록한다. 요청을 나누어도 각 요청에는 기존 규칙을 유지하라는 조건과 실행으로 확인할 항목이 들어 있어야 한다.
먼저 매주 반복 예약의 명세와 영향 범위를 정리하라. 첫 날짜 포함 1~4회, 충돌 시 전체 거절, 기존 단일 예약 유지가 조건이다. 달력 화면·묶음 취소·알림은 별도 요청으로 기록하고 이번에는 구현하지 말라.
확정한 명세에 따라 반복 예약 테스트를 추가한 뒤 기능을 구현하라. 기존 테스트와 신규 테스트를 모두 실행하고, 저장 전 전체 충돌 검사와 묶음 저장이 어디에 있는지 설명하라. 변경 내용을 검토할 수 있도록 바뀐 함수 목록을 제시하라.
기능을 늘릴 때의 기준은 코드가 많아졌는지가 아니라, 바뀐 약속이 테스트로 확인되는지다. 다음 작업에서도 먼저 현재 동작을 읽고 근거를 찾는다. 이름이나 AI의 설명만으로 함수의 역할을 단정하지 않고, 실제 코드와 실행 결과를 대조하는 습관을 이어 간다.