문제 인식
서비스 간 통신은 타임아웃, 네트워크 통신 오류 등으로 인해 수많은 실패 요소를 관리해야 합니다
특히, 주문 생성 그리고 재고 차감과 같은 중요한 변경에 대한 API 를 재시도할때 멱등성을 보장하여 시스템의 신뢰성을 보장해야 합니다
이에 대해서 주문(Order) 서비스와 재고(Inventory) 서비스 간의 API 통신에서 멱등성을 확보하기 위해 세 차례의 설계 과정을 공유하려고 합니다
1차 설계
최초 설계 목표는 "API 재시도 시, 중복 요청을 막고 이전 결과를 반환하자" 였습니다. 하지만 이 과정에서 멱등키에 대한 설계 원칙을 위반하는 실수가 발생하였습니다
먼저 1차 설계의 시퀀스 다이어그램은 아래와 같습니다
[시퀀스 다이어그램] 주문 생성 → 재고 차감 시나리오

시퀀스 설명
정상 시나리오
- 주문 생성/취소시 재고 서비스에게 멱등키 발급을 요청
- 멱등키 발급을 요청할 각 서비스가 발급
- 멱등키 등록 → DB 영속화
- 이후 멱등키를 반환
- 주문 서비스는 멱등키와 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 검증
- 멱등키 상태(
DONE/PENDING) 을 통해 검증 PENDING시 검증 성공 /DONE시 검증 실패
- 멱등키 상태(
- 멱등키 검증에 성공한다면 재고 차감/증가
- 멱등키 사용 처리 → 멱등키
PENDING→DONE
같은 멱등키로 재시도 시나리오
- 이미 발급된 멱등키로 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 검증
- 멱등키 상태(
DONE/PENDING) 을 통해 검증
- 멱등키 상태(
DONE상태이므로 재고 서비스는 이전 결과를 재전송
그러나 멱등키 발급을 요청받는 쪽에서 발급하므로 요청한 주체가 반대로 멱등키에 대해서 의존하는 관계가 되버린다
멱등키 설계시 원칙은 요청을 시작하는 쪽이 키를 생성해야 한다는 것 입니다
1차 설계에서 주문 서비스가 멱등성을 보장받기 위해 재고 서비스에 키 발급을 요청하는 구조이고 이는 주문 서비스가 키 발급을 위해 재고 서비스에 종속되어 결합도를 높였습니다
2차 설계
1차 설계의 문제를 인식하고 "요청 시작자가 키를 생성"하도록 수정하였습니다. 즉 주문 서비스가 멱등키를 생성하고 재고 차감 요청에 포함시킵니다.
[시퀀스 다이어그램] 주문 생성 → 재고 차감 시나리오

시퀀스 설명
정상 시나리오
- 주문 생성/취소시 주문 자체에서 멱등키 발급
- 주문 서비스는 멱등키와 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 주문 서버에게 멱등키 검증 API 요청
- 멱등키 상태(
DONE/PENDING) 을 통해 검증 PENDING시 검증 성공 /DONE시 검증 실패
- 멱등키 상태(
- 멱등키 검증에 성공한다면 재고 차감/증가
- 멱등키 사용 처리 → 멱등키
PENDING→DONE
같은 멱등키로 재시도 시나리오
- 이미 발급된 멱등키로 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 주문 서버에게 멱등키 검증 API 요청
- 멱등키 상태(
DONE/PENDING) 을 통해 검증
- 멱등키 상태(
DONE상태이므로 재고 서비스는 이전 결과를 재전송
요청을 시작하는 쪽이 멱등키를 생성하도록 변경했으나 해당 시나리오는 큰 문제가 존재합니다.
문제 시나리오
- 주문이 재고 차감시 멱등키를 같이 보내줌
- 재고는 멱등키를 검증하기 위해 주문에게 멱등키 검증 API 를 요청
- 해당 요청이 30초 Timeout 에러로 인해 실패 → 재고 차감 실패
- 그러나 주문 서버는 31초 이후 멱등키 검증 성공을 보냄
- 이후 다시 주문 서버가 멱등키로 재고 차감 재시도
- 재고는 요청으로 받은 멱등키를 다시 주문 서버에게 멱등키 검증 API 요청
- 주문은 이미 멱등키가 요청 완료(
DONE)되었다고 알림 - 재고는 주문 서비스에게 "해당 재고는 이미 차감됨" 이라는 응답으로 200 ok 상태를 응답
결국 주문은 생성되었는데 재고는 차감되지 않는 데이터 불일치 문제가 발생하게 됩니다.
그래서 이를 해결하기 위해 각 서버가 멱등키 검증을 독립적으로 관리 및 수행해야 한다고 생각했습니다.
3차 설계
2차 설계에서의 문제를 통해 "요청을 받는 서비스가 스스로 멱등성을 보장"할 수 있는 설계를 생각하게 되었습니다. 핵심은 DB 의 UNIQUE 제약조건을 활용하여 검증과 사용 처리를 하나의 원자적 연산으로 묶는 것입니다
[시퀀스 다이어그램] 주문 생성 → 재고 차감 시나리오

시퀀스 설명
정상 시나리오
- 주문 생성/취소시 주문 자체에서 멱등키 발급
- 주문 서비스는 멱등키와 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 자신의 DB를 통해 멱등키 검증
- 멱등키는 UNIQUE 제약조건으로 관리
- save 시 예외가 발생한다면 이미 사용된 멱등키로 판단 → Already Used 상태 반환
- 멱등키 검증에 성공한다면 재고 차감/증가
같은 멱등키로 재시도 시나리오
- 이미 발급된 멱등키로 재고 정보를 포함하여 재고 차감/증가 API 요청
- 재고 서비스는 멱등키를 재고 처리 전에 자신의 DB를 통해 멱등키 검증
- 멱등키는 UNIQUE 제약조건으로 관리
- save 시 예외가 발생하여 이미 사용되었다고 반환 → Already Used 상태 반환
- 해당 멱등키는 이미 사용됨 상태로 반환
이를 통해 요청하는 서버(주문 서버)가 멱등키를 발급하며 요청 받는 서버(재고 서버)는 멱등키를 자체적으로 검증하는 구조가 되었습니다.
멱등키는 DB 트랜잭션 범위내에서 검증과 사용 처리가 동시에 이루어집니다.
검증
1. Repository 레벨의 멱등성 검증
tryAcquireIdempotencyKey 메서드로 DB에 멱등키를 INSERT 시도합니다.
@Test
@DisplayName("같은 멱등키를 또 생성시도할때 false 를 반환한다")
void throwDuplicateIdempotencyKey() {
String idempotencyKeyA = "idempotencyKey";
inventoryRepository.tryAcquireIdempotencyKey(idempotencyKeyA);
String duplicateIdempotencyKey = "idempotencyKey";
boolean result = inventoryRepository.tryAcquireIdempotencyKey(duplicateIdempotencyKey);
assertFalse(result);
}
@Test
@DisplayName("처음 멱등키를 생성을 시도한다면 true 를 반환한다")
void createIdempotencyKey() {
String idempotencyKey = "idempotencyKey";
boolean result = inventoryRepository.tryAcquireIdempotencyKey(idempotencyKey);
assertTrue(result);
}

2. 동시 요청 환경에서의 멱등성 검증
여러 스레드가 동일한 멱등키와 동일한 재고 차감 요청을 동시에 보냈을 때 단 하나의 요청만 재고를 차감하고 나머지는 ALREADY_DEDUCTED 상태로 처리되는지 검증합니다
- 총 1,000 개의 재고 중 10개의 재고만 차감 → 결과 990 개
@Nested
class Idempotency {
@Test
@DisplayName("단 하나의 요청만 성공하고 나머지는 중복으로 처리되어야 한다")
void idempotencyConcurrency() throws InterruptedException {
String sharedIdempotencyKey = "sharedIdempotencyKey";
int qtyPerThread = initQuantity / threadCount; // 10개
// ...ExecutorService 와 CountDownLatch 설정 및 멀티 스레드 실행 로직
Inventory inventory = inventoryRepository.findByProductId(productId).orElseThrow();
int expectedQuantity = initQuantity - qtyPerThread; // 990 (처음 10개 재고 차감만 성공)
assertEquals(1, successCont[0]);
assertEquals(expectedQuantity, inventory.getQuantity());
}
}

회고
Keep
- 점진적 접근을 통한 단순화
- 점진적인 접근을 통해서 결국 멱등성 보장에서 필요한 것이 무엇일까를 알게되었고 이를 통해 간단하게 멱등키를 설계하고
INSERT만을 통해 사용여부를 조회하도록 하게 복잡한 설계를 하지 않게 되었습니다
- 점진적인 접근을 통해서 결국 멱등성 보장에서 필요한 것이 무엇일까를 알게되었고 이를 통해 간단하게 멱등키를 설계하고
- 멱등성 보장 원칙 정립
- 멱등성 보장은 여러 요청에 대해서 일관된 결과를 보장해야 하며 이를 통해 신뢰성이 보장된다는 것을 알게되었고, 멱등성은 "요청하는 곳에서 멱등키를 발급, 요청을 받는 쪽에서 멱등성을 검증" 하는 것이 원칙이라는 것을 알게되었습니다
- 시각화
- 문제 시나리오의 시퀀스 다이어그램을 그려 시각화하는 것이 다른 문제를 생각하게 되며 시나리오의 이해를 돕게 되었습니다
Problem
- 엣지 케이스 검증 부족
- 설계를 하면서 엣지 케이스를 생각하지 못하였고 미리 검토했다면 시행착오를 줄일 수 있었을 것입니다
- 각 서비스간 자율성 이해부족
- 각 서비스간의 자율성을 해치고 멱등성 보장을 위해 강결합된 구조를 생각하게 되었습니다
- DB 의존적인 멱등성 보장
- DB 에 의존하여 멱등성을 보장하는 구조이며 만약 DB에 장애 발생 혹은 샤딩시 대처방안을 생각, DB가 아닌 다른 방법을 활용하는 계획을 구성해야될 것 같습니다
Try
- DB 제약조건외의 다른 방법 생각
- 간단히 Redis 를 활용 혹은 Redis 활용시 장애가 발생한 경우 대처방안을 고려해야 합니다
- 설계 전 실패 시나리오 시각화
- 모든 설계는 시작 전 실패에 대한 시나리오를 찾고 이를 시각화하여 또 다른 문제를 찾는 것이 중요합니다