Spring

[Spring] @Transactional의 다양한 기능과 주의사항

이덩우 2024. 2. 27. 22:42

1. 트랜잭션 적용 여부 확인

`@Transactional`은 AOP를 통해 트랜잭션을 처리하도록 도와준다. 따라서 순수한 비지니스 로직만 남길 수 있다는 장점을 가져왔다.

하지만 반대로, 트랜잭션 관련 코드가 없기 때문에 실제로 트랜잭션이 잘 적용되고 있는건지 확인하기 어렵다.

간단하게 트랜잭션 적용 여부를 확인하는 방법에 대해 알아보자.

 

static class BasicService {
    @Transactional
    public void tx() {
        log.info("call tx");
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active={}", txActive);
    }

    public void nonTx() {
        log.info("call nonTx");
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active={}", txActive);
    }
}

테스트 코드의 일부를 발췌했다.

예시로 만든 Service 코드는 메소드 레벨에 `@Transactional`애노테이션을 붙여 트랜잭션을 AOP로 처리하고 있다.

이 때 트랜잭션 적용 여부를 확인하기 위해 *트랜잭션 동기화 매니저*를 활용할 수 있다.

`TransactionSynchronizationManager.isActualTransactionActive()`를 사용해 boolean 타입으로 적용 여부를 확인 가능하다.

 

 

* 참고로 `@Transactional`애노테이션을 클래스 레벨에 붙이든, 메소드 레벨에 붙이든 해당 객체는 스프링 컨테이너에 프록시 객체로 등록된다. 메소드 레벨에 붙였다면, 해당 메소드를 호출할 때 프록시 객체가 트랜잭션을 시작하고 실제 객체를 호출하며 일반 메소드인 경우 별도의 트랜잭션 작업 없이 실제 객체의 메소드를 호출하게 된다.


2. 트랜잭션 적용 위치(우선순위)

@Transactional(readOnly = true)
static class LevelService {
    
    @Transactional(readOnly = false)
    public void write() {
        log.info("call write");
        printTxInfo();
    }
    
    public void read() {
        log.info("call read");
        printTxInfo();
    }
    
    private void printTxInfo() {
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("txActive={}", active);
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        log.info("txReadOnly={}", readOnly);
    }
}

위와 같이 만약 클래스 레벨과 메소드 레벨에 서로 다른 옵션을 가진 `@Transactional`애노테이션을 붙이면 무엇이 적용될까?

*스프링은 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.* 클래스 레벨에 붙인 애노테이션으로 내부 모든 public 메소드에 대해 트랜잭션 처리를 선언했다면, 메소드 레벨에 직접 붙인 애노테이션은 해당 메소드만을 위한 더 구체적이고 자세한 트랜잭션 처리를 선언한 것이다.

따라서 이 경우 애노테이션이 붙은 메소드는 메소드 레벨에 설정한 트랜잭션 설정을 따르게 되고, 이외의 public 메소드는 클래스 레벨에 선언한 트랜잭션 설정을 따르게 된다.

 

 

* 인터페이스에도 `@Transactional`애노테이션을 붙일 수 있다. 우선순위는 구체 클래스의 메소드 레벨 -> 클래스 레벨 -> 인터페이스 메소드 레벨 -> 인터페이스 레벨 을 가진다. 하지만 스프링 공식 메뉴얼에서도 권장하지 않는 방법이다. 

스프링 AOP를 적용할 때 여러가지 방식이 있는데, 특정 방식에서는 인터페이스에 애노테이션을 선언하면 AOP가 적용되지 않는 경우도 있기 때문이다.


3. 트랜잭션 AOP 주의사항

- 프록시 내부 호출

`@Transactional`을 사용할 때 정말 주의해야하는 상황이 있다. *일반 메소드에서 트랜잭션이 걸려있는 내부 메소드를 호출하는 상황이다.

아래 예제에서는 일반 메소드인 `external()`과 트랜잭션 애노테이션이 붙어있는 `internal()`메소드가 존재하는 상황이다. 일반 메소드인 `external()` 내부에서는 또 다시 `internal()`을 호출한다.

@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 active = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("txActive={}", active);
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        log.info("txReadOnly={}", readOnly);
    }
}

먄약 외부에서 `internal()` 메소드를 직접 호출한다면, 프록시 객체가 트랜잭션 작업을 진행하고 실제 객체를 통해 비지니스 로직을 호출하며 정상적으로 작동 될 것이다.

정상 작동

 

 

아이러니 한 점은, 일반 메소드인 `external()`를 통해 `internal()`메소드를 호출하면 *트랜잭션이 적용되지 않는다.*

이는 최초 `external()`을 호출할 때 프록시 객체에서 "트랜잭션 메소드가 아니네?"라고 판단하고 실제 객체를 호출하게 되는데, 이 때 내부에서 `internal()`을 호출할 때는 다시 프록시 객체를 통해 호출하는게 아닌 `this.internal()`을 호출하기 때문에 트랜잭션 적용이 안되는 것이다. *정말 중요한 내용이다!*

트랜잭션 적용 X

 

- 초기화 시점

스프링 빈이 초기화 되는 시점에 특정 행동을 취하기 위한 `@PostConstruct` 애노테이션과 `@Transactional`을 같이 사용하는 경우에 대해 생각해보자.

초기화 되는 시점에 무언가 트랜잭션 처리가 필요한 행동을 하고싶을 수 있다. 그럼 아래와 같이 코드를 구성할 수 있을 것이다.

static class Hello {

    @PostConstruct
    @Transactional
    public void initV1() {
        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("Hello init @PostConstruct tx is Active={}", isActive);
    }

}

 

하지만 실제로 스프링 빈이 생성되고 초기화가 되는 시점에 로그를 살펴보면 트랜잭션은 적용이 되지 않는다.

Tx is Active = false

간단한 이유이다. 스프링 컨테이너를 띄우면서 초기화 코드가 먼저 실행되고, 그 다음에 트랜잭션 AOP가 적용되기 때문이다.

단순히 순서가 꼬여 발생하는 문제이다. 그럼 해결 방법이 무엇일까?

 

역시 간단하게 순서를 맞추면 된다! 스프링 컨테이너가 완전히 띄워진 시점에 트랜잭션을 적용해 초기화하는 코드를 작성하면 된다.

이 경우 `@EventListener(ApplicationReadyEvent.class)`를 활용할 수 있다.

static class Hello {

    @EventListener(ApplicationReadyEvent.class)
    @Transactional
    public void initV2() {
        boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("Hello init @EventListener tx is Active={}", isActive);
    }
}

 


4. 트랜잭션 옵션

`@Transactional` 애노테이션에는 다양한 트랜잭션 관련 옵션을 설정할 수 있다.

  • `value` : 어떤 트랜잭션 매니저를 사용할지, 안적으면 기본으로 스프링 빈에 등록된 트랜잭션 매니저를 사용, 만약 여러 트랜잭션 매니저를 사용해야한다면 지정
  • `rollbackFor` : 예외 발생 시 스프링 트랜잭션의 기본 정책은 다음과 같다.
    • 언체크 예외 : 롤백
    • 체크 예외 : 커밋
    • 만약 체크 예외가 발생해도 커밋하고 싶다면 해당 예외 클래스를 지정해주면 된다.
    • ex) @Transactioanl(rollbackFor = Exception.class)
  • `noRollbackFor` : 위와 반대, 롤백하지 않을 예외 지정
  • `propagation` : 트랜잭션 전파 관련, 추후 자세한 포스팅 기술 예정
  • `isolation` : 기본값은 DB에서 설정한 격리수준을 따른다. 개발자가 직접 지정하는 경우는 드물다. DBA가 정해준 수준을 따르자.
  • `timeout` : 트랜잭션 수행 시간에 대해 타임아웃을 초 단위로 지정한다. 기본 값은 트랜잭션 시스템의 타임아웃을 사용
  • `label` : 태그 기능, 일반적으로 잘 사용하지 않는다.
  • `readOnly` : 기본 값은 읽기와 쓰기가 가능한 트랜잭션, true로 설정하면 읽기 기능만 가능하다. 드라이버나 DB에 따라 정상작동되지 않을 수 있다. readOnly를 활성화하면 다양한 성능 최적화가 발생할 수 있다. 아래 3가지 정도가 있다.
    • JPA : 읽기 전용 트랜잭션을 사용하면 JPA는 변경에 사용되는 플러시를 호출하지 않는다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않고 더티 체킹을 하지 않아 성능 최적화가 발생한다.
    • JDBC 드라이버 : 슬레이브 DB(읽기 전용)에서 커넥션을 획득해서 사용한다. (DB마다 다름)
    • 데이터베이스 : DB에 따라서 읽기만 하면 되므로 내부에서 성능 최적화가 발생한다. (DB마다 다름)

5. 예외 발생과 트랜잭션 커밋, 롤백

기본적으로 스프링의 예외 대처 컨셉을 이해해보자.

 

언체크 예외의 경우 복구 불가능한 시스템 예외로 판단한다. 네트워크 문제나 치명적인 시스템 문제가 발생하는 경우에 발생하는 예외를 뜻한다. *따라서 트랜잭션을 롤백하는게 기본 설정이다.*

 

체크 예외의 경우 비지니스 예외로, 비지니스적으로 의미가 있는 경우 사용한다. 예를 들어 주문하는 고객의 잔고 부족 시 체크 예외로 처리하고, 이 후 결제 상태를 대기 상태로 두며 추가적인 로직을 수행하도록 만들 수 있다. 

*이 경우는 시스템은 아주 정상적으로 에러 없이 작동한 것이다.* 다만, 비지니스 로직 상의 예외가 발생한 것이다.

*따라서 트랜잭션을 커밋하는게 기본 설정이다.*

 

하지만 위는 스프링의 기본 컨셉에 따른 관례일 뿐이다. 특정 상황에서는 체크 예외도 언체크 예외와 마찬가지로 모두 롤백시키고 싶을 수 있다. 이 경우 위에서 살펴봤던 `rollbackFor`옵션을 통해 롤백시킬 체크 예외를 지정해주면 된다.

 

 

 

출처 : 인프런, 김영한의 스프링 DB 2편 - 데이터 접근 핵심 원리