본문 바로가기
Backend2026년 8월 27일18분 읽기

멱등성 키(Idempotency Key) — 결제·주문 API에서 중복 요청을 안전하게 막기

YS
김영삼
조회 66
멱등성 키(Idempotency Key) — 결제·주문 API에서 중복 요청을 안전하게 막기

결제 버튼을 두 번 눌렀는데 카드가 두 번 긁혔다면, 그건 사용자 실수가 아니라 API 설계 실수다.

네트워크는 언제든 응답을 잃어버리고, 클라이언트는 언제든 재시도한다. 멱등성 키는 "같은 요청을 여러 번 보내도 결과는 딱 한 번"을 서버가 보장하게 만드는 계약이다. 이 글은 그 계약을 결제·주문 API에서 어떻게 안전하게 구현하는지, 특히 동시성 경쟁 조건을 어떻게 막는지 실전 코드로 다룬다.

1회
동일 키 요청이 N번 와도 실제 처리 횟수
24h
Stripe 기본 키 보관(TTL) 기간
409
키는 같지만 본문이 다를 때 반환 코드
키 생성 주체는 서버가 아닌 클라이언트

왜 필요한가: 네트워크는 응답을 잃어버린다

분산 시스템에서 가장 흔한 착각은 "요청이 실패했다"와 "응답을 못 받았다"를 같은 것으로 취급하는 것이다. 클라이언트가 결제 요청을 보내고 타임아웃이 났다고 하자. 이때 서버가 결제를 처리하지 않았는지, 아니면 처리했지만 응답이 네트워크 어딘가에서 유실됐는지 클라이언트는 구분할 방법이 없다. 안전하게 재시도하면 이중 결제, 재시도하지 않으면 결제 누락. 둘 다 사고다.

중복 요청이 생기는 경로는 생각보다 많다. 사용자가 느린 응답에 답답해서 결제 버튼을 여러 번 누르는 더블클릭, 모바일 앱이 백그라운드로 갔다가 돌아오며 요청을 다시 쏘는 경우, 로드밸런서나 프록시의 자동 재시도, 그리고 뒤에서 다룰 at-least-once 메시지 큐의 재전달까지. 이 모든 상황에서 "두 번째 요청은 무시하고 첫 번째 결과를 그대로 돌려준다"를 서버가 스스로 판단하게 만드는 것이 멱등성 키의 목적이다.

참고HTTP 명세상 GET·PUT·DELETE는 이미 멱등하다고 정의된다. 문제는 대부분의 결제·주문 생성이 POST라는 점이다. POST는 본질적으로 비멱등이므로, 멱등성을 원한다면 애플리케이션 계층에서 명시적으로 키를 도입해야 한다.

멱등성 vs 재시도: 헷갈리기 쉬운 경계

두 개념은 짝이지만 같은 것이 아니다. 재시도(retry)는 클라이언트의 전략이다. 실패하거나 응답이 없을 때 요청을 다시 보내는 행위. 멱등성(idempotency)은 서버의 속성이다. 같은 요청이 여러 번 도착해도 부작용(side effect)이 한 번만 일어나도록 보장하는 것. 재시도는 멱등성이 있어야만 안전하다. 멱등성 없이 재시도하면 그게 바로 이중 결제다.

실무 규칙으로 정리하면 이렇다. 클라이언트는 지수 백오프 + 지터로 재시도하되, 첫 요청부터 멱등성 키를 붙여 보낸다. 재시도 시에는 반드시 같은 키를 재사용한다. 키를 새로 만들면 서버 입장에선 완전히 다른 요청이라 중복 방지가 무력화된다. 이 "재시도 = 동일 키" 원칙이 전체 설계의 핵심이다.

구분재시도멱등성
주체클라이언트서버
성격행동/전략보장/속성
없으면?일시 오류가 최종 실패로재시도가 이중 처리로
핵심 규칙백오프+지터, 상한 설정동일 키는 동일 결과 반환

Stripe식 설계: Idempotency-Key 헤더

업계 표준이 된 방식은 Stripe가 대중화한 HTTP 헤더 기반 설계다. 클라이언트가 요청마다 고유한 키(보통 UUID v4)를 생성해 Idempotency-Key 헤더에 담아 보낸다. 서버는 이 키를 기준으로 "처음 보는 키면 처리하고 결과를 저장, 이미 본 키면 저장된 결과를 그대로 반환"한다. 키는 요청 본문이 아니라 헤더에 두는데, 이렇게 하면 결제 로직과 중복 방지 로직을 미들웨어 수준에서 깔끔하게 분리할 수 있다.

POST /v1/payments HTTP/1.1
Host: api.example.com
Authorization: Bearer sk_live_...
Idempotency-Key: 3f2a9c14-8b7e-4d21-9f6a-1c0e5b8a7d42
Content-Type: application/json

{
  "amount": 42000,
  "currency": "KRW",
  "order_id": "order_20260831_00187",
  "payment_method": "pm_card_visa"
}

# 같은 결제를 재시도할 때 -> 반드시 동일한 Idempotency-Key 재사용
# 서버는 첫 응답(201 + payment 객체)을 그대로 다시 돌려준다.

키는 반드시 클라이언트가 생성해야 한다. 서버가 만들어 주면 요청이 서버에 도달하기 전에 유실됐을 때 재시도할 키가 없어져 목적을 잃는다. 키 범위(scope)도 중요하다. 보통 (API 키/계정 + 엔드포인트 + 키 값) 조합으로 유일성을 잡아, 다른 사용자의 키와 충돌하지 않게 한다.

키 저장과 응답 캐싱

서버는 키별로 요청의 최종 결과를 저장해 둔다. 저장하는 것은 상태와 응답 스냅샷이다. 상태는 최소 started(처리 중)와 completed(완료) 두 가지가 필요하다. 완료 시에는 HTTP 상태 코드와 응답 본문을 통째로 캐싱해 두었다가, 같은 키가 다시 오면 실제 결제 로직을 건너뛰고 그대로 재생(replay)한다.

CREATE TABLE idempotency_keys (
  id            BIGSERIAL PRIMARY KEY,
  -- 계정 + 키로 유일성 보장 (경쟁 조건 방어의 핵심)
  account_id    BIGINT      NOT NULL,
  idem_key      TEXT        NOT NULL,
  -- 요청 본문 지문: 같은 키인데 본문이 다른지 판별
  request_fingerprint TEXT  NOT NULL,
  status        TEXT        NOT NULL DEFAULT 'started', -- started | completed
  response_code INT,
  response_body JSONB,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  -- TTL 정리용
  expires_at    TIMESTAMPTZ NOT NULL,
  CONSTRAINT uq_account_key UNIQUE (account_id, idem_key)
);

-- 만료 스캔을 위한 인덱스
CREATE INDEX idx_idem_expires ON idempotency_keys (expires_at);
참고응답 캐싱에서 실수하기 쉬운 지점은 4xx·5xx 응답을 어떻게 다룰지다. 4xx(예: 잔액 부족)는 결정적 실패이므로 캐싱해서 같은 답을 돌려주는 게 맞다. 반면 5xx나 처리 도중 서버가 죽은 경우는 결과가 불확정이므로 캐싱하지 말고 재시도가 실제 처리로 이어지도록 두는 편이 안전하다.

경쟁 조건: 같은 키가 동시에 두 번 도착할 때

가장 까다로운 부분이다. 사용자가 더블클릭하면 두 요청이 거의 동시에 서버에 도착한다. 둘 다 "이 키 처음 보네?"라고 판단하면 결제가 두 번 일어난다. 단순히 "SELECT로 확인하고 없으면 INSERT"하는 코드는 이 사이의 틈(check-then-act)에서 깨진다. 해결책은 DB 유니크 제약에 판정을 위임하는 것이다.

핵심 트릭은 이렇다. 처리를 시작할 때 먼저 status='started' 행을 유니크 제약이 걸린 키로 INSERT한다. 동시에 온 두 요청 중 단 하나만 INSERT에 성공하고, 나머지는 유니크 위반으로 실패한다. 성공한 쪽이 실제 결제를 진행하고, 실패한 쪽은 "이미 처리 중"으로 판단해 완료를 기다리거나 409/425를 반환한다. 경쟁의 승자를 애플리케이션 락이 아니라 DB의 원자적 INSERT가 가리게 하는 것이 안정적이다.

-- 원자적 선점: 성공하면 내가 처리 담당, 충돌(ON CONFLICT DO NOTHING)이면
-- 0행 반환 -> 이미 다른 요청이 선점했다는 뜻
INSERT INTO idempotency_keys
  (account_id, idem_key, request_fingerprint, status, expires_at)
VALUES
  ($1, $2, $3, 'started', now() + interval '24 hours')
ON CONFLICT (account_id, idem_key) DO NOTHING
RETURNING id;
# 서버 의사코드 (트랜잭션 경계 포함)
def handle_payment(account, idem_key, body):
    fp = fingerprint(body)  # 본문 정규화 후 SHA-256

    # 1) 원자적 선점 시도
    row = db.execute(INSERT_ON_CONFLICT, account.id, idem_key, fp)

    if row is None:
        # 2) 이미 존재하는 키 -> 기존 레코드 조회
        existing = db.get_key(account.id, idem_key)

        # 2a) 지문 불일치: 같은 키 다른 본문 -> 명백한 클라이언트 버그
        if existing.request_fingerprint != fp:
            return http(409, {"error": "idempotency_key_reuse_with_different_body"})

        # 2b) 아직 처리 중 -> 아직 결과 없음
        if existing.status == 'started':
            return http(409, {"error": "request_in_progress"})  # 또는 425/짧은 폴링

        # 2c) 완료됨 -> 저장된 응답 그대로 재생
        return http(existing.response_code, existing.response_body)

    # 3) 내가 선점자 -> 실제 결제 수행
    try:
        result = charge_card(body)         # 외부 PG 호출
        db.complete_key(row.id, 201, result)  # status=completed + 응답 저장
        return http(201, result)
    except CardDeclined as e:              # 결정적 실패 -> 캐싱
        db.complete_key(row.id, 402, e.body)
        return http(402, e.body)
    except TransientError:                 # 불확정 -> started 행 삭제(재시도 허용)
        db.delete_key(row.id)
        raise

PG 호출 자체도 멱등해야 완전하다. 즉 charge_card가 외부 결제사에 요청할 때도 같은 멱등성 키(또는 파생 키)를 전달해, PG 레벨에서도 이중 청구가 막히도록 한다. 멱등성은 한 계층만 지켜서는 새고, 요청이 지나가는 모든 상태 변경 지점에서 지켜져야 한다.

지문(fingerprint) 불일치와 TTL

같은 키로 다른 본문이 오면 어떻게 할까. 예를 들어 첫 요청은 42,000원인데 재시도라며 온 요청이 50,000원이라면, 이건 재시도가 아니라 클라이언트의 키 재사용 버그다. 서버는 저장된 요청 지문(본문을 정규화한 뒤 해시한 값)과 새 요청의 지문을 비교해, 다르면 422 또는 409로 거부해야 한다. 조용히 첫 결과를 돌려주면 두 번째 의도(5만원)가 소리 없이 무시되어 더 위험하다.

키를 영원히 저장할 수는 없으니 TTL을 둔다. Stripe는 24시간을 쓴다. TTL이 지나면 같은 키가 와도 새 요청으로 처리된다. 재시도는 보통 수 초~수 분 내에 끝나므로 24시간이면 충분히 넉넉하다. 정리는 배치 잡이나 DB의 TTL 기능(예: 파티션 드롭, DELETE ... WHERE expires_at < now())으로 주기적으로 수행한다.

-- 만료된 키 정리 (야간 배치 또는 크론). 대량이면 배치 삭제로 락 시간 최소화
DELETE FROM idempotency_keys
WHERE expires_at < now()
  AND id IN (
    SELECT id FROM idempotency_keys
    WHERE expires_at < now()
    LIMIT 10000
  );
  • 키 생성은 클라이언트, 유일성 판정은 DB 유니크 제약에 맡긴다.
  • 완료 응답은 캐싱해 재생하되, 불확정(5xx)은 캐싱하지 않는다.
  • 같은 키·다른 본문은 조용히 무시하지 말고 명시적으로 거부한다.
  • TTL을 넉넉히(예: 24h) 두고 배치로 정리한다.

at-least-once 큐와의 조합

Kafka·SQS·RabbitMQ 같은 메시지 큐는 대부분 at-least-once 전달을 보장한다. 즉 메시지가 최소 한 번은 오지만, 컨슈머가 처리 후 ack를 보내기 전에 죽으면 같은 메시지가 재전달된다. 이 재전달은 앞서 말한 네트워크 재시도와 본질적으로 같은 문제다. 따라서 메시지에 비즈니스 키(예: order_id)나 메시지 고유 ID를 멱등성 키로 삼아, 컨슈머가 "이미 처리한 메시지면 건너뛰기"를 하면 exactly-once에 준하는 효과(effectively-once)를 얻는다.

여기서 흔한 함정은 offset commit 순서다. 처리를 완료(DB 반영)하기 전에 ack/commit하면 메시지 유실, 후에 하면 중복 가능. 정석은 후자(처리 후 commit)를 택하고 멱등성으로 중복을 흡수하는 것이다. DB 트랜잭션 안에서 "비즈니스 처리 + 처리완료 표시"를 함께 커밋하면(transactional outbox/inbox 패턴), 중복 메시지가 와도 유니크 제약에 막혀 두 번째는 무해하게 통과한다.

주의"exactly-once delivery"는 분산 시스템에서 일반적으로 불가능하다는 점을 기억하자. 우리가 실제로 만드는 건 at-least-once 전달 + 멱등한 처리 = effectively-once 처리다. 전달 자체를 정확히 한 번으로 만들려 하지 말고, 처리를 멱등하게 만들어 중복을 흡수하라.

언제 멱등성 키가 불필요한가

모든 엔드포인트에 키를 강제하는 것은 과설계다. 앞서 말했듯 GET·PUT·DELETE는 이미 멱등하다. 순수 조회(GET)에 멱등성 키를 요구하는 건 무의미하다. 또한 값을 절대값으로 덮어쓰는 연산(예: "프로필 이름을 X로 설정", PUT)은 몇 번을 실행해도 결과가 같으므로 별도 키가 필요 없다. 반대로 balance += 100 같은 증분/누적연산은 실행 횟수가 결과를 바꾸므로 반드시 키가 필요하다.

비용도 고려해야 한다. 멱등성 저장소는 쓰기 부하·저장 공간·정리 배치를 요구한다. 초당 수십만 건이 오가는데 중복이 결과에 큰 해가 없는 로그성 이벤트라면, 키 인프라의 비용이 이득을 넘어설 수 있다. 판단 기준은 단순하다. 중복 실행이 금전·재고·계약 같은 되돌리기 어려운 부작용을 낳는가? 그렇다면 키를 넣고, 아니라면 생략을 검토하라. 결제·주문·환불·포인트 적립은 거의 항상 전자에 속한다.

연산 유형예시키 필요?
조회GET /orders/123불필요
절대값 설정PUT 프로필 이름=X불필요(자연 멱등)
증분/생성POST 결제·포인트+100필수
비가역 부작용환불·이메일 발송필수

자주 묻는 질문

멱등성 키는 클라이언트가 만들어야 하나요, 서버가 만들어야 하나요?

클라이언트가 만들어야 합니다. 멱등성 키의 목적은 요청이 유실됐을 때 안전하게 재시도하는 것인데, 서버가 키를 발급하면 요청이 서버에 닿기 전에 유실됐을 때 재시도에 쓸 키가 없습니다. 클라이언트가 요청을 만드는 순간 UUID v4 같은 고유값을 생성하고, 재시도 시 반드시 같은 키를 재사용하세요.

같은 키인데 요청 본문이 다르면 어떻게 처리하나요?

거부해야 합니다. 저장해 둔 요청 지문(본문을 정규화 후 해시한 값)과 새 요청의 지문을 비교해, 다르면 409나 422로 명시적으로 실패시키세요. 조용히 첫 번째 결과를 돌려주면 두 번째 요청의 의도가 소리 없이 사라져 오히려 더 위험합니다. 이 상황은 대개 클라이언트가 키를 잘못 재사용한 버그 신호입니다.

동시에 같은 키로 두 요청이 오는 경쟁 조건은 어떻게 막나요?

애플리케이션 코드의 "조회 후 삽입"으로는 그 사이 틈에서 깨집니다. DB 유니크 제약을 이용해, 처리 시작 시 status='started' 행을 원자적으로 INSERT(ON CONFLICT DO NOTHING)하세요. 동시에 온 요청 중 하나만 성공하고 나머지는 충돌합니다. 승자만 실제 처리하고, 나머지는 진행 중(409)으로 응답하거나 완료를 기다립니다.

at-least-once 큐를 쓰면 exactly-once가 가능한가요?

전달을 정확히 한 번으로 만드는 건 일반적으로 불가능합니다. 대신 at-least-once 전달을 받아들이고, 컨슈머가 메시지의 비즈니스 키나 고유 ID로 멱등하게 처리하면 결과적으로는 한 번만 처리(effectively-once)됩니다. 처리 완료 표시와 비즈니스 처리를 같은 DB 트랜잭션으로 커밋하면 중복 메시지가 와도 유니크 제약에 막혀 무해합니다.

키는 얼마나 오래 보관해야 하나요?

재시도가 끝날 시간보다 넉넉하면 됩니다. Stripe는 24시간을 씁니다. 대부분의 재시도는 수 초에서 수 분 내에 끝나므로 24시간이면 충분합니다. TTL이 지나면 같은 키도 새 요청으로 처리됩니다. 만료된 키는 배치 삭제나 파티션 드롭으로 주기적으로 정리해 저장소가 무한히 커지지 않게 하세요.

모든 API에 멱등성 키를 붙여야 하나요?

아닙니다. 중복 실행이 되돌리기 어려운 부작용(결제, 재고 차감, 이메일 발송, 포인트 적립)을 낳는 경우에만 필요합니다. GET 같은 조회나 절대값을 덮어쓰는 PUT은 본래 멱등하므로 키가 필요 없습니다. 증분·생성·비가역 연산에만 선별적으로 적용해 저장소와 정리 배치의 비용을 아끼세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록