본문으로 건너뛰기
API 멱등성 아키텍처 다이어그램

# 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)를 같이 보내라”**는 것입니다.

흐름은 이렇습니다

  1. 클라이언트: 요청을 보낼 때 Idempotency-Key라는 헤더에 고유한 값(UUID 등)을 생성해서 담아 보냅니다.
POST /payments
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
  1. 서버:
  • 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와 환불 소동을 막아주는 방패가 되어줄 것입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# WebSocket 대규모 확장 완벽 가이드: Redis Pub/Sub로 100만 동시 연결 처리하기

게시:

실시간 채팅, 알림, 라이브 스트리밍 서비스를 위한 WebSocket 서버 확장 전략을 다룹니다. Node.js Socket.IO + Redis Pub/Sub 아키텍처, 100k+ 연결에서의 메모리 누수 해결, 로드 밸런싱 전략, 그리고 2025년 프로덕션 검증된 스케일링 패턴까지 모두 포함합니다.

읽기

# Circuit Breaker 패턴: 마이크로서비스가 도미노처럼 무너지는 것을 막는 법

게시:

외부 API 장애가 우리 서비스까지 전파되는 '연쇄 장애(Cascading Failure)'를 겪어보셨나요? 서킷 브레이커는 시스템의 퓨즈 역할을 합니다. Resilience4j 사용법부터 OPEN, CLOSED, HALF-OPEN 상태 전이의 원리, 그리고 적절한 타임아웃 설정 전략까지 상세히 알아봅니다.

읽기