2024. 6. 7. 17:25ㆍBackend/Spring
@Async로 실행하는 메서드에 대해 @Transactional 테스트를 수행하게 되면 문제가 발생합니다. (주석은 무시하셔도 좋습니다)
위 메서드에 대해 테스트를 수행하기 위해
위와 같이 저장 후 테스트를 수행하여 검증하는 로직이 있는데,
값이 정상적으로 update되어 저장됐는지를 확인해야하지만,
분명히 동기적으로 작동시켰을 때는 정상적으로 동작했던 코드임에도 불구하고 @Async 옵션을 통해 수행하면 오류가 발생했습니다.
또한, 해당 메서드에서는 given에서 분명히 저장했던 Submit을 찾을 수 없다는 예외가 발생합니다.
이는 테스트 메서드에 달려있는 @Transactional로 인해, 현재 "Submit" 인스턴스를 저장한 트랜잭션이 커밋되지 않은 상태에서 다른 비동기 스레드가 해당 사항을 조회할 수 없기 때문입니다. (Isolation Level이 READ COMMITTED라 Dirty Read가 방지된 결과입니다.)
다른 트랜잭션
TransactionSynchronizationManager를 통해 현재 트랜잭션의 이름을 비교해보면
서로 다른 트랜잭션이 열린 것을 알 수 있습니다.
비동기 메서드가 현재 트랜잭션에 참여하도록 하고 싶지만,
@Transactional은 ThreadLocal로 관리되기 때문에 비동기 메서드의 트랜잭션이 메인 스레드의 트랜잭션에 참여할 수 없습니다.
해결하기 : 단위 테스트에서 @Async 메서드의 유효성을 검사하기 위하여
비동기 메서드 내부에서 호출되는 submit.updateStatusToAccepted는 단위 테스트로 이미 검증되어 있기 때문에
통합 테스트에서는 verify()로 메서드 호출이 됐는지만 검증해도 될 것입니다.
하지만 해당 서비스의 단위테스트에서는 BaekjoonSumit을 find, 수정, save가 정확하게 수행되는지 검증하는 것이기 때문에 위의 방법은 제외했습니다. ( 비동기 메서드가 호출되는지만 검증하는 것이 아님)
그렇다면 트랜잭션이 커밋되지 않은 상태에서 다른 스레드가 DB에서 데이터를 읽으려고 하는 점을 해결해야 합니다.
PlatformTransactionManager로 수동 트랜잭션 사용하기?
@Transactional어노테이션을 계속 사용하면서도 트랜잭션 경계를 분리하려면 PlatformTransactionManager로 직접 트랜잭션 경계를 만들어 주어야합니다.
PlatformTransactionManager로 given에 주어져야하는 값을 save하는 트랜잭션을 독립적으로 만든 뒤
비동기 작업을 get으로 잡아서 기다리는 방식입니다.
이렇게 하면 트랜잭션 경계를 성공적으로 분리하여 테스트를 정상적으로 수행할 수 있기는 하지만,
테스트가 지나치게 복잡해지는 문제가 발생합니다.
게다가 PlatformTransactionManager로 실행한 트랜잭션은 @Test메서드에서 진행되는 롤백이 안 되는 문제가 발생합니다
위와 같이 클렌징이 정상적으로 이루어지지 않아 테스트가 실패합니다
따라서 위 방법은 @Transactional을 이용하는 테스트에서는 실제로 사용하기에 어려움이 있습니다.
AopTestUtils.getTargetObject()로 실제 객체 얻기
@Async는 내부적으로 AOP로 동작합니다.
AopTestUtils.getTargetObject()를 이용하면 AOP Proxy의 실제 객체를 얻을 수 있습니다.
테스트 중에는 실제 객체를 얻어 같은 트랜잭션 컨텍스트 내에서 실행하게 하여 간단하게 해결할 수 있습니다.
AOP에 의해 CGLIB 바이트코드 조작된 프록시가 AopTestUtils.getTargetObject()를 이용하면
실제 BaekjoonSubmitService 객체로 들어가게 됩니다.
결론
Spring 애플리케이션에서 @Async와 @Transactional을 함께 사용하는 테스트를 수행할 때 서로 다른 트랜잭션 경계를 사용하기 떄문에 문제가 발생할 수 있습니다.
살펴본 방법은 아래 두 가지입니다.
PlatformTransactionManager로 수동 트랜잭션 경계 설정: 이 방법은 트랜잭션을 수동으로 관리하여 독립적인 트랜잭션을 생성합니다. 그러나 테스트가 복잡해지고, 수동으로 설정한 트랜잭션은 테스트 메서드의 기본 롤백 메커니즘과 호환되지 않을 수 있습니다.
AopTestUtils.getTargetObject() 사용: @Async 메서드는 내부적으로 AOP로 동작합니다. AopTestUtils.getTargetObject()를 이용하면 AOP 프록시의 실제 객체를 얻어올 수 있으며, 이를 통해 같은 트랜잭션 컨텍스트 내에서 실행하도록 할 수 있습니다. 이를 통해 비동기 메서드가 현재 트랜잭션에 참여하게 하여, 트랜잭션 경계 문제를 해결할 수 있습니다.
테스트 코드의 간결성과 유지보수성을 위해서는 가능한 한 단순한 방법을 사용하는 것이 좋을 것으로 생각합니다. 따라서 위에서 언급한 두 번째 방법, 즉 AopTestUtils.getTargetObject()를 사용하여 실제 객체를 얻어오는 방법이 테스트 환경에서 비동기 메서드의 트랜잭션 경계를 해결하는 데 더 유용할 것으로 생각합니다.
이를 통해 트랜잭션 경계를 분리하여 발생하는 복잡성을 줄이고, 테스트 메서드의 기본 롤백 메커니즘도 그대로 유지할 수 있었습니다.
Spring 애플리케이션에서 @Async 메서드의 정상 동작을 검증할 때는 AopTestUtils.getTargetObject()를 활용하여 실제 객체를 얻어오는 방법을 이용하면 좋을 것 같습니다.
반대로, @Async 메서드의 정상 동작을 검증하는 테스트가 아니라면 AopTestUtils.getTargetObject()를 통해 실제 객체를 얻기보다 테스트 환경을 실제 운영환경과 비슷하게 가져가는 것이 적절하다고 생각합니다.