멱등성(Idempotency)에 대하여
- -

들어가며
이번에 데브코스 프로젝트를 진행하며, 가장 주요 로직이었던 주문/환불 흐름에서 반드시 고려해야할 점이 있었다.
상황은 다음과 같았다.
Toss Payments(PG)의 confirm 요청이 서버까지 도달해서 DB에 저장됐는데, 응답이 유실되면 클라이언트는 실패로 판단하고 재시도한다. -> 즉 이때 이미 완료된 결제가 한번 더 발생하여 이중으로 결제가 되는 문제가 발생할 수도 있었다.
세미 프로젝트 때 이미 이런 이중 차감 문제의 해답은 멱등성 고려인걸 알고 있었지만, 스프린트 일정상 구현 난이도가 높고 복잡하여 파이널 프로젝트로 미루기로 했다.
세미 때는 얘기만 하고 그냥 넘어갔던 멱등성을 파이널 때 도입했고, 이 과정에서 학습한 내용을 복습하고 실제로는 어떻게 쓰이는지 궁금해서 이에 대해 글을 작성한다.
멱등성이란 무엇인가
멱등성(Idempotency)은 같은 연산을 여러 번 수행해도 결과가 처음 한 번 수행한 것과 동일한 성질이다.
수학에서 유래한 개념으로, f(f(x)) = f(x) 로 표현된다.
일상적인 비유를 들면 이렇다.

엘리베이터 버튼은 아무리 많이 눌러도 한 번만 호출된다. 하지만 입금은 누를 때마다 잔액이 계속 증가한다.
HTTP 메서드와 멱등성
HTTP 메서드는 멱등성과 안전성 기준으로 다음과 같이 분류된다.
| 메서드 | 멱등성 | 안전성 | 설명 |
| GET | O | O | 조회만 수행, 상태 변경 없음 |
| PUT | O | X | 전체 교체, 같은 데이터로 몇 번 보내도 동일 |
| DELETE | O | X | 이미 삭제된 리소스를 다시 삭제해도 결과 동일 |
| POST | X | X | 매번 새 리소스 생성 가능 |
| PATCH | X | X | 상대적 변경 시 결과가 달라질 수 있음 |
PUT은 리소스 전체를 덮어쓰므로 여러 번 호출해도 같은 상태가 된다.
POST는 호출할 때마다 새 리소스가 생성될 수 있어 멱등하지 않다.
PATCH는 "잔액 +1000원"처럼 상대적 변경이면 멱등하지 않다. "잔액을 5000원으로 설정"처럼 절대값으로 지정하면 멱등하게 만들 수 있다.
멱등성이 필요한 상황
항상 유의해야할 점이, 네트워크를 신뢰해서는 안된다.
예를들어 다음 시나리오를 생각해보자.

- 클라이언트가 결제 요청을 보냄
- 서버가 요청을 정상 처리하고 DB에 저장
- 응답이 네트워크 장애로 유실됨
- 클라이언트는 실패로 판단하고 재시도
- 서버는 같은 요청을 다시 처리 → 이중 결제 발생
멱등성이 없으면 재시도 자체가 오류 포인트가 된다.
그 결과로는 이중 결제, 이중 주문이 실제 서비스에서 발생한다.
멱등성 키 설계
멱등성 키(Idempotency Key)는 각 요청을 고유하게 식별하는 값이다.
클라이언트가 생성하여 Idempotency-Key HTTP 헤더에 포함해 전송하는 것이 업계 표준이다. (Stripe, PayPal 등 주요 결제 API가 이 방식을 채택하고 있다.)
키 생성 방식은 세 가지가 대표적이다.
| 방식 | 예시 | 특징 |
| UUID | 550e8400-e29b-41d4-a716-446655440000 | 범용, 충돌 확률 극히 낮음 |
| 비즈니스 키 조합 | order-user123-20260415-001 | 의미 있는 키, 디버깅 용이 |
| 해시 기반 | SHA256(userId + amount + timestamp) | 요청 내용 기반 자동 생성 |
멱등성 키 생명주기 - 재시도할 때 같은 키가 어떻게 보장되는가
여기서 자연스럽게 드는 의문이 하나 있다.
"버튼을 여러 번 누르면 그때마다 UUID가 새로 생성되는 거 아닌가? 그러면 멱등성이 깨지는 거 아닌가?"
핵심은 UUID를 누가, 언제 생성하느냐이다.
버튼 클릭 핸들러(API call) 안에서 매번 UUID를 생성하면 클릭마다 다른 키가 만들어져 멱등성 의미가 없다. 올바른 구현은 결제 시작 시점에 키를 한 번 생성하고, 재시도 전 구간에서 동일하게 유지하는 것이다.
// 전역 또는 모듈 스코프
let idempotencyKey = null;
async function pay() {
// 처음 시도 시 키 생성, 재시도 시 기존 키 재사용
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
}
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await fetch('/payments', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey }
});
idempotencyKey = null; // 성공 시 키 초기화
return;
} catch (e) {
if (attempt === 3) {
idempotencyKey = null; // 최종 실패 시 키 초기화
throw new Error('결제 실패');
}
await sleep(1000 * Math.pow(2, attempt)); // 지수 백오프
}
}
}
결국 "키 생성 → 최대 3번 시도 → 성공하든 최종 실패하든 키 버림" 이 전부다.
재시도 간에 키가 동일하게 유지되는 이유는 단순하다.
idempotencyKey 변수가 살아있는 동안 재시도 루프 전체에서 같은 값을 참조하기 때문이다.
저장 위치는 상황에 따라 달라진다.
| 상황 | 저장 위치 | 이유 |
| 단순 네트워크 재시도 | 메모리 변수 | 같은 세션 내에서만 유지하면 충분 |
| 버튼 중복 클릭 방지 | sessionStorage | 탭 닫으면 사라짐 |
| 앱 재시작 후에도 재시도 | localStorage / DB | 영속성 필요 |
쿠키는 적합하지 않다. 도메인의 모든 요청에 자동으로 붙기 때문에 다른 결제 요청에 이전 키가 섞여 들어가는 오염이 생긴다 + 변조 가능성도 있다.
그래서 백엔드 입장에서는 사실 단순하다.
헤더에 키가 있냐 없냐, 있으면 캐시에 있냐 없냐만 보면 된다. 키를 어떻게 생성하고 관리하는지는 전적으로 프론트엔드의 책임이다.
멱등성 처리 흐름
서버가 요청을 수신했을 때의 판단 흐름은 다음과 같다.

핵심 로직은 4가지다.
- 요청 수신 시 멱등성 키로 기존 처리 여부를 먼저 확인
- 이미 처리된 요청이면 저장된 응답을 그대로 반환
- 신규 요청이면 처리 후 결과를 멱등성 키와 함께 저장
- 동시에 같은 키로 요청이 들어오면 락을 통해 하나만 처리
결제 API 적용 예시
기존 방식 — 중복 결제 위험
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> pay(
@RequestBody PaymentRequest request) {
// 매번 새 결제 생성 -- 중복 결제 위험!
Payment payment = Payment.builder()
.userId(request.getUserId())
.amount(request.getAmount())
.status(PaymentStatus.COMPLETED)
.build();
paymentRepository.save(payment);
return ResponseEntity.ok(
new PaymentResponse(payment.getId(), "SUCCESS"));
}
네트워크 장애로 클라이언트가 재시도하면 결제가 두 번 실행된다. 즉, 같은 주문에 대해 중복 차감이 발생하는 치명적인 버그이다.
개선된 방식 — 멱등성 키로 중복 방지
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> pay(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest request) {
// 이미 처리된 요청인지 확인
Optional<IdempotencyRecord> existing =
idempotencyStore.findByKey(key);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get().getResponse());
}
// 신규 요청 처리
Payment payment = paymentService.process(request);
idempotencyStore.save(key, new PaymentResponse(
payment.getId(), "SUCCESS"));
return ResponseEntity.ok(
new PaymentResponse(payment.getId(), "SUCCESS"));
}
멱등성 키로 중복 요청을 감지하여 같은 응답을 반환한다. 재시도가 와도 추가 결제가 발생하지 않는다.
데이터베이스 유니크 제약 활용
애플리케이션 레벨만으로는 사실 완벽하게 방어하기 힘들다.
그래서 동시 요청이 들어오면 둘 다 조회 시점에 "없음"으로 판단하고 중복 삽입을 시도할 수 있다. (동시성)
그래서 최종적으로는 DB 레벨에서 유니크 제약으로 이를 검사하고 방어해야한다.
@Entity
@Table(uniqueConstraints = @UniqueConstraint(
columnNames = {"idempotency_key"}))
public class IdempotencyRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "idempotency_key", nullable = false)
private String idempotencyKey;
@Column(columnDefinition = "TEXT")
private String responseBody;
private int statusCode;
private LocalDateTime createdAt;
private LocalDateTime expiresAt;
}
유니크 제약 조건으로 동일 키의 중복 삽입을 DB 레벨에서 방지하고, DataIntegrityViolationException 발생 시 기존 레코드의 응답을 반환하면 된다.
멱등성 응답 캐싱 (Redis 활용)
멱등성을 구현할 때, 캐싱을 활용하게 되면 성능적 이점을 얻어갈 수 있게 된다. 즉 DB 조회를 매번 하는 것보다 Redis에 캐싱하면 성능이 향상 된다.
@Component
@RequiredArgsConstructor
public class IdempotencyStore {
private final RedisTemplate<String, String> redis;
private final ObjectMapper objectMapper;
public void save(String key, Object response) {
String json = objectMapper.writeValueAsString(response);
redis.opsForValue().set(
"idempotency:" + key, json, Duration.ofHours(24));
}
public Optional<String> find(String key) {
String value = redis.opsForValue()
.get("idempotency:" + key);
return Optional.ofNullable(value);
}
}
위 예시처럼, Redis에 멱등성 키와 응답을 TTL과 함께 저장하게 되면 24시간 이후 자동 만료되어 저장 공간을 효율적으로 관리할 수 있다.
추가적으로 "idempotency: prefix"를 붙여 다른 Redis 키와 네임스페이스를 구분하는 것이 국룰이라고 한다.
분산 환경에서의 멱등성
서버가 여러 대로 스케일아웃되면 단순히 In-Memory 캐시로는 멱등성을 보장할 수 없다.

여러 서버 인스턴스가 동시에 같은 멱등성 키를 처리할 수 있기 때문에, Redis 분산 락(Distributed Lock) 으로 동시 처리를 방지해야 한다.
처리 순서는 다음과 같다.
- 락 획득 — Redis로 분산 락 획득 시도
- 처리 — 비즈니스 로직 실행
- 결과 저장 — Redis에 응답 캐싱
- 락 해제 — 다른 인스턴스가 처리 가능하도록 해제
만약 락 획득을 실패하면, 짧은 대기 이후 저장된 결과를 조회하여 반환한다.
재시도 전략
그러면 멱등성을 구현만 하면 되는걸까 ?
멱등성이 보장된다고 해서 무한정 재시도해도 되는 건 아니다.
지수 백오프(Exponential Backoff) 와 지터(Jitter) 를 함께 사용하여 적절한 재시도 로직을 추가로 구현해야한다.
| 전략 | 설명 |
| 지수 백오프 | 재시도 간격을 지수적으로 늘림 (1초 → 2초 → 4초 → ...) |
| 지터 | 재시도 간격에 랜덤값을 추가해 thundering herd 방지 |
| 최대 재시도 횟수 | 무한 재시도로 인한 리소스 낭비 방지 |
정리
| 항목 | 핵심 정리 |
| 멱등성 | 같은 요청을 여러 번 보내도 결과가 동일한 성질 |
| 멱등성 키 | 클라이언트가 생성하여 헤더로 전달하는 고유 식별자 |
| HTTP 멱등성 | GET, PUT, DELETE는 멱등 / POST, PATCH는 비멱등 |
| 저장소 | Redis로 키-응답 쌍을 TTL과 함께 캐싱 |
| DB 보호 | 유니크 제약 조건으로 중복 삽입 방지 |
| 분산 환경 | Redis 분산 락으로 동시 처리 제어 |
| 재시도 | 지수 백오프 + 지터로 안전하게 재시도 |
| TTL | 재시도 윈도우보다 길게 설정, 24시간이 일반적 |
'🧑💻 Backend' 카테고리의 다른 글
| 웹 서버, WAS, 그리고 Ingress까지 (0) | 2026.06.08 |
|---|---|
| 동기 / 비동기와 블로킹 / 논블로킹 개념 (0) | 2026.02.23 |
| 트랜잭션&락 2편 - InnoDB의 Redo / Undo Log, MVCC (0) | 2025.12.22 |
| 트랜잭션&락 1편 - All Or Nothing / ACID (0) | 2025.12.22 |
| Index 4편 - MySQL의 구조(Optimizer, Storage Engine), 쿼리 플랜 (1) | 2025.12.17 |
소중한 공감 감사합니다