spring
spring 에서 Redis와 AOP로 주문 API 멱등성 보장하기

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 를 발급하고, 서버는 이 값을 기준으로 같은 요청이 이미 처리 중인지 또는 이미 성공했는지 확인한다

흐름은 대략 아래와 같다

  1. 클라이언트가 주문 요청에 Idempotency-Key 헤더를 붙여서 보낸다
  2. 서버 AOP가 컨트롤러 실행 전에 헤더 값을 읽는다
  3. Redis에 SET key value NX EX 형태로 PROCESSING 상태를 저장한다
  4. 저장에 성공하면 최초 요청이므로 실제 주문 로직을 실행한다
  5. 저장에 실패하면 이미 같은 키의 요청이 들어온 것이므로 Redis에 저장된 상태를 확인한다
  6. 상태가 PROCESSING 이면 409 Conflict 를 반환한다
  7. 상태가 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를 따로 만들어 내려줘도 된다

주문 화면 기준으로는 아래 방식이 실무에서 다루기 편하다

  1. 사용자가 주문서 작성 화면에 진입한다
  2. 프론트엔드가 GET /api/v1/orders/ticket 를 호출해서 UUID를 받는다
  3. 화면 메모리 또는 상태 저장소에 UUID를 보관한다
  4. 사용자가 주문 버튼을 누를 때 Idempotency-Key 헤더에 UUID를 넣는다
  5. 같은 화면에서 버튼을 연타하거나 재시도하면 같은 UUID가 사용된다
  6. 사용자가 주문 화면을 새로 진입하면 새 UUID를 받는다

이렇게 하면 실수로 같은 주문을 두 번 만드는 것은 막고, 사용자가 정말로 같은 상품을 한 번 더 주문하는 것은 막지 않을 수 있다

간단한 티켓 발급 API는 아래처럼 만들 수 있다

OrderTicketController.java
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 기준이다

필요한 의존성은 대략 아래와 같다

build.gradle
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만 저장하지 않고 요청 상태와 응답 본문을 같이 저장한다

그래야 같은 요청이 다시 들어왔을 때 이전 응답을 재사용할 수 있다

IdempotencyStatus.java
package com.example.idempotency.dto;
 
public enum IdempotencyStatus {
    PROCESSING,
    SUCCESS
}
IdempotencyRecord.java
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은 분리했다

처리 중인 요청은 장애가 났을 때 너무 오래 막혀 있으면 안 되고, 성공한 응답은 클라이언트 재시도를 처리할 수 있도록 더 오래 남겨두는 편이 좋다

IdempotencyService.java
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 를 필수로 두는 편이 안전하다

Idempotent.java
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 또는 이전 응답 반환
  • 최초 요청이면 컨트롤러 실행 후 성공 응답 저장
IdempotencyAspect.java
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 만 붙이면 된다

OrderApiController.java
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는 예시로 아래처럼 두면 된다

OrderRequest.java
package com.example.idempotency.dto;
 
public record OrderRequest(
        Long productId,
        int quantity
) {
}
OrderResponse.java
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에 두 번 들어가는 것을 한 번 더 막을 수 있다

schema.sql
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를 이미 호출한 뒤에 서버에서 예외가 발생했다면 얘기가 달라진다

예를 들어 아래 상황을 생각해보자

  1. Redis에 PROCESSING 저장 성공
  2. 결제사 승인 API 호출 성공
  3. 서버에서 주문 저장 중 예외 발생
  4. catch에서 Redis key 삭제
  5. 사용자가 재시도
  6. 결제 승인 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 제약 조건과 외부 시스템 재처리 정책까지 같이 잡아두는 것이 안전하다