Goose Kuzi 추첨 API의 request ID 멱등성

Goose Kuzi 추첨 API의 request ID 멱등성

한눈에 보기

데이터베이스 트랜잭션이 원자적이어도 같은 HTTP 요청이 두 번 도착하면 정상 트랜잭션이 두 번 실행될 수 있다. 추첨처럼 포인트·재고·당첨 결과를 함께 바꾸는 API에서는 (사용자, request_id)를 유일하게 만들고 요청 지문과 최종 응답을 함께 저장해야 한다. 같은 키와 같은 요청에는 같은 결과를 재생하고, 같은 키와 다른 요청은 충돌로 거절한다.

추첨 버튼을 누른 직후 네트워크가 끊겼다고 해 보자. 서버에서는 이미 포인트가 빠지고 당첨 상품도 정해졌지만, 응답은 사용자에게 도착하지 않았다. 앱은 실패로 보이고 사용자는 버튼을 다시 누른다.

두 번째 요청을 서버가 새로운 추첨으로 받아들이면 첫 번째 추첨이 성공했는데도 포인트와 재고가 다시 차감된다. 데이터베이스 관점에서는 두 트랜잭션 모두 완벽하게 정상이다. 문제는 트랜잭션이 깨진 것이 아니라 같은 사용자 의도가 두 번 실행된 것이다.

Goose Kuzi처럼 유한 재고와 포인트가 연결된 기능을 생각하면서, 원자성과 별개로 요청 멱등성이 필요하다는 점을 정리했다. 아래 스키마와 코드는 실제 저장소 코드를 옮긴 것이 아니라 설계를 설명하기 위해 만든 예시다.

목차

트랜잭션과 멱등성은 다른 문제다

추첨 로직을 하나의 데이터베이스 트랜잭션으로 묶으면 중간 상태는 막을 수 있다.

포인트 확인
→ 재고 행 잠금
→ 당첨 상품 선택
→ 포인트 차감
→ 재고 차감
→ 보관함 상품 생성
→ 추첨 로그 저장
→ COMMIT

중간에 오류가 나면 전부 롤백되므로 ‘포인트만 빠지고 상품은 없는 상태’가 생기지 않는다. 이 내용은 Supabase Postgres에서 유한 재고 추첨을 원자적으로 처리하기에서 다룬 문제와 가깝다.

하지만 같은 요청이 순서대로 두 번 실행되면 이야기가 다르다.

sequenceDiagram
    participant C as Client
    participant A as Draw API
    participant D as PostgreSQL

    C->>A: 추첨 요청
    A->>D: 트랜잭션 1 실행
    D-->>A: COMMIT
    A--xC: 응답 전송 중 연결 끊김
    C->>A: 같은 의도로 재시도
    A->>D: 트랜잭션 2 실행
    D-->>A: COMMIT
    A-->>C: 두 번째 당첨 결과

첫 번째와 두 번째 트랜잭션은 각각 원자적이다. 그런데 사용자 의도는 한 번이었고 부수 효과는 두 번 발생했다.

보장 막는 문제 막지 못하는 문제
트랜잭션 원자성 일부 변경만 반영되는 상태 같은 명령의 반복 실행
행 잠금 동일 재고의 동시 갱신 충돌 재시도 자체의 중복
unique constraint 특정 식별자의 중복 생성 다른 ID로 온 같은 의도
request ID 멱등성 같은 요청 키의 반복 실행 잘못 생성된 새 키
핵심

원자성은 ‘한 번의 실행이 전부 성공하거나 전부 실패함’을 보장한다. 멱등성은 ‘같은 논리적 요청이 여러 번 도착해도 부수 효과가 한 번만 발생함’을 보장한다.

request ID가 대표하는 범위를 정한다

멱등성 키는 단순 UUID가 아니라 사용자의 한 번의 실행 의도를 식별한다. 먼저 키의 유일 범위를 정해야 한다.

전역 유일 키를 요구할 수도 있지만, 실무에서는 다음과 같이 범위를 묶는 경우가 많다.

(actor_id, operation, request_id)

동일한 문자열을 서로 다른 사용자가 우연히 생성해도 충돌하지 않고, 추첨과 배송 신청이 같은 키 공간을 공유하지 않는다.

CREATE TABLE idempotency_requests (
    actor_id uuid NOT NULL,
    operation text NOT NULL,
    request_id text NOT NULL,
    request_fingerprint text NOT NULL,
    status text NOT NULL,
    response_status integer,
    response_body jsonb,
    resource_id uuid,
    error_code text,
    lease_version bigint NOT NULL DEFAULT 1,
    lease_expires_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now(),
    completed_at timestamptz,
    expires_at timestamptz NOT NULL,
    PRIMARY KEY (actor_id, operation, request_id),
    CHECK (status IN ('processing', 'succeeded', 'rejected'))
);

이 스키마는 개념 예시다. actor_id는 반드시 인증 결과에서 가져와야 한다. 요청 본문에 들어온 사용자 ID를 믿으면 다른 사용자의 키를 조회하거나 결과를 재생하는 권한 문제가 생긴다.

request ID 형식에도 제한을 둔다.

function validateRequestId(value: string | null): string {
  if (!value || value.length < 16 || value.length > 128) {
    throw new Error("INVALID_IDEMPOTENCY_KEY");
  }
  if (!/^[A-Za-z0-9:_-]+$/.test(value)) {
    throw new Error("INVALID_IDEMPOTENCY_KEY");
  }
  return value;
}

너무 긴 값이나 제어 문자를 허용하면 로그, 인덱스, 저장 비용에 불필요한 부담이 생긴다. 서버가 키를 새로 발급해 주는 방식도 가능하지만, 요청을 보내기 전에 클라이언트가 가져야 재시도에 사용할 수 있다.

키만 저장해서는 부족하다

request_id가 이미 존재하면 예전 결과를 반환하는 것만으로는 안전하지 않다. 클라이언트 버그로 같은 키를 다른 요청 본문에 재사용할 수 있기 때문이다.

첫 번째 요청
request_id = req-demo-001
box_id = starter
quantity = 1

두 번째 요청
request_id = req-demo-001
box_id = premium
quantity = 10

두 번째 요청에 첫 번째 결과를 반환하면 사용자는 premium 10회를 요청했는데 starter 1회 결과를 받는다. 반대로 두 번째 본문으로 다시 실행하면 멱등성 보장이 깨진다. 따라서 request ID와 함께 **요청 지문(request fingerprint)**을 저장한다.

지문은 멱등성 의미에 영향을 주는 필드만 정규화해 해시한다.

import { createHash } from "node:crypto";

type DrawCommand = {
  boxId: string;
  quantity: number;
};

function fingerprint(command: DrawCommand): string {
  const canonical = JSON.stringify({
    boxId: command.boxId.trim(),
    quantity: command.quantity,
  });
  return createHash("sha256").update(canonical).digest("hex");
}

JSON 객체를 그대로 문자열화하면 키 순서, 기본값 생략, 공백에 따라 같은 의미가 다른 해시가 될 수 있다. 필드를 명시적으로 뽑고 정규화한 뒤 직렬화한다.

같은 키가 이미 있을 때의 규칙은 명확하다.

기존 키 요청 지문 처리
없음 새 지문 실행 소유권 획득
있음 같음 상태에 따라 대기 또는 응답 재생
있음 다름 409 IDEMPOTENCY_KEY_REUSED
민감한 원문을 저장하지 않는다

지문을 만들기 위해 요청 본문 전체를 그대로 저장할 필요는 없다. 비밀번호, 결제 토큰 같은 값은 멱등성 레코드나 로그에 남기지 않고, 비교에 필요한 정규화 필드의 해시만 저장한다.

멱등성 레코드의 상태를 설계한다

멱등성 테이블에는 최소한 처리 중과 완료 상태가 필요하다.

stateDiagram-v2
    [*] --> processing: 키 선점
    processing --> succeeded: 부수 효과와 응답 확정
    processing --> rejected: 결정적인 업무 오류
    processing --> processing: lease 만료 후 복구
    succeeded --> [*]: 같은 응답 재생
    rejected --> [*]: 같은 오류 재생

각 상태의 의미를 먼저 고정해 두면 재요청 처리도 단순해진다.

상태 의미 같은 요청 재도착 시
processing 다른 실행자가 처리 중이거나 이전 실행이 불명확 짧게 대기, 202, 또는 상태 조회 안내
succeeded 부수 효과와 응답이 확정됨 저장된 성공 응답 재생
rejected 잔액 부족처럼 같은 입력에서 반복될 결정적 거절 저장된 오류 응답 재생

failed를 별도 상태로 둘 수도 있다. 다만 ‘실패’라는 말 안에는 서로 다른 상황이 섞인다.

각 경우의 재실행 가능 여부가 다르므로 하나의 failed 상태로 뭉개지 않는 편이 좋다.

한 트랜잭션 안에서 소유권을 선점한다

가장 안전하게 이해하기 쉬운 구조는 멱등성 키 선점과 업무 변경을 같은 데이터베이스 트랜잭션에 넣는 것이다.

BEGIN;

INSERT INTO idempotency_requests (
    actor_id, operation, request_id, request_fingerprint,
    status, lease_expires_at, expires_at
)
VALUES (
    :actor_id, 'draw', :request_id, :fingerprint,
    'processing', now() + interval '30 seconds', now() + interval '7 days'
)
ON CONFLICT DO NOTHING;

-- 선점 결과를 확인하고, 소유권을 얻은 요청만 아래 업무 로직을 실행한다.
-- 포인트 확인 → 재고 잠금 → 추첨 → 포인트/재고 변경 → 결과 저장

UPDATE idempotency_requests
SET status = 'succeeded',
    response_status = 200,
    response_body = :response_body,
    resource_id = :draw_batch_id,
    completed_at = now(),
    lease_expires_at = NULL
WHERE actor_id = :actor_id
  AND operation = 'draw'
  AND request_id = :request_id;

COMMIT;

INSERT ... ON CONFLICT DO NOTHING을 실행한 것 자체가 아니라 정말 내가 행을 만들었는지 확인하는 것이 중요하다. 영향을 받은 행 수가 0이면 기존 요청을 읽어야 한다.

WITH claimed AS (
    INSERT INTO idempotency_requests (
        actor_id, operation, request_id, request_fingerprint,
        status, lease_expires_at, expires_at
    )
    VALUES (
        :actor_id, 'draw', :request_id, :fingerprint,
        'processing', now() + interval '30 seconds', now() + interval '7 days'
    )
    ON CONFLICT DO NOTHING
    RETURNING actor_id, request_id
)
SELECT EXISTS (SELECT 1 FROM claimed) AS acquired;

트랜잭션이 롤백되면 키 선점, 포인트 차감, 재고 차감, 결과 저장이 모두 함께 사라진다. 그래서 확실히 롤백된 요청은 같은 키로 다시 실행할 수 있다.

반대로 멱등성 저장소를 Redis에, 업무 데이터를 PostgreSQL에 나누면 두 시스템 사이 원자성을 별도로 설계해야 한다. 초기에 굳이 분리하면 ‘추첨은 커밋됐지만 Redis 결과 저장은 실패’ 같은 간격이 생긴다.

성능보다 경계를 먼저 본다

멱등성 레코드를 빠른 캐시로 옮기는 것은 가능하다. 다만 돈·재고처럼 중요한 부수 효과라면 먼저 업무 트랜잭션과 같은 저장소에서 정확성을 만든 뒤, 병목이 측정됐을 때 분리 비용을 검토하는 편이 안전하다.

동시에 같은 요청이 들어오면

모바일 앱의 이중 탭이나 네트워크 라이브러리의 자동 재시도로 같은 키가 거의 동시에 도착할 수 있다.

sequenceDiagram
    participant A as Request A
    participant B as Request B
    participant D as PostgreSQL

    par 동시 요청
        A->>D: INSERT key
        B->>D: INSERT same key
    end
    D-->>A: acquired
    D-->>B: conflict
    A->>D: 추첨과 응답 저장
    B->>D: 기존 상태 조회
    D-->>B: processing 또는 succeeded

유일 제약이 최종 승자를 정하므로 애플리케이션의 ‘먼저 조회한 뒤 삽입’ 패턴은 피한다.

// 경쟁 조건이 있는 나쁜 예시
const existing = await findRequest(key);
if (!existing) {
  await insertRequest(key); // 두 요청이 모두 여기까지 올 수 있다.
  await executeDraw();
}

동시 요청의 패자는 세 가지 방식으로 응답할 수 있다.

  1. 짧은 시간 DB 상태를 폴링하고 완료되면 같은 결과를 반환한다.
  2. 즉시 202 Accepted와 상태 조회 URL을 반환한다.
  3. 409 REQUEST_IN_PROGRESS와 재시도 간격을 반환한다.

사용자 인터랙션이 짧고 추첨이 보통 수백 밀리초 안에 끝난다면 제한된 시간만 기다리는 방식이 편하다.

async function waitForCompletion(
  load: () => Promise<StoredRequest>,
  timeoutMs = 1500,
): Promise<StoredRequest | null> {
  const deadline = Date.now() + timeoutMs;
  let delayMs = 40;

  while (Date.now() < deadline) {
    const row = await load();
    if (row.status !== "processing") return row;

    await new Promise((resolve) => setTimeout(resolve, delayMs));
    delayMs = Math.min(delayMs * 2, 250);
  }
  return null;
}

무한히 기다리거나 고정 간격으로 DB를 과도하게 조회하지 않는다. 시간 예산을 넘으면 명시적인 처리 중 응답을 보낸다.

HTTP/1.1 202 Accepted
Retry-After: 1
Location: /api/draw-requests/req-demo-001
Content-Type: application/json

{
  "status": "processing",
  "requestId": "req-demo-001"
}

완료된 응답을 그대로 재생한다

멱등성은 ‘두 번째에는 비슷한 현재 상태를 다시 계산해 반환한다’는 뜻이 아니다. 첫 요청에서 확정된 결과를 재생해야 한다.

추첨 뒤 사용자가 상품을 교환하거나 배송 신청을 했다면 현재 보관함을 다시 조회한 응답은 최초 추첨 응답과 달라질 수 있다. 랜덤 결과를 다시 생성하는 것은 더 위험하다.

따라서 완료 시 다음 중 하나를 저장한다.

가장 명확한 것은 직렬화된 응답 스냅샷이다.

{
  "requestId": "req-demo-001",
  "drawBatchId": "batch_demo_a1",
  "results": [
    {
      "drawId": "draw_demo_01",
      "prizeId": "prize_demo_blue",
      "rank": "B"
    }
  ],
  "chargedPoints": 500,
  "remainingPoints": 4200
}

응답에 시각 자산의 일회성 서명 URL이나 서버 시각처럼 매번 달라지는 필드를 넣으면 재생하기 어렵다. 불변 식별자를 저장하고, 필요한 URL은 응답 계층에서 안전하게 다시 붙이는 식으로 경계를 나눌 수 있다.

재생 응답에는 헤더를 추가하면 디버깅에 도움이 된다.

HTTP/1.1 200 OK
Idempotency-Replayed: true
Content-Type: application/json
상태 코드를 포함해 저장한다

본문만 저장하면 최초에는 201 Created, 재시도에는 실수로 200 OK를 반환하는 식의 계약 차이가 생긴다. 클라이언트가 상태 코드에 의존한다면 최초 상태도 함께 저장한다.

처리 중 멈춘 요청을 어떻게 복구할까

멱등성 레코드를 processing으로 커밋한 뒤 외부 작업을 수행하는 구조라면 프로세스가 죽어 행이 영원히 남을 수 있다. 같은 DB 트랜잭션 안에서 모든 업무를 처리하면 롤백되므로 문제가 단순하지만, 외부 결제나 메시지 발행이 끼면 긴 트랜잭션을 유지할 수 없다.

이때 lease_expires_at을 둔다.

processing + lease 유효
→ 다른 실행자가 처리 중이라고 판단

processing + lease 만료
→ 실제 부수 효과를 조회
→ 이미 완료됐다면 결과 복구
→ 확실히 미실행이면 소유권 재획득
→ 커밋 여부가 불명확하면 자동 재실행 금지

lease가 만료됐다는 사실만으로 즉시 추첨을 다시 실행하면 안 된다. 이전 작업이 느리게 계속 실행 중일 수도 있다. fencing token이나 세대 번호를 발급해 오래된 실행자가 최종 결과를 덮어쓰지 못하게 할 수 있다.

UPDATE idempotency_requests
SET lease_version = lease_version + 1,
    lease_expires_at = now() + interval '30 seconds'
WHERE actor_id = :actor_id
  AND operation = 'draw'
  AND request_id = :request_id
  AND status = 'processing'
  AND lease_expires_at < now()
RETURNING lease_version;

후속 업데이트는 자신이 받은 lease_version과 일치할 때만 허용한다.

UPDATE idempotency_requests
SET status = 'succeeded',
    response_body = :response_body,
    completed_at = now()
WHERE actor_id = :actor_id
  AND operation = 'draw'
  AND request_id = :request_id
  AND status = 'processing'
  AND lease_version = :my_lease_version;

다만 추첨처럼 DB 내부에서 끝낼 수 있는 작업이라면 이런 복잡성을 도입하기보다 키 선점과 업무 변경을 한 트랜잭션으로 묶는 것이 낫다.

오류 결과도 저장해야 할까

모든 오류를 같은 방식으로 캐시하면 안 된다. 오류를 결정적 결과일시적 실행 실패로 나눈다.

오류 같은 입력으로 즉시 재시도 멱등성 결과 저장
수량 범위 오류 성공 가능성 없음 짧게 저장하거나 요청 전 검증
포인트 부족 보통 동일 결과 정책에 따라 rejected 저장
판매 종료 동일 결과 rejected 저장 가능
DB 연결 실패, 트랜잭션 시작 전 성공 가능 완료 결과로 저장하지 않음
트랜잭션 롤백 확인 성공 가능 키도 함께 롤백
커밋 응답 유실 이미 성공했을 수 있음 결과 조회 후 복구
내부 응답 렌더링 실패 부수 효과는 성공 성공 리소스에서 응답 재구성

포인트 부족을 오래 저장하면 사용자가 포인트를 충전한 뒤 같은 키로 재시도해도 계속 실패한다. 이것이 올바른지는 request ID의 의미에 달려 있다. 보통 사용자가 다시 버튼을 누르는 새로운 의도에는 새 request ID를 만들고, 전송 오류에 의한 재시도만 같은 ID를 사용한다.

{
  "error": {
    "code": "INSUFFICIENT_POINTS",
    "message": "포인트가 부족합니다.",
    "requestId": "req-demo-001",
    "replayed": true
  }
}

TTL과 데이터 정리 정책

멱등성 레코드를 영구 보관할 필요는 없지만 너무 빨리 지우면 늦은 재시도가 새 요청으로 실행된다.

TTL을 정할 때는 다음 시간을 본다.

예를 들어 멱등성 응답은 7일 보관하고, 실제 추첨·포인트 원장은 별도 보존 정책을 적용할 수 있다. TTL이 지났더라도 동일한 도메인 작업을 탐지할 보조 식별자가 필요할 수 있다.

CREATE INDEX idempotency_requests_expiry_idx
ON idempotency_requests (expires_at);

-- 실제 운영에서는 작은 배치와 vacuum 영향을 함께 고려한다.
DELETE FROM idempotency_requests
WHERE expires_at < now()
  AND status <> 'processing';

응답 본문에 개인정보가 포함된다면 TTL과 별개로 최소화하거나 암호화해야 한다. 가능하면 멱등성 테이블에는 추첨 결과의 최소 스냅샷만 둔다.

클라이언트에서 request ID를 다루는 법

서버만 잘 만들어도 클라이언트가 재시도마다 새 ID를 만들면 멱등성이 동작하지 않는다.

flowchart LR
    A[사용자가 추첨 확정] --> B[request ID 한 번 생성]
    B --> C[요청 본문과 함께 보관]
    C --> D{응답 수신?}
    D -->|네트워크 실패| E[같은 ID와 같은 본문 재전송]
    E --> D
    D -->|결정적 결과| F[요청 종료]
    F --> G[다음 사용자 의도에 새 ID 생성]

브라우저 예시는 다음과 같이 구성할 수 있다.

type PendingDraw = {
  requestId: string;
  boxId: string;
  quantity: number;
};

async function submitDraw(pending: PendingDraw) {
  return fetch("/api/draw", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": pending.requestId,
    },
    body: JSON.stringify({
      boxId: pending.boxId,
      quantity: pending.quantity,
    }),
  });
}

버튼을 잠그는 UI도 필요하지만 정확성 보장은 아니다. 더블 클릭은 줄여도 탭 새로고침, 타임아웃, 프록시 재전송은 막지 못한다.

오프라인 큐에 저장할 때는 request ID와 정규화된 본문을 함께 원자적으로 보관한다. 사용자가 수량을 바꾸면 기존 ID를 재사용하지 않고 새로운 의도로 취급한다.

GET 요청으로 추첨하지 않는다

브라우저, 프리패처, 크롤러는 GET을 미리 호출할 수 있다. 상태를 변경하는 추첨은 POST로 만들고, 인증과 CSRF 방어를 포함한다. request ID가 보안 통제를 대신하지는 않는다.

테스트해야 하는 실패 시나리오

정상 요청 두 번만 테스트해서는 멱등성을 검증했다고 보기 어렵다. 실패가 발생하는 지점을 의도적으로 나눠야 한다.

같은 키와 같은 본문

test("same key replays the original draw", async () => {
  const key = "req_test_same_0001";
  const command = { boxId: "box-demo", quantity: 1 };

  const first = await draw(command, key);
  const second = await draw(command, key);

  expect(second.body).toEqual(first.body);
  expect(second.headers["idempotency-replayed"]).toBe("true");
  expect(await countDrawBatches(key)).toBe(1);
  expect(await countPointDebits(key)).toBe(1);
});

같은 키와 다른 본문

test("same key with a different body is rejected", async () => {
  const key = "req_test_conflict_0001";
  await draw({ boxId: "box-demo", quantity: 1 }, key);

  await expect(
    draw({ boxId: "box-demo", quantity: 10 }, key),
  ).rejects.toMatchObject({
    status: 409,
    code: "IDEMPOTENCY_KEY_REUSED",
  });
});

동시 요청

test("concurrent duplicates create one draw batch", async () => {
  const key = "req_test_race_0001";
  const command = { boxId: "box-demo", quantity: 1 };

  const results = await Promise.all(
    Array.from({ length: 8 }, () => draw(command, key)),
  );

  expect(new Set(results.map((result) => result.body.drawBatchId)).size).toBe(1);
  expect(await countDrawBatches(key)).toBe(1);
});

커밋 후 응답 유실

서버가 DB 커밋 직후 연결을 끊도록 fault injection을 넣는다. 클라이언트의 재시도가 저장된 결과를 받고, 포인트와 재고가 한 번만 변했는지 확인한다.

롤백 후 재시도

재고 차감 전에 강제로 예외를 내고 트랜잭션 전체가 롤백되는지 확인한다. 같은 키로 재시도했을 때 정상적으로 실행 가능해야 한다.

오래된 processing

lease가 만료된 행이 있을 때 이전 부수 효과를 확인하지 않고 곧바로 재실행하지 않는지 테스트한다.

불변식으로 검증한다

응답 성공 여부만 보지 말고 포인트 원장 합계, 재고 감소량, draw batch 수, inventory item 수가 요청 수량과 일치하는지 확인한다. 멱등성 버그는 여러 테이블 사이 불변식을 깨뜨린다.

운영 지표와 감사 로그

멱등성은 정상 동작하면 사용자에게 보이지 않는다. 그래서 운영 지표가 없으면 재시도가 얼마나 발생하는지, 충돌이 클라이언트 버그인지 알기 어렵다.

로그에는 원문 키 전체 대신 해시나 앞부분만 남기는 방법을 고려한다.

{
  "event": "idempotency_replayed",
  "operation": "draw",
  "request_key_hash": "demo:8ad1...",
  "actor_hash": "demo:2c90...",
  "stored_status": "succeeded",
  "wait_ms": 84,
  "app_version": "example-2.4.0"
}

동일 키·다른 지문 충돌이 특정 앱 버전에서 급증하면 클라이언트가 request ID를 화면 세션 전체에서 재사용하는 버그일 수 있다. 반대로 처리 중 timeout이 늘면 추첨 트랜잭션이나 DB 락 대기를 확인한다.

로그 답하는 질문
멱등성 로그 같은 사용자 명령이 몇 번 전달됐고 무엇을 재생했는가
추첨 감사 로그 어떤 재고와 규칙으로 어떤 결과가 확정됐는가
포인트 원장 왜 잔액이 이 값이 되었는가
HTTP 접근 로그 어떤 클라이언트가 언제 요청했는가

서로 연결할 request_id, draw_batch_id, ledger_entry_id를 남기되 외부 노출 범위는 제한한다.

구현하면서 놓치기 쉬운 경계

랜덤 시드만 고정하면 충분하지 않다

request ID로 랜덤 시드를 만들면 같은 번호를 뽑을 수는 있다. 하지만 재고와 포인트 차감은 여전히 두 번 일어날 수 있고, 첫 요청 뒤 재고 순서가 변하면 결과도 달라질 수 있다. 확정 결과와 부수 효과를 저장해야 한다.

클라이언트 키를 업무 리소스의 기본 키로 그대로 쓰지 않는다

외부 입력을 내부 추첨 ID로 그대로 쓰면 키 형식과 수명, 노출 정책이 강하게 결합된다. 멱등성 요청과 생성된 draw_batch_id를 분리해 연결한다.

응답 캐시와 멱등성 저장소는 다르다

일반 HTTP 캐시는 읽기 성능을 위한 것이고, 멱등성 레코드는 부수 효과의 중복을 막기 위한 조정 상태다. 캐시 eviction 때문에 정확성이 깨져서는 안 된다.

서버 timeout이 롤백을 뜻하지 않는다

API 함수가 timeout됐어도 DB 쿼리가 이미 커밋됐을 수 있다. ‘응답을 못 받았으니 실패’라고 판단해 새 키로 재실행하지 않는다. 같은 키로 상태를 확인한다.

멱등성 키는 인증과 권한 검사를 건너뛰지 않는다

저장된 결과를 재생할 때도 현재 요청의 사용자와 키 소유자가 같은지 확인한다. 단순히 request ID만 알면 결과를 조회할 수 있게 만들면 정보 노출이 된다.

모든 엔드포인트에 같은 정책을 강제하지 않는다

읽기 요청은 본래 안전해야 하고, 프로필 이름 변경처럼 최종 상태를 설정하는 PUT은 자연스럽게 멱등적일 수 있다. 추첨, 결제 승인, 포인트 전환처럼 한 번의 사건을 생성하는 POST에 우선 적용한다.

다운스트림 이벤트에도 식별자를 전파한다

DB 트랜잭션 뒤 메시지를 발행한다면 API 멱등성만으로 소비자의 중복 처리를 막을 수 없다. outbox event에 안정적인 event ID를 넣고 소비자도 처리 기록을 둔다. 관련 원리는 재시도 가능한 API에 Idempotency-Key 적용하기와 연결된다.

정리

Goose Kuzi의 추첨은 랜덤 결과만 생성하는 API가 아니다. 포인트, 유한 재고, 사용자 보관함, 감사 로그가 동시에 변하는 명령이다. 이런 API는 트랜잭션만으로는 네트워크 재시도의 중복을 막지 못한다.

설계를 정리하면 다음과 같다.

  1. request ID를 사용자의 한 번의 실행 의도로 정의한다.
  2. 유일 범위를 (인증 사용자, 명령 종류, request ID)로 고정한다.
  3. 멱등성에 영향을 주는 요청 필드로 지문을 만든다.
  4. 같은 키·같은 지문은 기존 상태를 따르고, 다른 지문은 409로 거절한다.
  5. 가능하면 키 선점과 포인트·재고·결과 저장을 한 DB 트랜잭션에 둔다.
  6. 동시 중복은 unique constraint로 한 실행자만 선점하게 한다.
  7. 완료된 결과는 다시 계산하지 않고 상태 코드와 응답을 재생한다.
  8. 처리 중 timeout, 커밋 응답 유실, 롤백을 서로 다른 상태로 다룬다.
  9. 클라이언트는 재시도에 같은 ID와 같은 본문을 사용하고 새 의도에는 새 ID를 만든다.
  10. 응답뿐 아니라 원장과 재고의 불변식을 동시성 테스트로 검증한다.

멱등성의 목적은 요청을 한 번만 받는 것이 아니다. 요청이 몇 번 도착하더라도 사용자의 한 번의 의도만 실행되게 만드는 것이다.

관련 노트