@Transactional을 사용하면서 마주쳤던 특이사항에 대해 정리한다.
1. @Async와 함께 사용하는 경우(ThreadLocal)
2. 같은 Class 내에서 사용하는 경우(AOP)
로 나뉜다.
@Async와 함께 사용하는 경우
@Transactional이 붙은 메서드에서 다른 메서드를 호출하는데 그 메서드를 비동기로 호출해야 하는 일이 생겼다. 호출하는 메서드에 @Async를 붙여서 비동기로 호출해주려는데 Exception이 나면 Rollback 되어야 하는 @Transactional annotation이 잘 동작하지 않는다. 왤까?
간단한 코드로 확인해보자.
// 예시를 위해 만든 간략한 코드라 실제로 돌아가지는 않음
class TestService {
private final TestRepository testRepository;
private final AsyncTestService asyncTestService;
@Transactional
func callWithoutAsync() {
log("Call Without Async Thread Id: " + Thread.currentThread().getId());
testRepository.save(new TestEntity());
asyncTestService.withoutAsync();
}
@Transactional
func callWithAsync() {
log("Call With Async Thread Id: " + Thread.currentThread().getId());
testRepository.save(new TestEntity());
asyncTestService.withAsync();
}
}
class AsyncTestService {
func withoutAsync() {
log("Without Async Thread Id: " + Thread.currentThread().getId());
throw Exception();
}
@Async
func withAsync() {
log("With Async Thread Id: " + Thread.currentThread().getId());
testRepository.save(new TestEntity());
throw Exception();
}
}
실행을 위한 테스트 코드
@Test
class AsyncTest {
@Autowired
private TestService testService;
@Autowired
private TestRepository testRepository;
@Test
func `Transaction 메서드에서 호출한 일반 메서드 예외 발생 시, 호출한 메서드는 롤백 된다`() {
try {
testService.callWithoutAsync();
} catch (Exception e) {
log("[testService] exception");
}
assertThat(testRepository.count()).isEqualTo(0);
}
@Test
func `Transaction 메서드에서 호출한 async 메서드 예외 발생 시, 호출한 메서드는 커밋 된다`() {
try {
testService.callWithAsync();
} catch (Exception e) {
log("[testService] async exception");
}
assertThat(testRepository.count()).isEqualTo(1);
}
}
우선 결론부터 말하자면 위 테스트 코드는 둘다 성공할 것이고 아래와 같이 출력될 것이다.
// @Transactional과 일반 메서드
Call Without Async Thread Id: 11
Without Async Thread Id: 11
// @Transactional과 @Async가 달린 메서드
Call With Async Thread Id: 11
With Async Thread Id: 13
두 테스트 코드가 성공하고, 위와 같이 출력된 이유는 두 Annotation의 원리를 알면 간단하다.
1. @Transactional은 트랜잭션에 대한 정보들을 ThreadLocal로 관리하고 있고
ThreadLocal은 JAVA 1.2버전부터 제공된 기능으로 Thread 단위로 로컬 변수를 할당하는 기능이다. 즉, 변수의 수명이 같은 Thread 내에서 유효하다.
2. @Async는 새로운 Thread를 생성해서 메서드를 비동기로 실행시킨다.
정리해서 말하면,
@Transactional이 붙은 메서드에서 @Async가 붙은 메서드를 호출하면 @Async annotation은 새로운 Thread를 생성하는데, @Transactional annotation은 Transaction을 ThreadLocal로 관리하기 때문에 새로운 Thread에서는 기존 Transaction이 유효하지 않다. 따라서 Transaction 메서드에서 호출한 async 메서드 예외 발생 시, 호출한 메서드는 커밋 된다.
같은 Class 내에서 사용하는 경우
@Transactional을 사용할 때 또다른 이슈가 있다. 하나의 클래스에서 @Transactional이 붙지 않은 메서드가 @Transactional이 붙은 메서드를 호출하는 경우 @Transactional이 동작하지 않는다는 것이다. 코드로 확인해보자.
@Service
class TransactionalTest {
func startFunction() {
saveFunction();
}
@Transactional
func saveFunction() {
...
}
}
위와 같은 코드에서 startFunction에서 saveFunction을 호출하면 @Transactional이 동작하지 않는다. 즉, 예외가 발생해도 롤백되지 않고 커밋된다. 이유는 @Transactional의 동작원리인 AOP(Aspect Oriented Programming)에 있다.
Spring에선 메서드나 클래스에 단순히 @Transactional annotation을 붙여주면 하나의 Transaction으로 동작하게 해준다. 이는 Spring의 특징인 AOP를 통해 구현이 되는데, Transaction으로 묶어서 실행하고자 하는 메서드 앞뒤로 Transaction 처리 코드를 자동으로 생성해줘 편리하고 깔끔하게 개발이 가능하도록 한다.
AOP는 "핵심기능 코드에 존재하는 공통된 부가기능 코드를 독립적으로 분리해주는 기술" 이라고 한다. @Transactional을 붙이게 되면 해당 메서드의 이전, 이후에 트랜잭션을 커밋하고 롤백하는 코드가 부가적으로 추가되게 된다.
이렇게 추가해주는 부가기능을 어드바이스(Advice)라고 하고 부가기능이 부여될 타깃을 선정하는 룰을 포인트컷(Point Cut)이라고 한다. 어드바이스와 포인트컷을 통틀어 어드바이저라고 하면서 어드바이저는 단순한 형태의 에스펙트(Aspect)라 부를 수 있다. 에스펙트란 핵심기능에 부가되는 특별한 모듈을 뜻하며, 이 에스펙트를 통해 애플리케이션을 설계하여 부가기능을 분리하며 개발하는 방법을 AOP라고 한다.
AOP는 이를 2가지 방식으로 제공하고 있다.
- JDK Dynamic Proxy
- CGlib Proxy
JDK Dynamic Proxy는 아래와 같이 동작하는 방식이다. 이 방식은 interface를 상속받아서 추상메서드를 구현한다. 구현한 메서드 내부에서는 타겟의 메서드를 호출하고 앞뒤로 필요한 로직을 붙여준다. 이는 Java의 리플렉션 패키지의 Proxy 클래스를 통해 동적으로 프록시 객체를 생성한다.
이 방식은 타겟이 interface를 implements하고 있지 않으면 프록시 객체를 생성할 수 없다. 그래서 두번째 방식인 CGlib Proxy는 이를 해결해 준다.
CGlib Proxy는 Java 리플랙션 대신 바이트 코드 생성 프레임워크를 사용해 런타임 시점에 프록시 객체를 만들어준다. 타겟이 인터페이스를 상송하고 있지 않으면 CGlib를 사용해서 인터페이스 대신 타겟을 상속하는 프록시 객체를 만든다.
즉, @Transactional을 붙임으로써 해당 클래스가 실행되기 전/후 등의 단계에서 자동으로 트랜잭션을 묶게 되고 내부에서 호출하는 메서드는 처음으로 호출하는 메서드나 클래스의 속성을 따라가게 된다. 그래서 동일한 클래스/빈 안에 상위 메서드가 @Transactional이 없으면 하위에는 선언이 되었다고 해도 전이되지 않는다. 예제로 애기하면, startFunction에서 객체로 감싸진 saveFunction이 아닌, 내부에서 바로 saveFunction을 호출하기 때문에 트랜잭션이 적용되지 않는다.
그럼 마지막으로 위 개념을 숙지했다고 하고, 아래 예시를 보자.
@Service
class Test {
@Transactional
func first() {
save()
this.second()
this.third()
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
func second() {
save()
}
@Transactional
func third() {
save()
throw Exception()
}
}
이렇게 서비스 안에 3개의 메서드가 있다. first메서드 내부에서 second, third 메서드를 실행하고 세 메서드 모두 @Transactional이 달려있다. 다만, second만 propagation = Propagation.REQUIRES_NEW 옵션이 추가되어있다. Third 메서드에서는 예외가 던져진다.
이때 외부에서 first 메서드를 호출한다면 어떻게 될까?
결론은 모두 롤백된다.
REQUIRES_NEW 옵션을 줬기 때문에 새로운 트랜잭션이 생성되었을 것이라고 생각할 수 있다. 하지만 @Transactional은 proxy로 감싸져 동작하기 때문에 외부에서 호출한 경우에만 동작한다. Self 호출(this.xxx)에서는 내부 메서드를 호출하는 것이기 때문에 proxy가 동작하지 않는다.
비슷한 원인의 오류로 private 메서드에는 @Transactional이 붙지 못한다던지.. 다양한 문제상황들이 발생한다. 이 경우 트랜잭션의 동작원리만 알면 이유를 금방 깨달을 수 있다.
이를 해결하기 위해서는 self injection과 같은 방법이 있지만, 가장 좋은 방법은 빈을 분리해주는 방법이라고 생각한다. Self injection은 Autowired를 사용해야 하고 직관적이지 않다고 느껴졌기 때문이다.
결론
1. @Async는 새로운 Thread를 생성하는데 @Transactional은 트랜잭션을 ThreadLocal로 관리하기 때문에 동작하지 않는다.
2. @Transactional은 AOP를 통해 제공된다. AOP는 proxy로 구현이 되어있어 앞뒤로 원하는 로직을 붙여주는데, 이 때문에 내부에서 @Transactional이 붙은 메서드를 호출하는 경우 트랜잭션이 동작하지 않을 수 있다.
Reference
https://newwisdom.tistory.com/m/127
https://javacan.tistory.com/entry/ThreadLocalUsage
https://cheese10yun.github.io/spring-transacion-same-bean/
https://hwannny.tistory.com/98
'TIL' 카테고리의 다른 글
SQL 성능 확인, Plan(실행계획) 보는 법 (0) | 2023.12.04 |
---|---|
Maven Lifecycle (+ Gradle, Ant) (0) | 2023.10.18 |
@Transactional 개념, 사용법 (1) | 2023.05.08 |
MapStruct (+ ModelMapper, Reflection) 사용법 (0) | 2022.09.05 |
CQRS(Command Query Responsibility Segregation) 패턴 (0) | 2022.08.12 |