분산 환경에서 네트워크 재시도, 클라이언트 중복 클릭, 메시지 큐 재처리 등으로 인해 동일한 요청이 여러 번 실행될 수 있다. 멱등성(Idempotency)은 이러한 상황에서도 결과가 일관되게 유지되도록 보장하는 핵심 설계 원칙이다.
이 글에서는 Spring 기반 API에서 멱등성을 구현하는 세 가지 접근법을 다루며, 각 방법의 장단점과 적용 시나리오를 실전 예제와 함께 설명한다.
멱등성이 필요한 상황
멱등성은 같은 연산을 여러 번 수행해도 결과가 동일함을 의미한다. HTTP 메서드 중 GET, PUT, DELETE는 본질적으로 멱등하지만, POST는 멱등하지 않다.
비멱등 API의 문제점
결제 API를 예로 들어보자:
@PostMapping("/payments")
public PaymentResponse processPayment(@RequestBody PaymentRequest request) {
// 매번 새로운 결제 생성 — 중복 요청 시 중복 결제 발생
Payment payment = paymentService.createPayment(request);
return new PaymentResponse(payment.getId(), payment.getAmount());
}클라이언트가 네트워크 타임아웃으로 재시도하면:
- 첫 번째 요청: 결제 완료, 응답 손실
- 두 번째 요청: 중복 결제 발생 (심각한 비즈니스 오류)
멱등성이 반드시 필요한 시나리오
| 도메인 | 시나리오 | 위험 |
|---|---|---|
| 결제 | 클라이언트 재시도, API Gateway 중복 라우팅 | 중복 결제 |
| 주문 | 메시지 큐 재처리, 네트워크 분할 | 재고 중복 차감 |
| 알림 | 이벤트 리플레이, 장애 복구 | 중복 푸시 발송 |
| 포인트 | 동시 요청, Lock 실패 재시도 | 중복 적립/차감 |
접근법 1: Redis 기반 멱등키 검증
가장 간단하고 낮은 레이턴시를 제공하는 방법. 클라이언트가 제공한 idempotency-key를 Redis에 저장하고, 중복 요청 시 캐싱된 응답을 반환한다.
구현 예제
@Service
@RequiredArgsConstructor
public class IdempotencyService {
private final StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "idempotency:";
private static final Duration EXPIRY = Duration.ofHours(24);
public Optional<String> getCachedResponse(String idempotencyKey) {
String key = KEY_PREFIX + idempotencyKey;
String cached = redisTemplate.opsForValue().get(key);
return Optional.ofNullable(cached);
}
public void cacheResponse(String idempotencyKey, String responseJson) {
String key = KEY_PREFIX + idempotencyKey;
redisTemplate.opsForValue().set(key, responseJson, EXPIRY);
}
}@PostMapping("/payments")
public ResponseEntity<PaymentResponse> processPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
// 1. 캐시 조회
Optional<String> cached = idempotencyService.getCachedResponse(idempotencyKey);
if (cached.isPresent()) {
return ResponseEntity.ok(objectMapper.readValue(cached.get(), PaymentResponse.class));
}
// 2. 실제 처리
Payment payment = paymentService.createPayment(request);
PaymentResponse response = new PaymentResponse(payment.getId(), payment.getAmount());
// 3. 응답 캐싱
idempotencyService.cacheResponse(idempotencyKey, objectMapper.writeValueAsString(response));
return ResponseEntity.ok(response);
}장단점 분석
장점:
- 초저지연 (Redis 조회 ~1ms)
- 구현 간단
- 읽기 부하 오프로드
단점:
- Race Condition 취약: 동시 요청 시 중복 처리 가능
- Redis 장애 시 멱등성 보장 불가
- 응답 직렬화 오버헤드
접근법 2: DB Unique 제약조건 활용
데이터베이스의 Unique Index를 활용해 물리적으로 중복을 차단하는 방법. 가장 강력한 보장을 제공한다.
스키마 설계
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE, -- 중복 방지
amount DECIMAL(19, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_idempotency_key ON payment(idempotency_key);Spring Data JPA 구현
@Entity
@Table(
name = "payment",
uniqueConstraints = @UniqueConstraint(columnNames = "idempotency_key")
)
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "idempotency_key", nullable = false, unique = true, length = 64)
private String idempotencyKey;
@Column(nullable = false, precision = 19, scale = 2)
private BigDecimal amount;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PaymentStatus status;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}@Service
@Transactional
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
public PaymentResponse createPayment(String idempotencyKey, PaymentRequest request) {
// 1. 기존 처리 확인
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return PaymentResponse.from(existing.get());
}
// 2. 새 결제 생성 (Unique 제약조건이 동시성 보장)
Payment payment = Payment.builder()
.idempotencyKey(idempotencyKey)
.amount(request.getAmount())
.status(PaymentStatus.PENDING)
.build();
try {
paymentRepository.save(payment);
} catch (DataIntegrityViolationException e) {
// Race condition 발생 시 재조회
return PaymentResponse.from(
paymentRepository.findByIdempotencyKey(idempotencyKey)
.orElseThrow(() -> new IllegalStateException("멱등키 처리 실패"))
);
}
// 3. 외부 PG 호출 등 후속 처리
processPgPayment(payment);
return PaymentResponse.from(payment);
}
}핵심 메커니즘
- Unique 제약조건: 동시 INSERT 시 하나만 성공, 나머지는
DataIntegrityViolationException발생 - 예외 처리: 중복 키 예외 시 기존 레코드 조회 후 반환 (멱등 보장)
- 트랜잭션 격리:
@Transactional로 원자성 확보
접근법 3: 하이브리드 (Redis + DB)
최상의 성능과 안정성을 모두 확보하는 전략. Redis로 빠른 중복 검사, DB로 최종 보장.
구현 패턴
@Service
@Transactional
@RequiredArgsConstructor
public class HybridIdempotencyService {
private final StringRedisTemplate redisTemplate;
private final PaymentRepository paymentRepository;
public PaymentResponse processPayment(String idempotencyKey, PaymentRequest request) {
// Phase 1: Redis 빠른 경로 (99% 케이스)
String cacheKey = "idempotency:" + idempotencyKey;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return objectMapper.readValue(cached, PaymentResponse.class);
}
// Phase 2: DB 확인 (Redis miss 시)
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
PaymentResponse response = PaymentResponse.from(existing.get());
// Redis 재캐싱 (향후 요청 가속화)
redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(response),
Duration.ofHours(24));
return response;
}
// Phase 3: 새 처리 (Unique 제약조건으로 동시성 제어)
Payment payment = new Payment(idempotencyKey, request.getAmount());
try {
paymentRepository.save(payment);
} catch (DataIntegrityViolationException e) {
// Race condition — 재귀 호출로 처리된 결과 반환
return processPayment(idempotencyKey, request);
}
// 외부 처리
processPgPayment(payment);
// 응답 캐싱
PaymentResponse response = PaymentResponse.from(payment);
redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(response),
Duration.ofHours(24));
return response;
}
}흐름도 (ASCII)
┌──────────────┐
│ Client 요청 │
└──────┬───────┘
│ Idempotency-Key: abc123
▼
┌────────────────┐ Hit ┌─────────────┐
│ Redis 조회 │────────────▶│ 캐시 응답 │
└────────┬───────┘ └─────────────┘
│ Miss
▼
┌────────────────┐ Found ┌─────────────┐
│ DB 조회 │────────────▶│ DB 응답 + │
└────────┬───────┘ │ Redis 재캐싱│
│ Not Found └─────────────┘
▼
┌────────────────┐ Conflict ┌─────────────┐
│ DB INSERT │────────────▶│ 재귀 호출 │
└────────┬───────┘ └─────────────┘
│ Success
▼
┌────────────────┐
│ 비즈니스 로직 │
│ + Redis 캐싱 │
└────────────────┘
성능 비교
| 접근법 | 평균 레이턴시 | 동시성 안정성 | Redis 장애 대응 | 복잡도 |
|---|---|---|---|---|
| Redis only | ~2ms | 낮음 | 불가 | 낮음 |
| DB only | ~15ms | 높음 | 가능 | 중간 |
| Hybrid | ~2ms (캐시), ~15ms (miss) | 높음 | 가능 | 높음 |
실전 체크리스트
멱등성 설계 시 반드시 고려해야 할 항목들:
설계 단계
-
멱등키 생성 주체
- 클라이언트 생성 (UUID v4 권장)
- 서버 생성 시 중복 요청 구분 불가
-
만료 정책
- 24시간 (일반 API)
- 7일 (금융 트랜잭션, 감사 추적 필요)
무제한(스토리지 폭발)
-
에러 응답 캐싱 여부
- 재시도 가능한 에러 (500, 503)는 캐싱 X
- 재시도 불가능한 에러 (400, 409)는 캐싱 O
구현 단계
-
분산 환경 고려
- Redis Cluster 사용 시 Hash Tag 적용 (키 라우팅 일관성)
- DB 복제 지연 (10~100ms) 감안
-
테스트 전략
- 동시 요청 시뮬레이션 (JMeter, Gatling)
- Redis 장애 시나리오 (Chaos Engineering)
관련 기술 스택
추천 라이브러리
- Redisson: Redis 기반 분산 락 지원
- Spring Retry: 재시도 로직 선언적 처리
- Micrometer: 멱등성 위반 메트릭 수집
모니터링 지표
| 지표 | 설명 | 임계값 예시 |
|---|---|---|
idempotency_cache_hit_ratio | Redis 캐시 적중률 | > 95% |
duplicate_request_rate | 중복 요청 비율 | < 5% |
idempotency_violation_count | 멱등성 위반 횟수 | 0 |
마무리
멱등성 설계는 신뢰할 수 있는 API의 기본 요건이다. 특히 결제, 주문, 알림처럼 중복 실행이 비즈니스에 치명적인 영향을 주는 도메인에서는 필수적이다.
핵심 원칙을 정리하면:
- Redis: 빠른 응답, 읽기 부하 감소 (보조 수단)
- DB Unique 제약조건: 최종 안전망 (필수)
- Hybrid: 성능 + 안정성 균형 (프로덕션 권장)
이 글의 코드는 GitHub 저장소에서 확인할 수 있으며, 실제 프로젝트에 적용 시 트랜잭션 격리 수준, 타임아웃 설정, 모니터링을 함께 고려해야 한다.
참고 자료
- RFC 9110 - HTTP Semantics (Idempotent Methods)
- Stripe API - Idempotent Requests
- Martin Kleppmann, Designing Data-Intensive Applications, Chapter 8