에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/분산 & 운영

[분산시스템] 멱등 API 설계: Idempotency-Key로 중복 결제 막기

반응형
[분산시스템] 멱등 API 설계: Idempotency-Key로 중복 결제 막기

[분산시스템] 멱등 API 설계: Idempotency-Key로 중복 결제 막기

"같은 결제가 두 번 됐어요"는 백엔드에서 가장 아픈 장애예요. 그런데 범인은 버그가 아니라 정상 동작일 때가 많아요 — 타임아웃 난 클라이언트가 재시도한 것뿐이거든요. 중복 요청은 분산 시스템에서 막을 수 없는 날씨 같은 거라, "안 오게"가 아니라 "와도 무해하게" 만들어야 해요. 그게 멱등 API예요.

중복 요청이 왜 필연인지에서 출발해, Idempotency-Key 패턴의 동작, 동시 중복 레이스 처리, 키·응답 설계 디테일까지 짚어볼게요. 분산 패턴 시리즈의 문을 여는 글이자, 카프카 컨슈머 멱등성의 API 버전이에요.

01. 중복 요청은 왜 필연인가

중복 요청 발생 구조. 서버는 결제를 정상 처리했지만 응답이 유실되어 클라이언트가 타임아웃으로 재시도하면 같은 결제가 두 번 처리된다. 재시도는 올바른 동작이라 중복 요청 자체는 막을 수 없다

핵심은 이 비대칭이에요 — 타임아웃이 났을 때 클라이언트는 "요청이 처리됐는지" 알 수 없어요. 안 갔을 수도, 갔는데 응답만 유실됐을 수도 있죠. 안 갔는데 재시도 안 하면 주문이 사라지고, 갔는데 재시도하면 이중 결제예요. 재시도는 해야 하고(가용성), 그러면 중복은 옵니다. 더블클릭, 모바일 네트워크 재전송, 게이트웨이·메시지큐의 at-least-once까지 — 출처도 다양해요.

그래서 결론은 서버 쪽 책임이에요. 같은 요청이 몇 번 와도 결과가 한 번과 같게 — 멱등하게 만드는 거예요.

02. Idempotency-Key 패턴

업계 표준 패턴(토스페이먼츠·Stripe 등 결제 API가 다 쓰는 방식)은 단순해요. 클라이언트가 작업 단위마다 고유 키를 만들어 헤더로 보내고 서버는 그 키 기준으로 "한 번만" 처리해요.

Idempotency-Key 상태머신. 키 없음에서 SETNX나 유니크 제약으로 원자 선점해 IN_PROGRESS가 되고, 처리 성공 시 응답을 저장하며 COMPLETED가 된다. IN_PROGRESS 중 재시도는 409, COMPLETED 재시도는 저장된 응답 재생이고, 실패는 FAILED로 정책에 따라 재처리하며 서버가 죽으면 TTL로 회수한다
POST /payments
Idempotency-Key: 0f7a6c2e-...   (클라이언트가 작업 단위마다 생성한 UUID)

서버:
  키 첫 등장 → 결제 처리 → (키, 응답) 저장 → 응답
  같은 키 재등장 → 저장된 응답 그대로 반환 (재처리 X)

포인트 두 개예요. 첫째, 키의 단위는 "작업"이에요. 사용자가 결제 버튼을 누른 그 시도 하나가 한 키예요. 재시도엔 같은 키, 새로운 주문엔 새 키죠. 클라이언트가 "결제 화면 진입 시 키 생성 → 재시도 시 재사용"으로 구현해요. 둘째, 저장하는 건 처리 여부만이 아니라 응답 자체예요. 재시도한 클라이언트도 1차와 똑같은 응답(주문번호 포함)을 받아야 흐름이 이어지니까요.

03. 함정, 동시 중복은 "확인 후 처리"로 못 막아요

멱등 키 동시 중복 레이스. 처리 후 키를 저장하면 동시에 도착한 두 요청이 모두 키 없음 확인을 통과해 둘 다 결제된다. 처리 전에 유니크 제약이나 Redis SETNX로 키를 원자적으로 선점해야 한다

순진한 구현 — "키 조회 → 없으면 처리 → 처리 후 키 저장" — 은 거의 동시에 도착한 중복(더블클릭!)에 뚫려요. 둘 다 "키 없음"을 통과하거든요. 익숙한 구조죠? 읽고-판단-쓰기의 틈, 그 레이스예요.

해법도 익숙해요 — 처리 전에 키를 원자적으로 선점해요.

-- 방법 1: DB 유니크 제약 — INSERT가 곧 선점
INSERT INTO idempotency_keys (idem_key, status) VALUES ('abc123', 'IN_PROGRESS');
-- 중복이면 Duplicate entry 에러 = 누군가 선점함

Redis로 하면 SET key IN_PROGRESS NX EX 300이에요(분산 락에서 본 그 SETNX). 선점에 성공한 요청만 처리하고 실패한 요청은 키의 상태를 봐요 — 키는 상태 머신이에요.

  • IN_PROGRESS — 다른 요청이 처리 중. "처리 중입니다" 응답(409 Conflict)으로 잠시 후 재시도를 유도해요. 절대 같이 처리하면 안 돼요.
  • COMPLETED — 저장된 응답을 그대로 반환해요.
  • FAILED — 정책에 따라 재처리를 허용하거나(키 해제), 같은 실패 응답을 줘요.
IN_PROGRESS에 TTL 또는 타임아웃 복구를 꼭 둬요. 처리하던 서버가 죽으면 키가 영원히 "처리 중"으로 남아 그 작업이 잠겨버려요 — 분산 락의 만료 없는 락과 같은 사고예요. "처리 중 상태가 N분을 넘으면 실패로 간주하고 해제" 같은 회수 규칙까지가 한 세트예요.

04. 설계 디테일

  • 키 저장소 — 결제처럼 돈이 걸리면 DB(처리와 같은 트랜잭션으로 묶을 수 있어요 — 키 INSERT와 결제 저장이 함께 커밋/롤백). 가벼운 중복 방지면 Redis + TTL이 간편해요.
  • 보관 기간 — 영원히 둘 필요 없어요. 클라이언트 재시도가 일어날 수 있는 윈도(보통 수 시간~수일)만 보관하고 정리해요.
  • 같은 키, 다른 본문 — 키는 같은데 금액이 다른 요청이 오면? 재사용 실수일 가능성이 높으니 422로 거부하는 게 안전해요(요청 본문 해시를 키와 함께 저장해 비교).
  • GET·PUT·DELETE — HTTP 의미상 원래 멱등이어야 하는 메서드들이에요. 문제는 늘 POST고 Idempotency-Key는 사실상 POST(그리고 PATCH 같은 비멱등 메서드)를 위한 장치예요.

05. 어디까지 적용하나

모든 API에 깔 필요는 없어요. 기준은 "중복 실행의 피해"예요. 결제·포인트 적립·주문 생성·송금 — 돈과 재고가 움직이는 쓰기엔 필수고, 조회나 "덮어쓰기형" 수정(set 연산이라 원래 멱등)은 불필요해요. 그리고 멱등 키는 입구의 방어고 내부 처리(카프카 컨슈머의 재처리 등)는 컨슈머 멱등성으로 따로 — 양쪽이 한 세트가 돼야 끝까지 안전해요.

06. 자주 만나는 문제

이중 결제가 났어요

키 선점이 "처리 후"거나 아예 없는 거예요. 처리 전 원자적 선점(유니크 제약·SETNX)으로 바꿔요.

재시도한 클라이언트가 빈 응답을 받아요

처리 여부만 저장하고 응답을 저장 안 한 거예요. 1차 응답 본문을 키와 함께 저장해 재반환해요.

"처리 중" 상태에서 영영 안 풀려요

처리 서버 사망 + 회수 규칙 부재예요. IN_PROGRESS에 타임아웃을 두고 만료 시 FAILED 전환·해제해요.

정리

중복 요청은 막을 수 없으니 서버가 멱등해져야 해요. Idempotency-Key로 작업 단위를 식별하고, 처리 전에 키를 원자적으로 선점해요(유니크 제약·SETNX). 응답까지 저장해 재시도에 같은 답을 줘요. 키는 없음→처리 중→완료의 상태 머신이고 처리 중엔 회수 규칙이 반드시 따라붙고요. 적용 여부는 상황으로 갈라요.

  • 돈·재고가 움직이는 쓰기(결제·적립·주문·송금)라면 → 필수예요.
  • 조회나 덮어쓰기형 수정(set 연산)이라면 → 원래 멱등이라 불필요해요.
  • 내부 처리(메시지 재처리)라면 → 키가 아니라 컨슈머 멱등성의 몫 — 입구와 안쪽이 한 세트예요.

"DB 커밋과 메시지 발행을 어떻게 원자적으로 묶느냐"는 숙제는 아웃박스 패턴 글에서 이어서 풀어요.

출처: IETF — The Idempotency-Key HTTP Header Field (draft) · Stripe — Idempotent Requests

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."