최근에 처음으로 기술 면접을 경험했다. 프로젝트에 관한 질문이 많았는데 당시에 비즈니스 로직 중 비동기 처리를 해야 했었다. DB에 접근하는 로직이었기에 @Transactional과 @Async를 함께 적용시켜 사용했다.
이에 대해 면접관이 물었다.
@Async와 @Transactional이 내부에서 어떻게 동작하는지 아시나요?
덧붙여서 상상력을 발휘해도 좋다고 했다. 정확하게 AOP 내부 구현을 몰랐던 나는 다음과 같이 대답했다.
아마 트랜잭션 관련 AOP 프록시가 부가 기능을 수행한 뒤 비동기 관련 AOP 프록시가 비동기 부가 기능을 넣고 마지막으로 target을 호출할 것 같습니다. 내부적으로 순서가 있을 것 같은데 반대의 순서도 상관없을 것 같습니다.
정확히 저렇게 말하진 않았겠지만 저런 흐름이었다. 자신이 없어서 목소리가 기어들어갔던 것으로 기억한다… 이후 면접이 끝나고 공부를 해보니 저 대답이 완전히 틀린 대답이라는 것을 알게 되었다.
이전 글을 읽고 오면 아래 글의 이해가 더 쉬울 것이다.
하나의 프록시, n개의 어드바이저
스프링에서는 적용해야 하는 어드바이저가 여러 개더라도 하나의 프록시만 생성하고 여러 어드바이저를 적용하도록 되어 있다.
아마 트랜잭션 관련 AOP 프록시가 부가 기능을 수행한 뒤 비동기 관련 AOP 프록시가 비동기 부가 기능을 넣고 마지막으로 target을 호출할 것 같습니다…
따라서 위의 대답은 틀린 것이었다. @Transactional과 @Async를 같이 붙여도 프록시는 하나만 생성되는 것이다.
트랜잭션 처리와 비동기 Advice 테스트
직접 만든 커스텀 어노테이션인 @MyTransactional과 @MyAsync로 AOP 구현을 확인해보고자 한다.
실제 호출 메서드
MyTransactionalAspect
MyAsyncAspect
@Order 어노테이션을 통해 일단 비동기 부가 기능이 먼저 적용되도록 설정하였다.
테스트 코드
롤백이 일어났을 때 한 트랜잭션으로 묶여 잘 롤백되는지 확인하는 테스트이다.
CglibAopProxy
프록시로 대체된 UserService가 메서드를 호출하고 디버그를 찍어보니 CglibAopProxy의 intercept 메서드 안으로 들어와 지는 것을 확인할 수 있다.
내부에 chain이라는 지역변수를 발견했는데 안에 @Aspect로 적용한 비동기와 트랜잭션 Advise가 들어 있는 것을 확인했다. 하나의 프록시가 여러 Advice를 가지고 있으면서 재귀적으로 부가 기능을 적용해 가면서 로직을 호출해 간다.
순서에 따라 상이한 테스트 결과
비동기를 먼저 적용했을 때 테스트를 통과하는 것을 확인할 수 있다.
그런데 트랜잭션을 먼저 적용하자 테스트가 실패하였다.
@Transactional과 스레드
스프링 공식 문서에는 다음과 같이 나와 있다.
@Transactional은 일반적으로 PlatformTransactionManager가 관리하는 thread-bound 트랜잭션에서 동작하며, 현재 실행 스레드 내의 데이터 접근 트랜잭션을 노출한다. NOTE: 새로운 스레드에게는 전파되지 않는다.
스프링은 Transaction 관련 정보를 ThreadLocal에 저장하는데 다른 스레드로 분기해 버리면, 즉 트랜잭션 내부에 비동기 처리가 일어나면 그 로직은 트랜잭션에 합류시킬 수 없어서 트랜잭션이 적용되지 않는 것이다.
결론적으로 비동기와 트랜잭션 처리를 함께 해주고 싶다면 먼저 비동기 Advice를 적용하고 후에 트랜잭션 Advice를 적용하여서 새로운 스레드 안에서 트랜잭션을 시작해야 한다는 것이다.
스프링이 보장하는 @Transactional, @Async 순서
내부적으로 순서가 있을 것 같은데 반대의 순서도 상관 없을 것 같습니다.
즉 위처럼 말했던 것은 틀린 답변이었다. @Async 부가 기능이 먼저 적용되어야 두 부가 기능이 정상적으로 동작한다. 그리고 스프링은 내부적으로 이 둘의 순서를 보장하고 있다.
아래 사진은 CglibAopProxy 클래스에 디버그를 찍고 확인한 사진이다. Adivce를 List로 가지고 있는 것을 확인하였는데 두 어노테이션을 둘 다 붙인 메서드를 호출했을 때 내부적으로 아래와 같은 순서로 되어 있었다.
AsyncExecutionInterceptor
@Async가 적용되는 Advice에는 Order가 Integer.MIN_VALUE가 적용되어 가장 먼저 동작하도록 되어 있다.
EnableTransactionManagemet
@Transactional이 적용되는 Advice에는 Order가 Integer.MAX_VALUE가 적용되어 가장 나중에 동작하도록 되어 있다.
정리
AOP 동작 과정에 대해 공부해보니 지금까지 @Transactional 같은 어노테이션을 잘 모르고 사용하고 있었다는 느낌을 받았다. 아마 커스텀 AOP를 만들었을 때 순서에 대한 개념이 없었다면 예상치 못한 버그에 대해 갈피를 못 잡았을 수도 있었을 것이다.
결론적으로 다시 질문을 받는다면 다음과 같이 답변을 수정할 수 있을 것 같다.
내부적으로 프록시가 하나 생성됩니다. 프록시는 부가 기능의 모음인 Advise 리스트를 가지고 있습니다. Advise들은 각자의 Order 값에 의해 순서가 정해지는데 스프링에서 내부적으로 AsyncExecutionInterceptor는 Integer.MIN_VALUE이고 TransactionInterceptor는 Integer.MAX_VALUE라서 무조건 비동기 부가 기능을 씌우고 트랜잭션을 씌우도록 되어 있습니다. 반대의 순서라면 트랜잭션이 동작하지 않기 때문입니다.
사용하는 기술에 대해 어느 정도 알아보고 사용해야 한다는 것을 더 뼈저리게 느끼게 된 면접이었다.
참고
'스프링' 카테고리의 다른 글
[Spring] AOP와 동적 프록시 (0) | 2022.11.17 |
---|---|
왜 스프링 프레임워크인가 (0) | 2022.04.19 |