
API 멱등성이란 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 똑같이 유지되는 성질을 말합니다. 결제 API에서 이게 지켜지지 않으면 손님 카드에서 돈이 두 번 빠지는 사고가 납니다.
| "결제 버튼을 두 번 눌렀는데 두 번 결제됐어요" "타임아웃 나서 재시도했더니 중복 주문이 생겼습니다" "Idempotency-Key 헤더가 정확히 뭘 하는 건가요?" "멱등 키는 서버에서 만들면 안 되나요?" |
멱등성이라는 단어가 어렵게 들리지만, 실제로 하는 일은 단순합니다. "이 요청은 아까 그 요청이랑 같은 거야"라고 알아보는 꼬리표를 붙이고, 서버가 그 꼬리표를 기억하는 것뿐입니다.
이 글은 멱등성이 뭔지부터 시작해서, 결제 중복 요청을 막는 실무 방법을 Node.js(Express) 예시로 설명합니다. 결제 연동을 처음 맡은 분이나, 중복 결제 장애를 한 번 겪어본 분에게 도움이 될 겁니다.
목차
- API 멱등성이란 무엇인가
- 결제 요청이 두 번 들어오는 흔한 상황
- HTTP 메서드별 멱등성 차이
- 멱등 키로 POST 결제 요청을 막는 원리
- 서버에서 멱등 키를 처리하는 흐름
- 멱등 키 저장소 설계
- 동시에 두 요청이 들어올 때 처리
- 멱등성 적용할 때 흔한 실수
1. API 멱등성이란 무엇인가
엘리베이터 버튼을 떠올리면 쉽습니다. 5층 버튼을 한 번 누르든 열 번 누르든 엘리베이터는 5층에 한 번만 섭니다. 이게 멱등성입니다. 여러 번 눌러도 결과가 달라지지 않습니다.
반대로 자판기 버튼은 멱등하지 않습니다. 콜라 버튼을 두 번 누르면 콜라가 두 개 나오고 돈도 두 번 빠집니다. 결제 API는 기본적으로 자판기 쪽에 가깝습니다. 그래서 일부러 엘리베이터처럼 만들어 줘야 합니다.
여기서 중요한 건 "요청을 막는다"가 아니라 "결과가 같다"입니다. 두 번째 요청이 와도 에러를 내는 게 아니라, 첫 번째 요청의 결과를 그대로 돌려주는 게 제대로 된 멱등성입니다. 클라이언트 입장에서는 첫 요청이 성공했는지 몰라서 다시 보낸 것이기 때문에, 성공 응답을 받아야 흐름이 끝납니다.
| 📌 멱등성 한 줄 정의 같은 요청을 1번 보내든 N번 보내든 서버 상태와 응답이 똑같이 유지되는 성질. 결제에서는 "돈이 한 번만 빠지고, 재요청에는 같은 성공 응답을 준다"로 이해하면 됩니다. |
2. 결제 요청이 두 번 들어오는 흔한 상황
중복 결제는 사용자가 이상한 짓을 해서 생기는 게 아닙니다. 아주 평범한 상황에서 자연스럽게 발생합니다. 대표적인 경우를 정리하면 아래와 같습니다.
| 상황 | 왜 두 번 오는가 |
|---|---|
| 버튼 더블클릭 | 응답이 늦으면 사용자가 불안해서 한 번 더 누름 |
| 타임아웃 후 재시도 | 서버는 처리를 끝냈는데 응답이 도착하기 전에 클라이언트가 포기하고 다시 보냄 |
| 네트워크 끊김 | 모바일에서 와이파이와 LTE가 바뀌는 순간 요청이 유실된 것처럼 보임 |
| 라이브러리 자동 재시도 | HTTP 클라이언트의 retry 옵션이 켜져 있으면 개발자가 모르는 사이 재전송 |
이 네 가지의 공통점이 있습니다. 클라이언트는 첫 번째 요청이 성공했는지 모른다는 겁니다. 서버는 이미 결제를 끝냈지만, 그 사실을 알려주는 응답이 중간에 사라졌을 뿐입니다.
그래서 프론트에서 버튼을 비활성화하는 것만으로는 부족합니다. 더블클릭은 막을 수 있어도 타임아웃 재시도나 자동 재시도는 막지 못합니다. 결국 서버가 "이 요청 아까 처리했어"라고 알아봐야 합니다.
3. HTTP 메서드별 멱등성 차이
HTTP 명세에는 메서드마다 멱등해야 하는지가 정해져 있습니다. 이걸 알면 왜 하필 결제(POST)에서 문제가 되는지 이해가 됩니다.
| 메서드 | 멱등 여부 | 이유 |
|---|---|---|
| GET | 멱등 | 읽기만 하니 몇 번 해도 상태가 안 바뀜 |
| PUT | 멱등 | "이 값으로 바꿔라"는 두 번 해도 결과가 같음 |
| DELETE | 멱등 | 이미 지워진 걸 또 지워도 결과는 "없음" |
| POST | 멱등 아님 | "새로 만들어라"는 두 번 하면 두 개가 생김 |
결제 생성은 거의 항상 POST입니다. "결제를 새로 만들어라"는 요청이니까요. 그래서 HTTP가 보장해 주는 건 없고, 개발자가 직접 멱등하게 만들어야 합니다.
참고로 PUT이나 DELETE도 명세상 멱등일 뿐, 서버 코드를 이상하게 짜면 얼마든지 깨집니다. 예를 들어 DELETE 핸들러 안에서 "삭제 횟수"를 카운트하면 멱등이 아니게 됩니다. 명세는 약속이지 강제가 아닙니다.
4. 멱등 키로 POST 결제 요청을 막는 원리
POST를 멱등하게 만드는 가장 널리 쓰이는 방법이 멱등 키(Idempotency Key)입니다. 클라이언트가 요청마다 고유한 값을 하나 만들어서 Idempotency-Key 헤더에 담아 보내는 방식입니다.
흐름은 이렇습니다. 사용자가 결제 버튼을 누르는 순간 클라이언트가 UUID 같은 랜덤 값을 하나 만듭니다. 그 값을 헤더에 붙여서 서버로 보냅니다. 타임아웃이 나서 다시 보낼 때도 같은 값을 그대로 씁니다. 서버는 그 값을 보고 "아, 이건 아까 그 요청이구나"라고 알아봅니다.
핵심은 키를 누가 만드느냐입니다. 반드시 클라이언트가 만들어야 합니다. 서버가 키를 만들면, 재시도 요청이 들어왔을 때 서버는 새 키를 또 만들고 새 요청으로 처리합니다. 재시도를 알아볼 방법이 없어집니다. 이 부분은 뒤에서 다시 다루겠습니다.
| 💡 실제 PG사도 같은 방식을 씁니다 토스페이먼츠는 Idempotency-Key 헤더를 받으며 최대 300자, 처음 요청한 날부터 15일간 유효합니다. 스트라이프는 최대 255자이고 24시간이 지난 키는 삭제될 수 있습니다. (2026년 10월 각 공식 문서 기준, 정책은 바뀔 수 있습니다) |
5. 서버에서 멱등 키를 처리하는 흐름
서버 쪽 로직은 단계가 많아 보이지만, 실제로는 "저장소에 키가 있나 확인하고, 없으면 처리하고 저장한다"가 전부입니다.
| 단계 | 서버가 하는 일 |
|---|---|
| 1. 키 확인 | 헤더에 멱등 키가 없으면 400 에러로 거부 |
| 2. 저장소 조회 | 키가 이미 있으면 저장해 둔 응답을 그대로 반환하고 끝 |
| 3. 결제 처리 | 키가 없으면 실제 결제 로직 실행 |
| 4. 응답 저장 | 키와 함께 응답 상태 코드·본문을 저장소에 기록 |
이걸 Express 미들웨어로 옮기면 아래 정도가 됩니다. 아래 코드는 동작 원리를 보여주는 예시이며 Express 4 기준(2026년 시점)으로 작성했습니다. store는 뒤에서 설명할 저장소 객체라고 보시면 됩니다.
async function idempotency(req, res, next) {
const key = req.get('Idempotency-Key');
if (!key) {
return res.status(400).json({ error: 'Idempotency-Key 헤더가 필요합니다' });
}
const saved = await store.get(key);
if (saved) {
return res.status(saved.status).json(saved.body);
}
req.idemKey = key;
next();
}
미들웨어가 키를 확인하고 통과시키면, 결제 라우터는 평소처럼 결제를 처리한 뒤 결과를 저장소에 남깁니다.
app.post('/payments', idempotency, async (req, res) => {
const result = await processPayment(req.body);
await store.set(req.idemKey, {
status: 200,
body: result
}, { ttl: 60 * 60 * 24 });
res.json(result);
});
성공 응답만 저장할지, 실패 응답도 저장할지는 정책 선택입니다. 스트라이프는 500 에러까지 포함해 첫 응답을 그대로 저장합니다. 다만 일시적 장애로 실패한 경우에는 같은 키로 재시도했을 때 다시 시도해 주는 게 사용자에게 유리할 수 있어서, 실무에서는 "결제가 실제로 시작된 경우만 저장"하는 쪽을 택하기도 합니다.
6. 멱등 키 저장소 설계
키를 어디에 저장하느냐가 실무에서 고민되는 지점입니다. 보통 DB 테이블과 Redis 중 하나를 고르는데, 각각 장단점이 뚜렷합니다.
| 항목 | DB 테이블 | Redis |
|---|---|---|
| 속도 | 상대적으로 느림 | 매우 빠름 |
| 만료 처리 | 배치로 직접 삭제해야 함 | TTL 옵션으로 자동 삭제 |
| 결제와 같은 트랜잭션 | 가능 (같은 DB면) | 불가능 |
| 데이터 유실 위험 | 낮음 | 설정에 따라 있음 |
결제처럼 돈이 걸린 작업이라면 개인적으로는 DB 테이블 쪽을 권합니다. 결제 레코드와 멱등 키를 같은 트랜잭션으로 묶을 수 있어서, "결제는 됐는데 키 저장은 실패"하는 어중간한 상태가 안 생깁니다.
Redis는 조회가 빠르고 TTL이 편해서 매력적이지만, 재시작이나 메모리 부족으로 키가 날아가면 그 순간 중복 결제가 열립니다. 트래픽이 아주 많아서 DB 조회가 부담되는 경우에 "Redis로 먼저 거르고 DB로 최종 확인"하는 2단 구성을 쓰기도 합니다.
보관 기간은 재시도가 일어날 수 있는 시간보다 넉넉하게 잡으면 됩니다. 앞서 본 것처럼 PG사들은 24시간에서 15일 사이로 운영합니다. 자체 서비스라면 24시간 정도로 시작해서 필요에 따라 늘리는 게 무난합니다.
7. 동시에 두 요청이 들어올 때 처리
5번 섹션의 코드에는 빈틈이 하나 있습니다. 더블클릭으로 두 요청이 거의 동시에 들어오면, 둘 다 "저장소에 키가 없네"라고 판단하고 둘 다 결제를 진행합니다. 조회와 저장 사이의 짧은 틈이 문제입니다.
해결은 "조회 후 저장"이 아니라 "먼저 저장 시도"로 순서를 바꾸는 겁니다. 멱등 키 컬럼에 유니크 제약을 걸어 두고, 요청이 오자마자 "처리 중" 상태로 INSERT를 시도합니다. 같은 키로 두 번째 INSERT가 오면 DB가 유니크 에러를 내므로, 그 요청은 자연스럽게 걸러집니다.
CREATE TABLE idempotency_keys (
idem_key VARCHAR(255) PRIMARY KEY,
status VARCHAR(20) NOT NULL, -- processing / done
response JSON,
created_at DATETIME NOT NULL
);
이 구조에서 요청 처리는 아래 순서로 흘러갑니다. 유니크 제약이 사실상 락 역할을 하기 때문에, 별도의 분산 락 없이도 동시 요청을 막을 수 있습니다.
| INSERT 결과 | 서버 동작 |
|---|---|
| 성공 | 결제 처리 후 status를 done으로, response에 응답 저장 |
| 유니크 에러 + status가 done | 저장된 response를 그대로 반환 |
| 유니크 에러 + status가 processing | 409 응답으로 "처리 중이니 잠시 후 재시도" 안내 |
한 가지 더 챙길 게 있습니다. 결제 처리 도중 서버가 죽으면 status가 processing인 채로 남습니다. 이런 키는 일정 시간이 지나면 다시 처리할 수 있게 풀어주는 로직이 필요합니다. 그렇지 않으면 그 사용자는 영원히 409만 받게 됩니다.
8. 멱등성 적용할 때 흔한 실수
멱등 키를 붙였는데도 중복 결제가 나는 경우가 있습니다. 대부분 아래 실수 중 하나입니다.
| 실수 | 왜 문제인가 |
|---|---|
| 서버가 키를 생성 | 재시도마다 새 키가 생겨 중복을 알아볼 수 없음 |
| 재시도 때 키를 새로 발급 | 클라이언트가 재시도할 때 UUID를 다시 만들면 의미 없음 |
| 응답을 저장하지 않음 | 재요청에 돌려줄 게 없어서 에러만 반환하게 됨 |
| 같은 키로 다른 내용 전송 | 금액이 다른데 같은 키면 첫 결제 응답이 돌아감 |
| 주문 ID를 키로 사용 | 같은 주문에 결제 재시도와 취소가 섞이면 구분 불가 |
첫 번째가 제일 자주 보이는 실수입니다. "키는 서버가 안전하게 만들어야 하지 않나?"라고 생각하기 쉬운데, 멱등 키는 보안용이 아니라 식별용입니다. 클라이언트가 "이 요청과 저 요청은 같은 거야"라고 알려주는 게 목적이라서, 서버가 만들면 그 역할을 할 수 없습니다.
네 번째 실수를 막기 위해 PG사들은 요청 본문도 함께 검사합니다. 스트라이프와 마스터카드 API 모두 같은 키로 다른 내용이 오면 에러를 돌려줍니다. 자체 구현에서도 요청 본문의 해시를 키와 함께 저장해 두고 비교하면 안전합니다.
마지막 실수도 자주 보입니다. 주문 ID는 "무엇에 대한 요청인지"를 나타내고, 멱등 키는 "몇 번째 시도인지"를 나타냅니다. 둘은 역할이 다르므로 따로 두는 게 맞습니다. 다만 "주문당 결제는 무조건 한 번"이라는 정책이 확실하다면 주문 ID에 유니크 제약을 거는 것도 추가 안전장치로 유효합니다.
자주 묻는 질문 (FAQ)
프론트에서 버튼을 비활성화하면 멱등 키가 필요 없지 않나요?
더블클릭만 막을 수 있고, 타임아웃 재시도나 네트워크 끊김은 막지 못합니다. 버튼 비활성화는 사용자 경험용이고, 멱등 키는 서버 안전장치입니다. 둘 다 하는 게 맞습니다.
멱등 키는 어떤 값으로 만들어야 하나요?
UUID v4처럼 충돌 가능성이 거의 없는 랜덤 값이면 됩니다. 토스페이먼츠와 스트라이프 모두 UUID v4를 권장합니다. 이메일이나 전화번호 같은 개인정보를 키로 쓰는 건 피해야 합니다.
PG사가 멱등 키를 지원하면 우리 서버에는 안 만들어도 되나요?
PG 호출만 멱등해지고, 우리 서버의 주문 생성이나 포인트 차감은 여전히 두 번 실행될 수 있습니다. 우리 서버가 받는 요청에도 별도로 멱등 키를 적용하는 게 안전합니다.
결제 중복을 막는 건 결국 서버의 기억력입니다
멱등성이라는 말이 어려워 보여도, 하는 일은 "같은 요청인지 알아보고 같은 답을 돌려준다"가 전부입니다. 그 알아보는 꼬리표가 멱등 키이고, 꼬리표는 클라이언트가 만들어야 서버가 재시도를 알아볼 수 있습니다.
저장소는 돈이 걸린 작업이면 DB 테이블에 유니크 제약으로 시작하는 게 안전하고, 동시 요청은 "조회 후 저장"이 아니라 "먼저 저장 시도"로 막습니다. 이 두 가지만 지켜도 중복 결제 사고의 대부분은 걸러집니다.
📚 함께 보면 좋은 글
API 뜻, 식당 웨이터 비유로 끝내는 가장 쉬운 설명
API 뜻을 검색해 보면 '응용 프로그램 프로그래밍 인터페이스'라는 번역만 나오고, 몇 번을 읽어도 머리에 남지 않는 경우가 많습니다. 그런데 이 개념은 레스토랑에서 주문하는 장면 하나만 떠
devwar.tistory.com
CQRS, 읽기와 쓰기를 굳이 나누는 이유 — 후배에게 먼저 외우게 하는 개념
CQRS(Command Query Responsibility Segregation)는 읽기와 쓰기를 서로 다른 모델로 나눠서 다루는 아키텍처 패턴입니다. 이름은 거창하지만 뿌리는 단순합니다. 상태를 바꾸는 명령(Command)과 값을 돌려주는
devwar.tistory.com
웹 API 서버 디도스 방어에 스로틀링 기술이 필수인 숨은 이유가 있습니다
웹 프론트엔드 성능 최적화와 백엔드 서버 방어에 있어서 스로틀링(Throttling)은 선택이 아닌 필수 기술입니다. 트래픽 폭주로 인한 브라우저 렌더링 지연과 백엔드 서버 마비를 막으려면 이 개념
devwar.tistory.com
'개발철학&개발용어' 카테고리의 다른 글
| 비밀번호 해시와 암호화 차이, 운영자도 내 비밀번호를 모르는 이유 (0) | 2026.10.16 |
|---|---|
| 웹 API 서버 디도스 방어에 스로틀링 기술이 필수인 숨은 이유가 있습니다 (0) | 2026.09.23 |
| 2차 도메인 뜻, 서브도메인과 SLD 헷갈리는 이유 (0) | 2026.08.26 |
| HTTP/1.1 vs HTTP/2 vs HTTP/3 차이 완벽 정리 (2026) - 실무에서 뭘 써야 할까 (0) | 2026.08.17 |
| 스펙 주도 개발 SDD가 API 개발 생산성을 비약적으로 높이는 이유 (0) | 2026.08.12 |