Post

Idempotency-Key로 안전한 API 만들기: 쿠폰 배치/발급/사용 설계와 테스트

Django REST API의 멱등성 처리, 테스트 코드, 그리고 실전 트러블슈팅 경험 정리

Idempotency-Key로 안전한 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단계를 구분하는 것이 핵심

  1. 키 선점(atomic): get_or_create()로 유니크 충돌을 한 번에 처리
  2. 뷰 실행(트랜잭션 밖): 발급/사용 같은 본 처리에서 긴 락을 피함
  3. 응답 저장(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 설정 미로드로 발생하는 ImproperlyConfiguredpytest.iniDJANGO_SETTINGS_MODULE로 해결


디버깅 체크리스트

  • DRF 설정 미로드로 인한 ImproperlyConfiguredpytest.ini에 설정을 추가
  • 첫 요청이 400으로 떨어지는 문제 → 헤더 추출 유틸에서 request.headersrequest.META를 모두 커버
  • APIView 데코레이터 시그니처 오류 → wrapper(self, request, …) 형태로 맞춘다.
  • IntegrityError 이후 TransactionManagementError 연쇄 → 키 선점은 get_or_create()로, 뷰 실행은 트랜잭션 밖에서 진행
  • JsonResponse에는 .data가 없음 → 재응답은 DRF Response로 통일

운영 관점 보완점

  • TTL/청소 작업: 만료된 IdempotencyKey 레코드를 주기적으로 삭제하는 관리 커맨드를 두는 것이 좋다.
  • 지문 범위 확장: 필요하면 HTTP 메서드와 쿼리스트링을 지문에 포함해 충돌 여지를 줄일 수 있다.
  • 관측 가능성: 키 히트율, 불일치율, in-progress 비율을 메트릭으로 수집하면 재시도 정책 품질을 평가하기 쉽다.
  • 상태 전이 제약: 모델 레벨 CheckConstraint 또는 서비스 계층 검증으로 상태 전이를 더 엄격히 관리할 수 있다.

마무리

멱등성은 문서의 약속이 아니라 저장소의 기록과 트랜잭션 경계로 구현되는 특성이다. 본 구현은 키 선점, 경계 분리, 응답 스냅샷, 재응답 통일이라는 네 가지 축을 중심으로 쿠폰 발급/사용의 중복 요청을 안전하게 처리한다. 이 기반 위에서 TTL 정리, 관측 지표, 지문 확장 등을 더하면 운영 환경에서도 일관성과 복원력을 확보할 수 있다.

This post is licensed under CC BY 4.0 by the author.