# API 멱등성(Idempotency): 결제 중복을 막는 가장 확실한 방법
Table of Contents
“고객님, 5만 원이 두 번 빠져나갔는데요?”
이런 CS 문의가 들어오면 개발자의 멘탈은 흔들리기 시작합니다. 로그를 봅니다. 결제 요청이 두 번 들어왔습니다. “아니, 클라이언트에서 버튼을 두 번 누른 거 아니에요?”라고 핑계를 대고 싶지만, 사실 이건 네트워크의 불확실성을 고려하지 않은 시스템 설계의 문제입니다.
지하철에서 와이파이가 끊겼다가 다시 연결될 때, 앱은 자동으로 요청을 재시도합니다. 만약 첫 번째 요청이 서버에서는 이미 처리되었는데 응답만 유실된 거라면? 재시도 요청으로 인해 중복 결제가 발생합니다.
이런 사태를 막기 위해 필요한 개념이 바로 **멱등성(Idempotency)**입니다.
멱등성(Idempotency)이란 무엇인가요?
수학적 정의는 거창하지만, API 세계에서는 간단합니다. **“동일한 요청을 한 번 보내든, 백 번 보내든 결과가 똑같아야 한다”**는 것입니다.
GET: 여러 번 조회해도 데이터는 그대로죠? (멱등하다)DELETE: 삭제된 걸 또 삭제해도 ‘삭제된 상태’는 같습니다. (멱등하다)POST: 결제 요청을 10번 보내면? 10번 결제됩니다. (멱등하지 않다!)
문제는 돈이 오가는 결제, 주문 생성 같은 핵심 로직은 대부분 POST라는 점입니다. 그래서 우리는 POST 메서드에 인위적으로 멱등성을 부여해야 합니다.
해결책: Idempotency Key 패턴
Stripe, PayPal, Toss Payments 등 잘 만든 결제 API들은 모두 이 패턴을 사용합니다. 핵심은 **“이 요청이 처음인지, 아니면 재시도인지 구분할 수 있는 고유한 ID(Key)를 같이 보내라”**는 것입니다.
흐름은 이렇습니다
- 클라이언트: 요청을 보낼 때
Idempotency-Key라는 헤더에 고유한 값(UUID 등)을 생성해서 담아 보냅니다.
POST /payments
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
- 서버:
- Redis나 DB에서 이 키를 조회합니다.
- 처음 보는 키라면? 정상적으로 결제를 처리하고, 결과와 함께 키를 저장합니다.
- 이미 있는 키라면? 결제 로직을 다시 실행하지 않고, 저장해둔 이전 응답을 그대로 반환합니다.
이렇게 하면 클라이언트가 타임아웃 때문에 100번 재시도를 해도, 실제 결제는 딱 한 번만 일어납니다.
실전 구현: 주의할 점 3가지
개념은 쉽지만 막상 구현하려면 디테일이 중요합니다.
1. 동시성 이슈 (Race Condition)
클라이언트가 실수로 0.001초 간격으로 똑같은 요청을 두 번 쏘면 어떻게 될까요? 두 요청이 동시에 서버에 도착해서, 둘 다 “어? 처음 보는 키네?” 하고 결제를 진행해버릴 수 있습니다 (Double Spending).
이를 막으려면 **분산 락(Distributed Lock)**이 필수입니다.
Redis의 SETNX 같은 명령어를 써서, 해당 Idempotency-Key에 대한 처리가 진행 중일 때는 다른 요청이 끼어들지 못하게 막아야 합니다.
2. 요청 바디(Body) 검증
누군가 악의적으로 키는 똑같은데 금액만 바꿔서 요청을 보내면 어떡할까요? 멱등성 키가 같으면 무조건 저장된 응답을 주게 되어 있는데, 이걸 악용할 수 있습니다.
그래서 저장된 키를 찾았을 때, “그때 그 요청이랑 지금 요청이랑 내용도 똑같은지” 검증해야 합니다. 보통 요청 바디의 해시(Hash) 값을 같이 저장해두고 비교합니다. 다르면 에러(422 Unprocessable Entity)를 뱉어야겠죠.
3. 키의 유효기간 (TTL)
멱등성 키를 영원히 저장할 수는 없습니다. Redis 메모리가 터질 테니까요. 보통 24시간에서 48시간 정도의 TTL(Time To Live)을 설정합니다. “하루 지난 중복 요청은 재시도로 보지 않고 새로운 요청으로 보겠다”는 정책을 세우는 거죠. 물론 환불 가능 기간 등을 고려해 정책을 정해야 합니다.
Redis를 활용한 간단한 구현 예시 (Node.js)
// 미들웨어 형태의 의사 코드(Pseudo-code)
async function idempotencyMiddleware(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key) return next();
// 1. 락 획득 시도 (동시 요청 방지)
// NX: 키가 없을 때만 설정 (Set if Not Exists)
// EX: 10초 후 자동 만료 (Deadlock 방지)
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 10);
if (!lock) {
// 이미 처리 중인 요청이 있다면 409 Conflict 또는 잠시 대기 후 재시도 유도
return res.status(409).send('Processing...');
}
try {
// 2. 이미 처리된 결과가 있는지 확인
const cached = await redis.get(`result:${key}`);
if (cached) {
// 요청 해시 비교 로직 추가 필요 (보안)
return res.send(JSON.parse(cached));
}
// 3. 실제 로직 실행을 위해 응답 메서드 가로채기
const originalSend = res.send;
res.send = (body) => {
// 4. 결과 저장 (24시간 유지)
redis.set(`result:${key}`, body, 'EX', 86400);
originalSend.call(res, body);
};
next();
} finally {
// 5. 락 해제
redis.del(`lock:${key}`);
}
}
마치며: 신뢰는 디테일에서 온다
멱등성은 있으면 좋은 기능(Nice-to-have)이 아니라, **돈을 다루는 시스템이라면 반드시 있어야 하는 기능(Must-have)**입니다.
사용자는 네트워크 상황을 이해해주지 않습니다. 그저 “내 돈이 두 번 빠져나갔다”는 사실에 분노할 뿐입니다. 여러분의 API가 불안정한 네트워크 속에서도 단단하게 동작하도록, 오늘 당장 멱등성 키를 도입해 보는 건 어떨까요? 이 작은 키 하나가 수많은 CS와 환불 소동을 막아주는 방패가 되어줄 것입니다.