https://hyeri-dev.tistory.com/55 에서 이어집니다.
💡 Kotlin + Spring boot로 작성된 글입니다.
1. 문제
@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))
}
지난 포스팅에서 요청이 들어왔을 때 고유한 식별키를 사용해 Redis에 저장된 결과값을 토대로 중복된 요청인지 판단하는 서비스 로직을 작성하였다. 또한 해당 로직을 실행하기 위해 Controller 단에서 중복 요청 판단 메서드(isDuplicateRequst)와 실질적인 로직 이후 결과값과 식별키를 저장하는 메서드(saveRequestResult)를 실행하였다.
이 상항에서 발생할 수 있는 문제점은 다음과 같다.
1. 중복 코드가 발생한다.
2. 트랜잭션 경계 밖에서 실행되어 원자성을 보장 받을 수 없다.
이러한 문제점을 개선하고자 AOP 방식으로 리팩토링을 진행하였다.
2. 요구사항
요구사항은 다음과 같다.
- 확장성 있게 사용할 수 있을 것
- 실질적인 로직의 실행 전에는 중복 확인을 실행 후에는 결과값과 식별키 저장을 실행할 것
- 트랜잭션 경계 내에 실행하여 원자성을 확보할 것
3. 코드
Custom Annotation 작성
AOP는 @PointCut을 통해 특정한 부분에만 어드바이스를 적용할 수 있는데 확장성을 위해 Custom Annotation을 활용하여 어드바이스 적용 시점을 제어하고자 하였다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckDuplicateRequest
해당 어노테이션을 메서드에 적용하기 위해 @Target 설정을 FUNCTION으로 하였으며 런타임 내내 적용될 수 있도록 설정하였다.
AOP Configuration
@Aspect
@Component
class DuplicateRequestAop(){
@Pointcut("@annotation(CheckDuplicateRequest)")
fun checkDuplicateRequest() {}
@Before("checkDuplicateRequest()")
fun beforeDuplicateRequest(joinPoint: JoinPoint) {
}
@AfterReturning("checkDuplicateRequest()")
fun afterDuplicateRequest(joinPoint: JoinPoint) {
}
}
중복 요청을 처리하기 위한 Aspect 클래스를 정의하고, @CheckDuplicateRequest 어노테이션을 기준으로 포인트컷을 지정하였다. 또한 요청 처리 전후에 필요한 로직을 분리하기 위해, @Before와 @AfterReturning 어드바이스를 각각 구현하였다.
private fun saveRequestResult(
key: String,
result: Any,
) {
redisTemplate.opsForValue().set(key, result, 10, TimeUnit.SECONDS)
}
private fun isDuplicateRequest(
key: String,
) {
val result = redisTemplate.opsForValue().get(key)
if(result != null) {
DuplicateException()
}
}
@Before와 @AfterReturning 어드바이스에서 실행할 로직을 작성해주었다. 두 메서드는 Aspect 클래스 안에서만 사용하는 보조 메서드이기 때문에 외부의 접근을 제어하기 위해 private 설정을 해주었다.
💭 프록시 객체는 내부 private 메서드를 호출하지 않는다. 대신, 프록시를 거쳐 실행된 메서드 안에서 일반적인 내부 호출로 private 메서드를 사용하는 것은 전혀 문제가 없다.
해당 코드에서는 요청에 대한 결과값을 식별키와 함께 Redis에 저장하는데 여기서 주의해야 하는 점은 결과값이 없는 경우이다. 그 경우 isDuplicateRequest() 요청에서 redis에서 값을 불러왔음에도 value는 null이기 때문에 원하는 방향으로 작동하지 않는다.
어떻게 알았냐면..... 저도 알고 싶지 않았어요..
식별키 만들기
1편에서는 서비스 로직을 만들어 중복 요청을 체크하였기 때문에 method argument를 통해 식별키를 만들었다.
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"
}
하지만 이 방법은 AOP 방식에서는 method argument를 통해 값을 전달받을 수 없다. 대신 Join Point를 사용하여 어드바이스가 적용된 시점의 데이터들(method argument, method name 등등)을 끌고 올 수 있다.
private fun buildDuplicateRequestKey(joinPoint: JoinPoint): String {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val args = joinPoint.args
return buildString {
append("${method.name}:")
args.forEach { arg ->
append(arg)
}
}
위의 방식대로 요청에 필요한 값들로 식별키를 만들 수 있다. 다만 이 경우에는 식별에 불필요한 값들까지 식별키로 만들어 무분별하게 키가 길어질 수 있는 단점을 지닌다.
원하는 값으로 식별키 만들기
다시 Custom Annotation으로 돌아가서 아래는 중복 요청을 체크해야 하는 서로 다른 2개의 메서드이다.
@CheckDuplicateRequest
fun cancelOrder(
userId: String,
orderId: String,
refundReason: String?
) {}
@CheckDuplicateRequest
fun approvalPayment(
request: ApprovalRequestDto
){}
cancelOrder() 는 userId, orderId, refundReason 3가지의 파라미터를 전달받는다. 그중 refundReason은 nullable 하고 사용자에 따라 값이 달라질 수 있는 변수가 많기 때문에 식별키에 사용하기에 부적합하다고 판단하였다.
approvalPayment() 단일 파라미터가 아니라 Dto를 전달 받는다. request dto 안에서 식별할 수 있는 값을 가져와서 식별키를 만들 필요가 있었다.
이처럼 두 메서드는 어드바이스를 적용하기 위해 같은 어노테이션을 사용하지만 다른 요청 파라미터를 가지고 있으며 식별키에 사용해야 할 값도 다르다. 이를 확장성 있게 사용하기 위해서는 어노테이션을 변경할 필요가 있다.
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CheckDuplicateRequest(vararg val keys: String)
어노테이션에 가변인자로 이루어진 파라미터 keys를 사용하여 식별키를 생성할 때 필요한 요청 파라미터를 구분할 수 있게 한다.
@CheckDuplicateRequest("userId", "orderId")
fun cancelOrder(
userId: String,
orderId: String,
refundReason: String?
) {}
@CheckDuplicateRequest("request")
fun approvalPayment(
request: ApprovalRequestDto
){}
위 처럼 cancelOrder()는 userId 와 orderId를 명시하고, approvalPayment()는 request를 사용하겠다 명시한다. 이어서 어드바이스가 실행되고 JoinPoint에 의하여 아래와 같이 데이터를 추출할 수 있다.
private fun buildDuplicateRequestKey(joinPoint: JoinPoint): String {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(CheckDuplicateRequest::class.java)
val keys = annotation.keys
val paramNames = signature.parameterNames
val paramValues = joinPoint.args
val paramMap = paramNames.zip(paramValues).toMap()
// return 생략
}
1. JoinPoint를 통해 Method Signature를 가져온다.
2. Method Signature를 통해 method를 가져오고 해당 메서드에 적용된 어노테이션 데이터를 가져온다.
3. 어노테이션의 파라미터인 keys 값을 가져온다.
4. Method Signature를 통해 해당 메서드에서 사용하는 파라미터 명을 가져온다.
5. JoinPoint를 통해 method argument를 가져온다.
6. 파라미터명과 아규먼트를 매핑하고 어노테이션 keys를 통해 원하는 데이터를 추출하여 식별키를 만든다.
private fun buildDuplicateRequestKey(joinPoint: JoinPoint): String {
val signature = joinPoint.signature as MethodSignature
val method = signature.method
val annotation = method.getAnnotation(CheckDuplicateRequest::class.java)
val keys = annotation.keys
val paramNames = signature.parameterNames
val paramValues = joinPoint.args
val paramMap = paramNames.zip(paramValues).toMap()
return buildString {
append("${method.name}:")
keys.forEach { key ->
val value = paramMap[key]
when(key) {
"userId" -> append("$value:")
"orderId" -> append("$value:")
// dto는 별도의 보조 메서드를 통해 데이터를 추출하여 사용하였음. 해당과정은 생략
"request" -> value?.let { append(buildRequestParameter(it)) }
}
}
}.removeSuffix(":")
}
이 방식이 가장 적절한 방식인지는 모르겠지만 현 상황에서는 최적의 방식이라고 생각한다. 앞으로 더 좋은 방법이 생각난다면 그때 수정하기로 하겠다..
소감.
너무 어려웠다.
AOP가 무엇인지 어떻게 작동하는지 어렴풋하게만 알고 있었는데 직접 작성하고 왜 안되는지 파악하는 과정에서 역시 기초가 가장 중요하다는 사실을 깨닫게 되었다. 또 어떻게 보다는 왜에 중점을 두고 개발을 해야 겠다는 생각이 들었다. 방식이야 구글링이든 지피티든 생각보다 쉽게 찾을 수 있지만 '왜' 이렇게 구현하는 지 이해를 못하다보니 오류를 파악하는데 쉽지 않다는 생각이 들었다.
'Backend > Spring' 카테고리의 다른 글
[Spring] 따닥으로 인한 중복 요청 방지하기 - 1 (0) | 2025.07.20 |
---|---|
Servlet 와 Spring MVC (0) | 2023.09.26 |
리플렉션(Reflection)과 Spring (1) | 2023.09.06 |