spring 에서 Redis와 AOP로 주문 API 멱등성 보장하기
주문 생성 API를 만들 때 은근히 놓치기 쉬운 부분이 멱등성 이다
사용자가 주문 버튼을 여러 번 누르거나, 앱에서 네트워크 타임아웃 때문에 같은 요청을 재시도하면 서버 입장에서는 거의 동시에 같은 POST /orders 요청이 여러 번 들어올 수 있다
이때 아무 방어가 없다면 주문 row가 2개 생기거나, 결제가 2번 승인되는 문제가 발생할 수 있다
이번 글에서는 Spring Boot에서 Idempotency-Key 헤더와 Redis의 SET NX 패턴을 이용해 주문 API의 중복 실행을 막는 방법을 정리해보자
멱등성이란?
멱등성은 같은 연산을 여러 번 수행해도 결과가 한 번 수행했을 때와 동일하게 유지되는 성질이다
GET /orders/1 처럼 단순 조회 API는 여러 번 호출해도 서버 상태가 변하지 않으니 보통 멱등하게 만들기 쉽다
반대로 POST /orders 는 호출할 때마다 주문을 새로 생성할 수 있기 때문에 기본적으로 멱등하지 않다
그래서 주문 생성, 결제 승인, 포인트 차감처럼 side effect가 있는 API는 별도의 장치가 필요하다
기본 아이디어
클라이언트는 하나의 주문 시도에 대해 고유한 Idempotency-Key 를 발급하고, 서버는 이 값을 기준으로 같은 요청이 이미 처리 중인지 또는 이미 성공했는지 확인한다
흐름은 대략 아래와 같다
- 클라이언트가 주문 요청에
Idempotency-Key헤더를 붙여서 보낸다 - 서버 AOP가 컨트롤러 실행 전에 헤더 값을 읽는다
- Redis에
SET key value NX EX형태로PROCESSING상태를 저장한다 - 저장에 성공하면 최초 요청이므로 실제 주문 로직을 실행한다
- 저장에 실패하면 이미 같은 키의 요청이 들어온 것이므로 Redis에 저장된 상태를 확인한다
- 상태가
PROCESSING이면409 Conflict를 반환한다 - 상태가
SUCCESS이면 컨트롤러를 다시 실행하지 않고 이전 응답을 그대로 반환한다
핵심은 SET NX 이다
Redis는 단일 명령을 원자적으로 처리하므로 아주 짧은 시간에 같은 키의 요청이 여러 개 들어와도 단 하나의 요청만 PROCESSING 상태를 선점할 수 있다
Idempotency-Key는 어떤 단위로 만들어야 하나?
중요한 점은 Idempotency-Key 하나만 믿으면 안 된다는 것이다
예를 들어 어떤 사용자가 abc-123 이라는 키로 주문을 만들었는데, 다른 사용자도 우연히 같은 키를 보내면 서로 영향을 받으면 안 된다
그래서 Redis key는 보통 아래 정보들을 합쳐서 만든다
- 사용자 식별자
- HTTP method
- 요청 path와 query string
- 클라이언트가 보낸
Idempotency-Key
그리고 같은 Idempotency-Key 로 다른 요청 본문을 보내는 것도 막아야 한다
따라서 요청 본문 또는 컨트롤러 인자를 hash로 만든 requestHash 를 함께 저장해두고, 재요청이 들어왔을 때 기존 hash와 다르면 잘못된 요청으로 판단하는 것이 좋다
클라이언트의 키 발급 방식
키는 클라이언트에서 UUID 로 만들어도 되고, 서버에서 티켓 발급 API를 따로 만들어 내려줘도 된다
주문 화면 기준으로는 아래 방식이 실무에서 다루기 편하다
- 사용자가 주문서 작성 화면에 진입한다
- 프론트엔드가
GET /api/v1/orders/ticket를 호출해서 UUID를 받는다 - 화면 메모리 또는 상태 저장소에 UUID를 보관한다
- 사용자가 주문 버튼을 누를 때
Idempotency-Key헤더에 UUID를 넣는다 - 같은 화면에서 버튼을 연타하거나 재시도하면 같은 UUID가 사용된다
- 사용자가 주문 화면을 새로 진입하면 새 UUID를 받는다
이렇게 하면 실수로 같은 주문을 두 번 만드는 것은 막고, 사용자가 정말로 같은 상품을 한 번 더 주문하는 것은 막지 않을 수 있다
간단한 티켓 발급 API는 아래처럼 만들 수 있다
package com.example.idempotency.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/orders")
public class OrderTicketController {
@GetMapping("/ticket")
public Map<String, String> issueTicket() {
return Map.of("idempotencyKey", UUID.randomUUID().toString());
}
}구현 환경
아래 코드는 Spring Boot 3.x, Java 17 기준이다
필요한 의존성은 대략 아래와 같다
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}Redis에 저장할 상태 객체
Redis에는 단순히 lock만 저장하지 않고 요청 상태와 응답 본문을 같이 저장한다
그래야 같은 요청이 다시 들어왔을 때 이전 응답을 재사용할 수 있다
package com.example.idempotency.dto;
public enum IdempotencyStatus {
PROCESSING,
SUCCESS
}package com.example.idempotency.dto;
import com.fasterxml.jackson.databind.JsonNode;
public record IdempotencyRecord(
IdempotencyStatus status,
String requestHash,
int httpStatus,
JsonNode responseBody
) {
public static IdempotencyRecord processing(String requestHash) {
return new IdempotencyRecord(IdempotencyStatus.PROCESSING, requestHash, 0, null);
}
public static IdempotencyRecord success(String requestHash, int httpStatus, JsonNode responseBody) {
return new IdempotencyRecord(IdempotencyStatus.SUCCESS, requestHash, httpStatus, responseBody);
}
public boolean isProcessing() {
return this.status == IdempotencyStatus.PROCESSING;
}
public boolean isSuccess() {
return this.status == IdempotencyStatus.SUCCESS;
}
public boolean isSameRequest(String requestHash) {
return this.requestHash.equals(requestHash);
}
}여기서는 StringRedisTemplate 에 JSON 문자열로 저장한다
RedisTemplate<String, Object> 로 Java 객체를 바로 저장하는 방법도 가능하지만, 운영 환경에서는 serializer 설정에 따라 클래스 정보가 붙거나 역직렬화 타입이 애매해질 수 있다
그래서 예제에서는 Jackson으로 JSON 문자열을 직접 저장하는 방식으로 작성했다
Redis 제어 서비스
tryStart() 함수가 가장 중요하다
setIfAbsent(key, value, ttl) 은 Redis의 SET key value NX EX 형태로 실행되며, 키가 없을 때만 값을 저장한다
PROCESSING 상태의 TTL과 SUCCESS 상태의 TTL은 분리했다
처리 중인 요청은 장애가 났을 때 너무 오래 막혀 있으면 안 되고, 성공한 응답은 클라이언트 재시도를 처리할 수 있도록 더 오래 남겨두는 편이 좋다
package com.example.idempotency.service;
import com.example.idempotency.dto.IdempotencyRecord;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
@RequiredArgsConstructor
public class IdempotencyService {
private static final String KEY_PREFIX = "idempotency:";
private static final Duration PROCESSING_TTL = Duration.ofMinutes(10);
private static final Duration RESULT_TTL = Duration.ofHours(24);
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public boolean tryStart(String scopedKey, String requestHash) {
IdempotencyRecord record = IdempotencyRecord.processing(requestHash);
Boolean success = redisTemplate.opsForValue().setIfAbsent(
redisKey(scopedKey),
serialize(record),
PROCESSING_TTL
);
return Boolean.TRUE.equals(success);
}
public IdempotencyRecord getRecord(String scopedKey) {
String value = redisTemplate.opsForValue().get(redisKey(scopedKey));
if (value == null) {
return null;
}
try {
return objectMapper.readValue(value, IdempotencyRecord.class);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Failed to deserialize idempotency record.", exception);
}
}
public void saveSuccess(String scopedKey, String requestHash, int httpStatus, Object responseBody) {
JsonNode body = objectMapper.valueToTree(responseBody);
IdempotencyRecord record = IdempotencyRecord.success(requestHash, httpStatus, body);
redisTemplate.opsForValue().set(
redisKey(scopedKey),
serialize(record),
RESULT_TTL
);
}
public void release(String scopedKey) {
redisTemplate.delete(redisKey(scopedKey));
}
private String redisKey(String scopedKey) {
return KEY_PREFIX + scopedKey;
}
private String serialize(IdempotencyRecord record) {
try {
return objectMapper.writeValueAsString(record);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Failed to serialize idempotency record.", exception);
}
}
}PROCESSING_TTL 은 실제 주문 로직이 끝나는 최대 시간보다 길게 잡아야 한다
만약 주문 처리가 30초 이상 걸릴 수 있는데 TTL을 10초로 잡으면 첫 요청이 끝나기 전에 Redis key가 사라져 중복 요청이 다시 들어갈 수 있다
커스텀 어노테이션
멱등성을 적용하고 싶은 컨트롤러 메서드에 붙일 어노테이션을 만든다
주문 생성 API라면 Idempotency-Key 를 필수로 두는 편이 안전하다
package com.example.idempotency.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
boolean required() default true;
boolean releaseOnException() default true;
}releaseOnException 은 비즈니스 로직 실패 시 Redis lock을 삭제할지 결정하는 값이다
재고 부족, 검증 실패처럼 DB 변경이 롤백되는 예외라면 lock을 삭제해서 사용자가 값을 고쳐 다시 요청할 수 있게 하는 편이 좋다
다만 외부 결제 승인처럼 되돌리기 어려운 side effect가 이미 발생한 뒤의 예외라면 무조건 삭제하면 위험하다
그런 경우에는 실패 상태를 별도로 저장하거나, 결제사에도 같은 idempotency key를 넘기거나, DB에 주문 시도 상태를 남긴 뒤 재처리하는 구조가 필요하다
AOP 구현
컨트롤러 실행 전후를 AOP로 감싼다
여기서 하는 일은 아래와 같다
Idempotency-Key헤더 확인- 사용자, method, path, header key를 합쳐 Redis key scope 생성
- 컨트롤러 인자를 hash로 만들어 같은 key로 다른 요청이 들어오는지 검증
- Redis에
PROCESSING선점 - 중복 요청이면 기존 상태에 따라
409 Conflict또는 이전 응답 반환 - 최초 요청이면 컨트롤러 실행 후 성공 응답 저장
package com.example.idempotency.aop;
import com.example.idempotency.annotation.Idempotent;
import com.example.idempotency.dto.IdempotencyRecord;
import com.example.idempotency.service.IdempotencyService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {
private static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
private final IdempotencyService idempotencyService;
private final HttpServletRequest request;
private final ObjectMapper objectMapper;
@Around("@annotation(idempotent)")
public Object handleIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
String idempotencyKey = request.getHeader(IDEMPOTENCY_KEY_HEADER);
if (!StringUtils.hasText(idempotencyKey)) {
if (idempotent.required()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key header is required.");
}
return joinPoint.proceed();
}
String scopedKey = createScopedKey(idempotencyKey);
String requestHash = createRequestHash(joinPoint);
boolean firstRequest = idempotencyService.tryStart(scopedKey, requestHash);
if (!firstRequest) {
return handleDuplicatedRequest(scopedKey, requestHash);
}
Object result;
try {
result = joinPoint.proceed();
} catch (Throwable throwable) {
if (idempotent.releaseOnException()) {
idempotencyService.release(scopedKey);
}
throw throwable;
}
ResponseSnapshot snapshot = ResponseSnapshot.from(result);
idempotencyService.saveSuccess(
scopedKey,
requestHash,
snapshot.httpStatus(),
snapshot.body()
);
return result;
}
private Object handleDuplicatedRequest(String scopedKey, String requestHash) {
IdempotencyRecord record = idempotencyService.getRecord(scopedKey);
if (record == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "The same request is being processed.");
}
if (!record.isSameRequest(requestHash)) {
throw new ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"The same Idempotency-Key cannot be used for a different request."
);
}
if (record.isProcessing()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "The same request is already being processed.");
}
if (record.isSuccess()) {
return ResponseEntity
.status(record.httpStatus())
.body(record.responseBody());
}
throw new ResponseStatusException(HttpStatus.CONFLICT, "Invalid idempotency state.");
}
private String createScopedKey(String idempotencyKey) {
String rawScopedKey = getCurrentUserKey()
+ "|"
+ request.getMethod()
+ "|"
+ getRequestTarget()
+ "|"
+ idempotencyKey;
return sha256(rawScopedKey);
}
private String createRequestHash(ProceedingJoinPoint joinPoint) {
Object[] businessArguments = Arrays.stream(joinPoint.getArgs())
.filter(argument -> !(argument instanceof ServletRequest))
.filter(argument -> !(argument instanceof ServletResponse))
.filter(argument -> !(argument instanceof BindingResult))
.toArray();
try {
String serializedArguments = objectMapper.writeValueAsString(businessArguments);
return sha256(request.getMethod() + "|" + getRequestTarget() + "|" + serializedArguments);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Failed to create request hash.", exception);
}
}
private String getRequestTarget() {
String queryString = request.getQueryString();
if (!StringUtils.hasText(queryString)) {
return request.getRequestURI();
}
return request.getRequestURI() + "?" + queryString;
}
private String getCurrentUserKey() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return "anonymous";
}
return authentication.getName();
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 algorithm is not available.", exception);
}
}
private record ResponseSnapshot(int httpStatus, Object body) {
private static ResponseSnapshot from(Object result) {
if (result instanceof ResponseEntity<?> responseEntity) {
return new ResponseSnapshot(responseEntity.getStatusCode().value(), responseEntity.getBody());
}
return new ResponseSnapshot(HttpStatus.OK.value(), result);
}
}
}위 코드는 응답의 status와 body만 저장한다
만약 Location 같은 응답 header까지 동일하게 재사용해야 한다면 IdempotencyRecord 에 header 정보도 같이 저장하면 된다
컨트롤러 적용
이제 멱등성이 필요한 주문 생성 API에 @Idempotent 만 붙이면 된다
package com.example.idempotency.controller;
import com.example.idempotency.annotation.Idempotent;
import com.example.idempotency.dto.OrderRequest;
import com.example.idempotency.dto.OrderResponse;
import com.example.idempotency.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderApiController {
private final OrderService orderService;
@PostMapping
@Idempotent
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
OrderResponse response = orderService.placeOrder(request);
return ResponseEntity.ok(response);
}
}요청 DTO와 응답 DTO는 예시로 아래처럼 두면 된다
package com.example.idempotency.dto;
public record OrderRequest(
Long productId,
int quantity
) {
}package com.example.idempotency.dto;
public record OrderResponse(
Long orderId,
String status
) {
}RDB unique key도 같이 두자
Redis lock만으로 끝내면 불안하다
Redis 장애, TTL 만료, 배포 중 재시도 같은 상황까지 생각하면 최종 방어선은 RDB에 두는 것이 좋다
주문 테이블 또는 주문 요청 테이블에 user_id + idempotency_key 복합 unique key를 두면 같은 주문 시도가 DB에 두 번 들어가는 것을 한 번 더 막을 수 있다
alter table orders
add column idempotency_key varchar(100) not null;
create unique index uk_orders_user_id_idempotency_key
on orders (user_id, idempotency_key);Redis는 빠른 중복 요청 차단과 응답 재사용을 담당하고, RDB unique key는 최종 데이터 무결성을 담당하는 구조로 보는 것이 좋다
Redis 장애가 발생하면 어떻게 할까?
이 부분은 무조건 정답이 하나로 정해져 있지 않다
선택지는 크게 두 가지다
- fail-closed: Redis가 죽으면 주문 API도 실패시킨다
- fail-open: Redis가 죽어도 주문 API는 진행시킨다
중복 주문이나 중복 결제가 치명적인 서비스라면 fail-closed가 더 안전할 수 있다
반대로 Redis 장애 때문에 주문 전체가 막히는 것이 더 큰 문제라면 fail-open을 선택할 수 있다
다만 fail-open을 하려면 반드시 RDB unique key 같은 2차 방어선이 있어야 한다
그렇지 않으면 Redis가 잠깐 흔들리는 순간 중복 주문을 그대로 허용하는 구조가 된다
트랜잭션과 외부 결제 호출에서 주의할 점
DB 변경만 있는 주문 생성이라면 예외 발생 시 Redis lock을 삭제하고 재시도를 열어주는 방식이 자연스럽다
하지만 결제 승인 API를 이미 호출한 뒤에 서버에서 예외가 발생했다면 얘기가 달라진다
예를 들어 아래 상황을 생각해보자
- Redis에
PROCESSING저장 성공 - 결제사 승인 API 호출 성공
- 서버에서 주문 저장 중 예외 발생
- catch에서 Redis key 삭제
- 사용자가 재시도
- 결제 승인 API가 한 번 더 호출됨
이런 구조는 위험하다
결제 같은 외부 side effect가 있는 경우에는 보통 아래 중 하나를 같이 사용한다
- 결제사 API에도 같은 idempotency key를 전달한다
- 주문 시도 상태를 DB에 먼저 저장하고 상태 머신으로 처리한다
- outbox pattern 등을 사용해 외부 호출과 내부 상태 변경의 불일치를 복구할 수 있게 만든다
- 실패 상태를 Redis나 DB에 저장하고 단순 재시도가 아니라 조회 또는 복구 플로우로 보낸다
즉 Redis SET NX 는 중복 요청을 막는 좋은 첫 번째 장치이지만, 결제까지 포함한 전체 정합성을 혼자 책임지는 장치는 아니다
정리
Spring에서 주문 API 멱등성을 구현할 때는 아래 정도를 같이 챙기는 것이 좋다
- 클라이언트가 같은 주문 시도에 같은
Idempotency-Key를 보내도록 한다 - 서버는 사용자, method, path, key를 조합해 Redis key를 만든다
- Redis
SET NX로 최초 요청만PROCESSING상태를 선점하게 한다 - 같은 key로 다른 요청 본문이 들어오면 차단한다
- 성공 응답은 Redis에 저장해두고 중복 요청에는 이전 응답을 반환한다
- Redis만 믿지 말고 RDB unique key를 최종 방어선으로 둔다
- 외부 결제 호출이 있다면 결제사 idempotency key와 상태 복구 전략을 같이 설계한다
AOP + Redis SET NX 패턴은 컨트롤러와 비즈니스 로직을 크게 오염시키지 않으면서도 중복 요청을 꽤 깔끔하게 막을 수 있는 방법이다
다만 주문과 결제는 돈이 걸린 영역이므로 Redis lock 하나로 끝냈다고 생각하지 말고, DB 제약 조건과 외부 시스템 재처리 정책까지 같이 잡아두는 것이 안전하다