Idempotency-Key로 안전한 API 만들기: 쿠폰 배치/발급/사용 설계와 테스트
Django REST API의 멱등성 처리, 테스트 코드, 그리고 실전 트러블슈팅 경험 정리
API 구현 연습 및 테스트: 쿠폰 발급·사용과 멱등성 설계
- Django/DRF로 쿠폰 배치 생성, 발급, 사용 API를 구현하고 Idempotency-Key 기반 멱등성을 보장한다.
- 멱등성 키 선점은
get_or_create()
와transaction.atomic()
으로 처리하고, 뷰 실행은 트랜잭션 밖, 응답 스냅샷 저장은 별도 트랜잭션으로 분리한다. - 저장된 재응답은 반드시 DRF
Response
로 반환하여 테스트 클라이언트의.data
접근을 보장한다. - pytest로 “정상 → 재시도(동일 응답) → 제한/중복 위반(409)” 흐름을 검증한다.
배경
쿠폰 도메인은 중복 요청이 빈번하다. 네트워크 재시도, 모바일 앱의 중복 전송, 프런트엔드의 낙관적 업데이트 등으로 동일 POST가 여러 번 들어올 수 있다. 이때 서버는 데이터를 추가로 변경하지 않고 동일 응답을 재현해야 한다. 이를 위해 요청을 식별할 키와, 요청 지문과 응답 스냅샷을 안전하게 저장하는 설계가 필요하다.
상위 구조
엔드포인트
POST /v1/coupon-batches/
배치 생성(201)POST /v1/coupons/issue/
쿠폰 발급(200)POST /v1/coupons/redeem/
쿠폰 사용(200)GET /v1/coupons/
조회(200)
변경성 요청은 Idempotency-Key 헤더를 필수로 요구
라우팅은 config/urls.py
에서 /v1/
로 coupons.urls
를 include하는 전형적인 구성으로 시작
데이터 모델 개요
- CouponBatch: 할인 유형(fixed/percent), 개인·총 한도, 만료 시점을 가짐
- Coupon: 유니크 코드, 상태 전이(issued→redeemed), 사용 주체, 주문 ID를 가진다. 배치와의 FK는
PROTECT
로 설정해 일관성을 보장 - IdempotencyKey: 요청 키, 요청 지문(fingerprint), 응답 상태/본문 스냅샷, 만료 시각을 저장한다. 키는 유니크해야 하며, 기본 TTL을 두고 정리 작업을 수행할 수 있음
멱등성 처리 전략
요청 헤더의 Idempotency-Key와 (path, body)
를 JSON 직렬화해 해시로 만든 지문을 저장
- 최초 요청: 키 행을
get_or_create()
로 선점한 뒤 비즈니스 로직을 실행하고, 응답을 스냅샷으로 저장 - 동일 지문 재요청: 저장된 응답을 즉시 반환
- 상이 지문 재요청: 409
IDEMPOTENCY_KEY_BODY_MISMATCH
로 거부 - 선행 요청 처리 중: 409
REQUEST_IN_PROGRESS
를 반환
헤더는 실행 환경마다 진입 경로가 다를 수 있으므로 안전하게 읽어야 한다.
1
2
3
4
5
6
7
8
def _get_idempotency_key(request):
req = getattr(request, "_request", request) # DRF Request → WSGIRequest 보정
return (
getattr(request, "headers", {}).get("Idempotency-Key")
or getattr(req, "META", {}).get("HTTP_IDEMPOTENCY_KEY")
or getattr(req, "META", {}).get("Idempotency-Key")
or getattr(req, "META", {}).get("HTTP_IDEMPOTENCYKEY")
)
트랜잭션 경계
다음 3단계를 구분하는 것이 핵심
- 키 선점(atomic):
get_or_create()
로 유니크 충돌을 한 번에 처리 - 뷰 실행(트랜잭션 밖): 발급/사용 같은 본 처리에서 긴 락을 피함
- 응답 저장(atomic): 상태 코드와 본문을 스냅샷으로 기록
이렇게 나누면 유니크 충돌 이후 트랜잭션 손상이 다른 쿼리로 전파되는 문제를 방지할 수 있다.
구현 하이라이트
1) 서비스 레이어의 동시성 제어
발급은 배치 행을 select_for_update()
로 잠그고, 개인 한도·총 한도·만료를 순차적으로 검증한다. 코드 유니크는 데이터베이스가 최종 보루이므로 생성 시점 예외 처리와 짧은 재시도 루프를 둔다.
1
2
3
4
5
6
7
8
9
10
11
@transaction.atomic
def issue_coupon(*, batch_id: int, user_id: str) -> Coupon:
batch = CouponBatch.objects.select_for_update().get(id=batch_id)
# 만료·한도 검사 …
for _ in range(5):
code = _gen_code()
if not Coupon.objects.filter(code=code).exists():
break
else:
raise RuntimeError("CODE_GENERATION_FAILED")
return Coupon.objects.create(…)
사용(redeem)은 쿠폰 행과 배치를 조인해 잠그고, 만료·중복 사용을 검사한 뒤 상태를 갱신
2) 멱등성 데코레이터
APIView 메서드를 감싸므로 래퍼 시그니처는 wrapper(self, request, *args, **kwargs)
형태여야 한다. 저장된 응답을 재사용할 때는 반드시 DRF Response
로 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def idempotent_view(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
key = _get_idempotency_key(request)
if not key:
return Response({"error": {"code": "IDEMPOTENCY_KEY_REQUIRED"}}, status=400)
body = getattr(request, "data", {}) or {}
fp = _fingerprint(request.path, body)
with transaction.atomic():
obj, created = IdempotencyKey.objects.get_or_create(
key=key,
defaults={"user_id": str(getattr(request.user, "id", "anon")),
"request_fingerprint": fp},
)
if not created:
if obj.request_fingerprint and obj.request_fingerprint != fp:
return Response({"error": {"code": "IDEMPOTENCY_KEY_BODY_MISMATCH"}}, status=409)
if obj.response_status is not None:
return Response(obj.response_body or {}, status=obj.response_status)
return Response({"error": {"code": "REQUEST_IN_PROGRESS"}}, status=409)
response = view_func(self, request, *args, **kwargs)
with transaction.atomic():
IdempotencyKey.objects.filter(key=key).update(
response_status=response.status_code,
response_body=(getattr(response, "data", None) or {}),
)
return response
return wrapper
3) 뷰와 유효성 검증
- 필수 필드 누락 시 400을 반환
- 도메인 오류(한도 초과, 이미 사용 등)는 409로 통일
- 존재하지 않는 리소스는 404로 응답 응답 스키마는 시리얼라이저로 일관되게 관리
테스트 전략(pytest)
통합 시나리오를 한 흐름으로 검증
- 발급 1회차: 200,
code
획득 - 발급 2회차(동일 키): 200,
code
동일 - 발급 3회차(다른 키, 같은 사용자): 409
PER_USER_LIMIT_REACHED
- 사용 1회차: 200,
status="redeemed"
- 사용 2회차(동일 키): 200, 동일
code
- 사용 3회차(다른 키): 409
COUPON_ALREADY_REDEEMED
테스트 클라이언트에는 항상 HTTP_IDEMPOTENCY_KEY
로 헤더를 전달한다. Django 설정 미로드로 발생하는 ImproperlyConfigured
는 pytest.ini
의 DJANGO_SETTINGS_MODULE
로 해결
디버깅 체크리스트
- DRF 설정 미로드로 인한
ImproperlyConfigured
→pytest.ini
에 설정을 추가 - 첫 요청이 400으로 떨어지는 문제 → 헤더 추출 유틸에서
request.headers
와request.META
를 모두 커버 - APIView 데코레이터 시그니처 오류 →
wrapper(self, request, …)
형태로 맞춘다. IntegrityError
이후TransactionManagementError
연쇄 → 키 선점은get_or_create()
로, 뷰 실행은 트랜잭션 밖에서 진행JsonResponse
에는.data
가 없음 → 재응답은 DRFResponse
로 통일
운영 관점 보완점
- TTL/청소 작업: 만료된
IdempotencyKey
레코드를 주기적으로 삭제하는 관리 커맨드를 두는 것이 좋다. - 지문 범위 확장: 필요하면 HTTP 메서드와 쿼리스트링을 지문에 포함해 충돌 여지를 줄일 수 있다.
- 관측 가능성: 키 히트율, 불일치율, in-progress 비율을 메트릭으로 수집하면 재시도 정책 품질을 평가하기 쉽다.
- 상태 전이 제약: 모델 레벨
CheckConstraint
또는 서비스 계층 검증으로 상태 전이를 더 엄격히 관리할 수 있다.
마무리
멱등성은 문서의 약속이 아니라 저장소의 기록과 트랜잭션 경계로 구현되는 특성이다. 본 구현은 키 선점, 경계 분리, 응답 스냅샷, 재응답 통일이라는 네 가지 축을 중심으로 쿠폰 발급/사용의 중복 요청을 안전하게 처리한다. 이 기반 위에서 TTL 정리, 관측 지표, 지문 확장 등을 더하면 운영 환경에서도 일관성과 복원력을 확보할 수 있다.