김영한 개발자님의 스프링 DB 2편 강의를 수강하고 중요한 내용을 정리했습니다.
@Transactional
은 코드가 눈에 보이지 않고, AOP를 기반으로 동작하기 때문에, 실제 트랜잭션의 적용여부 확인이 어렵다.
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired
BasicService basicService;
@Test
void proxyCheck() {
//BasicService$$EnhancerBySpringCGLIB...
log.info("aop class={}", basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@Slf4j
static class BasicService {
@Transactional
public void tx() {
log.info("call tx");
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
...
선언적 트랜잭션 방식에서 스프링 트랜잭션은 AOP를 기반으로 동작한다. 실제 객체 대신에 트랜잭션을 처리해주는 프록시 객체가 스프링 빈에 등록된다.
@Transactional
애노테이션이 특정 클래스나 메서드에 하나라도 있으면 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다.
프록시는 tx()
메서드가 트랜잭션을 사용할 수 있는지 확인해본다. tx()
는 트랜잭션 적용 대상이므로 트랜잭션을 시작한 다음에 실제 basicService.tx()
를 호출한다.
그리고 실제 basicService.tx()
의 호출이 끝나서 프록시로 제어가(리턴) 돌아오면 프록시는 트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.
현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능
스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 이것만 기억하면 스프링에서 발생하는 대부분의 우선순위를 쉽게 기억할 수 있다.
예를 들어서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.
- 클래스의 메서드 (우선순위가 가장 높다.)
- 클래스의 타입
- 인터페이스의 메서드
- 인터페이스의 타입 (우선순위가 가장 낮다.)
AOP를 적용하는 방식에 따라서 인터페이스에 애노테이션을 두면 AOP가 적용이 되지 않는 경우도 있기때문에 권장하지 않는다.
@Transactional
을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다.
앞서 배운 것 처럼 @Transactional
을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출해준다.
따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다. 이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고, 이후에 대상 객체를 호출하게 된다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다.
대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional
이 있어도 트랜잭션이 적용되지 않는다. 실무에서 반드시 한번은 만나서 고생하는 문제라고 한다.
@Slf4j
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
external()
은 @Transactional
애노테이션이 없다. 따라서 트랜잭션 없이 시작한다. 그런데 내부에서 @Transactional
이 있는 internal()
을 호출하는 것을 확인할 수 있다.
하지만, 프록시가 아닌 실제 callService
에서 남긴 로그만 확인된다. internal()
내부에서 호출한 tx active=false 로그를 통해 확실히 트랜잭션이 수행되지 않은 것을 확인할 수 있다.
자기 자신의 내부 메서드를 호출하는 this.internal()
을 호출하는데, 여기서 this
는 자기 자신을 가리키므로, 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다.
internal()
메서드를 별도의 클래스로 분리하면 해결된다.
@Slf4j
static class InternalService {
@Transactional
public void internal() {
...
InternalService
클래스를 만들고 internal()
메서드를 여기로 옮겼다. 이렇게 메서드 내부 호출을 외부 호출로 변경했다.
스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다.
@Transactional
public class Hello {
public method1();
method2():
protected method3();
private method4();
}
이렇게 클래스 레벨에 트랜잭션을 적용하면 모든 메서드에 트랜잭션이 걸릴 수 있다. 그러면 트랜잭션을 의도하지 않는 곳 까지 트랜잭션이 과도하게 적용된다. 트랜잭션은 주로 비즈니스 로직의 시작점에 걸기 때문에 대부분 외부에 열어준 곳을 시작점으로 사용한다.
@PostConstruct
@Transactional
public void initV1() {
log.info("Hello init @PostConstruct");
}
초기화 코드 (예: @PostConstruct
)와 @Transactional
을 함께 사용하면 트랜잭션이 적용되지 않는다. 왜냐하면 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용되기 때문이다.
가장 확실한 대안은 ApplicationReadyEvent
이벤트를 사용하는 것이다.
@EventListener(value = ApplicationReadyEvent.class)
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정할 수 있다.
@Transactional(rollbackFor = Exception.class)
체크 예외인 Exception 이 발생해도 커밋 대신 롤백된다.
스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?
스프링 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정한다.
주문을 하는데 상황에 따라 다음과 같이 조치한다.
이때 결제 잔고가 부족하면 NotEnoughMoneyException
이라는 체크 예외가 발생한다고 가정하자. 이 예외는 시스템은 정상 동작했지만, 비즈니스 상황에서 문제가 되기 때문에 발생한 예외이다.
잔고 부족은 결제 상태를 대기 상태로 두고, 체크 예외가 발생하지만, order
데이터는 커밋되기를 기대한다.
NotEnoughMoneyException
은 시스템에 문제가 발생한 것이 아니라, 비즈니스 문제 상황을 예외를 통해 알려준다. 마치 예외가 리턴 값 처럼 사용된다. 따라서 이 경우 롤백하면 생성한 Order 자체가 사라지기 때문에 커밋해야한다.