본문 바로가기

내가 잊어버리기 싫어서 적는 개발 지식

[Spring AOP] Custom AOP와 @Transactional Order에 관하여

 

최근 특정 요청이 중복되어 처리되는 이슈가 발생했다.

 

어떤 요청의 중복 처리 요청은 우선적으로 클라이언트에서 막아주면 좋지만, 서버는 모든 상황을 대비해야 하기에 이를 사전에 대비해야한다.

 

배경


우리팀은 특정 요청의 중복처리를 방지하기 위해 Redisson 분산락을 활용한다.

 

서비스 특성상, 어떤 로직이 처리되는 도중 들어오는 중복된 요청은 무시해도 되는 상황이 많기 때문에 다음과 같은 속성을 이용한다.

 

RLock lock = redissonClient.getLock(key);
lock.tryLock(0, 60L, TimeUnit.SECONDS)

* wait = 0 (이미 다른 곳에서 해당 lock을 점유했다면 대기하지 않고 false 반환)
* lease = 60 (lock을 획득한 이후엔 60초 이후 자동 반환)

 

또한, 모든 서비스 로직에 lock 획득/해제 관련 코드를 넣을 순 없기 때문에 Spring에서 제공하는 AOP를 사용한다.

 

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodLock {

    boolean isOptional() default false;

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    @interface LockOption {

    }
}

@MethodLock(isOptional = true)
public void method(@LockOption String param1, Integer param2, @LockOption Long param3) {
    // logic
}

 

@MethodLock 내부의 옵션 필드를 통한 lock key 생성은 apect에서 관리하는데, 이는 이번 포스팅과는 큰 관련이 없어서 나중에 다루겠다.

 

정리하자면, 우리는 메소드의 중복 처리를 방지하기 위해 Reddison 분산락 + AOP를 통해 메소드 호출을 제어하고 있다. 

 

문제


위와 같은 꽤 보수적?인 lock 정책으로 인해 나름대로 중복처리에 대해 안정적인 방어 스탠스를 취하고 있다고 생각했고, 만약 요청1에 대한 중복 요청2요청1이 모두 완료된 이후에 들어온다면 요청1의 트랜잭션 커밋이 모두 완료된 이후이기 때문에 중복처리될 수가 없는 구조였다.

 

그러나 있을 수 없는 일이 벌어졌다.

예시

 

분명 중복처리 될 일이 없는 메소드인데, 분명 중복되어 실행됐고 처리도 두번 됐다.

 

 

첫번째 그림은 커스텀으로 지정한 MethodLock으로 인해 발생할리가 없어야 하고, 두번째 그림은 요청1의 트랜잭션 커밋으로 인해 요청2의 서비스 로직이 실행 될리가 없어야 한다.

 

 

타임 테이블을 그려보자면 위와 같은 일이 발생한 것과 같은데, 도대체 어디서 부터 잘못된건지 알 수가 없었다.

서비스 로직을 한참 보아도 모르겠어서 요청부터 응답까지 모든곳에 브레이크 포인트를 걸고 디버깅을 했다.

 

 

원인


원인은 메소드에 덕지덕지 붙어있는 어노테이션들의 순서때문이었다.

 

해당 메소드에는 위에서 언급한 @MethodLock 이외에도 로깅을 위한 어노테이션 및 @Transaction 등 다양한 어노테이션이 존재하는데, 각 aspect에 아무런 Order를 지정하지 않은 상태였다.

 

문제가 되었던 프로세스는 MethodLock이 Transaction보다 낮은 우선순위를 가질 때 발생한다.

(낮은 우선순위란 타겟 메소드와 더 가깝다는 것을 의미)

 

ex) Transaction이 MethodLock 보다 우선순위가 높다

요청 > 트랜잭션 시작 > Lock 획득 > 메소드 실행 > Lock 해제 > 트랜잭션 종료 > 응답

 

S.S = 서비스 시작 / S.E = 서비스 종료

T.S = 트랜잭션 시작 / T.E = 트랜잭션 종료

M.S = 메소드락 시작 / M.E = 메소드락 종료

 

 

 

위 프로세스로 요청1과 요청2가 진행되어 중복 처리되는 문제가 발생했다.

그림에서 볼 수 있듯이 해당 문제가 발생하기 위해선 여러 조건들이 충족해야 하는데, 특히 요청2의 Lock 획득과 DB 조회가 모두 요청1의 Lock 해제와 트랜잭션 커밋 이전에 이루어져야만 한다.

 

이처럼 매우 까다로운 조건으로 인해 그동안 발생하지 않다가 최근에 알게 된 것이다.

 

더불어, 로깅 AOP 조차도 순서 지정이 안되어있어서 실제 로그가 찍힌 시점이 @MethodLock 및 @Trasnaction 이후인지 이전인지 알 방법이 없었다. 

이게 생각보다 크게 작용을 했는데, 에러가 발생 한 후 시간단위로 로그를 나누어 볼 때 디버깅이 너무 힘들었다. 

 

해결


AOP에 우선순위를 지정해주면 된다.

 

로깅, Lock 등 메소드의 실질적인 처리와 상관없는 로직은 모두 @Transaction 보다 우선순위로 지정해줘야 한다.

@Transaction의 Order는 이를 매니징하는 @EnableTransactionManagement 에서 관리한다.

 

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({TransactionManagementConfigurationSelector.class})
public @interface EnableTransactionManagement {
    boolean proxyTargetClass() default false;

    AdviceMode mode() default AdviceMode.PROXY;

    int order() default Integer.MAX_VALUE;
}

 

위와 같이 기본값으로 Integer.MAX_VALUE로 선언하고 있다.

따라서 @Transaction의 Order는 다음과 같이 변경할 수 있다.

 

@EnableTransactionManagement(order = 원하는 값)

 

 

Custom으로 지정한 annotation은 이를 구현하는 aspect 컴포넌트 bean의 Order로 지정해주면 된다.

 

@Aspect
@Component
@Order(원하는 값)
public class LockAspect {
}

 

 

여기서 의문이 생긴다.

 

@Transaction의 기본값에 따르면 항상 최하위 우선순위를 가져야 하는데, 위 이슈가 발생한 이유는 @Transaction이 @MethodLock보다 먼저 실행되었기 때문이다. 

이 이유를 찾기 위해 많은 노력을 했는데.. 명쾌한 답을 찾지는 못했다.

 

https://github.com/spring-projects/spring-framework/issues/32639

 

Change @Transactional AOP Default Order to 0 · Issue #32639 · spring-projects/spring-framework

I know this is a huge change, so I want to hear from the community. A while ago, I was using the method security feature in Spring Security and I got this idea. In Spring Security, Method Security ...

github.com

https://github.com/spring-projects/spring-framework/issues/10126

 

The default Aspect Order should not be Ordered.LOWEST_PRECEDENCE [SPR-5453] · Issue #10126 · spring-projects/spring-framework

Kek opened SPR-5453 and commented I have a situation, when I need to guarantie that my Aspect is the last one in the invocation chain. So I annotated the Aspect class with @Order(Ordered.LOWEST_PRE...

github.com

 

위 문제에 관해서 다양한 의견들이 오간다.

 

Spring 공식 문서를 찾아보면 @Transaction의 advisor콜에 관한 글이 있는데 뭔가.. 뚜렷한 설명없이 그림만 존재하긴 한다.

 

커스텀으로 지정한 Advisor는 Transaction Advisor보다 타겟 메소드에 가깝다

 

테스트를 해보면 커스텀 어노테이션의 Order에 Integer.MAX를 할당하면 트랜잭션부터 시작되고, Integer.MAX-1 을 할당하는 순간부터 커스텀 어노테이션부터 시작한다.

 

공식문서와 여러번의 자체테스트로 인해 내린 결과는 다음과 같다.

@Transaction은 가장 낮은 우선순위를 가지려 노력하지만 커스텀으로 만들고 Order를 지정하지 않은 어노테이션은 @Transaction보다 우선순위가 낮다.

 

 

또한, 커스텀으로 지정한 어노테이션들의 aspect에 별도의 Order를 지정해주지 않으면 Spring이 bean을 스캔한 순서에 따라 순서가 지정된다. 즉, 순서를 보장할 수 없으므로 정말 순서가 상관없을 때만 유효하다.

 

정리하자면 우리는 커스텀 어노테이션들과 @Transaction에 별도의 Order를 지정해주지 않았기 때문에 다음과 같은 순서로 AOP가 동작했을 것이다.

커스텀 어노테이션들 우선 (내부적으론 뭐가 우선인지 모름) > 트랜잭션

 

 

각 커스텀 AOP에 명시적으로 Order를 지정해주며 해결했다.

 

 

결론


꺼진불도 다시보자