5장
결제 버튼 두 번
결제 버튼을 연달아 누르거나 앱이 알아서 다시 보내면, 돈이 몇 번 빠져나갈까요?
1결제 버튼을 눌렀는데, 화면이 멈춘 것 같아요.
2답답해서 또 누르고, 앱은 응답이 늦자 알아서 한 번 더 보내요.
3서버가 요청마다 돈을 빼면, 결제 문자가 여러 번 와요.
4요청마다 같은 번호표(멱등성 키)를 붙이면, 처음 것만 결제하고 나머지엔 같은 영수증을 보여줘요.
아래 큰 버튼 하나로 두 실험이 나란히 돌아요. 두 줄 모두 똑같이 요청 4개(세 번 누름 + 앱의 자동 재시도)를 보내고, 주문도 줄마다 따로 하나씩이에요. 다른 건 번호표가 있느냐 없느냐 하나뿐이에요.
실험 A · 번호표 없이 · 실제 결제 기록 4건
실험 B · 번호표(Idempotency-Key)와 함께 · 실제 결제 기록 1건
지난 실측 기록 (2026-10-20, 로컬 MySQL)
4번 → 1번
실제로 결제된 횟수 (같은 결제 요청 4개)
실제 서버로 결제 요청을 보내요: 실험마다 버튼을 0.09초 간격으로 세 번 누르고, 첫 응답이 0.6초 안에 안 오면 앱이 한 번 더 보내요. 그래서 한 번 누르면 요청은 모두 8개(실험마다 4개)예요. 결제사 승인은 0.9초 걸리게 해 뒀어요(가짜 결제, 돈은 안 나가요).
번호표가 없으면 요청 4개가 결제 4건(48,000원). 같은 번호표를 붙이면 결제 1건, 나머지 요청은 같은 영수증(결제 #29)을 돌려받았어요. 앱이 포기한 첫 요청도 서버에선 끝까지 처리됐어요.
버튼만 잠그면 될까요?
화면에서 할 수 있는 건 버튼을 누르자마자 잠그는(disabled) 거예요. 사람의 연타는 막지만, 응답이 늦을 때 앱이 알아서 다시 보내는 요청은 못 막아요.
버튼 잠금만, 번호표 없이 · 실제 결제 기록 2건
2번
버튼은 한 번 눌렀는데 결제된 횟수
지난 실측 기록 (2026-10-20). 눌러서 직접 확인해 보세요.
동시에 도착해도 딱 한 번
"이 번호표로 결제한 적 있나 먼저 찾아보고, 없으면 결제"는 위험해요. 두 요청이 동시에 찾아보면 둘 다 "없음"을 보고 둘 다 결제하거든요(1장 티켓팅과 같은 문제). 그래서 번호표 칸에 "같은 값은 한 줄만" 규칙(unique 제약)을 걸고, 저장을 먼저 시도해요. 데이터베이스가 둘 중 하나만 받아 주고, 진 쪽은 이긴 쪽의 결제가 끝나길 기다렸다가 같은 영수증을 돌려줘요.
숫자로 본 결과
- 4번 → 1번
- 결제된 횟수 (번호표 없이 → 함께)
- 같은 결제 요청 4개: 연달아 세 번 누름 + 앱의 자동 재시도 한 번
- 3번
- 같은 영수증을 돌려받은 요청
- 두 번째부터는 새로 결제하지 않고 첫 결제 결과를 그대로 돌려줘요
- 2번
- 버튼만 잠갔을 때 결제된 횟수
- 버튼은 한 번만 눌렸지만, 앱의 자동 재시도가 두 번째 결제를 만들었어요
개발자용 자세히Idempotency-Key 헤더, unique 제약 + INSERT 먼저, processing → paid, 재전송 응답, 실제 Payment 행
흐름 따라가기
비유 ↔ 용어
- 번호표
Idempotency-Key헤더. 결제 시도 한 번마다 UUID를 만들고, 재시도에도 같은 값을 써요- "같은 값은 한 줄만"
Payment.idempotencyKey의 UNIQUE 제약- 같은 영수증
- 저장된 결과 재전송 (
Idempotent-Replayed: true헤더) - 결제사
- 가짜 PG 승인. 0.9초 걸려요 (돈은 안 나가요)
요청 흐름
- 브라우저 실험마다
orderId를 하나 만들어요. 실험 B만crypto.randomUUID()로 키도 하나 만들어요. - 브라우저 → API 처음 한 번은 사전 확인
OPTIONS /payments가 먼저 가요. 204 +Access-Control-Max-Age: 600이라 10분 동안은 다시 안 가요. - 브라우저 → API
POST /payments{ orderId, amount: 12000 }+Idempotency-Key. 0.09초 간격으로 세 번 보내요.- 첫 요청이 0.6초 안에 안 끝나면
AbortController로 끊고, 같은 키로 한 번 더 보내요 (앱의 자동 재시도)
- 첫 요청이 0.6초 안에 안 끝나면
- API 컨트롤러가 키 모양과 본문을 확인해요.
- 키가 영문·숫자·하이픈 8~64자가 아니면 400
- orderId 모양이 틀리거나 금액이 100~1,000,000 밖이면 400
- API → MySQL
PaymentsService.pay가 먼저INSERT … status='processing'로 키를 선점해요.- INSERT 성공: 가짜 PG 0.9초 →
UPDATE status='paid'→ 201{ payment, replayed: false } - 같은 키가 이미 있음 (Prisma
P2002): 50ms마다 그 키의 행을 다시 읽어요paid가 되면 201{ payment, replayed: true }+Idempotent-Replayed: true- 같은 키인데 주문이나 금액이 다르면 422
- 5초가 지나도 processing이면 409
- 정리 잡이
failed로 닫은 키면 409previous_attempt_failed(새 키로 다시)
- 키가 없음:
idempotencyKey가 NULL이라 UNIQUE에 안 걸려요. 요청마다 새 결제가 생겨요
- INSERT 성공: 가짜 PG 0.9초 →
- 브라우저 → API 끝날 때까지 200ms마다
GET /payments?orderId=…로 실제 행을 읽어요. 끝나고 0.3초 뒤 한 번 더 읽어요.
DB에는 이렇게 남아요
| id | orderId | amount | status | idempotencyKey | createdAt | paidAt |
|---|---|---|---|---|---|---|
| 440 | order-2c14… (B) | 12000 | paid | fa617a9f… | 05:00:51.942 | 05:00:52.847 |
| 441 | order-8ae4… (A) | 12000 | paid | NULL | 05:00:51.943 | 05:00:52.846 |
| 443 | order-8ae4… (A) | 12000 | paid | NULL | 05:00:52.043 | 05:00:52.946 |
| 444 | order-8ae4… (A) | 12000 | paid | NULL | 05:00:52.140 | 05:00:53.043 |
| 446 | order-8ae4… (A) | 12000 | paid | NULL | 05:00:52.245 | 05:00:53.148 |
주문 A는 키 없이 4번, 주문 B는 같은 키로 4번 보냈어요. 시각은 UTC예요.
표를 이렇게 읽어요
- 주문 B는 4번 보냈는데 행이 하나예요. 나머지 3번은 440번 행을 그대로 돌려받았어요.
- id 442, 445가 비어 있어요. UNIQUE에 막힌 INSERT도 auto-increment 번호는 써 버리기 때문이에요.
- createdAt과 paidAt 사이가 약 0.9초예요. 가짜 PG가 승인하는 시간이에요.
프론트 관점
- 버튼
disabled는 사람의 연타만 막아요. 타임아웃 재시도, 새로고침 후 재전송, 모바일 네트워크의 중복 전송은 못 막아요. - 그래서 키는 화면이 아니라 "결제 시도"에 묶어서 만들어요.
- API가
Access-Control-Expose-Headers를 안 줘서 JS는Idempotent-Replayed헤더를 못 읽어요. 앱은 본문의replayed를 봐요.
한계·운영에선
- 첫 요청이 PG 호출 중에 죽으면 키가 processing으로 남아요. 매분 도는 정리 잡이 5분 넘은 processing을
UPDATE … SET status='failed' WHERE status='processing'로 닫아요. 조건부 UPDATE 한 문장이라 blue-green 배포 중 두 컨테이너가 같이 돌려도 한 번만 바뀌어요. 가짜 PG라 "승인 기록 없음 = 돈 안 나감"으로 보지만, 진짜 PG라면 여기서 결제사 조회 API로 승인 여부를 물어paid/failed를 정해야 해요. - 기다리는 요청은 최대 5초 동안 서버 연결을 잡고 있어요.
- 데모 행은 30분 뒤 자동으로 지워져요 (10분마다 도는 정리 잡).
개발자 도구로 보면
- 큰 버튼 한 번에
POST /payments8개예요 (실험 A·B 각 4개, 실험마다orderId하나). - 끝날 때까지 200ms마다
GET /payments?orderId=…가 두 주문을 번갈아 조회해요. - 빨간(canceled) 2개는 실험마다 첫 요청을 앱이 0.6초 만에 끊은 거예요. 서버는 그 요청도 끝까지 처리해요 (A에선 결제 1건, B에선 키를 먼저 잡은 쪽).
- 재전송된 응답도 상태는 201이에요. Response Headers에서
Idempotent-Replayed: true를 볼 수 있어요. - 사전 확인(
OPTIONS)은 10분에 한 번만 보여요. 전에는 요청에cache: 'no-store'가 붙어 Chrome이 매번 다시 보냈어요. 지금은 GET에만 붙여요.
코드·문서
- PaymentDemo.tsx · 연타·0.6초 재시도
- double-pay/api.ts
- payments.controller.ts · 400·409·422, 응답 헤더
- payments.service.ts · INSERT 먼저 → PG → paid
- schema.prisma · Payment 모델
- main.ts · CORS maxAge 600
- docs/chapters-4-6.md
번호표 없이 — Payment 행 (지난 기록)
| id | status | idempotencyKey | createdAt |
|---|---|---|---|
| 30 | paid | NULL | 790 |
| 31 | paid | NULL | 877 |
| 33 | paid | NULL | 966 |
| 35 | paid | NULL | 396 |
번호표와 함께 — Payment 행 (지난 기록)
| id | status | idempotencyKey | createdAt |
|---|---|---|---|
| 29 | paid | d75953f7… | 791 |