이 글은 Velog에서 이전한 글입니다. Velog 원문 보기
문제 상황: 중복 요청이 일으키는 치명적 결과
게시판 서비스에서 사용자 글을 작성하고 '등록' 버튼을 눌렀습니다.
그런데 서버에서 늦어지고, 브라우저는 멈춘 것처럼 보입니다.
당황한 사용자는 새로고침을 누르거나 버튼을 또 누르게 됩니다.
이 결과는 동일한 게시글이 두 개 이상 등록되는 중복 현상이 발생합니다.
게시글의 중복 등록의 경우에는 "그냥 하나 삭제하면 되지" 싶을 수 있습니다.
하지만, 상황이 금융 서비스라면 이야기가 달라집니다.
예를 들어 토스나 카카오페이 같은 송금 기능에서, 사용자가 친구에게 10만 원을 보내는 중 네트워크 지연이 생겼다고 가정하겠습니다.
화면이 멈춘 것 같아 다시 버튼을 누른다면 어떻게 될까요?
같은 송금 요청이 두 번 처리되어 20만 원이 빠져나갑니다. 이건 사용자 입장에서 단순한 버그가 아니라, 재산 피해로 이어지는 치명적인 장애입니다.
이처럼 중복 요청은 단순 UI/UX 문제를 넘어 서비스 신뢰성, 법적 책임, 데이터 무결성 문제까지 불어올 수 있는 무서운 이슈입니다.
특히 다음과 같은 상황에서 중복 요청이 쉽게 발생할 수 있습니다:
- 사용자의 브라우저에서 새로고침 / 뒤로가기
- 모바일 앱의 재시도 로직 (네트워크 불안정 시)
- 프론트엔드 또는 중간 서버의 재전송 로직
- 클라이언트/서버간 타임아웃 미스매치
그렇다면 서버는 어떻게 한 번만 처리되게 만들 수 있을까요?
다음 섹션에서는 이 문제를 해결하기 위한 멱등성(idempotency) 개념에 대해 정리합니다.
Idempotency란 무엇인가
중복 요청 문제를 이야기할 때 가장 먼저 떠올려야 할 개념이 바로 멱등성(Idempotency)입니다.
API에서의 멱등성 개념
멱등성
- 같은 요청을 여러 번 수행하더라도 결과가 같아야한다는 성질을 말합니다.
즉,
- 한 번 요청을 보내든
- 같은 요청을 두 번, 세 번 보내든
서버는 항상 한 번만 처리한 것과 동일한 결과를 반환해야 합니다.
이 개념은 HTTP 표준 메서드의 설계 철학에도 그대로 반영되어 있습니다.
GET은 괜찮고, POST는 왜 위험할까?
HTTP 메서드마다 멱등성 여부가 다릅니다.
| 메서드 | 멱등성 여부 | 설명 |
|---|---|---|
GET |
✅ 멱등함 | 같은 URL로 조회하면 항상 같은 결과가 나와야 한다. |
DELETE |
✅ 멱등함 | 같은 자원을 여러 번 삭제해도 결과는 동일해야 한다. |
PUT |
✅ 멱등함 | 같은 리소스를 같은 값으로 덮어쓰면 변화 없음. |
POST |
❌ 멱등하지 않음 | 호출할 때마다 새로운 리소스를 생성하거나 행위를 유발함. |
예: POST
/posts로 글을 등록하는 경우,
같은 요청이 2번 가면 글이 2개 생성됩니다.
이처럼 POST는 기본적으로 멱등하지 않습니다.
때문에 결제, 송금, 주문, 회원가입 같은 행위는 POST 방식으로 이루어지는 경우가 많고,
중복 요청 시 매우 민감한 문제를 일으킵니다.
그래서 POST 요청도 멱등하게 만들기 위한 다양한 전략이 실무에서는 사용됩니다.
대표적인 것이 Idempotency-Key 기반의 UUID 요청 추적 방식입니다.
요청 중복을 어떻게 막을 것인가
문제의 본질은 명확합니다.
사용자는 여러 번 요청을 보낼 수 있지만, 서버는 단 한 번만 처리해야 합니다.
그렇다면 서버는 이 요청을 이미 처리했는지 아닌지를 어떻게 구분할 수 있을까요?
답은 요청을 식별하는 고유 키를 두는 것입니다.
가장 대표적인 방식이 UUID와 Idempotency-Key입니다.
UUID를 활용한 중복 식별
서버가 요청의 고유성을 판별하기 위해 가장 자주 쓰는 방식은,
클라이언트가 UUID (Universally Unique Identifier)를 생성해서 요청에 포함시키는 것입니다.
POST /transfer
Idempotency-Key: 7f4a2d21-8b74-4e6c-9d4b-9dfb1a3023c2
이렇게 요청마다 유일한 키를 붙여두면, 서버는 이 키를 보고 중복 여부를 판단할 수 있습니다.
즉, "이 키로 처리한 요청이 있나?" -> 있다면 처리하지 않습니다.
Q: 요청마다 UUID를 생성해서 보낸다 -> 그렇다면 중복 요청이면 서로 다른 UUID가 아닐까?
핵심은 클라이언트가 "요청 단위"로 UUID를 "직접" 생성해야 한다는 것입니다.
예를 들어 사용자 A가 10만원을 송금했다고 하겠습니다.
- 이 요청을 만들 때 클라이언트가 하나의 UUID를 생성해서 Idempotency-Key로 서버에 보낸다.
- 이후 서버 응답이 늦거나 실패했을 때, 프론트는 같은 UUID로 다시 요청을 보내야 합니다.
즉, 중복인지 아닌지를 구분할 책임은 클라이언트에 있습니다.
클라이언트는 '사용자 행동 = 하나의 트랜잭션'임을 판단할 수 있는 구조를 잡아야 합니다.
예시: 송금 요청 처리 로직
- 송금 요청을 만들 때 클라이언트가 UUID를 생성해 함께 전송한다.
- 서버는 이 UUID를 기준으로 Redis나 DB에 저장된 이전 요청과 비교한다.
- 만약 처리 이력이 있다면: 응답을 재전송만 하고, 실제 처리는 생략한다.
- 만약 처리 이력이 없다면: 비즈니스 로직을 실행하고, 결과와 함께 UUID를 저장한다.
MSA에서는 서버가 어디에 저장된 UUID와 비교하는 것일까? Redis? 메시지 큐?
MSA 구조에서는 중복 요청을 감지할 책임이 각 마이크로서비스에 분산되어 있어야 합니다.
일반적인 처리 방식:
- API Gateway나 BFF에서 Idempotency-Key를 받아서
-> 각 서비스의 Redis 또는 DB에 저장/검사한다.
메시지 큐(Kafka/SQS) 사용 시:
- 메시지에 Idempotency-Key 포함
- Consumer 단에서 Redis나 DB에 처리 여부 기록
- 메시지 큐는 "At Least Once"가 기본
- 메시지 큐는 메시지를 줄 뿐이지, "이 메시지가 이미 처리되었나?"를 판단할 수 없습니다.
- 이것은 Consumer의 책임이고, 그래서 Redis나 DB를 사용해서 검사를 진행합니다.
- 이미 처리한 메시지는 skip
Kafka는 Exactly Once는 존재하긴 하지만
구성 복잡도도 크고, 성능 저하도 있어서 모든 경우에 사용하진 않습니다.
- 그래서 보통은 Idempotency-Key + Redis/DB 체크 방식을 선호합니다.
Idempotency-Key를 메타데이터(HTTP 헤더)로 사용하는 패턴
실제 실무에서는 위에서 본 UUID를 Idempotency-Key라는 이름으로 HTTP 헤더에 명시하는 패턴이 표준처럼 사용됩니다.
POST /payments
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 9dcba6ee-fb5e-4d0c-950e-5a3df49f3067
서버는 이 키를 기준으로 요청 중복을 감지하고,
같은 키에 대해 처리 결과를 재사용하거나 중복을 거부합니다.
클라이언트가 키를 안 보냈다면?
서버가 자동 생성할 수도 있지만, 멱등성을 보장하려면 클라이언트 측에서 명확히 관리하는 게 바람직합니다.
실제 시스템에서는 어떻게 처리할까?
앞에서 멱등성 개념과 Idempotency-Key의 동작 원리를 알아봤습니다.
그렇다면 실제로 송금, 결제, 주문과 같은 실무 시스템에서는 이걸 어떻게 구현하고 있을까요?
정답은 하나입니다.
❗️사용자 요청이 아무리 여러 번 오더라도, 서버는 단 한 번만 처리되어야 한다.
❗️그리고 이걸 정확하고, 신뢰할 수 있게 보장해야 한다.
송금/결제 시스템의 중복 방지 전략 사례
Idempotency-Key 기반 처리
- 클라이언트가 요청마다 Idempotency-Key UUID를 생성해서 메타데이터에 포함
- 서버는 이 키를 Redis 또는 DB에 기록
- 같은 키로 다시 요청이 들어오면, 기존 처리 결과를 그대로 반환
- 키는 TTL(예: 24시간)을 두고 자동 만료되도록 설계
멱등성을 API 스펙으로 강제함 -> 결제 API는 Idempotency-Key 없으면 요청 자체를 거부할 수 있음
DB 기반 멱등성 보장
- 송금 요청 시 내부적으로 transactionId나 requestId 같은 고유 식별자를 생성
- 이 식별자에 대해 DB Unique Key 제약조건을 걸어 중복 INSERT 자체를 방지
- 이미 처리된 요청이면 -> 에러가 아니라, 기존 결과를 응답하도록 설계
"한 번만 처리한다"를 보장하기 위한 고민
실제 시스템에서 이걸 지키기 위해 정말 다양한 고민들이 필요합니다.
| 문제 상황 | 해결 전략 |
|---|---|
| 요청이 중복으로 도달 | Idempotency-Key로 요청 식별, Redis/DB로 중복 확인 |
| 응답 실패로 사용자 재시도 | 기존 키로 처리 결과 재사용 |
| 멀티 인스턴스 환경에서 동시에 처리될 위험 | Redis Lock / DB Unique 제약으로 Race Condition 방지 |
| Consumer에서 메시지 중복 수신 | 메시지에 ID 포함 + 처리 이력 Redis에 저장 |
💬 고민 포인트들
- 처리 결과를 어디에 저장할 것인가? (Redis, DB, Disk?)
- 저장된 결과는 언제까지 유지할 것인가? (TTL 10분? 24시간?)
- 트랜잭션이 실패했는지, 성공했는데 응답이 실패한 건지 어떻게 구분할 것인가?
- Concurrent 요청이 동시에 처리되지 않도록 어떻게 막을 것인가?
이 모든 것들이 한 번만 처리되게 만드는 아키텍처 설계의 고민입니다.
서버에서 중복 요청을 방어하는 기술들
서버는 언제든지 같은 요청을 여러 번 보낼 수 있습니다.
심지어 의도치 않게, 또는 네트워크 불안정 때문에 자동으로 여러 번 요청이 발생할 수도 있습니다.
그래서 서버는 반드시, 요청이 여러 번 오더라도 단 한 번만 처리되도록 방어 로직을 갖춰야 합니다.
Redis 분산 락을 이용한 선점 처리
중복 요청이 동시에 들어오는 경우, Race Condition이 발생할 수 있습니다.
이때 한 요청이 먼저 선점하고, 나머지를 차단되도록 하기 위해 분산 락(distributed lock)을 사용합니다.
💡 대표 기술: Redis + SETNX or Redisson
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:transfer:abc-123", "1", Duration.ofSeconds(10));
if (!locked) {
throw new DuplicateRequestException("이미 처리 중인 요청입니다.");
}
- 첫 번째 요청만 락을 획득하고 처리
- 나머지 요청은 처리하지 않고 바로 리턴
- 락은 일정 시간 후 만료되도록 TTL 설정 필요
✅ 선점 처리 -> “처리 중인 요청은 기다리지 않고 거절”
DB Unique Key로 중복 저장 방지
한 번 처리된 요청에 대해 동일한 결과가 DB에 중복 저장되지 않도록 하는 가장 확실한 방법은,
데이터베이스에 Unique 제약조건을 설정하는 것입니다.
💡 예시: 송금 요청에 대해 request_id를 유니크하게 관리
CREATE TABLE transfer (
id SERIAL PRIMARY KEY,
request_id VARCHAR(36) UNIQUE,
from_user_id UUID,
to_user_id UUID,
amount INT
);
- 동일한 request_id로 INSERT 시도 시, DB가 중복을 막고 예외 발생
- 서버는 이 예외를 캐치해서 -> 기존 요청으로 간주하고 처리 결과를 재전송
try {
transferRepository.save(transfer);
} catch (DataIntegrityViolationException e) {
return getPreviousTransferResult(requestId);
}
✅ 장점: 동시성 이슈까지 포함해서 가장 안전하게 중복 방지 가능
메시징 시스템의 Deduplication (Kafka, SQS 등)
MSA 구조에서는 서비스 간 통신에 Kafka, SQS 같은 메시징 큐를 많이 사용합니다.
하지만 이 시스템들은 기본적으로 At-Least-Once (최소 1회 전송)을 보장하기 때문에,
같은 메시지를 중복 소비하는 상황이 반드시 발생합니다.
이를 방지하기 위해 메시지 자체에 식별자(ID)를 포함하고, 처리 이력을 기록합니다.
💡 예시: Kafka Consumer에서 Redis 기반 Deduplication
String messageId = message.getHeader("eventId");
if (redis.exists("processed:" + messageId)) {
// 이미 처리된 메시지
return;
}
processMessage(message);
redis.set("processed:" + messageId, "1", Duration.ofHours(1));
- 메시지에 포함된 고유 ID로 처리 여부를 체크
- 이미 처리된 메시지면 무시
- Redis TTL을 두어 메모리 낭비 최소화
✅ 정리: 결국 중요한 건 ‘한 번만 처리되게 만들기’
지금까지 중복 요청에 대한 개념부터
- 멱등성(Idempotency)의 정의,
- UUID 기반 요청 식별,
- 실제 시스템에서의 중복 방지 전략,
- 그리고 서버 측의 방어 기술까지 살펴봤습니다.
많은 기술이 등장했고, 아키텍처도 복잡했습니다.
하지만 결국 그 모든 복잡함은 단 하나의 목적을 위해 존재합니다.
❗️사용자의 요청은 반드시 단 한 번만 처리돼야 한다.
실무에서 정말 중요한 건
- 사용자는 요청이 여러 번 전송되는 걸 모를 수 있다.
- 버튼을 두 번 눌렀거나, 네트워크가 불안정했거나, 앱이 멈췄거나.
하지만 시스템은 항상
이 사용자가 의도한 요청이 단 한 번만 수행되었는가?
이 질문에 "예"라고 답할 수 있어야 합니다.
그리고 그걸 보장하기 위해:
- 클라이언트는 요청 단위로 Idempotency-Key를 생성하고 재사용해야 하고,
- 서버는 그 키를 기준으로 중복 처리 여부를 식별하고, 처리 결과를 기억해야 하며,
- DB나 메시지 시스템, 분산 락 등의 하위 시스템과의 연동까지 고려해야 합니다.
💬 마무리하며
저는 이 주제에 대해 예전에는 단순히
“프론트에서 버튼을 비활성화시키면 되지 않을까?”
생각했지만, 실제로 면접에서 이 질문에 제대로 답변하지 못한 경험을 통해
“한 번만 처리되게 만드는 것”은 백엔드 시스템의 본질적인 과제라는 걸 알게 되었습니다.
이 글이 중복 요청 문제에 대해 고민 중인 개발자들에게
“어떻게 동작해야 하는가”보다 “어떻게 책임져야 하는가”를 먼저 묻는 계기가 되기를 바랍니다.
🔗 참고 자료
Stripe 공식 문서 – Idempotency
카카오페이 기술 블로그
토스 기술 블로그
MDB - HTTP 멱등성
'Language·CS > Backend Interview' 카테고리의 다른 글
| DB Replication 구조와 Read/Write 분리 전략의 장단점 (0) | 2026.08.02 |
|---|---|
| 무중단 배포 전략(Rolling, Blue-Green, Canary ): 트래픽 제어부터 롤백까지 (0) | 2026.08.02 |
| JPA - Cascade와 orphanRemoval, 진짜 이해하고 쓰고 있나요? (0) | 2026.08.02 |
| Redis 키 네임스페이스와 Hash 구조, 실무에서 어떻게 설계할까? (0) | 2026.08.02 |