생각해보면 현실에서 주문 연동을 할 때, 아래와 같은 문제가 자주 생길 수도 있다고 생각했다.
실제 이런 주문을 연동하는 시스템을 가지고 있는 기업들에서는 눈에 진물이 나도록 고민하고 지금도 끊임없는 개선과 유지보수를 이어 나가고 있을 것이다. 나는 이런 주문 시스템이 현대 사회를 움직이는 가장 기본적이면서도 중요한 요소라고 생각한다. 그래서 단순 API만 붙어 있는 주문 앱 말고, 외부 파트너와 안정적으로 주문을 연동할 수 있는 플랫폼에 대해 고민하게 됐다. 이 프로젝트는 중복 주문을 막고, 이벤트가 유실되지 않고 결국 처리되게 만드는 것이 목표다. 추후 계속 생각나는 부분들이 있다면 적극적으로 고민하고 실험해볼 생각이다.
Github Link: 외부 파트너와 안정적으로 주문을 연동하기 위한 주문 연동 플랫폼
배달앱에서 결제 버튼을 눌렀는데, 갑자기 화면이 멈춘 상황을 상상해보자. 사용자는 이런 상황에서 뒤로 갔다가 다시 결제를 시도하거나 분노에 쌓인 채 폭풍 새로고침을 할 수도 있고, 결제 버튼을 계속 연타할 수도 있다.
이런 상황을 서버 입장에서 생각해본다면, 동일 주문 요청이 여러 번 들어오고 있는 상황인 것이다. 만약 서버가 이를 곧이 곧대로 받아들인다면 같은 주문이 무지막지하게 생성되고, 사용자의 잔고에서는 계속해서 돈이 빠져나갈 것이다.
이때 필요한 것이 바로 “멱등성(Idempotency)” 이라고 생각했다. 멱등성에 대한 자세한 내용은 아래 링크를 참고하도록 하자.
멱등성이 뭔가요? | 토스페이먼츠 개발자센터
[식구하자_MSA] 멱등키를 활용한 사용자 중복 요청 방지 (Feat: 따닥 이슈)
본 프로젝트에서는 클라이언트가 주문 요청 헤더에 Idempotency-Key를 넣고, 같은 주문 재시도라면 같은 키를 재사용하는 것으로 멱등성을 실현했다. 예를 들어, A 주문을 내가 생성했고, 뭔가 문제가 생겨서 다시 주문 버튼을 눌러도 새 주문을 생성하지 않고, 기존 A 주문을 반환하도록 한 것이다.
이 과정을 어떻게 하면 효율적으로 구현할 수 있을지 생각을 하다 2가지의 선택의 기로에 놓이게 됐다. DB 기반으로 하느냐, Redis 기반으로 하느냐…
순수하게 DB를 사용한다고 한다면, 그냥 orders 테이블에 idempotency_key 컬럼을 UNIQUE로 박아두는 것이다. 만약 같은 주문을 재시도하면, orders 테이블에 중복 삽입이 되므로 예외를 터뜨리고, 기존 주문에서 해당 idempotency_key를 가진 주문을 반환하는 방법이다.
public OrderResponse createOrder(OrderCreateRequest req, String idempotencyKey) {
validateIdempotencyKey(idempotencyKey);
var existOrder = orderRepository.findByIdempotencyKey(idempotencyKey);
if (existOrder.isPresent()) {
return existOrder.get();
}
boolean existStore = storeRepository.findById(req.storeId()).isPresent();
if (!existStore) {
throw new StoreNotFoundException(req.storeId());
}
Long orderId;
try {
orderId = orderRepository.save(req, idempotencyKey);
} catch (DuplicateKeyException e) {
return orderRepository.findByIdempotencyKey(idempotencyKey)
.orElseThrow(() -> new IdempotencyKeyInconsistentStateException(idempotencyKey, e));
}
이 방법은 딱히 무슨 기술이 필요한 것이 아니라 코드 작성만 하면 되고, DB가 알아서 잘 보장해주기 때문에 강한 정합성을 기대할 수 있었다. 하지만, 만약 중복 요청이 많아지면 DB에 충돌 및 조회에 대한 부담이 커질 수도 있을 것 같다는 우려가 있었다.
이 프로젝트를 진행할 당시에 Redis가 뭔지만 알고, 사용해본 경험이나 사용하는 방법에 대해서도 잘 몰랐다. 그래서 여러 자료나 AI를 활용해서 프로젝트에 적용하려 노력했다. 결론만 말하면 Redis는 메모리 기반이라 중복 요청이 발생했다면 DB까지 가지 않고, 빠르게 주문 정보를 가지고 올 수 있다는 점에서 충분히 고민해볼 가치가 있었다.
일단 멱등키를 요청마다 헤더로 받고, Redis를 상태 저장소로 이용해보기로 했다. 상태는 “지금 누군가가 처리 중(IN_PROGRESS)” 와 “이미 처리 완료하고 결과를 캐시(DONE)” 총 2가지 정도를 생각했다.
@Service
public class IdempotencyRedisService {
private static final String PREFIX = "idempotency:";
private static final String IN_PROGRESS = "IN_PROGRESS";
private static final String DONE = "DONE";
private final StringRedisTemplate redisTemplate;
public IdempotencyRedisService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 멱등키를 선점
* 해당 멱등키를 가지고 최초 요청한 경우 True 주문 생성
* 중복된 요청이면 False 반환
*/
public boolean tryAcquire(String idempotencyKey, Duration ttl) {
String redisKey = toRedisKey(idempotencyKey);
String value = IN_PROGRESS + ":" + UUID.randomUUID();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(redisKey, value, ttl);
return Boolean.TRUE.equals(success);
}
/**
* 정상적으로 처리된 주문이 있는지 확인
* DONE:<OrderId> 형태면 orderId를 반환
*/
public Optional<String> findDoneResponseJson(String idempotencyKey) {
String value = getValue(toRedisKey(idempotencyKey));
if (value == null) {
return Optional.empty();
}
if (value.startsWith(DONE + ":")) {
return Optional.of(value.substring((DONE + ":").length()));
}
return Optional.empty();
}
/**
* 주문을 정상적으로 생성하고 나서 결과를 저장
*/
public void markDoneResponse(String idempotencyKey, String responseJson, Duration ttl) {
String redisKey = toRedisKey(idempotencyKey);
String value = DONE + ":" + responseJson;
redisTemplate.opsForValue().set(redisKey, value, ttl);
}
/**
* 실패 시 락 해제
* IN_PROGRESS 상태에서만 삭제 가능
*/
public void releaseIfInProgress(String idempotencyKey) {
String redisKey = toRedisKey(idempotencyKey);
String value = getValue(redisKey);
if (value != null && value.startsWith(IN_PROGRESS + ":")) {
redisTemplate.delete(redisKey);
}
}
private String getValue(String redisKey) {
return redisTemplate.opsForValue().get(redisKey);
}
private String toRedisKey(String idempotencyKey) {
return PREFIX + idempotencyKey;
}
}
이렇게 하면 만약 동일한 멱등키로 요청이 100개가 들어 온다고 해도, 첫 번째 요청만 받아 들인다. 즉, 해당 멱등키에 대한 처리 권한을 선점한 요청은 처음에 온 요청 딱 하나인 것이다.
아래는 OrderService의 일부분이다.
@Transactional
public OrderResponse createOrder(OrderCreateRequest req, String idempotencyKey) {
validateIdempotencyKey(idempotencyKey);
// 해당 멱등키로 들어온 요청이 DONE이면 바로 반환
Optional<OrderResponse> cached = findCachedDoneResponse(idempotencyKey);
if (cached.isPresent()) {
return cached.get();
}
// 그렇지 않다면 Redis 락 선점
boolean acquired = idempotencyRedisService.tryAcquire(idempotencyKey, IN_PROGRESS_TTL);
if (!acquired) { // 락 선점 실패 시
Optional<OrderResponse> cachedAgain = findCachedDoneResponse(idempotencyKey);
if (cachedAgain.isPresent()) {
return cachedAgain.get();
}
// 바로 409 에러를 반환
throw new IdempotencyInProgressException(idempotencyKey);
}
try {
boolean existStore = storeRepository.findById(req.storeId()).isPresent();
if (!existStore) {
throw new StoreNotFoundException(req.storeId());
}
Long orderId;
try {
orderId = orderRepository.save(req, idempotencyKey);
} catch (DuplicateKeyException e) {
OrderResponse existing = orderRepository.findByIdempotencyKey(idempotencyKey)
.orElseThrow(() -> new IllegalStateException(
"멱등키 중복이 감지되었지만 기존 주문 조회에 실패했습니다: " + idempotencyKey, e
));
cacheDoneResponse(idempotencyKey, existing);
return existing;
}
OrderResponse created = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
cacheDoneResponse(idempotencyKey, created);
return created;
} catch (RuntimeException e) {
// 처리 중 실패 시 IN_PROGRESS 상태의 주문의 락을 해제
idempotencyRedisService.releaseIfInProgress(idempotencyKey);
throw e;
}
}
...
최종적으로 DB를 기반으로 했을 때와 Redis를 도입한 상황을 각각 k6 라이브러리로 테스트를 수행해봤다.
| 항목 | DB 기반 | Redis 기반 | 성능 향상(Redis-DB) |
|---|---|---|---|
| 처리량(RPS) | 6023.80/s | 5451.02/s | -9.5% |
| 평균 지연(avg) | 16.54ms | 18.29ms | +10.6% |
| p95 | 30.31ms | 33.52ms | +10.6% |
| p90 | 22.34ms | 19.9ms | -11% |
| 최대(max) | 181.95ms | 174.40ms | -4.15% |
내가 생각했던 결과보다 그렇게(?) Redis에서의 눈에 띄는 성능 향상은 볼 수 없었다. 생각해보니, Redis에서 락을 잡는다 하더라도 결국 DB를 조회해서 반환하는 부분이 있기 때문에 엄청난 성능 향상은 기대하기 어려웠던 것 같다. 그래서 본 프로젝트에서는 DB 멱등을 기반으로 하고, 향후 Redis는 옵션으로 붙이는 것이 더 낫다고 판단했다.
하지만 이후에 바로 더 큰 문제에 봉착했다. 지금 프로젝트 내부에서 DB에 접근하는 과정은 쉽게 트랜잭션을 적용할 수 있다. 근데 지금 프로젝트는 내부의 DB 트랜잭션과는 다른 별개의 시스템 호출에 대해서도 일관성을 유지해야 한다. 만약 내가 프로젝트 내부에서 어떤 결과를 롤백한다 하더라도 외부 시스템은 그 사실을 알 수 있는 방법이 없다는 것이다. 이 부분에 대해서는 다음 글에서 설명하도록 하겠다.