백엔드 질문 - API 설계·인증·예외·테스트
이 장에서 배우는 것
앞 장에서 데이터를 안전하게 저장하고 조회하는 방법을 살펴보았다. 이번에는 저장된 데이터를 누가, 어떤 요청으로, 어떤 응답을 받아 사용할 수 있는지 설명한다. 중고 거래 서비스의 상품 등록 기능을 구현했다는 말만으로는 설계를 평가하기 어렵다. 잘못된 가격을 받았을 때의 응답, 다른 판매자의 상품을 삭제하려는 요청, 수정 후에도 동작을 확인할 테스트까지 설명해야 구현의 경계가 드러난다.
면접관은 프레임워크의 함수 이름보다 요청이 처리되는 과정을 이해하는지 확인한다. 작은 프로그램으로 입력 검증, 로그인, 권한 확인, 오류 변환을 연결하면 답변에 구체적인 근거가 생긴다. 이 장의 코드는 웹 서버를 띄우지 않고 요청을 함수에 전달하는 실험이다. 통신 설정을 덜어 내고 백엔드의 판단 과정에 집중한다.
- 자원과 행위를 구분하고 상태 코드와 버전 정책을 설명한다.
- 페이지네이션의 일관성 문제와 인증·인가의 차이를 예로 든다.
- 비밀번호 해시와 솔트의 역할을 구분하고 오류 응답의 계약을 정한다.
- 계층별 책임과 단위·통합 테스트의 경계를 설명한다.
- 작은 라우터로 성공 요청과 실패 요청의 응답을 직접 확인한다.
문제 상황
가상의 중고 거래 서비스 팀에서 상품 등록과 삭제 기능을 만들었다. 첫 시연에서는 모든 기능이 동작했다. 그러나 구매자 계정으로 판매자의 상품을 삭제할 수 있었고, 가격에 문자열을 보내면 내부 예외 문장이 그대로 응답에 나타났다. 상품 목록의 두 번째 페이지에서는 첫 페이지에서 보았던 상품이 다시 나왔다. 개발자는 화면이 정상적으로 보이는 요청만 확인했던 것이다.
지원자는 이 경험을 1차 기술 면접에서 설명한다. 면접관은 “어떤 프레임워크를 썼는가”에서 멈추지 않고 “다른 사람의 상품이라는 사실은 어디에서 검사했는가”라고 묻는다. 좋은 답변은 기능 목록을 되풀이하지 않는다. 입력, 사용자 신원, 자원별 권한, 응답 계약을 나누어 문제를 설명한다.
| 질문 | 의도 | 답변 핵심 | 꼬리 질문 |
|---|---|---|---|
| 상품 등록 API를 어떻게 설계하는가? | 자원 중심 설계 이해 | POST /v1/products, 생성 시 201과 위치 정보 | 같은 요청을 재전송하면? |
| API 버전은 언제 나누는가? | 호환성 판단 | 기존 소비자가 깨지는 계약 변경을 구분한다. | 필드를 추가해도 새 버전인가? |
| 목록 페이지를 어떻게 나누는가? | 정렬과 변경의 영향 이해 | 오프셋과 커서의 장단점을 설명한다. | 새 상품이 중간에 추가되면? |
| 인증과 인가는 무엇이 다른가? | 보안 경계 이해 | 신원 확인과 자원 접근 허용을 구분한다. | 로그인했는데 왜 삭제가 거절되는가? |
| 비밀번호를 어떻게 저장하는가? | 유출에 대비한 설계 | 전용 해시 방식과 사용자별 솔트를 사용한다. | 솔트를 숨겨야 하는가? |
| 예외를 모두 400으로 반환해도 되는가? | 실패 원인의 분류 | 예상한 실패와 서버 결함을 구분한다. | 내부 오류 내용은 어디에 남기는가? |
| 컨트롤러와 서비스는 왜 나누는가? | 책임과 변경 경계 이해 | 입출력 변환과 업무 규칙을 분리한다. | 서비스가 상태 코드를 반환해도 되는가? |
| 단위 테스트와 통합 테스트는 어떻게 다른가? | 검증 범위 판단 | 대상 범위와 실제 연결 요소를 명시한다. | 저장소를 대역으로 바꾸면 무엇을 놓치는가? |
| 프레임워크가 입력을 검증하면 충분한가? | 도구와 업무 규칙 구분 | 형식 검증 뒤에도 소유권과 상태 규칙이 남는다. | 검증 코드는 어디에 두는가? |
| 개발 중 예외 화면을 운영에서 보여도 되는가? | 정보 노출과 관측 이해 | 외부 응답은 제한하고 내부 기록으로 추적한다. | 사용자 문의와 로그를 어떻게 연결하는가? |
자원과 응답을 하나의 계약으로 설계한다
REST는 자원을 식별하고 일관된 방식으로 다루는 설계 관점이다. 경로에 명사를 쓴다는 규칙만으로 설명을 끝내면 부족하다. 요청 메서드, 상태 코드, 응답 본문, 인증 요구 사항이 함께 계약을 이룬다. 상품 하나는 /v1/products/3으로 식별하고, 등록은 POST /v1/products로 표현할 수 있다. 특정 경로 모양이 REST의 필수 조건인 것은 아니다.
조회 성공에는 200, 생성 성공에는 201을 사용할 수 있다. 새 자원의 주소는 Location 헤더로 알린다. 삭제 후 전달할 본문이 없으면 204가 적절하다. 204 응답에 성공 메시지 본문을 붙이지 않는다. 입력 형식이나 값이 계약을 어기면 이 예제에서는 400을 사용한다. 의미 검증 실패를 422로 구분하는 정책도 가능하지만, 구분 기준을 문서와 구현에서 일치시켜야 한다.
경로가 없으면 404, 경로는 있지만 해당 메서드를 지원하지 않으면 405로 구분한다. 405에는 허용하는 메서드를 Allow 헤더에 담는다. 예상하지 못한 서버 결함은 500으로 반환한다. 모든 결과를 200으로 보내고 본문에만 실패 여부를 넣으면 호출자와 중간 도구가 상태 코드로 결과를 판단하기 어렵다.
깊이 보기: 상품 등록 API를 어떻게 설계하는가?
| 수준 | 답변 | 면접관이 확인할 수 있는 것 |
|---|---|---|
| 나쁜 답 | 프레임워크에서 POST 함수로 만들면 된다. | 도구 사용 경험만 드러난다. |
| 보통 답 | POST /products로 받고 성공하면 201을 반환한다. | 기본적인 경로와 상태 코드를 안다. |
| 좋은 답 | 자원 생성 계약을 먼저 정하고 입력, 소유자, 생성 결과와 재전송 문제까지 설명한다. | 호출자의 관점과 설계의 한계를 함께 이해한다. |
상품 등록은 POST /v1/products로 받고, 성공하면 201과 새 상품의 위치를 반환하겠다. 호출자가 생성 성공과 이후 조회 위치를 구분할 수 있기 때문이다. 프로젝트에서는 제목과 가격을 검사한 뒤, 소유자는 요청 본문이 아니라 인증된 사용자에서 가져오도록 구성했다. 다만 이 설계만으로 재전송에 의한 중복 등록을 막지는 못한다. 필요하면 요청 식별 키와 처리 결과를 함께 관리하는 정책을 추가하겠다.
면접관이 “POST를 두 번 보내면 같은 결과인가”라고 물으면 같은 데이터를 보냈다는 사실과 같은 요청이라는 사실을 구분한다. POST는 일반적으로 멱등성을 보장하지 않는다. 삭제는 반복해도 자원이 없는 최종 상태가 같을 수 있지만, 첫 응답과 다음 응답의 상태 코드까지 같아야 멱등적인 것은 아니다.
버전은 내부 코드가 바뀔 때마다 올리지 않는다. 필수 입력을 추가하거나 기존 필드의 의미를 바꾸면 소비자가 영향을 받을 수 있다. 선택 필드 추가는 대체로 호환 가능하지만, 알 수 없는 필드를 거부하는 소비자가 있다면 확인이 필요하다. /v1 같은 경로 방식은 버전을 표현하는 한 선택이다. 지원 기간과 이전 방법도 함께 정해야 운영 가능한 정책이 된다.
페이지네이션은 목록을 나누는 방법이다. 오프셋 방식은 “앞에서 몇 개를 건너뛸지”로 표현하므로 특정 페이지로 이동하기 쉽다. 다만 앞쪽에 데이터가 삽입되거나 삭제되면 다음 페이지의 경계가 움직인다. 커서 방식은 마지막으로 본 정렬 키를 기준으로 이어 읽는다. 정렬 기준이 생성 시각이라면 같은 시각을 구분할 식별자를 함께 사용해야 한다.
완성 코드는 증가하는 상품 식별자를 오름차순으로 정렬하고, 마지막 식별자보다 큰 상품을 읽는다. 식별자가 유일하고 변경되지 않는다는 전제다. 이는 스냅샷 조회를 보장하지 않는다. 읽는 동안 추가된 상품이 다음 페이지에 나타날 수 있고, 이미 본 상품의 내용도 달라질 수 있다. 커서가 있다는 말과 목록 전체를 같은 시점으로 본다는 말은 구분해야 한다.
인증·인가와 비밀번호 저장을 구분한다
인증(authentication)은 요청자가 누구인지 확인하는 과정이고, 인가(authorization)는 그 요청자가 특정 행동을 해도 되는지 판단하는 과정이다. 로그인에 성공했다고 모든 상품을 삭제할 수 있는 것은 아니다. 상품 삭제에서는 인증된 사용자와 저장된 상품의 소유자를 비교하는 규칙이 필요하다. 화면에서 삭제 버튼을 숨겨도 서버의 권한 검사는 남아 있어야 한다.
깊이 보기: 로그인한 사용자가 왜 삭제를 거절당하는가?
| 수준 | 답변 | 부족하거나 좋은 점 |
|---|---|---|
| 나쁜 답 | 로그인했으면 삭제할 수 있다. | 자원별 권한을 빠뜨린다. |
| 보통 답 | 인증은 로그인이고 인가는 권한 검사다. | 정의는 맞지만 검사 대상을 설명하지 않는다. |
| 좋은 답 | 신원을 확인한 뒤 저장된 상품 소유자와 비교하고, 본문의 소유자 값은 믿지 않는다. | 공격 가능한 입력과 업무 규칙을 구분한다. |
로그인은 신원 확인이고, 상품 삭제에는 별도의 소유권 검사가 필요하다. 사용자가 상품 식별자를 바꾸어 요청할 수 있기 때문이다. 내 예제에서는 토큰으로 사용자를 찾은 뒤 저장소에서 상품을 읽어 소유자를 비교한다. 인증 정보가 없거나 유효하지 않으면 401, 신원은 확인했지만 소유자가 아니면 403으로 처리한다. 다만 자원의 존재 자체를 숨겨야 한다면 404로 응답하는 정책도 검토하겠다.
“본문에 owner가 있으면 그 값과 비교해도 되는가”라는 꼬리 질문에는 본문을 사용자가 바꿀 수 있다고 답한다. 생성 시 소유자는 인증 결과에서 정하고, 삭제 시 소유자는 저장된 자원에서 읽는다. 역할이 관리자라는 사실도 검증된 서버 측 정보에서 가져와야 한다.
비밀번호는 복호화해서 원문을 복원할 필요가 없다. 비밀번호용 해시 함수로 만든 검증 정보를 저장하고, 로그인할 때 같은 조건으로 계산하여 확인한다. 빠른 일반 해시만 반복 없이 사용하면 대량의 후보를 시험하기 쉬워진다. 비밀번호 해시 방식은 계산 비용을 조절하여 추측 비용을 높인다.
솔트(salt)는 비밀번호마다 다르게 만드는 값이다. 같은 비밀번호를 쓰더라도 저장된 해시가 같아지는 것을 줄이고, 미리 계산한 결과를 여러 계정에 재사용하기 어렵게 한다. 솔트는 비밀키가 아니므로 해시와 함께 저장할 수 있다. 알고리즘과 비용 매개변수도 함께 관리해야 나중에 정책을 바꿀 수 있다.
이 실험은 표준 라이브러리에서 제공하는 PBKDF2-HMAC-SHA256을 사용한다. 비교에는 hmac.compare_digest를 쓴다. 비교 함수의 선택이 비밀번호 해시 계산을 대신하지는 않는다. 운영에서는 비밀번호 전용 방식의 선택, 서버 처리 용량에 맞춘 비용 측정, 로그인 시도 제한을 함께 검토한다. 예제의 반복 횟수는 실행을 위한 값이며 운영 권장값으로 제시하는 것이 아니다.
출력을 일정하게 유지하기 위해 실험용 계정의 솔트와 토큰은 고정한다. 운영에서는 솔트를 안전한 난수로 생성하고 토큰에도 예측하기 어려운 값을 사용해야 한다. 실제 인증에는 안전한 전송, 만료와 폐기, 로그아웃 정책도 필요하다. 이 코드는 그 기능을 구현한 인증 제품이 아니라 해시 검증과 권한 경계를 확인하는 모형이다.
예외와 계층, 테스트를 연결해 설명한다
컨트롤러(controller)는 요청 값을 해석하고 응답으로 바꾸는 경계다. 서비스(service)는 상품 등록과 삭제 같은 업무 규칙을 수행한다. 저장소(repository)는 데이터를 읽고 쓰는 방법을 감춘다. 이름보다 의존 방향과 책임이 중요하다. 서비스가 특정 웹 프레임워크의 요청 객체를 요구하면 웹 환경 없이 업무 규칙을 시험하기 어려워진다.
입력 검증에는 여러 층이 있다. 제목이 문자열인지, 가격이 양의 정수인지는 값의 계약이다. 판매자가 자신의 상품을 삭제하는지는 업무 규칙이다. 프레임워크의 자동 검증은 전자를 도울 수 있지만 후자를 모두 알아서 처리하지 않는다. 입력 형식이 올바른 요청도 권한 때문에 거절될 수 있다.
깊이 보기: 예외를 어떻게 응답으로 바꾸는가?
| 수준 | 답변 | 판단 근거 |
|---|---|---|
| 나쁜 답 | 예외가 나면 문자열로 바꾸어 반환한다. | 내부 정보 노출과 응답 불일치가 생길 수 있다. |
| 보통 답 | 예외 처리기로 오류 형식을 통일한다. | 형식은 설명하지만 실패 분류가 빠져 있다. |
| 좋은 답 | 예상한 실패는 정해진 코드로 변환하고, 예상하지 못한 예외는 제한된 500 응답과 내부 기록으로 나눈다. | 사용자 대응과 개발자 진단을 함께 고려한다. |
예상한 실패는 호출자가 판단할 수 있는 오류 코드로 바꾸고, 내부 결함은 일반적인 500 응답으로 제한하겠다. 원문 예외에는 경로나 내부 구현 정보가 포함될 수 있기 때문이다. 예제에서는 error 아래에 code와 message를 고정하고 가격 검증 실패를 INVALID_INPUT으로 표현한다. 다만 같은 코드에 여러 필드 오류가 묶이면 화면에서 처리하기 불편하다. 실제 계약에서는 필드별 오류 목록과 요청 추적 식별자를 추가하겠다.
면접관이 “예외를 숨기면 개발자는 어떻게 찾는가”라고 물으면 응답과 기록의 목적을 나누어 답한다. 서버 내부에서는 예외의 호출 경로와 요청 추적 정보를 기록하되 비밀번호나 토큰은 기록하지 않는다. 예제도 logger.exception으로 예상하지 못한 예외를 기록한다. 정상적인 실행 결과에는 이 경로가 나타나지 않는다.
깊이 보기: 테스트를 어디까지 나누는가?
| 수준 | 답변 | 평가할 수 있는 범위 |
|---|---|---|
| 나쁜 답 | 한 번 실행해 보니 동작했다. | 성공 조건과 실패 조건이 드러나지 않는다. |
| 보통 답 | 단위 테스트와 통합 테스트를 작성했다. | 명칭만으로는 실제 검증 범위를 알 수 없다. |
| 좋은 답 | 해시 검증과 값 검증은 따로 시험하고, 요청부터 저장소까지 연결하여 상태 코드와 결과를 확인한다. | 어떤 결함을 어느 테스트가 찾는지 설명할 수 있다. |
값과 규칙은 작은 단위로 확인하고, 연결 과정은 별도의 통합 테스트로 확인하겠다. 함수가 맞아도 라우팅이나 예외 변환에서 다른 응답이 나올 수 있기 때문이다. 예제에서는 비밀번호 일치·불일치와 가격 검증을 따로 시험한 뒤, 라우터에서 서비스와 메모리 저장소까지 연결해 요청 결과를 확인한다. 다만 실제 네트워크와 데이터베이스는 포함하지 않는다. 운영 구성까지 검증하려면 해당 연결을 포함한 테스트가 더 필요하다.
“어떤 프레임워크를 써 보았는가”라는 질문에도 이 흐름으로 답할 수 있다. 라우팅은 메서드와 경로를 처리 함수에 연결하고, 공통 요청 처리는 인증이나 기록을 수행하며, 예외 처리기는 내부 실패를 응답으로 바꾼다고 설명한다. 그다음 자신이 사용한 기능을 연결한다. 모르는 기능은 이름을 추측하기보다 어떤 책임을 담당하는지부터 확인하는 편이 낫다.
완성 코드
다음 내용을 main.py로 저장한다. 요청은 메서드, 경로, 본문, 쿼리, 토큰으로 나누어 전달한다. 반환값은 상태 코드, 본문, 헤더의 순서다. 실제 HTTP 파서와 전송 계층은 생략했다. 시간값은 사용하지 않고, 난수 대신 명시적인 실험 데이터를 사용하므로 출력이 일정하다.
import hashlib
import hmac
import json
import logging
logger = logging.getLogger(__name__)
ITERATIONS = 100_000
class ApiError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def derive(password, salt):
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, ITERATIONS
)
def verify(password, record):
salt, expected = record
return hmac.compare_digest(derive(password, salt), expected)
def validate_product(body):
if not isinstance(body, dict):
raise ApiError("INVALID_INPUT", "본문은 객체여야 한다")
title = body.get("title")
price = body.get("price")
if not isinstance(title, str) or not title.strip():
raise ApiError("INVALID_INPUT", "제목이 필요하다")
if type(price) is not int or price <= 0:
raise ApiError("INVALID_INPUT", "가격은 양의 정수여야 한다")
return title.strip(), price
def page_number(query, name, default, minimum, maximum):
value = query.get(name, default)
if not isinstance(value, str) or not value.isascii() or not value.isdecimal():
raise ApiError("INVALID_INPUT", "페이지 값이 올바르지 않다")
number = int(value)
if not minimum <= number <= maximum:
raise ApiError("INVALID_INPUT", "페이지 값이 범위를 벗어났다")
return number
class Repository:
def __init__(self):
self.rows = {
1: {"id": 1, "title": "책상", "price": 10000, "owner": "seller"},
2: {"id": 2, "title": "의자", "price": 5000, "owner": "seller"},
}
self.next_id = 3
def page(self, after, limit):
rows = [
dict(self.rows[key])
for key in sorted(self.rows)
if key > after
]
items = rows[:limit]
cursor = items[-1]["id"] if len(rows) > limit else None
return {"items": items, "next_after": cursor}
def add(self, title, price, owner):
item = {
"id": self.next_id, "title": title,
"price": price, "owner": owner,
}
self.rows[item["id"]] = item
self.next_id += 1
return dict(item)
def get(self, product_id):
item = self.rows.get(product_id)
return dict(item) if item is not None else None
def remove(self, product_id):
del self.rows[product_id]
class Service:
def __init__(self, repository):
self.repository = repository
def create(self, title, price, user):
return self.repository.add(title, price, user)
def delete(self, product_id, user):
item = self.repository.get(product_id)
if item is None:
raise ApiError("NOT_FOUND", "상품을 찾을 수 없다")
if item["owner"] != user:
raise ApiError("FORBIDDEN", "소유자만 삭제할 수 있다")
self.repository.remove(product_id)
class App:
def __init__(self):
self.repository = Repository()
self.service = Service(self.repository)
# 재현 가능한 실험용 값이다. 운영에서는 안전한 난수를 사용한다.
salt = b"demo-salt-0000001"
self.users = {"seller": (salt, derive("demo-pass", salt))}
self.tokens = {"seller-token": "seller", "buyer-token": "buyer"}
def authenticate(self, token):
user = self.tokens.get(token)
if user is None:
raise ApiError("UNAUTHORIZED", "인증이 필요하다")
return user
def dispatch(self, method, path, body, query, token):
if path == "/v1/sessions":
if method != "POST":
return 405, {"error": {
"code": "METHOD_NOT_ALLOWED",
"message": "지원하지 않는 메서드다",
}}, {"Allow": "POST"}
if not isinstance(body, dict):
raise ApiError("INVALID_INPUT", "본문은 객체여야 한다")
username = body.get("username")
password = body.get("password")
if not isinstance(username, str) or not isinstance(password, str):
raise ApiError("INVALID_INPUT", "로그인 입력이 올바르지 않다")
record = self.users.get(username)
if record is None or not verify(password, record):
raise ApiError("UNAUTHORIZED", "로그인 정보가 올바르지 않다")
return 200, {"token": "seller-token"}, {}
if path == "/v1/products":
if method == "GET":
after = page_number(query, "after", "0", 0, 1_000_000)
limit = page_number(query, "limit", "2", 1, 20)
return 200, self.repository.page(after, limit), {}
if method == "POST":
user = self.authenticate(token)
title, price = validate_product(body)
item = self.service.create(title, price, user)
return 201, item, {"Location": f"/v1/products/{item['id']}"}
return 405, {"error": {
"code": "METHOD_NOT_ALLOWED",
"message": "지원하지 않는 메서드다",
}}, {"Allow": "GET, POST"}
parts = path.split("/")
if (
len(parts) == 4 and parts[1:3] == ["v1", "products"]
and parts[3].isascii() and parts[3].isdecimal()
):
if method != "DELETE":
return 405, {"error": {
"code": "METHOD_NOT_ALLOWED",
"message": "지원하지 않는 메서드다",
}}, {"Allow": "DELETE"}
user = self.authenticate(token)
self.service.delete(int(parts[3]), user)
return 204, None, {}
raise ApiError("NOT_FOUND", "経路を 찾을 수 없다")
def handle(self, method, path, body=None, query=None, token=None):
try:
return self.dispatch(
method, path, body, {} if query is None else query, token
)
except ApiError as error:
status = {
"INVALID_INPUT": 400, "UNAUTHORIZED": 401,
"FORBIDDEN": 403, "NOT_FOUND": 404,
}[error.code]
headers = {"WWW-Authenticate": "Bearer"} if status == 401 else {}
return status, {"error": {
"code": error.code, "message": error.message,
}}, headers
except Exception:
logger.exception("요청 처리 중 예상하지 못한 오류")
return 500, {"error": {
"code": "INTERNAL_ERROR", "message": "서버 오류가 발생했다",
}}, {}
def run_tests():
salt = b"test-salt-0000001"
record = (salt, derive("sample", salt))
assert verify("sample", record)
assert not verify("different", record)
assert derive("sample", salt) != derive("sample", b"test-salt-0000002")
try:
validate_product({"title": "책", "price": True})
except ApiError as error:
assert error.code == "INVALID_INPUT"
else:
raise AssertionError("불리언 가격을 거절해야 한다")
app = App()
status, item, headers = app.handle(
"POST", "/v1/products",
{"title": "램프", "price": 3000}, token="seller-token"
)
assert (
status == 201 and headers["Location"] == "/v1/products/3"
and app.repository.get(3) == item
)
status, _, _ = app.handle(
"DELETE", "/v1/products/3", token="buyer-token"
)
assert status == 403 and app.repository.get(3) is not None
status, _, headers = app.handle("PATCH", "/v1/products")
assert status == 405 and headers["Allow"] == "GET, POST"
print("검증: 단위 4개, 통합 3개 통과")
def main():
run_tests()
app = App()
requests = [
("로그인 실패", "POST", "/v1/sessions",
{"username": "seller", "password": "wrong"}, None, None),
("로그인 성공", "POST", "/v1/sessions",
{"username": "seller", "password": "demo-pass"}, None, None),
("인증 누락", "POST", "/v1/products",
{"title": "램프", "price": 3000}, None, None),
("입력 오류", "POST", "/v1/products",
{"title": "램프", "price": True}, None, "seller-token"),
("등록 성공", "POST", "/v1/products",
{"title": "램프", "price": 3000}, None, "seller-token"),
("첫 페이지", "GET", "/v1/products",
None, {"limit": "2"}, None),
("다음 페이지", "GET", "/v1/products",
None, {"after": "2", "limit": "2"}, None),
("권한 부족", "DELETE", "/v1/products/3",
None, None, "buyer-token"),
("페이지 오류", "GET", "/v1/products",
None, {"limit": "0"}, None),
("삭제 성공", "DELETE", "/v1/products/3",
None, None, "seller-token"),
]
for label, method, path, body, query, token in requests:
status, payload, _ = app.handle(method, path, body, query, token)
rendered = (
"(본문 없음)" if payload is None else
json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
)
print(f"{label}: {status} {rendered}")
if __name__ == "__main__":
main()
줄별 해설
코드는 위에서 아래로 해시 검증, 값 검증, 저장소, 서비스, 요청 경계, 테스트 순서로 읽는다. 각 부분의 핵심 줄은 다음과 같다.
| 구문 | 실행 의미 | 설명할 수 있는 원리 |
|---|---|---|
| derive와 verify | 솔트와 비용을 적용해 계산하고 결과를 비교한다. | 원문 복원 없이 비밀번호를 확인한다. |
| type(price) is not int | 정수만 허용하고 불리언은 거절한다. | Python에서 bool은 int의 하위 타입이라는 점을 고려한다. |
| page_number | 숫자 문자열과 허용 범위를 검사한다. | 기본값과 잘못된 입력을 구분한다. |
| key > after | 마지막 식별자 뒤부터 조회한다. | 커서는 정렬 기준과 함께 정의한다. |
| len(rows) > limit | 뒤에 데이터가 있을 때만 다음 커서를 준다. | 페이지가 꽉 찼다는 사실만으로 다음 페이지를 단정하지 않는다. |
| Service.delete | 존재 확인과 소유권 검사를 수행한다. | 업무 규칙을 응답 형식과 분리한다. |
| authenticate 후 validate_product | 등록 요청에서 인증을 먼저 확인한다. | 복수 오류가 있을 때 응답 우선순위도 계약이다. |
| except ApiError | 예상한 실패를 상태 코드와 공통 본문으로 바꾼다. | 업무 실패와 서버 결함을 구분한다. |
| run_tests의 새 App | 테스트용 상태를 따로 만든다. | 테스트가 시연 데이터에 영향을 주지 않는다. |
validate_product는 제목의 앞뒤 공백을 제거하고 비어 있는지 검사한다. 가격 검사에 isinstance(price, int)만 사용하면 True도 통과하므로 정확한 타입을 비교한다. JSON 숫자와 불리언을 Python 값으로 변환한 뒤에도 이 구분은 필요하다. 이 실험은 제목 길이 제한과 알 수 없는 필드 거절까지 구현하지는 않는다.
Repository.page는 전체 자료를 정렬한 뒤 일부를 반환한다. 동작 원리를 보여 주기 위한 메모리 구현이므로 커서 방식이라고 해서 이 코드의 조회 비용이 작아지는 것은 아니다. 실제 저장소에서는 같은 정렬 조건으로 필요한 개수보다 하나 더 조회하는 방식 등을 검토한다. 여기서 중요한 실험 결과는 경계를 무엇으로 표현하는가다.
서비스는 상태 코드 대신 ApiError의 업무 코드를 사용한다. 다만 이 예제의 ApiError는 입력 오류와 업무 오류를 함께 담는 단순한 공통 표현이다. 규모가 커지면 업무 예외를 따로 정의하고 요청 경계에서 변환할 수 있다. 계층을 나눴다는 이유만으로 모든 책임이 자동으로 분리되지는 않는다.
라우터는 지원하는 경로와 메서드를 명시적으로 구분한다. 지원하지 않는 버전 경로도 등록된 경로가 아니므로 404가 된다. 이때 코드에 있는 “経路を 찾을 수 없다”는 안내 문구는 “경로를 찾을 수 없다”로 고쳐 사용한다. 실행 시연에는 없는 경로 요청이 포함되지 않아 아래 출력에는 영향을 주지 않지만, 오류 문구도 계약 검토 대상이라는 점을 확인할 수 있다.
마지막 예외 처리는 응용 프로그램의 바깥 경계에 둔다. 모든 함수에서 넓게 예외를 잡아 정상값으로 바꾸면 원인을 잃기 쉽다. 서비스 안의 예상하지 못한 실패는 경계까지 전달하고, 그곳에서 기록과 응답 변환을 수행한다. 실제 웹 서버에서는 이 역할을 공통 예외 처리 기능에 연결할 수 있다.
실행 결과
macOS 또는 Linux에서 다음 명령으로 실행한다. 헤더는 반환값에 포함되며 통합 테스트에서 Location과 Allow를 확인한다. 출력은 요청별 상태 코드와 본문에 집중한다.
python3 main.py
검증: 단위 4개, 통합 3개 통과
로그인 실패: 401 {"error":{"code":"UNAUTHORIZED","message":"로그인 정보가 올바르지 않다"}}
로그인 성공: 200 {"token":"seller-token"}
인증 누락: 401 {"error":{"code":"UNAUTHORIZED","message":"인증이 필요하다"}}
입력 오류: 400 {"error":{"code":"INVALID_INPUT","message":"가격은 양의 정수여야 한다"}}
등록 성공: 201 {"id":3,"title":"램프","price":3000,"owner":"seller"}
첫 페이지: 200 {"items":[{"id":1,"title":"책상","price":10000,"owner":"seller"},{"id":2,"title":"의자","price":5000,"owner":"seller"}],"next_after":2}
다음 페이지: 200 {"items":[{"id":3,"title":"램프","price":3000,"owner":"seller"}],"next_after":null}
권한 부족: 403 {"error":{"code":"FORBIDDEN","message":"소유자만 삭제할 수 있다"}}
페이지 오류: 400 {"error":{"code":"INVALID_INPUT","message":"페이지 값이 범위를 벗어났다"}}
삭제 성공: 204 (본문 없음)
등록 요청의 소유자는 seller로 정해진다. 이어서 buyer가 같은 상품을 삭제하려 하면 403이 반환되고, seller의 삭제 요청은 204로 끝난다. 이 차이가 인증과 인가를 구분한 결과다. 가격에 True를 넣은 요청은 저장소에 도달하기 전에 거절되므로 다음 정상 등록 상품의 식별자는 여전히 3이다.
실무에서 자주 틀리는 것
요청 본문을 권한의 근거로 사용한다
사용자가 owner를 보냈다는 사실은 소유권의 증거가 아니다. 소유자는 검증된 신원에서 정하고, 기존 자원에 대한 권한은 저장된 정보와 비교한다.
틀린 코드는 다음과 같다.
item = repository.add(title, price, body["owner"])
고친 코드는 다음과 같다.
user = app.authenticate(token)
item = repository.add(title, price, user)
빠른 해시 하나로 비밀번호 저장을 끝낸다
일반 해시를 한 번 계산하는 방식은 비밀번호 추측 비용을 충분히 조절하기 어렵다. 솔트와 비용을 받는 비밀번호용 함수를 사용하고 비교도 별도로 수행한다. 아래 salt는 계정에 저장된 솔트라고 가정한다.
틀린 코드는 다음과 같다.
stored = hashlib.sha256(password.encode("utf-8")).digest()
matched = stored == candidate
고친 코드는 다음과 같다.
stored = derive(password, salt)
matched = hmac.compare_digest(derive(candidate_password, salt), stored)
예외를 잡아 성공 응답으로 바꾼다
문자열로 만든 예외에는 내부 정보가 섞일 수 있다. 서버 결함은 기록하고, 호출자에게는 약속한 오류 형식과 상태를 반환한다.
틀린 코드는 다음과 같다.
try:
result = operation()
except Exception as error:
return 200, {"message": str(error)}, {}
고친 코드는 다음과 같다.
try:
result = operation()
except Exception:
logger.exception("처리 실패")
return 500, {"error": {
"code": "INTERNAL_ERROR", "message": "서버 오류가 발생했다",
}}, {}
거절 상태만 확인하고 저장 결과는 확인하지 않는다
403을 반환했더라도 그 전에 상품을 지웠다면 권한 검사는 실패한 것이다. 상태 코드와 함께 보호하려던 상태가 유지되는지도 확인한다.
부족한 테스트는 다음과 같다.
status, _, _ = app.handle(
"DELETE", "/v1/products/1", token="buyer-token"
)
assert status == 403
보완한 테스트는 다음과 같다.
before = app.repository.get(1)
status, _, _ = app.handle(
"DELETE", "/v1/products/1", token="buyer-token"
)
assert status == 403
assert app.repository.get(1) == before
한눈에 보기
| 주제 | 먼저 말할 결론 | 예제의 근거 | 한계와 대안 |
|---|---|---|---|
| API 설계 | 자원과 응답 계약을 함께 정한다. | 201과 Location, 204와 빈 본문 | 중복 등록은 별도 요청 식별 정책이 필요하다. |
| 버전 | 소비자가 깨지는 변경을 관리한다. | /v1 경로 | 지원 기간과 이전 절차도 정해야 한다. |
| 페이지네이션 | 커서는 안정적인 정렬 키와 짝이다. | 식별자 오름차순과 after | 같은 시점의 목록을 보장하지 않는다. |
| 인증·인가 | 신원 확인 뒤 행동의 허용 여부를 판단한다. | 401과 403, 소유권 검사 | 토큰 만료와 폐기는 추가 구현 대상이다. |
| 비밀번호 | 솔트와 비용을 적용한 검증 정보를 저장한다. | PBKDF2와 비교 함수 | 운영 비용은 측정하고 정책을 갱신한다. |
| 예외 | 예상한 실패와 내부 결함을 구분한다. | 공통 error 본문과 500 처리 | 필드 오류와 요청 추적을 확장할 수 있다. |
| 계층·테스트 | 책임과 검증 범위를 구체적으로 말한다. | 값 검증과 요청 흐름을 나누어 검사한다. | 실제 전송과 저장소 연결은 별도로 확인한다. |
면접 답변에서는 구현한 범위와 추가로 필요한 범위를 함께 말한다. “인증을 구현했다”보다 “실험용 토큰으로 사용자를 확인하고 소유권 검사를 연결했으며, 만료와 폐기는 구현하지 않았다”가 더 정확하다. 다음 장에서는 이 응답 계약을 사용하는 화면에서 렌더링과 상태를 어떻게 다루는지 살펴본다.
연습 문제
- 상품 두 개가 있는 첫 페이지를 읽은 뒤 식별자 4인 상품이 추가되었다. after=2, limit=2로 읽으면 어떤 상품을 받는가? 이 결과가 같은 시점의 목록을 보장하는지 설명한다.
- 존재하지 않는 상품을 인증 정보 없이 삭제하면 예제는 401과 404 중 무엇을 반환하는가? 처리 순서를 근거로 답하고, 다른 정책의 장단점도 설명한다.
- 서로 다른 솔트로 같은 비밀번호를 저장하는 단위 테스트와, 비밀번호가 틀린 로그인 요청의 통합 테스트를 각각 설계한다. 무엇을 검증하는지 구분한다.
- 상품 수정 요청을 추가한다고 가정한다. 가격 형식 검사, 소유권 검사, 저장, 응답 변환을 어느 책임에 둘지 정하고, 거절된 요청이 상품을 바꾸지 않았는지 확인하는 테스트를 제안한다.
정답과 해설
- 식별자 3과 4가 모두 존재한다면 두 상품을 받는다. 예제의 커서는 식별자가 2보다 큰 상품 중 앞의 두 개를 고른다. 뒤에 상품이 더 없으면 next_after는 null이다. 첫 페이지를 읽을 때 없던 상품이 포함되므로 같은 시점의 목록을 보장하지 않는다. 시점 고정이 필요하다면 첫 조회의 상한 키를 함께 유지하는 방법 등을 검토하되, 수정과 삭제의 일관성까지 해결되는 것은 아님을 밝혀야 한다.
- 401을 반환한다. 라우터가 authenticate를 먼저 호출하고, 인증에 성공해야 서비스에서 상품의 존재를 확인하기 때문이다. 존재 여부를 먼저 확인하면 인증하지 않은 요청에도 자원이 있는지 알려 줄 수 있다. 반대로 인증 우선 정책에서는 없는 상품에 대한 요청도 먼저 인증 오류를 받는다. 어느 순서든 공개 계약과 정보 노출 정책을 일관되게 적용해야 한다.
- 단위 테스트에서는 같은 비밀번호와 서로 다른 솔트로 계산한 결과가 다르고, 각 기록에 원래 비밀번호를 넣으면 검증이 성공하는지 확인한다. 통합 테스트에서는 별도의 App을 만든 뒤 틀린 비밀번호로 POST /v1/sessions를 호출한다. 401, UNAUTHORIZED 코드, 토큰이 없는 본문을 확인한다. 전자는 해시 함수의 사용을, 후자는 요청 해석부터 오류 응답까지 연결된 동작을 확인한다.
- 가격의 타입과 범위는 재사용 가능한 값 검증 함수에서 확인하고 요청 경계가 호출한다. 소유권과 수정 가능 상태는 서비스가 판단하고 저장소가 변경을 반영한다. 상태 코드와 본문 변환은 요청 경계에서 수행한다. 다른 사용자의 수정 요청 전후로 상품 전체를 비교하면, 거절 응답을 보냈으나 내부 값은 바뀌는 결함도 찾을 수 있다.