작은 웹 API - http.server 로 요청과 응답 다루기
이 장에서 배우는 것
앞 장에서 만든 예약 프로그램은 터미널에서 입력을 받아 저장했다. 이번에는 같은 예약 규칙을 웹 요청으로 부를 수 있게 만든다. 사용자는 공간 번호와 예약 시간을 JSON으로 보내고, 프로그램은 처리 결과를 JSON으로 돌려준다. 화면은 아직 만들지 않는다. 요청의 내용과 응답의 약속을 먼저 고정해야 다음 장에서 화면을 연결하기 쉽다.
웹 서버를 실행하면 프로그램은 요청을 기다리느라 끝나지 않는다. 따라서 이 장의 기본 실행은 서버를 띄우지 않는다. 요청 처리 함수를 직접 호출해 정상 예약, 충돌, 잘못된 입력의 결과를 확인하고 종료한다. 서버는 명령줄에 별도 인자를 주었을 때만 실행한다. 외부 패키지와 API 키는 필요하지 않다.
- HTTP 요청의 메서드·경로·본문과 응답의 상태 코드·본문을 구분한다.
- http.server의 핸들러가 요청을 받아 응답을 쓰는 과정을 읽는다.
- JSON 본문을 해석하고 예약 규칙에 맞는지 검사한다.
- 요청 처리 로직을 순수 함수로 분리해 서버 없이 테스트한다.
- AI가 제안한 코드를 실행 결과와 변경 내용으로 확인한다.
문제 상황
동아리 운영자는 공간 예약을 터미널에서 등록할 수 있다. 그러나 다른 구성원이 예약하려면 운영자에게 내용을 전달하거나 같은 프로그램을 직접 실행해야 한다. 다음 단계에서는 구성원이 사용할 화면을 만들 예정이다. 화면에서 보낸 예약을 받아 줄 입구가 필요하다.
입구를 만든다고 예약 규칙까지 다시 만들 필요는 없다. 같은 공간에서 시간이 겹치면 거절하고, 앞 예약의 종료 시각과 다음 예약의 시작 시각이 같으면 허용한다는 규칙은 그대로다. 달라지는 부분은 입력이 들어오는 방식과 결과를 전달하는 방식이다.
예를 들어 구성원이 A 공간을 2030년 4월 15일 10시부터 11시까지 예약한다. 첫 요청은 저장에 성공한다. 이어서 같은 공간을 10시 30분부터 11시 30분까지 예약하면 기존 예약과 겹친다. 이때 서버가 단순히 “실패”라고만 답하면 화면은 무엇을 안내해야 할지 알기 어렵다. 상태 코드와 짧은 오류 식별자를 함께 반환해야 한다.
먼저 AI에게 구현 범위를 좁힌 요청문을 보낸다고 가정한다. 요청문은 코드가 아니라 작업의 약속이다.
표준 라이브러리만 사용해 공간 예약 API를 작성하라. GET /rooms, GET /reservations, POST /reservations를 지원하라. 요청 처리 함수는 메서드, 경로, 본문 바이트, 기존 예약을 받아 상태 코드, 응답 객체, 새 예약 목록을 반환하라. 실패하면 기존 예약을 유지하라. 기본 실행에서는 이 함수를 직접 호출하고 종료하라. 서버는 --serve 인자에서만 실행하라.
AI의 답에 “서버를 실행해 확인하면 된다”라는 설명만 있다면 요구가 충분히 반영되지 않은 것이다. 기본 실행이 끝나는지, 서버 없이 충돌을 확인할 수 있는지, 실패한 요청이 저장 내용을 바꾸지 않는지를 직접 확인한다. 코드가 길어 보인다는 이유로 검토를 생략하지 않는다.
요청과 응답의 작은 약속
HTTP는 요청과 응답을 주고받는 통신 규칙이다. 요청에는 어떤 작업인지 나타내는 메서드(method), 대상을 나타내는 경로(path), 추가 정보를 담는 본문(body)이 있다. 이 장에서는 조회에 GET을, 새 예약을 만드는 데 POST를 쓴다. /reservations라는 경로가 같아도 메서드에 따라 작업이 달라진다.
응답에는 처리 결과를 나타내는 상태 코드(status code)와 본문이 있다. 200은 조회 성공, 201은 새 항목 생성 성공으로 사용한다. 400은 요청 내용이 잘못되었을 때, 404는 지원하는 경로가 없을 때, 409는 기존 예약과 충돌할 때 사용한다. 상태 코드만으로 구체적인 원인까지 전달할 수는 없으므로 본문에 오류 식별자도 넣는다.
| 메서드 | 경로 | 요청 본문 | 성공 상태 |
|---|---|---|---|
| GET | /rooms | 없음 | 200, 공간 목록 |
| GET | /reservations | 없음 | 200, 예약 목록 |
| POST | /reservations | 공간·시작·종료 | 201, 생성한 예약 |
JSON은 객체, 배열, 문자열, 숫자 등을 텍스트로 표현하는 형식이다. Python의 사전과 비슷해 보이지만 같은 것은 아니다. JSON 문자열은 큰따옴표를 쓰며, 서버에 도착한 본문은 아직 사전이 아니라 바이트다. 바이트를 UTF-8 문자열로 해석한 뒤 JSON을 Python 값으로 바꾸는 과정이 필요하다.
JSON 해석에 성공했다고 예약 입력으로 쓸 수 있는 것도 아니다. 배열이나 숫자도 올바른 JSON이다. 이 API는 room, start, end라는 세 항목을 가진 객체만 받는다. 공간은 A 또는 B이고, 시각은 날짜와 분을 포함한 문자열이어야 한다. 알 수 없는 항목을 조용히 무시하지 않고 거절하면 입력 항목의 오타를 발견하기 쉽다.
시간 형식은 2030-04-15T10:00처럼 고정한다. 이 장의 모든 시각은 모임이 사용하는 동일한 지역의 시각으로 취급한다. 시간대 변환은 다루지 않는다. 날짜와 분을 고정한 문자열은 같은 형식 안에서 정렬할 수 있지만, 입력 검증에서는 실제 날짜인지도 확인한다. 2030-02-30T10:00은 모양이 맞아도 존재하지 않는 날짜다.
지원하는 경로에서 메서드만 잘못되면 405를 반환한다. 이 응답에는 허용하는 메서드를 알리는 Allow 헤더도 붙인다. 헤더(header)는 본문 밖에 놓는 부가 정보다. Content-Type은 본문의 형식을, Content-Length는 본문의 바이트 수를 알려 준다. 이 정도만 알아도 이번 핸들러의 응답 부분을 읽을 수 있다.
순수 함수와 서버의 역할 나누기
순수 함수(pure function)는 같은 입력을 받으면 같은 결과를 반환하고, 함수 밖의 상태를 바꾸지 않는 함수다. 요청 처리 함수 안에서 데이터베이스를 직접 수정하면 테스트마다 저장 상태를 준비해야 한다. 서버까지 실행해야 한다면 테스트는 더 번거로워진다.
이번에는 요청 처리 함수에 기존 예약을 인자로 전달한다. 함수는 상태 코드, 응답 사전, 처리 후 예약을 반환한다. 성공한 생성 요청은 새 예약을 추가한 튜플을 반환한다. 조회와 실패한 요청은 기존 튜플을 반환한다. 튜플은 만들어진 뒤 항목을 추가하거나 바꿀 수 없는 자료형이다. 예약 한 건도 변경을 막은 데이터 클래스로 표현한다. 데이터 클래스는 항목을 가진 작은 객체를 간단히 정의하는 도구다.
이 구조에서 데이터베이스는 함수 밖에 있다. 실행 어댑터(adapter)는 서로 다른 입출력 방식을 연결하는 부분이다. 여기서는 데이터베이스에서 예약을 읽어 순수 함수에 전달하고, 결과가 바뀌었을 때 저장하는 call_api가 어댑터다. 서버 핸들러는 HTTP 본문을 읽고 call_api를 호출한 다음, 결과를 HTTP 응답으로 쓴다.
기본 실행과 서버 실행은 같은 call_api를 사용한다. 따라서 기본 실행에서도 저장까지 이어지는 흐름을 확인할 수 있다. 다만 요청 처리 함수의 테스트는 메모리 데이터만으로 수행한다. 규칙 검증과 저장 연결 검증을 나누면 실패한 위치를 찾기 쉽다.
데이터베이스는 메모리에 만들고 프로그램이 끝나면 사라진다. 서버 실행도 같은 방식이므로 서버를 다시 시작하면 예약은 비어 있다. 이번 장의 목적은 영속 저장 설정이 아니라 요청 처리의 연결을 확인하는 데 있다. sqlite3를 사용하므로 앞 장의 “입력을 받아 저장한다”는 흐름도 한 파일 안에서 이어진다.
http.server 핸들러가 맡는 일
http.server의 BaseHTTPRequestHandler를 상속한 클래스에 do_GET과 do_POST를 정의하면 메서드에 맞는 함수가 호출된다. 상속은 기존 클래스의 기능을 바탕으로 필요한 동작을 추가하는 방식이다. 요청 본문은 rfile에서 읽고, 응답 본문은 wfile에 바이트로 쓴다.
POST 본문을 읽을 때는 Content-Length를 확인한다. 이 예제는 길이가 명시된 본문만 받으며, 다른 방식으로 전송된 본문은 지원하지 않는다. 길이 값이 없으면 411, 숫자가 아니면 400, 정한 크기 제한을 넘으면 413을 반환한다. 작은 JSON 요청만 받도록 본문 크기는 4096바이트로 제한한다.
응답을 쓰는 순서는 상태 코드, 헤더, 헤더 종료, 본문이다. JSON 문자열을 먼저 UTF-8 바이트로 바꾸고 그 길이를 계산한다. 한글은 한 글자가 여러 바이트이므로 문자열 길이를 그대로 Content-Length에 넣으면 맞지 않는다.
이 서버는 로컬에서 요청과 응답의 구조를 살펴보기 위한 예제다. 127.0.0.1에만 연결하고, 인증이나 여러 사용자의 동시 처리 기능은 구현하지 않는다. 표준 라이브러리의 동작 사실은 http.server 공식 문서와 json 공식 문서에서 확인할 수 있다. 문서는 사실 확인 근거로 사용하고, 아래 프로그램은 이 장의 예약 약속에 맞추어 작성한다.
완성 코드
다음 코드를 main.py로 저장한다. Python 3.12 이상에서 외부 패키지 없이 실행한다. 날짜는 고정값을 사용하며, 예약 목록은 번호순으로 읽는다. 기본 실행은 네트워크를 사용하지 않고 테스트와 예시 요청을 처리한 뒤 종료한다.
import json
import re
import sqlite3
import sys
from dataclasses import asdict, dataclass
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
ROOMS = ("A", "B")
MAX_BODY = 4096
ALLOWED = {
"/rooms": ("GET",),
"/reservations": ("GET", "POST"),
}
@dataclass(frozen=True)
class Reservation:
id: int
room: str
start: str
end: str
def parse_time(value):
if not isinstance(value, str):
raise ValueError("time must be text")
if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}",
value) is None:
raise ValueError("invalid time format")
return datetime.strptime(value, "%Y-%m-%dT%H:%M")
def handle_request(method, path, body, reservations):
def fail(status, error):
return status, {"error": error}, reservations
if path not in ALLOWED:
return fail(404, "not_found")
if method not in ALLOWED[path]:
return fail(405, "method_not_allowed")
if method == "GET":
if path == "/rooms":
return 200, {"rooms": list(ROOMS)}, reservations
rows = [asdict(item) for item in reservations]
return 200, {"reservations": rows}, reservations
if len(body) > MAX_BODY:
return fail(413, "body_too_large")
try:
data = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return fail(400, "invalid_json")
if not isinstance(data, dict):
return fail(400, "invalid_fields")
if set(data) != {"room", "start", "end"}:
return fail(400, "invalid_fields")
if data["room"] not in ROOMS:
return fail(400, "invalid_room")
try:
start = parse_time(data["start"])
end = parse_time(data["end"])
except ValueError:
return fail(400, "invalid_time")
if start >= end:
return fail(400, "invalid_interval")
for item in reservations:
overlaps = start < parse_time(item.end) and parse_time(item.start) < end
if item.room == data["room"] and overlaps:
return fail(409, "reservation_conflict")
next_id = max((item.id for item in reservations), default=0) + 1
created = Reservation(next_id, data["room"], data["start"], data["end"])
return 201, {"reservation": asdict(created)}, reservations + (created,)
def open_database():
conn = sqlite3.connect(":memory:")
conn.execute(
"CREATE TABLE reservations ("
"id INTEGER PRIMARY KEY, room TEXT NOT NULL, "
"start TEXT NOT NULL, end TEXT NOT NULL)"
)
return conn
def load_reservations(conn):
rows = conn.execute(
"SELECT id, room, start, end FROM reservations ORDER BY id"
).fetchall()
return tuple(Reservation(*row) for row in rows)
def call_api(conn, method, path, body=b""):
before = load_reservations(conn)
status, payload, after = handle_request(method, path, body, before)
if after != before:
added = after[len(before):]
with conn:
conn.executemany(
"INSERT INTO reservations (id, room, start, end) "
"VALUES (?, ?, ?, ?)",
[(item.id, item.room, item.start, item.end) for item in added],
)
return status, payload
def encode_json(value):
return json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def make_handler(conn):
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, payload):
output = encode_json(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(output)))
if status == 405:
self.send_header("Allow", ", ".join(ALLOWED[self.path]))
self.end_headers()
self.wfile.write(output)
def do_GET(self):
status, payload = call_api(conn, "GET", self.path)
self.send_json(status, payload)
def do_POST(self):
raw_length = self.headers.get("Content-Length")
if raw_length is None:
self.send_json(411, {"error": "length_required"})
return
if re.fullmatch(r"[0-9]+", raw_length) is None:
self.send_json(400, {"error": "invalid_length"})
return
length = int(raw_length)
if length > MAX_BODY:
self.send_json(413, {"error": "body_too_large"})
return
body = self.rfile.read(length)
if len(body) != length:
self.send_json(400, {"error": "incomplete_body"})
return
status, payload = call_api(conn, "POST", self.path, body)
self.send_json(status, payload)
return Handler
def booking(start, end, room="A"):
return encode_json({
"room": room,
"start": "2030-04-15T" + start,
"end": "2030-04-15T" + end,
})
def run_tests():
original = ()
status, _, first = handle_request(
"POST", "/reservations", booking("10:00", "11:00"), original
)
assert status == 201 and original == () and len(first) == 1
status, _, unchanged = handle_request(
"POST", "/reservations", booking("10:30", "11:30"), first
)
assert status == 409 and unchanged == first
status, _, adjacent = handle_request(
"POST", "/reservations", booking("11:00", "12:00"), first
)
assert status == 201 and len(adjacent) == 2
status, _, other_room = handle_request(
"POST", "/reservations", booking("10:00", "11:00", "B"), first
)
assert status == 201 and len(other_room) == 2
for body in (b"{", b"[]", booking("11:00", "10:00")):
status, _, unchanged = handle_request(
"POST", "/reservations", body, first
)
assert status == 400 and unchanged == first
def run_demo(conn):
requests = (
("GET", "/rooms", b""),
("POST", "/reservations", booking("10:00", "11:00")),
("POST", "/reservations", booking("10:30", "11:30")),
("POST", "/reservations", booking("11:00", "12:00")),
("POST", "/reservations", b"{"),
("POST", "/reservations", b"[]"),
("POST", "/reservations", booking("12:00", "11:00")),
("GET", "/missing", b""),
("POST", "/rooms", b""),
("GET", "/reservations", b""),
)
for method, path, body in requests:
status, payload = call_api(conn, method, path, body)
text = encode_json(payload).decode("utf-8")
print(f"{method} {path} -> {status} {text}")
def main():
if sys.argv[1:] not in ([], ["--serve"]):
raise SystemExit("사용법: python3 main.py [--serve]")
conn = open_database()
try:
if sys.argv[1:] == ["--serve"]:
with HTTPServer(("127.0.0.1", 8000), make_handler(conn)) as server:
print("서버 주소: http://127.0.0.1:8000")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
else:
run_tests()
print("테스트 통과")
run_demo(conn)
finally:
conn.close()
if __name__ == "__main__":
main()
줄별 해설
처음의 import 문은 프로그램에서 사용할 표준 라이브러리를 불러온다. ROOMS는 예약 가능한 공간이고, ALLOWED는 경로마다 허용하는 메서드다. 이 두 값을 코드 앞에 모아 두면 요청의 약속을 찾기 쉽다. MAX_BODY는 HTTP 핸들러와 직접 호출 양쪽에서 같은 제한을 사용하게 한다.
Reservation의 frozen=True는 생성한 예약의 항목을 다시 대입하지 못하게 한다. asdict는 예약 객체를 JSON으로 내보내기 쉬운 사전으로 바꾼다. 기존 예약 객체를 응답에 그대로 노출하지 않고 새 사전을 만든다는 점도 확인한다.
parse_time은 먼저 문자열인지 확인한다. 다음으로 정규 표현식, 즉 문자열의 모양을 검사하는 규칙으로 날짜와 시간의 자릿수를 고정한다. 마지막으로 strptime이 실제 날짜와 시각을 확인한다. 두 검사를 함께 사용하므로 자릿수가 다른 입력과 존재하지 않는 날짜를 모두 거절한다.
handle_request 안의 fail은 실패 응답을 만드는 작은 함수다. 모든 실패에서 기존 reservations를 그대로 반환한다. 경로와 메서드를 먼저 확인하므로 알 수 없는 경로의 본문을 해석할 필요가 없다. 조회 요청도 JSON 본문을 읽지 않고 바로 결과를 만든다.
POST에서는 바이트를 UTF-8로 해석하고 json.loads로 읽는다. 여기서 잡는 예외는 문자 해석 실패와 JSON 해석 실패 두 종류다. 이후 입력 항목, 공간, 시각, 시간 순서를 차례로 검사한다. 서로 다른 문제를 다른 오류 식별자로 돌려주므로 화면이나 테스트가 원인을 구분할 수 있다.
overlaps의 두 비교는 두 시간 구간이 겹치는 조건이다. 새 시작이 기존 종료보다 빠르고, 기존 시작이 새 종료보다 빠르면 겹친다. 비교에 등호를 넣지 않았으므로 10시부터 11시까지의 예약 다음에 11시부터 12시까지의 예약을 넣을 수 있다. 공간까지 같을 때만 충돌로 처리한다.
next_id는 기존 번호의 최댓값에 1을 더한다. 기존 예약이 없으면 default=0을 사용한다. 마지막 반환문의 reservations + (created,)는 새 튜플을 만든다. 괄호 안의 쉼표는 항목 하나인 튜플을 만드는 데 필요하다. 기존 튜플은 바뀌지 않는다.
open_database와 load_reservations는 저장을 담당한다. SELECT에 ORDER BY id를 넣어 읽는 순서를 고정한다. call_api는 저장된 예약을 읽고 순수 함수를 호출한다. 결과가 달라졌을 때 추가된 예약만 INSERT한다. SQL의 물음표는 값을 별도로 전달하는 자리이며, 입력 문자열을 SQL 문장에 직접 이어 붙이지 않는다.
call_api의 저장 방식은 이번 API가 예약 추가만 지원한다는 약속에 맞춰져 있다. after의 앞부분은 기존 예약이고 뒷부분만 새 예약이다. 나중에 삭제나 수정을 추가한다면 이 저장 방식도 함께 바꿔야 한다. 순수 함수만 바꾸고 저장 연결을 그대로 두면 결과와 데이터베이스가 달라질 수 있다.
make_handler는 데이터베이스 연결을 사용할 핸들러 클래스를 만든다. send_json은 JSON 인코딩과 응답 헤더 작성을 한곳에 모은다. do_POST는 전송 길이를 검사한 뒤 정확히 그만큼 읽는다. URL의 쿼리 문자열은 이번 약속에 포함하지 않았으므로 /rooms?name=A는 /rooms와 다른 경로로 처리된다.
run_tests는 서버와 데이터베이스 없이 요청 처리 함수를 검사한다. 충돌, 맞닿은 시간, 다른 공간, 잘못된 본문을 확인한다. assert는 조건이 거짓일 때 실행을 중단하는 검사 문장이다. 검사를 생략하는 실행 옵션은 사용하지 않고 python3 main.py로 실행한다.
run_demo는 메모리 데이터베이스를 사용해 요청을 순서대로 호출한다. main은 --serve가 있을 때만 서버를 생성한다. finally의 conn.close는 기본 실행이나 서버 종료 뒤에 데이터베이스 연결을 닫는다. 파일 맨 아래의 조건은 이 파일을 직접 실행했을 때만 main을 호출하게 한다.
실행 결과
먼저 컴파일 검사를 하고 기본 실행을 확인한다. 첫 명령은 정상일 때 출력이 없다. py_compile은 문법을 확인하지만 예약 규칙이 맞는지는 판단하지 못한다. 둘째 명령의 테스트와 출력 확인이 별도로 필요하다.
python3 -W error -m py_compile main.py
python3 main.py
예상 출력은 다음과 같다. JSON의 키 순서와 공백을 고정했으므로 같은 실행에서는 같은 문자열이 나온다.
테스트 통과
GET /rooms -> 200 {"rooms":["A","B"]}
POST /reservations -> 201 {"reservation":{"end":"2030-04-15T11:00","id":1,"room":"A","start":"2030-04-15T10:00"}}
POST /reservations -> 409 {"error":"reservation_conflict"}
POST /reservations -> 201 {"reservation":{"end":"2030-04-15T12:00","id":2,"room":"A","start":"2030-04-15T11:00"}}
POST /reservations -> 400 {"error":"invalid_json"}
POST /reservations -> 400 {"error":"invalid_fields"}
POST /reservations -> 400 {"error":"invalid_interval"}
GET /missing -> 404 {"error":"not_found"}
POST /rooms -> 405 {"error":"method_not_allowed"}
GET /reservations -> 200 {"reservations":[{"end":"2030-04-15T11:00","id":1,"room":"A","start":"2030-04-15T10:00"},{"end":"2030-04-15T12:00","id":2,"room":"A","start":"2030-04-15T11:00"}]}
衝突した要求のあとでも、次に成功した予約番号は2である。失敗した要求が番号を消費せず、保存もされなかったことが読み取れる。最後の一覧には成功した二件だけが残る。
サーバーの起動分岐を確認する場合だけ、次のコマンドを使う。このコマンドは自動では終了しない。表示後は要求を待ち、Ctrl+Cで終了する。
python3 main.py --serve
서버 주소: http://127.0.0.1:8000
今回の基本実行はHTTP通信を行っていないため、ヘッダーの実際の送受信まで検証したとは扱わない。純粋関数の規則、データベースへの保存、ハンドラーの接続コードは、それぞれ確認できる範囲が異なる。
AI에게 수정을 맡겼다면 변경 전후 차이(diff)도 읽는다. 특히 main의 분기, 충돌 조건의 부등호, 실패 시 반환하는 예약, INSERT를 수행하는 조건을 확인한다. 출력이 맞아도 서버가 기본 실행에서 시작하도록 바뀌었다면 이번 장의 실행 약속을 어긴 것이다.
실무에서 자주 틀리는 것
JSON이면 모두 사전이라고 생각한다
다음 코드는 JSON 해석 자체에는 성공하지만 항목을 문자열 키로 읽는 줄에서 TypeError가 발생한다. 오류가 나는 예를 직접 확인할 수 있도록 예외 이름을 출력한다.
import json
data = json.loads('["A", "10:00"]')
try:
print(data["room"])
except TypeError as error:
print(type(error).__name__)
먼저 사전인지 확인해야 한다. 예약 항목 검사는 그다음이다. 고친 예는 잘못된 최상위 형식을 400 응답으로 분류한다.
import json
data = json.loads('["A", "10:00"]')
if not isinstance(data, dict):
print(400, {"error": "invalid_fields"})
else:
print("객체 형식 확인")
완성 코드에서 형식 검사를 항목 접근보다 먼저 둔 이유다. 빈 객체도 사전이므로 필요한 항목이 있는지는 추가로 확인해야 한다.
끝나는 시각까지 충돌에 포함한다
다음 예는 새 예약이 기존 예약 종료 시각에 시작해도 겹친다고 출력한다. 비교에 등호를 넣은 결과다.
old_start, old_end = 600, 660
new_start, new_end = 660, 720
overlaps = new_start <= old_end and old_start <= new_end
print(overlaps)
분으로 바꾼 이 예에서 660은 11시다. 시작은 포함하고 종료는 포함하지 않는 구간으로 정하면 연속 예약을 허용할 수 있다.
old_start, old_end = 600, 660
new_start, new_end = 660, 720
overlaps = new_start < old_end and old_start < new_end
print(overlaps)
고친 코드는 False를 출력한다. 시간 문자열을 해석하는 방식이 바뀌어도 이 경계 규칙은 회귀 테스트, 즉 기존 동작이 유지되는지 확인하는 테스트로 남겨 둔다.
문자 수를 응답 본문의 길이로 보낸다
다음 코드는 JSON 문자열의 문자 수와 바이트 수가 다르다는 사실을 놓친다. ASCII 문자만 있는 예시에서는 차이를 발견하기 어렵다.
import json
text = json.dumps({"message": "예약 완료"}, ensure_ascii=False)
content_length = len(text)
print(content_length == len(text.encode("utf-8")))
응답으로 쓸 바이트를 먼저 만들고 그 길이를 계산한다. 고친 코드는 True를 출력한다.
import json
text = json.dumps({"message": "예약 완료"}, ensure_ascii=False)
output = text.encode("utf-8")
content_length = len(output)
print(content_length == len(output))
완성 코드의 send_json도 이 순서를 따른다. 본문을 인코딩하는 위치와 길이를 계산하는 위치를 멀리 떨어뜨리면 수정할 때 서로 다른 값을 사용하기 쉽다.
한눈에 보기
| 부분 | 맡는 일 | 확인 방법 |
|---|---|---|
| parse_time | 시간 형식과 날짜 검사 | 잘못된 날짜 직접 입력 |
| handle_request | 경로·입력·충돌 판단 | 서버 없이 함수 호출 |
| call_api | 예약 읽기와 성공 결과 저장 | 여러 요청 뒤 목록 조회 |
| Handler | 본문 읽기와 HTTP 응답 쓰기 | 헤더·인코딩·분기 검토 |
| main | 기본 실행과 서버 실행 선택 | 인자 없는 실행이 종료하는지 확인 |
상태 코드는 처리 결과의 큰 분류이고, 오류 식별자는 구체적인 원인이다. JSON을 읽는 것과 예약 입력을 검증하는 것은 다른 단계다. 서버의 입출력에서 예약 규칙을 떼어 내면 네트워크 없이도 중요한 경계를 반복해서 확인할 수 있다.
연습 문제
- run_tests에 존재하지 않는 날짜를 보내는 검사를 추가하라. 응답 상태는 400, 오류 식별자는 invalid_time이어야 하며 기존 예약은 유지되어야 한다.
- 기존 예약이 10시부터 11시까지일 때, 새 예약이 9시부터 12시까지이면 충돌하는지 설명하라. 이를 확인하는 테스트를 추가하라.
- DELETE /reservations를 handle_request에 직접 전달하면 어떤 결과가 나오는지 예측하라. 이 결과가 실제 서버에 DELETE 요청을 보낸 결과와 같은지도 설명하라.
- GET /reservations 호출 전후의 예약 튜플을 비교하고, 응답 목록의 사전을 수정해도 기존 예약이 바뀌지 않는지 확인하라.
정답과 해설
존재하지 않는 날짜
다음 코드를 main.py와 같은 디렉터리에서 별도 Python 파일로 실행할 수 있다. main을 가져와도 서버와 기본 실행은 시작되지 않는다.
from main import encode_json, handle_request
original = ()
body = encode_json({
"room": "A",
"start": "2030-02-30T10:00",
"end": "2030-02-30T11:00",
})
status, payload, after = handle_request(
"POST", "/reservations", body, original
)
assert status == 400
assert payload == {"error": "invalid_time"}
assert after == original
print("날짜 검사 통과")
날짜의 모양은 정규 표현식을 통과하지만 strptime에서 실패한다. 예약을 만들기 전에 실패하므로 결과 튜플은 비어 있는 상태를 유지한다.
기존 예약 전체를 감싸는 요청
from main import Reservation, booking, handle_request
original = (
Reservation(1, "A", "2030-04-15T10:00", "2030-04-15T11:00"),
)
status, payload, after = handle_request(
"POST", "/reservations", booking("09:00", "12:00"), original
)
assert status == 409
assert payload == {"error": "reservation_conflict"}
assert after == original
print("전체 겹침 검사 통과")
9시는 기존 종료인 11시보다 빠르고, 기존 시작인 10시는 새 종료인 12시보다 빠르다. 두 조건이 모두 참이므로 충돌이다. 시작 시각만 기존 구간 안에 있는지 검사하면 이런 요청을 놓친다.
직접 호출과 HTTP 연결의 차이
from main import handle_request
status, payload, after = handle_request(
"DELETE", "/reservations", b"", ()
)
assert status == 405
assert payload == {"error": "method_not_allowed"}
assert after == ()
print("메서드 검사 통과")
직접 호출은 405를 반환한다. 그러나 완성 핸들러에는 do_DELETE가 없다. 실제 서버에서는 기본 핸들러가 구현되지 않은 메서드로 처리해 501 응답을 만든다. 순수 함수가 지원하지 않는 메서드를 분류하는 것과 서버가 그 메서드를 함수까지 전달하는 것은 별개의 일이다. 실제 DELETE에도 같은 JSON 응답이 필요하다면 do_DELETE를 구현하고 send_json으로 연결해야 한다.
조회 응답과 기존 예약의 분리
from main import Reservation, handle_request
original = (
Reservation(1, "A", "2030-04-15T10:00", "2030-04-15T11:00"),
)
status, payload, after = handle_request(
"GET", "/reservations", b"", original
)
assert status == 200 and after == original
payload["reservations"][0]["room"] = "B"
assert original[0].room == "A"
print("조회 분리 검사 통과")
asdict로 만든 응답 사전은 기존 예약 객체와 별개다. 응답 사전을 바꿔도 입력 예약은 바뀌지 않는다. 이 검사까지 남겨 두면 AI가 응답 작성 코드를 수정했을 때 입력 상태를 건드리는지 확인할 수 있다. 다음 장에서는 이번에 고정한 공간 목록과 예약 결과를 바탕으로 HTML 파일을 만드는 작업을 이어 간다.