잘못된 입력과 오류 - 검증·오류 메시지·로그
이 장에서 배우는 것
앞 장에서 예약 정보를 HTML로 만들어 파일에 내보냈다. 화면이 생기면 사람은 화면에 준비된 입력만 사용할 것처럼 보인다. 그러나 요청을 처리하는 함수에는 빈 문자열, 없는 공간 번호, 끝나는 시간이 더 이른 예약도 들어올 수 있다. 화면에서 입력을 제한해도 저장 직전에 같은 규칙을 확인해야 한다.
이 장에서는 공간 예약 서비스의 요청 처리 함수에 검증을 붙인다. 정상적인 예약과 잘못된 요청 묶음을 같은 함수에 넣고, 사용자에게 돌려줄 응답과 개발자가 확인할 기록을 나누어 출력한다. 웹 서버를 실행하지 않으므로 프로그램은 결과를 출력한 뒤 바로 끝난다.
- 입력의 형식·범위·권한을 어느 위치에서 확인할지 정한다.
- 사용자가 고칠 수 있는 오류 메시지와 개발자용 로그를 구분한다.
- 예상한 예외만 처리하고 실패를 성공으로 숨기지 않는다.
- AI가 만든 코드에서 빠진 검증을 요청 묶음과 코드 변경 비교로 찾는다.
- 고정된 요청과 로그 형식으로 실행 결과를 재현한다.
문제 상황
동아리 회원이 작은 회의실을 예약한다. 화면에는 시작 시각과 종료 시각을 고르는 입력 칸이 있고, 공간 목록에는 예약 가능한 공간만 나타난다. 처음에는 화면이 보내 준 값을 그대로 데이터베이스에 넣어도 문제가 없어 보인다.
어느 날 종료 시각이 시작 시각보다 앞선 예약이 저장된다. 다른 회원은 공간 번호 대신 문자열을 보냈고, 모임에 속하지 않은 사람도 예약 요청을 보냈다. 화면을 거치지 않고 요청 처리 함수를 호출하면 화면의 제한은 적용되지 않는다. 화면의 검사는 입력을 돕는 장치이고, 저장을 허용하는 판단은 요청을 받는 쪽에서 해야 한다.
오류 처리도 함께 흔들린다. 모든 예외를 잡아 “다시 시도해 주세요”만 보여 주면 사용자는 시간을 고쳐야 하는지 알 수 없다. 반대로 데이터베이스 예외의 내용을 그대로 응답에 넣으면 사용자가 필요로 하지 않는 내부 정보가 노출된다. 기록이 없으면 개발자도 어떤 요청이 실패했는지 찾기 어렵다.
이번 실습에서는 예약 생성 요청 하나만 다룬다. 입력은 공간 번호, 시작 시각, 종료 시각이다. 요청자의 회원 정보는 입력 본문과 분리한다. 실제 서비스에서는 로그인 확인을 마친 코드가 요청자 정보를 전달해야 한다. 여기서는 로그인 기능을 만들지 않고, 확인된 정보를 가정한 고정값을 함수 인자로 전달한다.
검증은 저장 전에 순서대로 한다
검증(validation)은 입력이 서비스의 규칙을 만족하는지 확인하는 작업이다. 검증 위치를 정할 때는 “이 값을 누가 보냈는가”와 “이 함수가 무엇을 보장하는가”를 함께 본다. 화면에서만 검사하면 화면을 우회한 입력을 막을 수 없다. 요청 처리 함수에서 검사하고, 저장 함수가 그 결과만 받도록 경로를 정한다.
이번 프로그램은 요청 해석, 형식 확인, 범위 확인, 권한 확인, 충돌 확인, 저장 순서로 진행한다. 앞 단계에서 실패하면 뒤 단계로 넘어가지 않는다. 예를 들어 공간 번호가 문자열이면 해당 공간이 존재하는지 조회하기 전에 형식 오류를 돌려준다. 여러 문제가 섞인 요청은 이 순서에 따라 첫 번째 오류 하나를 응답한다.
| 구분 | 질문 | 이번 장의 규칙 | 실패 응답 |
|---|---|---|---|
| 형식 | 값의 모양이 맞는가 | 필드 세 개, 정수 공간 번호, 정해진 시각 표기 | 400 |
| 범위 | 허용된 값인가 | 존재하는 공간, 미래 시각, 최대 두 시간 | 400 |
| 권한 | 이 사람이 실행해도 되는가 | 공간이 속한 모임의 회원 | 403 |
| 충돌 | 기존 예약과 함께 성립하는가 | 같은 공간의 시간 구간이 겹치지 않음 | 409 |
응답의 숫자는 HTTP에서 사용하는 상태 코드(status code)다. 여기서는 서버를 띄우지 않고 같은 뜻의 숫자를 사전에 담는다. 400은 요청을 고쳐야 하는 경우, 403은 요청자가 작업할 권한이 없는 경우, 409는 기존 예약과 충돌하는 경우에 사용한다. 저장 성공은 201, 내부 저장 실패는 500으로 표현한다.
형식 규칙에는 작은 함정이 있다. Python에서 참과 거짓을 나타내는 bool은 int의 하위 타입이다. 따라서 정수인지 넓게 검사하면 True가 공간 번호처럼 통과할 수 있다. 이번 입력 계약에서는 일반 정수만 허용하므로 type(value) is int로 확인한다. 이런 선택은 모든 코드에 적용할 관례가 아니라, 이 필드가 허용할 값을 좁게 정한 결과다.
시각은 2026-10-08T10:00처럼 분 단위 문자열로 받는다. 먼저 글자의 배치를 확인한 다음 실제 날짜인지 확인한다. 글자 모양이 맞아도 13월이나 32일은 날짜가 아니다. 두 검사를 나누면 “표기 방식이 다르다”와 “존재하지 않는 날짜다”를 각각 설명할 수 있다.
권한은 요청 본문에 적힌 회원 여부를 믿고 판단하지 않는다. 요청자가 member라는 값을 직접 보내게 하면 그 값을 바꿀 수 있다. 완성 코드의 actor는 요청 본문과 별개로 전달된다. 이 분리가 권한 확인의 전제가 된다. actor 자체를 외부 입력에서 그대로 만들면 검증을 나누어 놓아도 권한 확인은 성립하지 않는다.
이번 실행은 요청을 하나씩 처리한다. 여러 요청이 동시에 저장될 때의 예약 충돌 방지는 별도의 작업이다. 여기서 만든 조회 후 저장 흐름만으로 동시 요청까지 보호된다고 해석해서는 안 된다.
사용자 메시지와 개발자 기록을 나눈다
로그(log)는 프로그램이 처리 중에 남기는 기록이다. 사용자 메시지는 다음 행동을 알려 주고, 개발자용 로그는 실패를 찾을 단서를 남긴다. 같은 오류를 다루더라도 두 출력의 목적은 다르다. 사용자는 종료 시각을 바꾸면 되지만, 개발자는 어느 요청이 어떤 규칙에서 거절되었는지 알아야 한다.
오류 응답에는 짧고 고정된 오류 코드와 한국어 메시지를 함께 넣는다. 오류 코드는 프로그램이 구분하기 위한 이름이고, 메시지는 사람이 읽기 위한 설명이다. 메시지 문장이 바뀌어도 오류 코드가 유지되면 호출한 쪽은 같은 오류로 처리할 수 있다.
| 실패 | 사용자에게 전달 | 개발자 기록 |
|---|---|---|
| 시간 순서 오류 | 종료 시각은 시작 시각보다 늦어야 한다. | 요청 번호, TIME_ORDER, 경고 수준 |
| 예약 충돌 | 선택한 시간에 이미 예약이 있다. | 요청 번호, CONFLICT, 경고 수준 |
| 저장 실패 | 예약을 저장하지 못했다. 잠시 후 다시 요청한다. | 요청 번호, STORAGE_ERROR, 예외 종류 |
요청 번호는 이번 실행에서 R01, R02처럼 순서대로 붙인다. 실제 서비스에서는 요청을 받는 쪽에서 관리하는 식별값이 필요하다. 이 번호를 응답과 로그에 함께 넣으면 사용자에게 받은 번호로 해당 기록을 찾을 수 있다. 요청 본문 전체나 회원 정보 전체는 로그에 넣지 않는다.
표준 라이브러리의 logging은 기록 수준과 출력 형식을 관리한다. 이번 프로그램에서는 성공을 INFO, 잘못된 요청을 WARNING, 저장 실패를 ERROR로 남긴다. 출력 형식은 “수준 | 요청 번호 | 사건 | 오류 코드”로 고정한다. 실행마다 달라지는 현재 시각을 로그에서 제외해 결과를 비교하기 쉽게 한다.
개발 중에는 예외가 일어난 호출 경로를 보여 주는 traceback도 도움이 된다. 다만 이 장의 실행에서는 고정된 출력을 만들기 위해 예외 종류만 기록한다. 운영 환경에서 더 자세한 기록이 필요하다면 접근 권한과 정보 노출 범위를 정한 뒤 내부 로그에 추가한다. 상세 예외 문장을 사용자 응답에 복사하는 방식으로 해결하지 않는다.
예외를 숨기지 않고 빠진 검증을 찾는다
예외(exception)는 실행 중 정상 경로를 계속할 수 없을 때 전달되는 신호다. 잘못된 요청은 우리가 예상한 실패다. 반면 변수 이름을 잘못 쓰거나 필요한 사전 항목을 빠뜨린 것은 코드 결함일 수 있다. 두 경우를 모두 잡아서 같은 응답으로 바꾸면 코드 결함을 발견할 기회를 잃는다.
완성 코드에서는 입력 규칙 위반을 RequestError로 표현한다. 이 예외에는 상태 코드, 오류 코드, 메시지가 들어 있다. 요청 처리 함수는 이 예외를 오류 응답으로 바꾼다. 데이터베이스 작업에서는 sqlite3.Error만 따로 처리한다. 그 밖의 예외는 잡지 않으므로 실행 중 드러난다.
예외를 처리한다고 해서 모두 삼키는 것은 아니다. 실패를 응답으로 바꾸더라도 실패 상태와 기록을 보존하면 호출한 쪽이 결과를 알 수 있다. 문제가 되는 처리는 예외를 잡고 아무 일도 없었던 것처럼 성공을 반환하거나, 기록도 남기지 않고 계속 진행하는 방식이다.
AI에게도 검증 항목과 확인 방법을 함께 요청한다. “입력을 안전하게 처리해 달라”는 문장만으로는 어느 경계를 검사해야 하는지 판단하기 어렵다. 다음처럼 구체적인 입력 계약을 전달한다.
공간 예약 생성 함수에 검증을 추가한다. 본문은 room_id, start, end만 받는다. room_id는 bool을 제외한 정수다. 시각은 분 단위 표기를 사용하며, 미래 예약과 최대 두 시간 규칙을 검사한다. 회원 정보는 본문에서 받지 않는다. 사용자 메시지와 logging 기록을 분리한다. 빠진 검사와 변경 이유를 설명하고, 잘못된 요청 묶음으로 확인한다.
AI의 답변이 “검증을 추가했다”라고 설명해도 코드와 일치하는지 확인한다. 먼저 값이 데이터베이스에 도달하는 경로를 읽는다. 다음으로 True, 존재하지 않는 공간, 비회원, 역전된 시간처럼 정상 입력과 다른 요청을 실행한다. 마지막으로 diff, 즉 수정 전후 코드의 차이를 읽어 기존 충돌 검사나 저장 처리가 사라지지 않았는지 확인한다.
잘못된 요청이 예상대로 거절되는지만 보면 부족하다. 정상 요청도 한 번 저장하고, 거절된 요청이 추가 행을 만들지 않았는지 확인해야 한다. 완성 코드는 초기 예약 한 건과 정상 요청 한 건을 합쳐 두 건이 남는지 검사한다. 응답의 오류 코드도 assert로 비교한다. assert는 조건이 거짓일 때 실행을 중단하는 확인문이며, 이 장에서는 실행 결과를 점검하는 데만 사용한다. 입력 검증 자체는 if와 예외로 작성한다.
완성 코드
다음 내용을 main.py로 저장한다. 메모리 데이터베이스는 실행할 때 만들고 끝날 때 사라진다. 기준 시각은 2026년 10월 8일 오전 9시로 고정하며, 모든 시각은 같은 지역의 시각으로 취급한다. 시간대 변환은 이 입력 계약에 포함하지 않는다.
import json
import logging
import re
import sqlite3
import sys
from datetime import datetime, timedelta
NOW = datetime(2026, 10, 8, 9, 0)
TIME_PATTERN = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}")
REQUIRED_FIELDS = {"room_id", "start", "end"}
LOGGER = logging.getLogger("reservation")
LOGGER.setLevel(logging.INFO)
LOGGER.propagate = False
LOGGER.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
LOGGER.addHandler(handler)
class RequestError(Exception):
def __init__(self, status, code, message):
super().__init__(message)
self.status = status
self.code = code
self.message = message
def reject(status, code, message):
raise RequestError(status, code, message)
def parse_time(value, field):
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
reject(400, "TIME_FORMAT", f"{field}은 YYYY-MM-DDTHH:MM 형식이어야 한다.")
try:
return datetime.fromisoformat(value)
except ValueError as exc:
raise RequestError(
400, "TIME_VALUE", f"{field}에 존재하는 날짜와 시각을 입력한다."
) from exc
def read_request(raw):
try:
body = json.loads(raw)
except json.JSONDecodeError as exc:
raise RequestError(
400, "BAD_JSON", "요청 본문은 올바른 JSON이어야 한다."
) from exc
if not isinstance(body, dict):
reject(400, "BODY_OBJECT", "요청 본문은 객체여야 한다.")
if set(body) != REQUIRED_FIELDS:
reject(400, "FIELDS", "room_id, start, end만 모두 입력한다.")
if type(body["room_id"]) is not int:
reject(400, "ROOM_TYPE", "공간 번호는 정수여야 한다.")
start = parse_time(body["start"], "시작 시각")
end = parse_time(body["end"], "종료 시각")
if end <= start:
reject(400, "TIME_ORDER", "종료 시각은 시작 시각보다 늦어야 한다.")
if start < NOW:
reject(400, "PAST_TIME", "시작 시각은 기준 시각 이후여야 한다.")
if end - start > timedelta(hours=2):
reject(400, "TOO_LONG", "예약 시간은 두 시간을 넘을 수 없다.")
return body["room_id"], start, end
def create_booking(db, raw, actor):
room_id, start, end = read_request(raw)
room = db.execute(
"SELECT group_id FROM rooms WHERE id = ?", (room_id,)
).fetchone()
if room is None:
reject(400, "ROOM_UNKNOWN", "존재하는 공간 번호를 입력한다.")
if room[0] not in actor["group_ids"]:
reject(403, "FORBIDDEN", "이 공간은 소속 모임의 회원만 예약할 수 있다.")
start_text = start.isoformat(timespec="minutes")
end_text = end.isoformat(timespec="minutes")
conflict = db.execute(
"""
SELECT id FROM bookings
WHERE room_id = ? AND start < ? AND end > ?
LIMIT 1
""",
(room_id, end_text, start_text),
).fetchone()
if conflict is not None:
reject(409, "CONFLICT", "선택한 시간에 이미 예약이 있다.")
with db:
cursor = db.execute(
"""
INSERT INTO bookings (room_id, user_id, start, end)
VALUES (?, ?, ?, ?)
""",
(room_id, actor["user_id"], start_text, end_text),
)
return cursor.lastrowid
def handle_request(db, request_id, raw, actor):
try:
booking_id = create_booking(db, raw, actor)
except RequestError as exc:
LOGGER.warning("%s | rejected | %s", request_id, exc.code)
return {
"request_id": request_id,
"status": exc.status,
"code": exc.code,
"message": exc.message,
}
except sqlite3.Error as exc:
LOGGER.error(
"%s | failed | STORAGE_ERROR | %s",
request_id,
type(exc).__name__,
)
return {
"request_id": request_id,
"status": 500,
"code": "STORAGE_ERROR",
"message": "예약을 저장하지 못했다. 잠시 후 다시 요청한다.",
}
LOGGER.info("%s | created | CREATED", request_id)
return {
"request_id": request_id,
"status": 201,
"code": "CREATED",
"booking_id": booking_id,
}
def make_database():
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
db.executescript(
"""
CREATE TABLE rooms (
id INTEGER PRIMARY KEY,
group_id INTEGER NOT NULL
);
CREATE TABLE bookings (
id INTEGER PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
user_id INTEGER NOT NULL,
start TEXT NOT NULL,
end TEXT NOT NULL,
CHECK (start < end)
);
"""
)
with db:
db.execute("INSERT INTO rooms VALUES (1, 10)")
db.execute(
"""
INSERT INTO bookings (room_id, user_id, start, end)
VALUES (?, ?, ?, ?)
""",
(1, 7, "2026-10-08T13:00", "2026-10-08T14:00"),
)
return db
def payload(room_id=1, start="2026-10-08T10:00", end="2026-10-08T11:00"):
return json.dumps({"room_id": room_id, "start": start, "end": end})
def show(response):
print("응답 " + json.dumps(response, ensure_ascii=False))
def main():
db = make_database()
member = {"user_id": 8, "group_ids": {10}}
outsider = {"user_id": 9, "group_ids": set()}
cases = [
("R01", payload(), member, 201, "CREATED"),
("R02", "{", member, 400, "BAD_JSON"),
("R03", "[]", member, 400, "BODY_OBJECT"),
("R04", '{"room_id": 1}', member, 400, "FIELDS"),
("R05", payload(room_id=True), member, 400, "ROOM_TYPE"),
("R06", payload(start="2026/10/08 10:00"), member, 400, "TIME_FORMAT"),
("R07", payload(start="2026-13-08T10:00"), member, 400, "TIME_VALUE"),
("R08", payload(end="2026-10-08T09:30"), member, 400, "TIME_ORDER"),
("R09", payload(start="2026-10-08T08:00"), member, 400, "PAST_TIME"),
("R10", payload(end="2026-10-08T12:30"), member, 400, "TOO_LONG"),
("R11", payload(room_id=99), member, 400, "ROOM_UNKNOWN"),
("R12", payload(), outsider, 403, "FORBIDDEN"),
(
"R13",
payload(start="2026-10-08T13:30", end="2026-10-08T14:30"),
member,
409,
"CONFLICT",
),
]
for request_id, raw, actor, expected_status, expected_code in cases:
response = handle_request(db, request_id, raw, actor)
assert (response["status"], response["code"]) == (
expected_status, expected_code
)
show(response)
count = db.execute("SELECT COUNT(*) FROM bookings").fetchone()[0]
assert count == 2
print(f"保存件数ではなく予約件数: {count}" if False else f"予約件数: {count}")
db.close()
response = handle_request(db, "R14", payload(), member)
assert (response["status"], response["code"]) == (500, "STORAGE_ERROR")
show(response)
print("검증 완료: 응답 14개, 저장된 예약 2개")
if __name__ == "__main__":
main()
줄별 해설
가져오기와 상수. json은 요청 문자열을 Python 값으로 바꾸고, re는 문자열의 모양을 검사한다. sqlite3는 저장을 담당하고, logging은 처리 기록을 남긴다. NOW를 고정했으므로 같은 입력이 실행 날짜에 따라 통과하거나 실패하지 않는다. 실제 서비스에서는 현재 시각을 어디서 얻을지 따로 정해야 한다.
로그 설정. 이름이 reservation인 기록기를 만들고 출력 대상을 표준 출력으로 정한다. 표준 출력은 print가 사용하는 출력 통로다. 로그도 같은 통로에 보내므로 각 요청의 로그가 응답 바로 앞에 나타난다. 상위 기록기로 전달하지 않도록 설정하고 기존 처리기를 비워, 이 프로그램의 설정으로 같은 기록이 중복 출력되는 일을 막는다.
RequestError와 reject. 입력 오류에 필요한 세 값을 하나의 예외에 담는다. reject는 이 예외를 발생시키는 짧은 보조 함수다. 검사에 실패했을 때 바로 실행 경로를 끝내므로, 거절한 요청이 아래의 저장 코드까지 흘러가지 않는다.
parse_time. fullmatch는 문자열 전체가 정해진 모양인지 확인한다. 그다음 날짜 변환에서 ValueError가 발생하면 TIME_VALUE로 바꾼다. from exc는 원래 예외와 새 예외의 관계를 남긴다. 사용자는 정리된 메시지를 받고, 개발 중 예외를 살펴볼 때는 변환 실패의 원인도 추적할 수 있다.
read_request. JSON은 이름과 값을 담을 수 있는 데이터 표기다. 올바른 JSON이라도 목록이나 숫자일 수 있으므로 사전으로 해석되었는지 확인한다. set(body)는 사전의 키 집합이다. 필요한 키 집합과 같은지 비교하면 누락된 필드와 추가된 필드를 함께 거절할 수 있다.
시간 범위 검사. 종료가 시작보다 늦은지, 시작이 기준 시각보다 이르지 않은지, 길이가 두 시간을 넘지 않는지 차례로 검사한다. 기준 시각과 같은 시작은 허용하며, 정확히 두 시간인 예약도 허용한다. “이후”와 “최대”의 경계를 코드의 비교 연산자로 구체화한 것이다.
공간과 권한 조회. 형식과 시간 범위를 확인한 뒤 공간을 조회한다. 물음표는 SQL에 값을 전달할 자리를 나타낸다. 문자열을 이어 붙이지 않고 값을 별도 인자로 전달한다. 공간이 존재하면 그 공간의 모임 번호가 요청자의 소속 모임 집합에 있는지 확인한다.
충돌 조건. 기존 시작이 새 종료보다 이르고, 기존 종료가 새 시작보다 늦으면 두 예약이 겹친다. 두 조건에 등호를 넣지 않았으므로 기존 예약이 끝나는 순간 시작하는 예약은 허용한다. 저장하는 시각 문자열은 모두 같은 분 단위 형식이므로 이 프로그램에서는 문자열의 비교 순서가 시각 순서와 일치한다.
저장 구간. with db는 삽입이 정상 종료되면 변경을 확정하고, 구간 안에서 예외가 발생하면 변경을 되돌린다. 이 묶음을 트랜잭션(transaction)이라고 한다. 연결을 닫는 일은 별개이며 main에서 db.close로 수행한다. 데이터베이스의 CHECK는 시간 순서에 대한 추가 보호지만, 날짜 형식이나 회원 권한까지 대신 검사하지는 않는다.
handle_request. 예상한 입력 오류와 데이터베이스 오류를 각각 응답으로 바꾼다. 저장 성공 로그는 삽입과 변경 확정이 끝난 다음 남긴다. 따라서 저장이 실패했는데 CREATED 로그가 먼저 찍히는 흐름을 피한다. 일반 Exception을 잡지 않으므로 코드 결함은 성공이나 입력 오류로 둔갑하지 않는다.
main의 확인. R01은 정상 요청이고, R02부터 R13까지는 서로 다른 거절 경로를 확인한다. 초기 예약과 새 예약을 합쳐 두 건만 남는지 검사한다. R14는 연결을 일부러 닫은 뒤 요청을 보내 저장 계층의 실패를 확인한다. 이는 사용자 입력 검사가 아니라 내부 실패 응답을 점검하는 별도의 사례다.
실행 결과
macOS나 Linux의 터미널에서 다음 순서로 실행한다. 첫 명령은 경고를 오류로 취급하며 컴파일을 확인하는 명령이다. 정상적으로 끝나면 별도 출력이 없다. 두 번째 명령은 요청 묶음을 실행한다.
python3 -W error -m py_compile main.py
python3 main.py
예상 출력은 다음과 같다. 각 로그 다음에 같은 요청 번호의 응답이 나온다. 내부 실패도 성공으로 바뀌지 않고 500 응답으로 남는다.
INFO | R01 | created | CREATED
응답 {"request_id": "R01", "status": 201, "code": "CREATED", "booking_id": 2}
WARNING | R02 | rejected | BAD_JSON
응답 {"request_id": "R02", "status": 400, "code": "BAD_JSON", "message": "요청 본문은 올바른 JSON이어야 한다."}
WARNING | R03 | rejected | BODY_OBJECT
응답 {"request_id": "R03", "status": 400, "code": "BODY_OBJECT", "message": "요청 본문은 객체여야 한다."}
WARNING | R04 | rejected | FIELDS
응답 {"request_id": "R04", "status": 400, "code": "FIELDS", "message": "room_id, start, end만 모두 입력한다."}
WARNING | R05 | rejected | ROOM_TYPE
응답 {"request_id": "R05", "status": 400, "code": "ROOM_TYPE", "message": "공간 번호는 정수여야 한다."}
WARNING | R06 | rejected | TIME_FORMAT
응답 {"request_id": "R06", "status": 400, "code": "TIME_FORMAT", "message": "시작 시각은 YYYY-MM-DDTHH:MM 형식이어야 한다."}
WARNING | R07 | rejected | TIME_VALUE
응답 {"request_id": "R07", "status": 400, "code": "TIME_VALUE", "message": "시작 시각에 존재하는 날짜와 시각을 입력한다."}
WARNING | R08 | rejected | TIME_ORDER
응답 {"request_id": "R08", "status": 400, "code": "TIME_ORDER", "message": "종료 시각은 시작 시각보다 늦어야 한다."}
WARNING | R09 | rejected | PAST_TIME
응답 {"request_id": "R09", "status": 400, "code": "PAST_TIME", "message": "시작 시각은 기준 시각 이후여야 한다."}
WARNING | R10 | rejected | TOO_LONG
응답 {"request_id": "R10", "status": 400, "code": "TOO_LONG", "message": "예약 시간은 두 시간을 넘을 수 없다."}
WARNING | R11 | rejected | ROOM_UNKNOWN
응답 {"request_id": "R11", "status": 400, "code": "ROOM_UNKNOWN", "message": "존재하는 공간 번호를 입력한다."}
WARNING | R12 | rejected | FORBIDDEN
응답 {"request_id": "R12", "status": 403, "code": "FORBIDDEN", "message": "이 공간은 소속 모임의 회원만 예약할 수 있다."}
WARNING | R13 | rejected | CONFLICT
응답 {"request_id": "R13", "status": 409, "code": "CONFLICT", "message": "선택한 시간에 이미 예약이 있다."}
予約件数: 2
ERROR | R14 | failed | STORAGE_ERROR | ProgrammingError
응답 {"request_id": "R14", "status": 500, "code": "STORAGE_ERROR", "message": "예약을 저장하지 못했다. 잠시 후 다시 요청한다."}
검증 완료: 응답 14개, 저장된 예약 2개
실행 결과를 확인할 때는 메시지가 출력되는 것만 보지 않는다. 응답의 상태와 오류 코드, 로그의 수준, 저장된 예약 수를 함께 비교한다. 코드가 설명과 다른 결과를 내면 설명에 맞추어 결과를 꾸미지 말고, 입력 규칙과 구현 중 어느 쪽을 고쳐야 하는지 판단한다.
실무에서 자주 틀리는 것
참과 거짓을 공간 번호로 허용한다
다음 함수는 정수 범주에 포함되는 True까지 허용한다. 함수 정의 자체는 실행할 수 있지만, 이번 공간 번호 계약에는 맞지 않는다.
def valid_room_id(value):
return isinstance(value, int)
일반 정수만 받도록 고친다. 문자열 "1"을 자동으로 1로 바꾸는 처리도 넣지 않는다. 변환을 허용하려면 입력 계약부터 명시해야 한다.
def valid_room_id(value):
return type(value) is int
예외를 잡고 성공을 반환한다
다음 함수는 작업이 실패해도 호출한 쪽에 성공을 알린다. 로그도 남지 않아 저장 여부를 판단하기 어렵다.
def run_action(action):
try:
action()
except Exception:
pass
return {"status": 201}
예상한 저장 오류만 기록하고 다시 발생시키도록 고친다. 이 보조 함수의 호출자는 예외를 적절한 실패 응답으로 바꿀 수 있다. 예상하지 못한 예외도 그대로 드러난다.
import logging
import sqlite3
def run_action(action):
try:
action()
except sqlite3.Error:
logging.getLogger("reservation").exception("저장 작업 실패")
raise
return {"status": 201}
exception은 호출 경로를 포함한 기록을 남긴다. 이 예시는 내부 진단용 기록을 보여 주며, 완성 코드의 고정 출력 방식과는 다르다. 같은 실패를 여러 계층에서 반복 기록하면 원인 하나가 여러 사건처럼 보일 수 있으므로, 자세한 기록을 남길 위치도 정한다.
본문의 회원 표시를 권한으로 믿는다
요청자가 직접 바꿀 수 있는 필드로 권한을 판단하면 검사문이 있어도 보호가 되지 않는다.
def can_book(body, group_id):
return body.get("is_member") is True
로그인 확인을 마친 쪽에서 전달한 요청자 정보와 공간의 모임 번호를 비교한다. 함수 이름을 바꾸는 것으로 해결되는 문제가 아니라, 정보가 들어오는 경로를 바꾸어야 한다.
def can_book(actor, group_id):
return group_id in actor["group_ids"]
예외 내용을 사용자에게 그대로 보여 준다
다음 함수는 내부 예외 문장을 그대로 응답한다. 사용자가 어떤 값을 고쳐야 하는지 알려 주지 못하고, 저장 방식에 관한 세부 정보가 드러날 수 있다.
def storage_response(exc):
return {"status": 500, "message": str(exc)}
내부 기록에는 요청 번호와 예외 종류를 남기고, 사용자에게는 실패와 다음 행동을 알려 준다. 예외 종류를 기록했다고 원인 분석이 끝난 것은 아니다. 필요한 상세 진단은 내부 기록 정책에 따라 추가한다.
import logging
def storage_response(request_id, exc):
logging.getLogger("reservation").error(
"%s | STORAGE_ERROR | %s", request_id, type(exc).__name__
)
return {
"request_id": request_id,
"status": 500,
"code": "STORAGE_ERROR",
"message": "예약을 저장하지 못했다. 잠시 후 다시 요청한다.",
}
한눈에 보기
| 지점 | 할 일 | 확인 방법 |
|---|---|---|
| 요청 해석 | JSON과 본문 종류 확인 | 깨진 JSON과 목록 전달 |
| 형식 | 필드 집합과 타입 확인 | 누락, 추가 필드, True 전달 |
| 범위 | 시각 순서와 길이 확인 | 같은 시각, 과거, 두 시간 경계 전달 |
| 권한 | 확인된 요청자 정보 사용 | 비회원과 본문 위조 요청 전달 |
| 충돌 | 기존 예약과 겹침 확인 | 겹치는 구간과 맞닿는 구간 비교 |
| 오류 응답 | 상태·코드·수정 안내 전달 | 예상 응답과 실제 응답 비교 |
| 로그 | 요청 번호와 실패 종류 기록 | 응답 번호와 로그 번호 연결 |
| AI 코드 검토 | 실행·검사·변경 비교 수행 | 정상 저장과 거절 후 행 수 확인 |
검증 목록은 코드 옆에서 따로 유지하는 메모로 끝나지 않는다. 실제로 거절되는 입력과 통과하는 경계값으로 옮겨야 한다. 이 장의 요청 묶음은 그 출발점이다. 다음 장에서는 기능 추가 요청으로 규칙이 바뀔 때 이 확인 사례를 어떻게 유지할지 다룬다.
연습 문제
- 시작과 종료가 모두 2026-10-08T10:00인 요청을 추가한다. 예상 상태, 오류 코드, 로그 수준을 먼저 적고 실행 결과와 비교한다.
- 별도의 새 메모리 데이터베이스를 사용해 14:00부터 16:00까지의 예약과 14:00부터 16:01까지의 예약을 확인한다. 기존 예약은 13:00부터 14:00까지다. 두 요청의 차이를 설명한다.
- 정상 본문에 is_member라는 필드를 추가하고 비회원 정보로 요청한다. 어떤 검증이 먼저 실패하는지 확인한다. 추가 필드를 무시하도록 바꾸었을 때도 권한 판단이 유지되어야 하는 이유를 설명한다.
- AI가 수정한 함수가 모든 Exception을 잡아 400 응답으로 바꾼다. 어떤 코드 결함을 숨길 수 있는지 예를 들고, 이 장의 예외 처리 범위를 기준으로 수정 방향을 적는다.
정답과 해설
- 400과 TIME_ORDER가 나오고 WARNING 로그가 남는다. 종료 시각은 시작 시각보다 늦어야 하므로 같은 시각은 허용하지 않는다. 저장된 예약 수는 늘어나지 않아야 한다. 결과 메시지만 확인하지 말고 행 수도 함께 확인한다.
- 14:00부터 16:00까지는 201과 CREATED가 나온다. 기존 예약의 종료와 새 시작이 같아 충돌하지 않으며, 길이도 정확히 두 시간이다. 16:01까지의 요청은 400과 TOO_LONG이 나온다. 충돌 검사보다 시간 길이 검사가 먼저 실행된다. 두 사례를 독립적으로 확인하려면 데이터베이스를 각각 새로 만들고 사용 후 닫는다.
- 필드 집합이 다르므로 400과 FIELDS가 먼저 나온다. 권한 검사에는 도달하지 않는다. 추가 필드를 무시하는 계약으로 바꾸더라도 is_member를 권한 근거로 사용해서는 안 된다. 권한은 별도로 전달된 actor의 모임 정보로 판단해야 하며, 그 경우 비회원 요청은 403과 FORBIDDEN이 된다.
- 예를 들어 코드가 actor["user_id"]를 actor["usr_id"]로 잘못 적으면 KeyError가 발생한다. 이를 400으로 바꾸면 요청자의 잘못처럼 보인다. RequestError는 정리된 입력 오류 응답으로, sqlite3.Error는 기록을 남긴 저장 실패 응답으로 처리한다. 그 밖의 예외는 드러나게 두고 코드 결함을 수정한다. 수정 후에는 정상 요청과 잘못된 요청 묶음을 다시 실행하고 diff로 의도한 처리만 달라졌는지 확인한다.