멱등성 키(Idempotency Key)는 같은 요청이 여러 번 도착해도 서버가 딱 한 번만 처리하게 만드는 장치다. 클라이언트가 요청마다 고유한 키를 헤더에 실어 보내면, 서버는 그 키로 "이미 처리한 요청인지"를 판별해 중복 실행을 막고 저장된 결과를 그대로 돌려준다.
결제, 주문, 송금처럼 "두 번 일어나면 안 되는" API에 필수다. 네트워크는 언제든 타임아웃 나고, 클라이언트는 언제든 재시도한다. 나는 결제 붙일 때 이걸 몰라서 중복 결제 CS를 몇 건 받고 나서야 제대로 배웠다.
왜 필요한가 — 재시도의 함정
클라이언트가 결제 요청을 보냈는데 응답이 안 온다. 서버가 처리를 못 한 건지, 처리는 했는데 응답만 유실된 건지 클라이언트는 알 수가 없다. 그래서 재시도한다. 문제는 두 번째 경우 — 서버는 이미 카드를 긁었는데 클라이언트가 다시 긁는다. 사용자는 두 번 결제된다.
이걸 막는 정석이 멱등성 키다. 클라이언트가 "이 결제 시도"에 UUID 하나를 붙여 두면, 재시도할 때도 같은 키가 간다. 서버는 그 키를 보고 "아, 아까 그거네" 하고 두 번째는 실제 실행 없이 첫 결과만 반환한다.
서버 구현 — 상태를 기록하라
핵심은 키의 처리 상태를 원자적으로 기록하는 것이다. 단순히 "있으면 스킵"이 아니라, 동시에 같은 키가 두 개 들어오는 경쟁 상태까지 막아야 한다. Postgres라면 유니크 제약과 INSERT ... ON CONFLICT를 쓴다.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
status text NOT NULL DEFAULT 'in_progress', -- in_progress | done
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 요청 진입 시: 키를 먼저 선점 시도
INSERT INTO idempotency_keys (key) VALUES ($1)
ON CONFLICT (key) DO NOTHING
RETURNING key;
이 INSERT ... ON CONFLICT DO NOTHING ... RETURNING이 반환값을 주면 "내가 처음"이라는 뜻이니 실제 로직을 실행한다. 반환값이 비면 이미 누가 선점한 것이다. 이때 기존 행의 상태를 본다. done이면 저장된 응답을 그대로 돌려주고, 아직 in_progress면 409나 잠시 후 재시도를 안내한다.
async function handlePayment(key, body) {
const claimed = await db.query(
`INSERT INTO idempotency_keys(key) VALUES($1)
ON CONFLICT(key) DO NOTHING RETURNING key`, [key]);
if (claimed.rowCount === 0) { // 이미 존재
const row = await db.one(
`SELECT status, response_code, response_body
FROM idempotency_keys WHERE key=$1`, [key]);
if (row.status === 'done')
return { code: row.response_code, body: row.response_body };
return { code: 409, body: { error: 'request in progress' } };
}
const result = await chargeCard(body); // 진짜 부수효과
await db.query(
`UPDATE idempotency_keys
SET status='done', response_code=$2, response_body=$3
WHERE key=$1`, [key, 200, result]);
return { code: 200, body: result };
}
실전에서 놓치기 쉬운 것
몇 번 데인 뒤에야 챙기게 된 디테일들이다.
- 키에 요청 본문을 묶어라. 같은 키로 다른 내용을 보내는 건 클라이언트 버그다. 저장 때 본문 해시도 같이 넣고, 재요청의 해시가 다르면 422로 거절한다. 안 그러면 첫 요청 결과가 엉뚱한 요청에 반환된다.
- 부수효과와 상태 기록을 한 트랜잭션에. 카드 긁기가 외부 API라 트랜잭션에 못 넣는다면, 최소한 "실행 전 in_progress → 외부호출 → done" 순서를 지키고 크래시 복구 로직을 둔다.
- TTL로 청소하라. 키를 영원히 쌓아 두면 테이블이 계속 큰다. 보통 24시간에서 며칠 정도만 유지하고 만료 청소를 돈다. 재시도는 대개 몇 초~몇 분 안에 끝나니 그 창만 덮으면 된다.
Stripe, Adyen 같은 결제사가 Idempotency-Key 헤더를 요구하는 게 다 이 구조다. 표준 흐름을 그대로 따르는 게 안전하다.
| 상황 | 서버 응답 |
|---|---|
| 처음 보는 키 | 로직 실행 후 결과 저장·반환 |
| done 상태 재요청 | 저장된 응답 그대로 반환 |
| in_progress 재요청 | 409, 잠시 후 재시도 안내 |
| 같은 키·다른 본문 | 422 거절 |
자주 묻는 질문
멱등성 키는 누가 생성하나요?
클라이언트가 생성합니다. 보통 요청을 만드는 시점에 UUID v4를 하나 뽑아 Idempotency-Key 헤더에 넣고, 재시도할 때는 같은 값을 재사용합니다. 서버가 만들면 재시도 때 같은 키를 다시 보낼 방법이 없어 의미가 사라집니다.
GET 요청에도 멱등성 키가 필요한가요?
필요 없습니다. GET은 원래 부수효과가 없어 본질적으로 멱등합니다. 멱등성 키는 POST처럼 상태를 바꾸고 반복 실행이 위험한 요청에만 붙입니다. DELETE·PUT도 대개 자연히 멱등하지만, 카운터 증가 같은 비멱등 로직이 섞였다면 키를 고려하세요.
Redis로 구현해도 되나요?
됩니다. SET key value NX EX 86400로 원자적 선점이 가능해 가볍습니다. 다만 Redis는 휘발성이라 결제처럼 결과를 확실히 보존해야 하는 경우엔 DB에 함께 남기거나, 최소한 응답 본문까지 캐싱해 두는 설계가 필요합니다.
키가 만료된 뒤 재시도가 오면요?
만료 창은 재시도가 현실적으로 발생하는 기간보다 넉넉히 잡습니다. 그 창을 넘겨 도착한 요청은 새 요청으로 취급되어 다시 실행될 수 있으니, TTL은 짧지 않게(보통 24시간 이상) 두는 편이 안전합니다.

댓글 0