본문 바로가기
Backend/Spring

[Spring] 따닥으로 인한 중복 요청 방지하기 - 1

by Hyeri.dev 2025. 7. 20.

💡 Kotlin + Spring boot 로 작성된 글입니다.

1. 문제

사내 서비스에서 일명 따닥으로 인하여 중복 요청이 들어오는 경우가 발생하였다.

 

GET 요청은 멱등성을 보장하기 때문에 큰 문제가 발생하지는 않지만, POST, PATCH 와 같은 데이터의 변경이 있는 요청에는 여러가지 문제가 발생할 수 있다.



사내에서 발생한 문제는 결제가 정상적으로 취소되었음에도 불구하고 중복 요청에 의하여 Exception이 발생하였고 요청 실패 창이 뜨는 문제였다. 사용자 입장에서는 정상적으로 처리되지 않았다는 인식을 하게 되어 관련된 문의가 다수 발생하였다.

해당 문제는 화면단에서도 처리 해야 하는 문제이지만 서버 내부에서도 체크할 필요가 있다고 판단하여 작업을 진행하였다.

2.  요구사항

문제를 해결하기 위한 방안에서 필요한 요구사항은 아래와 같다.

  • 동일한 요청임을 식별할 수 있는 고유키값이 필요하다.
  • 단 시간(초단위)의 중복 요청만 방지할 수 있어야 한다. (요청이 실패할 경우 재요청 해야 하므로)
  • 실질적인 비즈니스 로직이 실행되지 않아야 한다.

고유한 식별키를 초단위의 시간동안 보관할 수 있는 저장소가 가장 중요한 요소였는데 사내 서비스에서는 Redis를 이미 사용하고 있어 이를 활용하기로 하였다.

3. 코드

Controller 단에서 처리하기

    @PatchMapping("/{order-id}")
    fun cancelOrders(
        httpRequest: HttpServletRequest,
        @AuthenticationPrincipal user: User,
        @PathVariable("order-id") orderId: String
    ): ResponseEntity<ResponseObject<String>> {

	// 중복요청인지 확인
        duplicateRequestService.isDuplicateRequest(
            key = ""
        )
        
	// 실질적인 비즈니스 로직
        val response = orderService.cancelOrder(
            user = user,
        )
        
	// 정상 처리 후 Key값 저장
        duplicateRequestService.saveRequestResult(
             key = "",
             result = response
        )

        return ResponseEntity.ok().body(ResponseObject.of(response))
    }

처리 순서를 정리하자면 아래와 같다.

  1. 중복 요청인지 확인한다.
  2. 실질적인 비즈니스 로직을 처리한다.
  3. 2번의 실행이 정상 완료되었을 경우 Redis에 key를 저장한다.

DuplicateRequestService 

@Service
class DuplicateRequestService(
    private val redisTemplate: RedisTemplate<String, Any>,
) {
    @Transactional
    fun isDuplicateRequest(
        key: String
    ) {
        val result = redisTemplate.opsForValue().get(key)

        if(result != null) {
            throw DuplicateException()
        }
    }
    
    @Transactional
    fun saveRequestResult(
        key: String,
        result: Any
    ) {
        redisTemplate.opsForValue().set(key, result, 10, TimeUnit.SECONDS)
    }
}

 

1. isDuplicateRequest() 를 통해 Redis에 저장된 값을 찾고, 만약 저장된 값이 있을 경우 DuplicateException을 던진다.

(DuplicateException는 프론트 개발자와의 협의하에 결정된 사항을 처리할 수 있도록 한다. 여기서 나는 Custom Exception을 만들어 Exception Handelr에서 잡아 처리할 수 있도록 하였으며 관련 내용은 해당 글에서 제외 하였다.)

2. 비즈니스 로직을 정상적으로 처리한 후, saveRequestResult()를 통해 식별키와 요청에 대한 결과값을 Redis에 저장한다.

이때 저장된 값을 유지할 수 있는 시간은 10초로 설정하였다.

식별키 만들기

어떤 값으로 식별키를 만들 수 있을까? UUID도 생각해보았지만 중복 요청 시에 같은 값을 만들 수 있다는 보장이 되지 않았다. 

@PatchMapping("/{order-id}")
    fun cancelOrders(
        httpRequest: HttpServletRequest,
        @AuthenticationPrincipal user: User,
        @PathVariable("order-id") orderId: String
    )

 

Controller단을 살펴보면 요청을 위해 보내는 특정한 값인 User Token, order id 가 존재하여 이를 통해 식별키를 만들고자 하였다.

fun buildDuplicateRequestKey(
        user: User,
        orderId: String,
        request: HttpServletRequest
    ): String {
        val method = request.method
        val url = request.requestURI.toString()
        val userId = user.userId.userId

        return "$method-$url-$userId-$orderId"
    }

 

order Id를 사용하는 요청이 많아 더 세분화된 식별을 위해 HttpServletRequest를 통해 해당 요청에 대한 method, request uri 를 가져와 모든 값을 조합한 식별키를 만들었다.

 

DuplicateReqestService

@Service
class DuplicateRequestService(
    private val redisTemplate: RedisTemplate<String, Any>,
) {
    @Transactional
    fun saveRequestResult(
        user: User,
        request: HttpServletRequest,
        result: Any
    ) {
        val key = buildDuplicateRequestKey(
            user = user,
            request = request
        )

        redisTemplate.opsForValue().set(key, result, 10, TimeUnit.SECONDS)
    }

    @Transactional
    fun isDuplicateRequest(
        user: User,
        request: HttpServletRequest,
    ) {
        val key = buildDuplicateRequestKey(
            user = user,
            request = request
        )

        val result = redisTemplate.opsForValue().get(key)

        if(result != null) {
            throw DuplicateException()
        }
    }

    private fun buildDuplicateRequestKey(
        user: User,
        request: HttpServletRequest
    ): String {
        val method = request.method
        val url = request.requestURI.toString()
        val userId = user.userId.userId

        return "$method-$url-$userId"
    }
}

 

 

이렇게 중복 요청을 판단하는 서비스단을 완성하였다. 중복 요정 방지가 필요한 요청이 있으면 해당 메서드를 통해 제어할 수 있다. 

4. 또 다른 문제점

위와 같이 작성된 서비스를 사용하는 코드를 살펴보자.

    @PatchMapping("/{order-id}")
    fun cancelOrders(
        httpRequest: HttpServletRequest,
        @AuthenticationPrincipal user: User,
        @PathVariable("order-id") orderId: String
    ): ResponseEntity<ResponseObject<String>> {

	// 중복요청인지 확인
        duplicateRequestService.isDuplicateRequest(
            user = user,
            request = httpRequest
        )
        
	// 실질적인 비즈니스 로직
        val response = orderService.cancelOrder(
            user = user,
        )
        
	// 정상 처리 후 Key값 저장
        duplicateRequestService.saveRequestResult(
             key = "",
             result = response,
             request = httpRequest
        )

        return ResponseEntity.ok().body(ResponseObject.of(response))
    }

 

Controller 단에서 직접 메서드를 호출해 중복 요청을 제어하면 중복 코드가 많아지고, 변경 사항 대응도 어려워진다.

 

하지만 더 중요한 문제는 해당 로직이 트랜잭션 경계 밖에서 실행된다는 점이다. 이렇게 되면 트랜잭션의 원자성을 보장받을 수 없어, 실제로는 실패한 요청임에도 Redis에는 성공한 것으로 기록될 수 있다.

 

비즈니스 로직이 정상적으로 수행된 경우에만 Redis에 중복 키를 저장하고자 했지만, 실패 시 롤백 여부와 관계없이 저장 로직이 실행되면 이후 재요청이 부당하게 차단되는 문제가 발생할 수 있다.

 

이를 다음 포스팅에서 AOP 방식으로 변경하여 리팩토링 해보겠다.

'Backend > Spring' 카테고리의 다른 글

[Spring] 따닥으로 인한 중복 요청 방지하기 - 2  (4) 2025.07.22
Servlet 와 Spring MVC  (0) 2023.09.26
리플렉션(Reflection)과 Spring  (1) 2023.09.06