기술 스택
- JDK 17
- Gradle
- Spring boot 3.3.5
- Spring Data Jpa
- H2 Database
먼저 트랜잭션에 대해 기본적인 설명을 보고싶다면 여기 를 들어가면 된다.
스프링 트랜잭션 소개
스프링 트랜잭션 추상화
각각의 데이터 접근 기술들은 트랜잭션을 처리하는 방식에 차이가 있다. 예를들어 JDBC 기술과 JPA 기술은 트랜잭션을 사용하는 코드 자체가 다르다.
따라서 JDBC 기술을 사용하다가 JPA 기술로 변경하게 되면 트랜잭션을 사용하는 코드도 모두 함께 변경해야 한다.
스프링은 이런 문제를 해결하기 위해 트랜잭션 추상화를 제공한다.
바로 PlatformTransactionManager라는 인터페이스를 통해 트랜잭션을 추상화 한다.
PlatformTransactionManager
package org.springframework.transaction; import org.springframework.lang.Nullable; public interface PlatformTransactionManager extends TransactionManager { TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }
스프링 트랜잭션 사용 방식
PlatformTransactionManager를 사용하는 방법은 크게 두가지가 있다.
- @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다. 선언적 트랜잭션 관리
- 프로그래밍 방식의 트랜잭션 관리: 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
보통 선언적 트랜잭션 관리를 사용한다.
트랜잭션 적용 확인
@Transactional을 통해 선언적 트랜잭션 방식을 사용하면 단순히 애노테이션 하나로 트랜잭션을 적용할 수 있다. 그런데 이 기능은 트랜잭션 관련 코드가 눈에 보이지 않고 AOP 기반으로 동작하기 때문에 실제 트랜잭션이 적용되고 있는지 아닌지를 확인하기가 어렵다.
실제 트랜잭션이 적용되고 있는지 확인하는 방법을 알아보자.
@Slf4j @SpringBootTest public class TxBasicTest { @Autowired BasicService basicService; @Test void txTest(){ basicService.tx(); basicService.nonTx(); } @TestConfiguration static class TxApplyBasicConfig{ @Bean BasicService basicService(){ return new BasicService(); } } @Slf4j 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); } } }
@Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 그럼 실제 basicService 객체 대신 프록시인 basicService$$CGLIB를 스프링 빈에 등록한다. 그리고 프록시는 내부에 실제 basicService를 참조하게 된다.
핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다.
참고로 TransactionSynchronizationManager.isActualTransactionActive() 는 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. (true면 트랜잭션 적용)
이제 실행하기전 application.properties에 로그를 추가해보자.
logging.level.org.springframework.transaction.interceptor=TRACE
실행결과
로그를 통해 tx( ) 호출 시 tx active = true 를 총해 트랜잭션이 적용된 것이 확인할 수 있다.
nonTx( ) 호출시에는 tx active = false를 통해 트랜잭션이 없는 것을 확인할 수 있다.
트랜잭션 적용 위치
스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 그래서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.
@SpringBootTest public class TxLevelTest { @Autowired LevelService service; @Test void orderTest(){ service.write(); service.read(); } @TestConfiguration static class TxLevelTestConfig{ @Bean LevelService levelService(){ return new LevelService(); } } @Slf4j @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 readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); log.info("read only = {}", readOnly); } } }
스프링의 @Transactional은 다음 두 가지 규칙이 있다.
- 우선순위 규칙
- 클래스에 적용하면 메서드는 자동 적용
트랜잭션을 사용할 때는 다양한 옵션을 사용할 수 있다. 클래스보다는 메서드가 더 구체적이므로 메서드에 있는 (write메서드 위에 있는 ) @Transactional(readOnly = false)이 먼저 적용이 된다.
트랜잭션 AOP 주의 사항 - 프록시 내부 호출
@Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용된다. 트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다. 앞서 말한 것 처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고, 실제 객체를 호출해준다. 따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체를 호출해야 한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고 트랜잭션도 적용되지 않는다.
코드를 통해 알아보자.
@Slf4j @SpringBootTest public class InternalCallV1Test { @Autowired CallService callService; @TestConfiguration static class InternalCallV1TestConfig{ @Bean CallService callService(){ return new CallService(); } } @Test void internalCall(){ callService.internal(); } @Test void externalCall(){ callService.external(); } @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); boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); log.info("read only = {}", readOnly); } } }
코드를 요약하자면 external( )은 트랜잭션이 없고, internal( )은 @Transactional을 통해 트랜잭션이 적용된다.
@Transactional이 하나라도 있으면 트랜잭션 프록시 객체가 만들어진다. 그리고 callService 빈을 주입 받으면 트랜잭션 프록시 객체가 대신 주입된다.
external( )은 @Transactional이 없다. 따라서 트랜잭션 없이 시작한다. 그런데 내부에서 @Transactional가 있는 internal( )을 호출하는 것을 확인할 수 있다. 이 경우 external( )은 트랜잭션이 없지만 internal( )에서는 트랜잭션이 적용되는 것 처럼 보인다.
한번 실행 해보자.
실행 로그를 보면 트랜잭션 관련 코드가 전혀 보이지 않는다. 프록시가 아닌 실제 callService에서 남긴 로그만 확인된다. 추가로 internal( ) 내부에서 호출한 tx active = false 로그를 통해 트랜잭션이 수행되지 않는 것을 확인할 수 있다.
왜 이런 문제가 발생하는 것일까?
테스트 코드는 callService.external( )을 호출한다. 여기서 callService는 트랜잭션 프록시이다.
external( ) 메서드에는 @Transactional이 없다. 따라서 트랜잭션 프록시는 트랜잭션을 적용하지 않는다. 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external( )을 호출한다.
그리고 external( )은 내부에서 internal( )을 호출한다. 메서드 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal( )이 되는데, 여기서 this는 자기 자신을 가리키므로 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다.
그렇다면 이 문제를 어떻게 해결할까? 그냥 내부 호출을 하지 않고 별도의 클래스로 분리하면 된다.
'Spring Transaction' 카테고리의 다른 글
스프링 트랜잭션 전파 - 2 (0) | 2024.11.14 |
---|---|
스프링 트랜잭션 전파 - 1 (1) | 2024.11.14 |