Django 멱등성 키 설계: 중복 요청과 같은 키의 다른 요청을 구분하기

DJANGO DEEP DIVE · 02 / 20

클라이언트가 주문 요청을 보낸 뒤 타임아웃을 받았다. 서버는 주문을 만들었을 수도 있고, 아직 만들지 않았을 수도 있다. 클라이언트의 재시도는 정상 동작이다. 서버가 같은 업무 효과를 다시 만들지 않도록 요청의 정체성과 결과를 저장해야 한다.

기준: Django 5.2 · PostgreSQL 17의 Read Committed. 아래 예제는 외부 API 호출 없이 하나의 DB에서 끝나는 주문 생성이다.

exists 검사로 중복을 막을 수 없는 이유

if not RequestLog.objects.filter(key=key).exists(): 다음에 주문을 만드는 코드는 두 요청이 동시에 검사를 통과할 수 있다. 판정은 애플리케이션의 선행 조회가 아니라 DB 유일 제약이 해야 한다.

키는 전역 문자열 하나보다 (tenant_id, operation, key)처럼 범위를 가진다. 다른 고객이 같은 문자열을 사용했다고 그 고객의 응답을 반환해서는 안 된다. UniqueConstraint는 복수 필드의 유일성을 DB에 정의할 수 있다. Django 제약 문서

같은 키와 다른 본문을 구별한다

멱등성 기록에는 최소한 다음 정보가 필요하다.

필드 목적
tenant, operation, key 요청 식별 범위
request_hash 동일 키로 다른 업무를 요청했는지 확인
responsebody, statuscode 재시도에 반환할 확정 결과
created_at 보존 기간과 정리 기준

해시는 원본 HTTP 본문의 바이트를 무작정 사용하기보다 검증과 정규화를 마친 업무 입력으로 만든다. JSON 공백과 키 순서는 달라도 동일한 요청일 수 있다. 반대로 주문 금액, 통화, 대상 상품은 반드시 비교 대상이어야 한다. 금액의 문자열 표현 규칙과 해시 스키마 버전도 고정한다.

import hashlib
import json

def request_digest(validated_payload):
    encoded = json.dumps(
        validated_payload, sort_keys=True, separators=(",", ":"),
        ensure_ascii=False, allow_nan=False,
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()

짧은 DB 작업은 하나의 커밋으로 묶는다

Idempotency 모델은 앞의 복합 유일 제약과 필드들을 가진다고 가정한다. create_order_in_db()는 같은 DB에서 주문을 만들고 JSON 직렬화 가능한 결과만 반환한다. 사용자 권한 검증은 이 함수를 부르기 전에 완료한다.

from django.db import transaction

def create_once(tenant_id, key, validated_payload):
    digest = request_digest(validated_payload)
    with transaction.atomic():
        record, created = Idempotency.objects.get_or_create(
            tenant_id=tenant_id,
            operation="create-order-v1",
            key=key,
            defaults={"request_hash": digest},
        )
        if not created:
            if record.request_hash != digest:
                raise ValueError("same key, different request")
            return record.status_code, record.response_body

        body = create_order_in_db(tenant_id, validated_payload)
        record.status_code = 201
        record.response_body = body
        record.save(update_fields=["status_code", "response_body"])
    return 201, body

충돌하는 두 번째 insert는 첫 요청의 트랜잭션 결과를 기다릴 수 있다. 첫 요청이 커밋되면 두 번째 요청은 저장된 결과를 읽고, 첫 요청이 롤백되면 새로 처리할 수 있다. 이 구조는 업무와 결과 기록이 같은 트랜잭션에 있다는 전제에 의존한다. get_or_create()는 DB 유일 제약 없이 이 성질을 제공하지 않는다. QuerySet 문서

실제 API에서는 본문 불일치를 명시적인 오류 코드로 매핑하고, 키 길이·문자 형식·보존 기간을 계약으로 정한다. 반환 결과에 권한이나 개인정보가 있다면 재요청 시에도 현재 호출자의 접근 권한을 확인한다.

외부 결제는 별도의 상태 머신이 필요하다

DB 트랜잭션 안에서 외부 결제를 호출하면 외부 결제 성공 후 DB 롤백이라는 상황이 생긴다. 위 코드를 그대로 확장해서 해결할 수 없다.

긴 작업은 PROCESSING → SUCCEEDED/FAILED 상태를 저장하고, 외부 제공자에게 동일한 멱등성 키를 보내며, 타임아웃 후에는 결과 조회로 상태를 조정한다. PROCESSING을 먼저 커밋하는 방식이라면 워커 중단에 따른 리스 만료와 재시도 소유권도 필요하다. 단순히 오래된 상태를 삭제하면 진행 중인 결제를 중복 실행할 수 있다.

테스트는 세 가지 경쟁을 구분한다

  • 같은 키·같은 입력 두 요청: 주문 하나, 동일한 저장 결과.
  • 같은 키·다른 입력 두 요청: 하나만 처리하고 다른 요청은 불일치 오류.
  • 첫 요청의 업무 처리 실패: 멱등성 기록도 롤백되어 정상 재시도가 가능.

여기에 응답을 받은 뒤 같은 키를 다시 보내는 순차 테스트만 있으면 동시성 검증이 빠진다. 독립 DB 연결로 두 요청을 겹쳐 실행해야 한다. 보존 기간 이후의 키 재사용도 문서화하자. 멱등성은 영원한 중복 제거가 아니라, 정의된 범위와 기간 안의 계약이다.

댓글