게임 보상 수령 API에 Idempotency Key 적용하기: 중복 지급을 막는 서버 설계

게임 보상 수령 API의 타임아웃과 재시도로 발생하는 중복 지급 문제를 PostgreSQL 트랜잭션, 유니크 제약조건, Idempotency Key로 해결하는 서버 설계와 구현 패턴을 정리합니다. 동시 요청 처리와 실패 복구 전략도 함께 설명합니다.

✓ 복사 완료!
챕터게임 백엔드 & 데이터베이스
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
게임 보상 수령 API에 Idempotency Key 적용하기: 중복 지급을 막는 서버 설계

핵심 요약

보상 수령 API는 클라이언트 타임아웃, 사용자의 연속 클릭, 게이트웨이 재시도로 같은 요청이 여러 번 처리될 수 있습니다. Idempotency-Key를 요청 단위의 고유 식별자로 저장하고 보상 원장과 같은 PostgreSQL 트랜잭션에서 처리하면 동일한 작업의 중복 지급을 막을 수 있습니다.

핵심 원리는 다음과 같습니다.

  • 같은 플레이어와 같은 작업에서 같은 키가 다시 오면 최초 처리 결과를 그대로 반환합니다.
  • 같은 키로 다른 보상이나 다른 파라미터를 요청하면 409 Conflict를 반환합니다.
  • 보상 원장에는 request_id에 대한 유니크 제약조건을 두고 잔액 변경과 함께 원자적으로 커밋합니다.

보상 중복 지급은 왜 발생하는가?

게임 클라이언트가 보상 수령 요청을 보낸 뒤 서버가 지급과 데이터베이스 커밋을 완료했다고 가정해 보겠습니다. 그러나 응답이 네트워크에서 유실되면 클라이언트는 성공 여부를 알 수 없습니다. 일반적인 재시도 로직은 같은 요청을 다시 보내고 서버는 이를 새로운 지급 요청으로 처리할 수 있습니다.

중복 지급은 다음 상황에서 자주 발생합니다.

상황중복 지급이 발생하는 이유
응답 타임아웃서버는 성공했지만 클라이언트가 응답을 받지 못함
보상 버튼 연속 클릭동일한 보상 수령 요청이 동시에 전송됨
API Gateway 재시도네트워크 오류를 일시적 실패로 판단해 요청을 재전송함
멀티 디바이스 접속모바일과 PC에서 같은 보상을 동시에 수령함
서버 프로세스 재시작처리 결과를 응답하기 전에 프로세스가 종료됨

인증과 권한 검사는 이 문제를 해결하지 못합니다. 요청이 모두 정상적인 플레이어의 요청이어도 동일한 비즈니스 작업을 여러 번 실행할 수 있기 때문입니다.

flowchart TD
    A[클라이언트 보상 수령 요청] --> B[Idempotency-Key 검증]
    B --> C{동일 키가 존재하는가?}
    C -->|아니오| D[요청 기록 생성]
    C -->|예| E{요청 본문 해시가 같은가?}
    E -->|아니오| F[409 Conflict 반환]
    E -->|예, 성공 처리됨| G[저장된 응답 재생]
    E -->|예, 처리 중| H[행 잠금 후 처리 결과 확인]
    D --> I[보상 원장 기록과 잔액 변경]
    H --> I
    I --> J[같은 트랜잭션에서 성공 결과 저장]
    J --> K[응답 반환]

Idempotency Key란 무엇인가?

Idempotency Key는 동일한 API 요청을 여러 번 받아도 서버의 최종 효과가 한 번만 발생하도록 만드는 요청 식별자입니다. 클라이언트는 보상 수령 버튼을 누를 때마다 무작위 UUID v4 또는 ULID를 생성하고 네트워크 재시도에서는 같은 키를 사용해야 합니다.

POST /v1/rewards/daily-2026-08-24/claim
Idempotency-Key: 01J5YQ3QY7J8N7H3M7Q8V2K4P6
Content-Type: application/json
Authorization: Bearer access-token

키는 전역적으로만 유일하면 충분하지 않습니다. 보통 다음 범위를 함께 사용합니다.

(player_id, operation_type, idempotency_key)

이렇게 하면 서로 다른 플레이어가 우연히 같은 키를 사용해도 충돌하지 않고 일일 보상 수령과 시즌 패스 보상 수령처럼 작업 종류가 다른 API도 분리할 수 있습니다.

항목권장 설계
키 형식UUID v4 또는 ULID
최대 길이128자 이하
저장 범위플레이어 ID와 작업 종류를 포함한 복합 유니크 키
요청 검증정규화한 요청 본문의 SHA-256 해시 저장
결과 재생HTTP 상태 코드와 응답 본문을 함께 저장
보존 기간클라이언트 재시도 기간 이상. 보상 원장은 영구 보존 권장

Idempotency Key는 보상 자체를 식별하는 reward_id와 다릅니다. reward_id는 어떤 보상인지 나타내고 Idempotency Key는 한 번의 수령 시도를 나타냅니다.

PostgreSQL에서 보상 지급을 어떻게 원자적으로 처리할까?

Idempotency 기록과 보상 원장을 분리하되 동일한 트랜잭션으로 묶는 것이 기본입니다. Idempotency 기록은 요청 결과를 재생하기 위한 데이터이고 보상 원장은 실제 경제 데이터의 감사 로그입니다.

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TYPE reward_claim_status AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED');

CREATE TABLE idempotency_requests (
    request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    player_id BIGINT NOT NULL,
    operation_type TEXT NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    status reward_claim_status NOT NULL DEFAULT 'PENDING',
    response_status INTEGER,
    response_json JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (player_id, operation_type, idempotency_key)
);

CREATE TABLE reward_grants (
    grant_id BIGSERIAL PRIMARY KEY,
    request_id UUID NOT NULL REFERENCES idempotency_requests(request_id),
    player_id BIGINT NOT NULL,
    reward_id TEXT NOT NULL,
    amount BIGINT NOT NULL CHECK (amount > 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (request_id)
);

특정 보상을 플레이어당 한 번만 받을 수 있다면 다음 비즈니스 제약조건도 필요합니다.

CREATE UNIQUE INDEX reward_grants_once_per_player
ON reward_grants (player_id, reward_id);

UNIQUE (request_id)는 같은 요청 키가 재처리되는 상황을 막습니다. 반면 다른 Idempotency Key를 사용해 같은 보상을 다시 요청하는 문제는 UNIQUE (player_id, reward_id) 같은 보상 정책 제약조건이 막아야 합니다. 두 제약조건은 서로 다른 문제를 해결합니다.

Idempotency Key를 보상 수령 API에 어떻게 구현할까?

1. 요청 경계를 먼저 검증한다

서버는 인증된 player_id를 기준으로 키를 저장해야 합니다. 클라이언트가 보낸 플레이어 ID를 신뢰하면 안 됩니다.

검증 순서는 다음과 같이 구성할 수 있습니다.

  1. 인증 토큰에서 player_id를 추출합니다.
  2. Idempotency-Key가 없거나 128자를 초과하면 400 Bad Request를 반환합니다.
  3. 보상 ID와 수령 조건을 검증합니다.
  4. 정규화한 요청 본문으로 SHA-256 request_hash를 계산합니다.

같은 키를 재사용했는데 요청 본문 해시가 다르면 키 충돌 또는 프로그래밍 오류입니다. 이 경우 기존 결과를 반환하지 말고 409 Conflict를 반환해야 합니다.

2. 요청 기록과 보상 지급을 같은 트랜잭션에서 처리한다

핵심 SQL 흐름은 다음과 같습니다.

BEGIN;

INSERT INTO idempotency_requests (
    player_id,
    operation_type,
    idempotency_key,
    request_hash
)
VALUES ($1, $2, $3, $4)
ON CONFLICT (player_id, operation_type, idempotency_key)
DO NOTHING;

SELECT *
FROM idempotency_requests
WHERE player_id = $1
  AND operation_type = $2
  AND idempotency_key = $3
FOR UPDATE;

-- 애플리케이션이 반환 행을 확인한다. request_hash가 다르면 ROLLBACK 후 409,
-- status가 SUCCEEDED이면 저장된 response_json을 반환하고 COMMIT한다.
-- 신규 요청이면 반환된 request_id를 다음 문장의 $1로 바인딩한다.

INSERT INTO reward_grants (request_id, player_id, reward_id, amount)
VALUES ($1, $2, $3, $4);

UPDATE player_balances
SET coins = coins + $1,
    updated_at = now()
WHERE player_id = $2;

UPDATE idempotency_requests
SET status = 'SUCCEEDED',
    response_status = $1,
    response_json = $2,
    updated_at = now()
WHERE request_id = $3;

COMMIT;

SELECT ... FOR UPDATE는 같은 Idempotency Key를 동시에 처리하는 트랜잭션이 한 행에 순서대로 접근하도록 만듭니다. 최초 요청이 성공하면 후속 요청은 저장된 응답을 읽고 보상 지급 SQL을 다시 실행하지 않습니다.

중요한 점은 보상 원장 INSERT와 잔액 UPDATE를 별도 트랜잭션으로 나누지 않는 것입니다. 원장만 기록되고 잔액이 증가하지 않거나 잔액만 증가하고 원장이 없는 상태가 생기면 경제 데이터 복구가 어려워집니다.

3. 성공 결과를 저장하고 그대로 재생한다

성공 응답은 Idempotency 테이블에 저장합니다.

{
  "rewardId": "daily-2026-08-24",
  "granted": {
    "currency": "coin",
    "amount": 100
  },
  "balance": 1250
}

같은 키로 재시도한 요청에는 현재 잔액을 다시 계산한 응답보다 최초 응답을 그대로 반환하는 편이 안전합니다. 재시도 시점에 다른 소비가 발생하면 잔액이 달라질 수 있기 때문입니다. 응답에 최초 지급 결과만 필요하다면 grant_id, 지급 수량, 지급 시각을 저장해도 됩니다.

동시 요청과 실패 복구는 어떻게 테스트할까?

Idempotency는 순차 테스트만으로 검증하기 어렵습니다. 다음 시나리오를 자동화 테스트에 포함해야 합니다.

테스트기대 결과
같은 키로 1회 요청보상 원장 1건, 성공 응답 1건
같은 키로 동시에 100회 요청보상 원장 1건, 모든 요청이 같은 결과를 받음
같은 키에 다른 요청 본문409 Conflict, 추가 지급 없음
다른 키로 같은 일일 보상 요청보상 정책에 따라 성공 또는 유니크 충돌 처리
지급 커밋 직후 응답 유실재시도 시 추가 지급 없이 저장된 결과 재생
트랜잭션 중 데이터베이스 연결 종료전체 롤백 후 재시도 가능
다른 리전에 동시 요청단일 쓰기 데이터베이스 또는 전역적으로 일관된 저장소에서 유니크 제약 적용

특히 응답 유실 테스트가 중요합니다. 서버가 COMMIT을 완료한 뒤 네트워크 응답을 버리는 장애를 주입하고 같은 키로 재시도했을 때 원장 행이 정확히 한 개만 존재하는지 확인해야 합니다.

Redis만으로 Idempotency Key를 처리해도 될까?

Redis의 SET key value NX EX 86400은 빠른 중복 요청 차단에 유용하지만 보상 지급의 최종 원장으로 사용하기에는 부족할 수 있습니다. Redis 키가 만료되거나 장애 복구 과정에서 유실되면 같은 요청이 다시 처리될 가능성이 있기 때문입니다.

권장 역할은 다음과 같습니다.

  • PostgreSQL: Idempotency 기록, 보상 원장, 잔액 변경의 최종 데이터
  • Redis: 짧은 시간 동안의 중복 트래픽 완화와 캐시
  • 메시지 큐: 비동기 지급이 필요할 때의 재처리 전달
  • 모니터링 시스템: 중복 키, 충돌, 실패율, 처리 지연 관측

비동기 지급이라면 API에서 요청 기록을 PENDING으로 커밋한 뒤 워커가 지급을 수행할 수 있습니다. 이때 워커는 반드시 request_id와 보상 원장의 유니크 제약조건을 사용해야 하며 처리 중인 요청을 회수할 수 있도록 lease 또는 재처리 정책을 함께 설계해야 합니다.

보안과 운영에서 놓치기 쉬운 부분

Idempotency Key 자체는 인증 토큰이 아닙니다. 키를 알고 있는 사용자가 다른 플레이어의 결과를 조회하지 못하도록 모든 조회와 재생은 인증된 플레이어 범위 안에서 수행해야 합니다.

또한 다음 항목을 운영 지표로 수집하면 장애 원인을 빠르게 찾을 수 있습니다.

  • 키가 없는 보상 요청 비율
  • 동일 키 재생 비율
  • 요청 해시 불일치로 발생한 409 비율
  • PENDING 상태의 최대 지속 시간
  • 보상 원장 유니크 충돌 횟수
  • API 성공 수와 실제 원장 생성 수의 차이

로그에는 전체 Idempotency Key와 액세스 토큰을 남기지 않는 것이 좋습니다. 필요하다면 키의 앞부분 일부 또는 SHA-256 해시만 기록하고 로그와 응답에는 플레이어 개인정보를 최소화합니다.

자주 묻는 질문 (FAQ)

Idempotency Key를 클라이언트가 생성해도 안전한가요?

가능합니다. 키는 작업 식별자일 뿐 보상 권한이 아니므로 서버의 인증과 보상 조건 검사가 반드시 필요합니다. 서버는 키를 플레이어 ID와 함께 저장해야 합니다.

Idempotency Key의 보존 기간은 얼마가 적절한가요?

최소한 클라이언트와 게이트웨이가 재시도할 수 있는 기간보다 길어야 합니다. 보상 원장은 영구 보존하고 Idempotency 응답 기록은 서비스의 재시도 정책과 감사 요구사항을 기준으로 삭제 기간을 정하는 것이 안전합니다.

같은 보상에 다른 키를 사용하면 중복 지급되지 않나요?

Idempotency Key만으로는 막을 수 없습니다. 플레이어당 한 번만 지급되는 보상이라면 player_id와 reward_id에 유니크 제약조건을 추가하고 이미 지급된 원장 행을 기준으로 처리해야 합니다.

정리

보상 수령 API의 중복 지급 문제는 재시도를 없애서 해결하는 것이 아니라 재시도가 발생해도 결과가 한 번만 반영되도록 설계해서 해결해야 합니다. Idempotency-Key로 요청을 식별하고 요청 해시 검증과 저장된 응답 재생을 적용하며 보상 원장과 잔액 변경을 PostgreSQL 트랜잭션과 유니크 제약조건으로 묶는 것이 핵심입니다.

추가로 보상 정책별 유니크 조건을 분리하고 Redis를 보조 수단으로만 사용하며 동시 요청과 응답 유실을 포함한 장애 주입 테스트를 수행해야 운영 환경에서도 안정적인 게임 경제 시스템을 만들 수 있습니다.