어제 오늘 내일

[Spring Boot] "결제 버튼 두 번 눌렀어요!" 중복 요청 방지: 멱등성(Idempotency) 설계와 Redis 본문

IT/SpringBoot

[Spring Boot] "결제 버튼 두 번 눌렀어요!" 중복 요청 방지: 멱등성(Idempotency) 설계와 Redis

hi.anna 2026. 3. 27. 09:52

 
멱등성(Idempotency)이란 수학 용어()에서 왔지만, 개발에서는 "연산을 한 번 수행하든 여러 번 수행하든 결과가 똑같아야 한다"는 뜻입니다.

  • GET: 조회는 100번 해도 데이터가 안 변하죠? (멱등하다)
  • POST: 결제 요청을 2번 보내면 2번 결제되죠? (멱등하지 않다!)

그래서 우리는 POST(생성/결제) 요청을 강제로 멱등하게 만들어줘야 합니다.


1. 해결 원리: "번호표(Key) 먼저 뽑아오세요"

핵심은 클라이언트(프론트)와 서버가 약속을 하는 것입니다.

  1. Client: 요청을 보낼 때 유니크한 ID(Idempotency-Key)를 헤더에 담아 보냅니다. (예: UUID)
  2. Server:
  • 이 키를 Redis에서 조회합니다.
  • 없으면: 처음 온 요청이네? -> 처리하고 Redis에 저장.
  • 있으면: 이미 처리된(또는 처리 중인) 요청이네? -> 거부(409 Conflict)하거나 이전 결과를 그대로 반환.

2. 왜 Redis인가요?

DB에 저장해도 되지만, 속도가 생명입니다. 모든 요청마다 검사를 해야 하는데, DB를 갔다 오면 느려집니다. 메모리 기반인 Redis가 딱입니다.

특히 Redis의 SETNX (Set if Not Exists) 명령어는 "값이 없을 때만 저장해라"라는 원자적(Atomic) 연산이라서, 동시성 이슈까지 해결해 줍니다.


3. 구현: AOP로 깔끔하게 분리하기

컨트롤러마다 중복 방지 로직을 넣으면 코드가 지저분해집니다. 커스텀 어노테이션 + AOP로 우아하게 해결해 봅시다.

① 어노테이션 정의 ()

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    String headerName() default "Idempotency-Key"; // 헤더 키 이름
    long expireTime() default 60L; // 중복 방지 시간 (초)
}

② AOP Aspect 구현

@Aspect
@Component
@RequiredArgsConstructor
public class IdempotencyAspect {

    private final RedisTemplate<String, String> redisTemplate;

    @Around("@annotation(idempotent)")
    public Object checkIdempotency(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {

        // 1. 헤더에서 키 가져오기
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String key = request.getHeader(idempotent.headerName());

        if (StringUtils.isEmpty(key)) {
            throw new IllegalArgumentException("Idempotency-Key 헤더가 없습니다.");
        }

        // 2. Redis 키 생성 (유니크해야 함)
        String redisKey = "idempotency:" + key;

        // 3. Redis에 저장 시도 (SETNX)
        // 값이 없을 때만 true 반환, 있으면 false 반환
        Boolean isFirstRequest = redisTemplate.opsForValue()
                .setIfAbsent(redisKey, "PROCESSING", Duration.ofSeconds(idempotent.expireTime()));

        if (Boolean.FALSE.equals(isFirstRequest)) {
            // 이미 키가 존재함 -> 중복 요청!
            throw new IllegalStateException("이미 처리 중이거나 완료된 요청입니다.");
        }

        try {
            // 4. 실제 비즈니스 로직 실행 (Controller)
            return joinPoint.proceed();
        } catch (Exception e) {
            // ★ 실패하면 키를 지워줘야 재시도 가능!
            redisTemplate.delete(redisKey);
            throw e;
        }
    }
}

4. 사용하기: 컨트롤러

이제 컨트롤러 메서드 위에 @Idempotent 하나만 붙이면 끝입니다.

@RestController
@RequestMapping("/payments")
public class PaymentController {

    private final PaymentService paymentService;

    @Idempotent // ★ 중복 결제 방지 적용!
    @PostMapping
    public ResponseEntity<String> processPayment(@RequestBody PaymentRequest dto) {
        paymentService.pay(dto);
        return ResponseEntity.ok("결제 성공");
    }
}

테스트 시나리오:

  1. 첫 번째 요청: (Header: Idempotency-Key: uuid-1234) -> 성공 (200 OK)
  2. 두 번째 요청: (Header: Idempotency-Key: uuid-1234) -> 실패 (500 Error: 이미 처리 중...)
  3. 다른 키 요청: (Header: Idempotency-Key: uuid-5678) -> 성공 (200 OK)

5. 더 나아가기: 결과까지 저장하기 (Advanced)

위 방식은 단순히 "막기만" 합니다. 더 친절한 시스템은 "이미 처리된 결과(Response)를 저장해 뒀다가 그대로 돌려주는 것"입니다.

  1. 요청 시작 시: Redis에 상태를 PROCESSING으로 저장.
  2. 처리 완료 시: Redis 값을 COMPLETE + 응답 데이터(JSON)로 업데이트.
  3. 중복 요청 시: Redis를 조회해서 COMPLETE면 저장된 JSON을 바로 리턴.

이러면 사용자는 "어? 에러 났나?" 하고 다시 눌러도, "결제 완료 (이미 처리됨)" 화면을 볼 수 있게 됩니다.


마치며

오늘의 결론입니다.

  1. 결제, 송금 등 중요한 로직은 반드시 멱등성을 보장해야 한다.
  2. 클라이언트가 생성한 유니크 키(Request ID)를 기준으로 중복을 체크한다.
  3. RedisAOP를 활용하면 비즈니스 로직 침범 없이 깔끔하게 구현할 수 있다.

이제 여러분의 서버는 사용자가 클릭을 연타해도 끄떡없는 철벽 방어 시스템을 갖췄습니다.
다음 포스팅에서는 "에러가 났는데 사용자가 신고하기 전까지 몰랐다고?" 실시간 에러 추적 플랫폼 Sentry 연동하기에 대해 알아보겠습니다. (슬랙으로 에러 알림 받는 법!)
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments