- 들어가며 — 두 번 청구되는 악몽
- 멱등성이란 무엇인가
- 재시도와 타임아웃의 문제
- 멱등성 키 — 요청에 이름표를 붙이다
- 중복 제거 윈도우와 저장
- 유니크 제약 — 데이터베이스에게 맡기는 안전장치
- 결제를 상태 기계로 모델링하기
- At-least-once 전달에 dedup 더하기
- Stripe 스타일 멱등성
- 실무 체크리스트
- 마치며
- 참고 자료
들어가며 — 두 번 청구되는 악몽
결제 시스템에서 가장 흔하고 가장 무서운 버그는 "사용자가 두 번 청구되는 것"입니다. 커피 한 잔 값이 두 번 빠져나가면 사용자는 화가 나고, 큰 금액이 두 번 빠지면 신뢰가 무너집니다. 그런데 이 버그는 코드에 명백한 실수가 없어도 발생합니다. 원인은 대개 네트워크의 불확실성과 그에 대응하는 재시도입니다.
분산 시스템의 근본 진실 하나는 "네트워크는 언젠가 반드시 실패한다"는 것입니다. 그리고 실패했을 때 클라이언트가 할 수 있는 합리적인 대응은 재시도입니다. 그런데 결제에서는 이 합리적인 재시도가 이중 결제라는 비합리적인 결과를 낳을 수 있습니다.
이 글은 이 문제를 정면으로 다룹니다. 왜 재시도가 이중 결제를 만드는지, 그것을 막는 **멱등성(idempotency)**이란 무엇인지, 멱등성 키와 중복 제거 윈도우와 유니크 제약을 어떻게 설계하는지, at-least-once 전달에 dedup을 어떻게 결합하는지, 결제를 상태 기계로 어떻게 모델링하는지, 그리고 Stripe 같은 실제 시스템이 어떻게 멱등성을 구현하는지까지 짚겠습니다. 재시도와 흐름 제어, 최소 한 번 전달 같은 개념을 눈으로 실험해 보고 싶다면 이 사이트의 메시지 큐 놀이터를 함께 열어 두면 좋습니다.
멱등성이란 무엇인가
**멱등성(idempotency)**은 수학과 컴퓨터 과학에서 온 개념입니다. 어떤 연산을 여러 번 적용해도 결과가 한 번 적용한 것과 같으면, 그 연산은 멱등하다고 합니다.
일상적인 예로 감을 잡을 수 있습니다. 엘리베이터의 층 버튼을 한 번 누르나 다섯 번 누르나 결과는 같습니다. 그 층으로 갈 뿐입니다. 이것이 멱등입니다. 반면 자판기에 동전을 넣는 것은 멱등이 아닙니다. 넣을 때마다 잔액이 늘어나기 때문입니다.
HTTP 메서드로 보면 더 분명합니다.
GET은 멱등입니다. 같은 자원을 몇 번 읽어도 서버 상태는 안 바뀝니다.PUT과DELETE도 멱등으로 설계됩니다. 같은 값을 여러 번 써도, 같은 자원을 여러 번 지워도 최종 상태는 같습니다.POST는 기본적으로 멱등이 아닙니다. 보통 새 자원을 만들거나 어떤 동작을 일으키므로, 두 번 호출하면 두 번 일어납니다.
문제는 결제가 대개 POST라는 데 있습니다. "결제를 생성한다"는 것은 본질적으로 부수 효과(돈이 빠져나감)를 일으키는 비멱등 연산입니다. 우리가 하려는 일은 이 비멱등 연산을, 멱등하게 만드는 것입니다. 즉 "같은 결제 요청을 몇 번 보내도 결제는 딱 한 번만 일어나게" 하는 것입니다.
재시도와 타임아웃의 문제
멱등성이 왜 필요한지 이해하려면, 재시도가 어떻게 이중 결제를 만드는지 정확히 봐야 합니다. 핵심은 타임아웃의 모호함입니다.
클라이언트가 서버에 결제 요청을 보내고 응답을 기다립니다. 그런데 타임아웃이 났습니다. 이때 클라이언트가 확실히 아는 것은 단 하나, "정해진 시간 안에 응답이 오지 않았다"는 사실뿐입니다. 실제로 무슨 일이 일어났는지는 여러 가지일 수 있습니다.
타임아웃이 났을 때 실제로 벌어졌을 수 있는 일들:
경우 A: 요청이 서버에 도달하지 못함 -> 결제 안 됨. 재시도해야 함.
경우 B: 서버가 처리했지만 응답이 유실됨 -> 결제 됨. 재시도하면 안 됨.
경우 C: 서버가 처리 중이라 아직 안 끝남 -> 진행 중. 재시도하면 경합 위험.
문제의 본질이 여기 있습니다. 클라이언트는 A, B, C를 구분할 수 없습니다. 응답이 없다는 사실만으로는 결제가 됐는지 안 됐는지 알 길이 없습니다.
이때 클라이언트가 취할 수 있는 선택은 둘입니다. 재시도하지 않으면, 실제로는 경우 A였는데 포기해 버려 결제를 놓칠 수 있습니다(사용자는 결제하려 했는데 안 됨). 반대로 재시도하면, 실제로는 경우 B였는데 다시 보내 이중 결제가 됩니다.
그래서 멱등성이 없으면 이 딜레마에서 빠져나올 수 없습니다. 멱등성은 이 매듭을 풉니다. "재시도해도 안전하다"를 보장하면, 클라이언트는 안심하고 재시도할 수 있고, 경우 A는 정상 처리되며 경우 B는 중복이 걸러집니다. 즉 멱등성은 재시도를 안전하게 만드는 장치입니다.
멱등성 키 — 요청에 이름표를 붙이다
멱등성을 구현하는 표준 방법은 **멱등성 키(idempotency key)**입니다. 아이디어는 단순합니다. 클라이언트가 각 결제 요청에 고유한 식별자를 하나 붙여 보내고, 서버는 그 키를 기준으로 "이 요청을 전에 본 적 있는지"를 판단하는 것입니다.
POST /v1/charges
Idempotency-Key: 9f2c1a7e-...-b3 <- 클라이언트가 생성한 고유 키
{ "amount": 5000, "currency": "krw", "source": "tok_..." }
서버의 처리 규칙은 이렇습니다.
요청 수신 시:
1) 이 멱등성 키를 전에 본 적 있는가?
- 없다 -> 결제를 실제로 처리하고, (키 -> 결과)를 저장한 뒤 결과 반환
- 있다 -> 저장해 둔 그 결과를 그대로 반환 (결제를 다시 하지 않음)
이 단순한 규칙 덕분에, 클라이언트가 같은 키로 몇 번을 재시도하든 결제는 딱 한 번만 일어납니다. 첫 요청만 실제 결제를 실행하고, 이후의 재시도는 저장된 첫 결과의 "복사본"을 받습니다.
여기서 키를 누가, 어떻게 만드느냐가 중요합니다.
- 키는 클라이언트가 생성합니다. 그래야 재시도할 때 같은 키를 다시 보낼 수 있습니다. 서버가 키를 만들면, 클라이언트는 재시도 때 어떤 키를 써야 할지 알 수 없습니다.
- 키는 하나의 논리적 작업에 하나씩 대응해야 합니다. 예를 들어 "장바구니 X를 결제한다"는 하나의 작업에 키 하나를 부여하고, 그 작업의 모든 재시도에 같은 키를 씁니다. UUID 같은 충분히 무작위한 값을 쓰면 우연한 충돌 걱정이 없습니다.
- 사용자가 결제 버튼을 다시 눌러 새 작업을 의도한 경우에는 새 키를 만들어야 합니다. 같은 키를 재사용하면, 시스템은 그것을 "재시도"로 보고 새 결제를 막아 버립니다. 이 점을 UX와 잘 맞추는 것이 중요합니다.
중복 제거 윈도우와 저장
멱등성 키를 저장하려면 "언제까지" 저장할지를 정해야 합니다. 이것이 **중복 제거 윈도우(dedup window)**입니다. 키-결과 기록을 영원히 보관할 수는 없으므로, 보통 일정 기간(예: 24시간)만 유지합니다.
윈도우 길이를 정하는 기준은 "재시도가 현실적으로 얼마의 시간 범위 안에서 일어나는가"입니다. 네트워크 오류로 인한 재시도는 대개 초·분 단위 안에서 일어납니다. 하지만 클라이언트가 잠시 오프라인이었다가 나중에 재시도하는 경우까지 감안해, 실무에서는 하루 정도를 넉넉히 잡는 경우가 많습니다. Stripe는 멱등성 키를 24시간 동안 유지합니다.
저장소 자체는 빠른 조회와 만료(TTL)를 지원하는 것이 좋습니다. 흔한 선택은 다음과 같습니다.
- 관계형 DB의 전용 테이블: 결제의 원장 데이터와 같은 트랜잭션 안에서 다룰 수 있어 정합성이 좋습니다. 뒤에서 볼 유니크 제약과 궁합이 좋습니다.
- Redis 같은 인메모리 저장소 + TTL: 매우 빠르고 만료가 자동입니다. 다만 원장 DB와 별개의 저장소이므로, 둘 사이의 정합성(예: 키는 기록됐는데 결제 트랜잭션은 실패)을 신중히 다뤄야 합니다.
무엇을 쓰든, 저장하는 값은 단순한 "이 키를 봤다" 플래그가 아니라 첫 요청의 결과 전체여야 한다는 점이 중요합니다. 재시도한 클라이언트에게 원래 응답과 똑같은 것(같은 결제 ID, 같은 상태, 같은 금액)을 돌려줘야 하기 때문입니다.
유니크 제약 — 데이터베이스에게 맡기는 안전장치
멱등성 키를 "먼저 조회하고, 없으면 삽입한다"는 방식으로만 구현하면 미묘한 경쟁 조건이 남습니다. 같은 키를 가진 두 요청이 거의 동시에 도착하면, 둘 다 "이 키는 없네"라고 판단한 뒤 둘 다 결제를 진행할 수 있습니다. 조회와 삽입 사이의 틈이 문제입니다.
이 틈을 근본적으로 막는 가장 견고한 도구가 데이터베이스의 **유니크 제약(unique constraint)**입니다. 멱등성 키 컬럼에 유니크 제약을 걸어 두면, 두 요청이 같은 키로 동시에 삽입을 시도해도 데이터베이스가 그중 하나만 성공시키고 나머지는 거부합니다. "동시에 딱 하나만"을 데이터베이스가 원자적으로 보장해 주는 것입니다.
CREATE TABLE payment_requests (
idempotency_key TEXT PRIMARY KEY, -- 유니크 제약: 같은 키 중복 삽입 불가
status TEXT NOT NULL, -- 'in_progress' | 'succeeded' | 'failed'
response_body JSONB, -- 첫 요청의 결과 전체를 저장
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
이 제약을 활용하는 흐름은 대략 이렇습니다.
1) 이 키로 'in_progress' 행을 INSERT 시도
- 성공하면 -> 내가 이 작업의 "소유자". 결제를 실제로 처리하고 결과로 행을 UPDATE
- 유니크 위반으로 실패하면 -> 다른 요청이 이미 이 키를 잡았다는 뜻
-> 기존 행을 조회:
- 이미 완료됐으면 저장된 결과를 반환
- 아직 in_progress면 잠깐 기다렸다 다시 보거나, "처리 중" 응답
핵심은 "먼저 자리를 맡는" 행위 자체를 유니크 제약이 있는 INSERT로 만드는 것입니다. 조회-후-삽입의 틈이 사라지므로, 동시에 들어온 재시도들 사이에서도 정확히 하나만 결제를 실행합니다. 원장 데이터와 같은 트랜잭션에서 이 행을 다루면, 결제 기록과 멱등성 기록이 항상 함께 커밋되어 정합성이 보장됩니다. SQL의 제약과 트랜잭션을 직접 실험해 보고 싶다면, 이런 종류의 스키마와 제약은 이 사이트의 SQL·Postgres 놀이터에서도 시험해 볼 수 있습니다.
결제를 상태 기계로 모델링하기
멱등성을 제대로 다루려면, 결제를 단순한 "성공/실패" 두 값이 아니라 **상태 기계(state machine)**로 모델링하는 것이 좋습니다. 결제는 여러 중간 상태를 거치고, 각 상태에서 재시도나 콜백이 어떻게 작용해야 하는지가 다르기 때문입니다.
전형적인 결제 상태의 흐름은 이렇습니다.
created ──▶ authorizing ──▶ authorized ──▶ capturing ──▶ captured
│ │ │
│ ▼ ▼
│ failed failed
▼
canceled
(별도 흐름) captured ──▶ refunding ──▶ refunded
각 상태의 의미를 짧게 정리하면 이렇습니다.
created: 결제 의도가 만들어졌지만 아직 승인 전.authorizing: 승인 요청을 PG/발급사로 보낸 상태(응답 대기).authorized: 승인 완료, 한도 홀드됨. 아직 매입 전.capturing/captured: 매입 진행/완료.failed/canceled/refunded: 종료 상태들.
상태 기계가 멱등성에 결정적으로 도움이 되는 이유는, 각 상태에서 허용되는 전이를 명확히 정의할 수 있기 때문입니다. 예를 들어 이미 captured인 결제에 매입 요청이 또 오면, 그것은 재시도이므로 다시 매입하지 않고 현재 상태를 그대로 돌려줍니다. authorizing 상태에서 같은 요청이 또 오면 "아직 처리 중"임을 알 수 있습니다. 즉 상태 기계는 "이 요청이 지금 상태에서 유효한가, 아니면 이미 처리된 재시도인가"를 판단하는 근거가 됩니다.
또한 상태를 명시적으로 저장하면 크래시 복구가 쉬워집니다. authorizing 상태로 멈춰 있는 결제가 있으면, 시스템은 PG에 그 거래의 실제 상태를 조회해 authorized인지 failed인지 확인하고 상태를 정정할 수 있습니다. 이런 조회를 흔히 정합성 맞추기(reconciliation) 또는 상태 동기화라 부릅니다.
At-least-once 전달에 dedup 더하기
멱등성은 결제 API뿐 아니라, 결제 시스템 곳곳의 비동기 메시지에서도 핵심입니다. 결제 이벤트는 흔히 메시지 큐를 통해 흐르는데(예: "결제 성공" 이벤트가 정산·알림·회계 서비스로 전파), 대부분의 큐는 at-least-once(최소 한 번) 전달을 보장합니다.
at-least-once의 의미를 정확히 봅시다. 큐는 메시지가 "적어도 한 번" 전달되는 것을 보장하지만, "정확히 한 번"은 보장하지 않습니다. 즉 같은 메시지가 두 번 이상 전달될 수 있습니다. 왜냐하면 소비자가 메시지를 처리한 뒤 "처리 완료(ack)"를 큐에 알리는 도중에 장애가 나면, 큐는 그 메시지가 처리됐는지 알 수 없어 안전하게 다시 보내기 때문입니다. 앞서 본 결제 API의 타임아웃 문제와 똑같은 구조입니다.
소비자가 "결제 성공" 이벤트를 수신
-> 처리(예: 잔액 적립, 영수증 발송)
-> ack를 보내는 도중 장애
-> 큐는 처리 여부를 모름 -> 재전달
-> 같은 이벤트가 두 번 처리될 위험
여기서 "정확히 한 번(exactly-once)"을 전달 계층에서 진짜로 보장하기는 매우 어렵습니다. 실무의 표준 해법은 at-least-once 전달 + 소비자 측 dedup입니다. 즉 큐는 최소 한 번 전달만 보장하고, 소비자가 "이 메시지를 이미 처리했는지"를 스스로 확인해 중복을 걸러 내는 것입니다. 이것을 흔히 **멱등 소비자(idempotent consumer)**라 부릅니다.
구현은 결제 API의 멱등성과 같은 원리입니다. 각 메시지에 고유 ID를 부여하고, 소비자는 처리한 메시지 ID를 저장해 둡니다. 새 메시지가 오면 그 ID가 이미 처리 목록에 있는지 확인하고, 있으면 조용히 무시합니다. 여기서도 유니크 제약이 든든한 안전장치가 됩니다. "처리한 메시지 ID"를 유니크 컬럼에 삽입하려다 실패하면, 그것은 곧 "이미 처리했다"는 신호입니다. 큐의 at-least-once, ack, 재전달의 동작을 눈으로 확인하고 싶다면 메시지 큐 놀이터에서 직접 다뤄 볼 수 있습니다.
Stripe 스타일 멱등성
지금까지의 조각들이 실제 제품에서 어떻게 합쳐지는지, Stripe의 멱등성 설계를 예로 정리해 봅시다. Stripe는 멱등성을 API의 일급 기능으로 노출하며, 그 규칙은 이 글에서 다룬 원리를 거의 그대로 따릅니다.
Stripe 멱등성의 핵심 규칙은 이렇습니다.
- 클라이언트가
Idempotency-Key헤더에 고유한 키를 넣어 요청합니다. 보통 UUID v4 같은 무작위 값을 씁니다. - 같은 키로 요청이 다시 오면, Stripe는 처음 요청의 결과를 그대로 재생(replay)해 돌려줍니다. 결제가 다시 일어나지 않습니다.
- 멱등성 키는 24시간 동안 유지됩니다. 그 이후 같은 키는 새 요청으로 취급됩니다.
- 첫 요청이 아직 처리 중일 때 같은 키로 동시 요청이 오면, 에러(예: 409 계열)를 반환해 경합을 막습니다. 클라이언트는 잠시 뒤 재시도하면 됩니다.
- 같은 키를 다른 요청 본문으로 보내면, Stripe는 이를 오용으로 보고 에러를 냅니다. 하나의 키는 하나의 요청에만 대응해야 하기 때문입니다.
1차 요청: Idempotency-Key: K, body: {amount: 5000}
-> 결제 실행, 결과를 K에 기록, 결과 반환
재시도: Idempotency-Key: K, body: {amount: 5000} (같은 본문)
-> 기록된 결과를 그대로 재생, 결제 안 일어남
오용: Idempotency-Key: K, body: {amount: 9999} (다른 본문)
-> 에러 (하나의 키에 서로 다른 요청은 허용 안 함)
이 설계에서 배울 실무 지침을 요약하면 이렇습니다. 클라이언트는 재시도 시 반드시 같은 키를 보낼 것, 하나의 논리적 작업에는 하나의 키만 쓸 것, 그리고 새 결제를 의도할 때만 새 키를 만들 것. 서버는 키를 결과와 함께 저장하고, 유니크 제약으로 동시성을 막고, 적절한 dedup 윈도우를 두는 것입니다.
실무 체크리스트
멱등성을 결제 시스템에 도입할 때 확인할 것들을 압축합니다.
- 모든 상태 변경 요청은 멱등해야 한다. 결제 생성, 매입, 환불, 취소 모두 재시도 가능해야 합니다. 읽기(GET)는 원래 멱등이지만, 돈을 움직이는 모든 POST에 멱등성 키를 요구하세요.
- 키는 클라이언트가 생성하고, 재시도 때 재사용한다. 서버가 키를 만들면 재시도의 의미가 사라집니다.
- 유니크 제약으로 동시성을 막는다. 조회-후-삽입만으로는 경쟁 조건이 남습니다. 데이터베이스의 유니크 제약이 최후의 방어선입니다.
- 키와 함께 결과를 저장한다. 플래그만 저장하면 재시도한 클라이언트에게 원래 응답을 돌려줄 수 없습니다.
- 결제를 상태 기계로 모델링한다. 각 상태에서 유효한 전이를 정의하면, 재시도와 크래시 복구가 명확해집니다.
- 비동기 이벤트에는 멱등 소비자를 둔다. at-least-once 큐를 가정하고, 소비자가 메시지 ID로 중복을 거릅니다.
- 적절한 dedup 윈도우를 정한다. 너무 짧으면 늦은 재시도를 놓치고, 너무 길면 저장 비용과 정상적인 재사용 충돌 위험이 커집니다. 24시간이 흔한 출발점입니다.
마치며
이중 결제는 코드의 명백한 버그가 아니라, "네트워크는 실패하고, 실패하면 재시도한다"는 분산 시스템의 근본 성질에서 자연스럽게 흘러나오는 문제입니다. 타임아웃은 결제가 됐는지 안 됐는지를 모호하게 만들고, 그 모호함 속에서 재시도가 중복을 낳습니다.
멱등성은 이 매듭을 푸는 열쇠입니다. 클라이언트가 고유한 키로 요청에 이름표를 붙이고, 서버가 그 키로 중복을 걸러 결과를 재생하면, 몇 번을 재시도해도 결제는 딱 한 번만 일어납니다. 여기에 유니크 제약으로 동시성을 잠그고, 상태 기계로 흐름을 명확히 하고, 비동기 경로에는 멱등 소비자를 두면, "재시도해도 안전한" 결제 시스템이 완성됩니다.
핵심 문장 하나로 요약하면 이렇습니다. 재시도를 없애려 하지 말고, 재시도를 안전하게 만들어라. 분산 시스템에서 재시도는 피할 수 없습니다. 그러니 재시도가 해가 되지 않도록, 모든 결제를 멱등하게 설계하는 것이 정답입니다.
참고 자료
- Stripe Docs: Idempotent requests — https://stripe.com/docs/api/idempotent_requests
- Stripe Engineering: Designing robust and predictable APIs with idempotency — https://stripe.com/blog/idempotency
- AWS Builders' Library: Making retries safe with idempotent APIs — https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- MDN: Idempotent (HTTP) — https://developer.mozilla.org/en-US/docs/Glossary/Idempotent
- Microservices.io: Idempotent Consumer — https://microservices.io/patterns/communication-style/idempotent-consumer.html
현재 단락 (1/111)
결제 시스템에서 가장 흔하고 가장 무서운 버그는 "사용자가 두 번 청구되는 것"입니다. 커피 한 잔 값이 두 번 빠져나가면 사용자는 화가 나고, 큰 금액이 두 번 빠지면 신뢰가 무...