@Transactional을 통해 선언적 트랜잭션 방식을 사용하면 단순히 애노테이션 하나로 트랜잭션을 적용할 수 있다. 그런데 이 기능은 트랜잭션 관련 코드가 눈에 보이지 않고, AOP를 기반으로 동작하기 때문에, 실제 트랜잭션이 적용되고 있는지 아닌지를 확인하기가 어렵다.
스프링 트랜잭션이 실제 적용되고 있는지 확인하는 방법을 알아보자.
@Transactional 테스트
@Slf4j
@SpringBootTest
public class TxBasicTest {
@Autowired BasicService basicService;
@Test
void proxyCheck(){
log.info("aop class={}",basicService.getClass());
assertThat(AopUtils.isAopProxy(basicService)).isTrue();
}
@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);
}
}
}
1.proxyCheck()를 실행해보면 aop class=class hello.springtx.apply.TxBasicTest$BasicService$$SpringCGLIB$$0으로 프록시가 적용된것을 확인할수 있다.
2.스프링은 클래스 메서드에 @Transactional이 하나라도 존재한다면 프록시는 무조건 생성된다. 실제 객체를 상속받아 만든 프록시 객체를 스프링 빈으로 등록하고 테스트 필드에 DI한다.(부모 타입이기 때문에 담을수 있다.)
3.테스트에서 프록시 객체의 메서드를 실행할때 실제 객체를 참조하여 @Transactional이 적용이 안되었다면 Target(실제 클래스 메서드) 로직에 위임한다.
@Transactional은 클래스 or 메서드같이 어디에서 적용가능하다. 스프링에서 우선순위는 항상 더 구체적이고 자세한것이 높은 우선순위를 가진다. 예를들어 메서드와 클래스에 @Transactional 있다면 메서드가 더 높은 우선 순위를 가진다.
@SpringBootTest
public class TxLevelTest {
@Autowired LevelService service;
@Test
void orderTest(){
service.read();
service.write();
}
@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 txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}",txActive);
boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
log.info("tx readOnly={}",readOnly);
}
}
}
readOnly 설정을 true로 하면 읽기 전용 트랜잭션으로 write가 불가능하다. orderTest를 실행해보면 LevelService 클래스에 readOnly를 true로 전역 설정했지만 구체적인 write() 메서드에 설정한 @Transactional(readOnly = false) 덕분에 로그에서 false로 나온다.
@Transational을 적용하면 스프링의 트랜잭션 AOP가 적용된다. 앞서 배운것 처럼 해당 클래스의 생성은 프록시 객체로 주입받고 메서드 호출도 프록시 객체를 통해 실체 객체 참조로 이루어진다. 하지만 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생하기도 한다.
Test
@Slf4j
@SpringBootTest
public class InternalCallV1Test {
@Autowired CallService callService;
@Test
void printProxy(){
log.info("callservice class={}",callService.getClass());
}
@Test
void internalCall(){
callService.internal();
}
@Test
void externalCall(){
callService.external();
}
@TestConfiguration
static class InternalCallV1TestConfig{
@Bean
CallService callService(){
return new CallService();
}
}
@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("tx readOnly={}",readOnly);
}
}
}
externalCall()를 실행해보면 CallService 클래스의 external 메서드는 트랜잭션이 적용이 안됬기 때문에 active는 당연 false로 뜬다.하지만 트랜잭션이 적용된 internal()을 다시 호출해보면 전부 false로 트랜잭션 적용이 안되었다.
문제의 원인은 트랜잭션이 적용안된 external 메서드를 호출하면서 실제 객체가 참조될때 메서드앞에 생략된 this로 인해 AOP,트랜잭션 적용이 안된 실제 참조 객체의 internal()를 호출하기 때문에 프록시의 internal을 거치지 않아 트랜잭션 적용이 안된다.
해당 문제를 프록시 방식의 AOP 한계라 한다. 메서드 내부의 또다른 메서드에 프록시(트랜잭션)를 적용할수 없다. 단순한 해결 방법은 internal() 메서드를 별도의 클래스로 분리 하는것이다.
static class InternalService{
@Transactional
public void internal(){
log.info("call internal");
printTxInfo();
}
private void printTxInfo(){
boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}",txActive);
}
}
InternalService 클래스를 새로 생성하여 Callservice에서 @Transactional이 존재하는 internal() 메서드를 구현했다. 이제 Callservice의 AOP적용이 안된 함수를 호출하더라도 외부 @Transactional로 인해 AOP가 적용된 외부 클래스의 함수를 호출하기 때문에 트랜잭션이 정상 동작한다.
스프링은 public 메서드에만 트랜잭션을 적용한다.
@Slf4j
static class Hello{
@PostConstruct
@Transactional
public void initV1(){
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init @PostConstruct tx active={}",isActive);
}
}
해당 클래스가 생성되고 초기화 시점에 트랜잭션은 적용되지 않는다. 이유는 초기화 코드가 호출될때 원본 객체에 대한 메서드 호출이기 때문에 트랜잭션 AOP가 적용되지 않는다. 해당 문제는 @EventListener(ApplicationReadyEvent.class)를 통해 해결 가능하다.
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2(){
boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
log.info("Hello init ApplicationReadyEvent tx active={}",isActive);
}
초기화 애노테이션과 동일한 기능으로 스프링 컨테이너가 로딩될때 ApplicationReadyEvent가 있다면 해당 메서드를 호출해준다.
트랜잭션을 사용하려면 빈에 등록된 트랜잭션 매니저를 사용한다. @Transactional 애노테이션에 트랜잭션 매니저를 생략하면 기본으로 등록된 매니저를 사용하고 둘 이상의 트랜잭션 매니저를 사용하면 값을 지정해줘야 한다.
public class TxService {
@Transactional("memberTxManager")
public void member() {...}
@Transactional("orderTxManager")
public void order() {...}
}
예외 발생시 체크 예외(컴파일 타임)와 언체크 예외의 커밋,롤백 전략이 다르다.
RuntimeException,Error와 그 하위 예외가 발생하면 롤백한다.Exception과 그 하위 예외는 커밋한다.@Transactional(rollbackFor = Exception.class) 해당 옵션을 사용하면 특정 예외가 발생할때 롤백할수 있다. noRollbackFor는 그 반대이다.
(1) READ UNCOMMITTED:다른 Thread에서 커밋되지 않은 데이터를 읽을수 있음,동시성이 높고 일관성이 보장되지 않음 (-> 읽기 성능이 중요한 경우)
(2) READ COMMITTED:다른 트랜잭션에서 커밋된 데이터만 읽을 수 있음(대부분 DB의 Default)
(3) REPEATABLE READ:동일 트랜잭션 내에서 동일한 데이터를 여러 번 읽어도 항상 동일한 결과를 보장,데이터를 읽는 동안 다른 트랜잭션이 데이터를 수정하거나 삭제하지 못함 (Phantom Read: 트랜잭션 중간에 다른 트랜잭션이 새로운 데이터를 INSERT하면, 동일 쿼리에서 새로운 데이터가 나타날 수 있음.)
(4) SERIALIZABLE:트랜잭션을 순차적으로 실행하는 것처럼 보이게 하여, 완벽한 데이터 일관성을 보장.
Read-Only 트랜잭션은 데이터베이스와 애플리케이션 레벨에서 읽기 전용 작업을 수행하는 트랜잭션으로 설정하여 성능을 최적화하고 불필요한 리소스 소비를 줄이는 데 목적이 있다. 특히 JPA에서 성능 최적화가 이루어지는데 JPA에서는 @Transactional(readOnly = true)를 설정하면 Hibernate 내부적으로 다음과 같은 최적화가 이루어진다.
1.Flush 방지
읽기 전용 트랜잭션에서는 flush 호출을 생략하여 변경 감지(Dirty Checking) 및 데이터 동기화 작업을 차단하여 성능을 향상시킨다.
2.스냅샷 객체 생성 방지
엔티티 변경 감지를 위해 스냅샷 객체를 생성하지만, 읽기 전용 트랜잭션에서는 이를 생략한다. 결과적으로 메모리 사용량이 감소하고 불필요한 네트워크 리소스가 줄어든다.
@Transactional이 적용된 AOP에서 예외가 발생하여 throws를 하면 어떻게 될까?
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
RuntimeException,Error와 그 하위 예외가 발생하면 롤백한다.Exception과 그 하위 예외가 발생하면 커밋한다.실제 동작을 코드로 확인하자.
정확한 commit,Rollback 확인을 위해 다음과 같은 설정을 추가한다.
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
Test
@SpringBootTest
public class RollbackTest {
@Autowired RollbackService service;
@Test
void runtimeException(){
Assertions.assertThatThrownBy(()->service.runtimeException())
.isInstanceOf(RuntimeException.class);
}
@Test
void checkedException(){
Assertions.assertThatThrownBy(()->service.checkedException())
.isInstanceOf(MyException.class);
}
@Test
void rollbackForException(){
Assertions.assertThatThrownBy(()->service.rollbackFor())
.isInstanceOf(MyException.class);
}
@TestConfiguration
static class RollbackTestConfig{
@Bean
RollbackService rollbackService(){
return new RollbackService();
}
}
@Slf4j
static class RollbackService{
//런타임 예외 발생:롤백
@Transactional
public void runtimeException(){
log.info("call runtimeException");
throw new RuntimeException();
}
//체크 예외 발생: 커밋
@Transactional
public void checkedException() throws MyException {
log.info("call checkedExcpetion");
throw new MyException();
}
//체크 예외 rollbackFor 지정: 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException {
log.info("call checkedExcpetion");
throw new MyException();
}
}
static class MyException extends Exception{
}
}
1.service의 RuntimeException() 실행시
2.service의 checkedException() 실행시
3.service의 @Transactional(rollbackFor)가 적용된 checkedException() 실행시
스프링에서 체크 예외는 커밋하고 런타임 예외를 롤백하는 이유는 기본적으로 스프링에서 체크 예외는 비지니스 의미가 있을때 사용하고 언체크 예외는 복구 불가능한 예외로 가정하기 때문이다.
RollbackFor를 통해 해당 정책을 따르지 않아도 되지만 다음과 같은 대표적인 상황이 있다.
비지니스 요구사항
1.정상: 주문시 결제에 성공하면 주문 데이터를 저장하고 결제 상태를 완료로 처리한다.
2.시스템 예외: DB 접근 불가,네트워크 통신 에러와 같이 시스템에 문제가 있어 복구 불가능한 예외는 롤백한다.
3.비지니스 예외: 주문시 고객의 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 대기로 commit 한다. -> 고객에게 잔고 부족 메세지를 보내고 별도의 계좌로 입금 요청을 한다. 해당 상황은 시스템에 문제가 있는것이 아니다. 비지니스 상황이 예외인 것이다.
Test
@Slf4j
@SpringBootTest
class OrderServiceTest {
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void complete() throws NotEnoughMoneyException {
//given
Order order = new Order();
order.setUsername("정상");
//when
orderService.order(order);
//then
Order findOrder = orderRepository.findById(order.getId()).get();
Assertions.assertThat(findOrder.getPayStatus()).isEqualTo("완료");
}
@Test
void runtimeException() throws NotEnoughMoneyException {
//given
Order order = new Order();
order.setUsername("예외");
//when
Assertions.assertThatThrownBy(()->orderService.order(order))
.isInstanceOf(RuntimeException.class);
//then
Optional<Order> orderOptional = orderRepository.findById(order.getId());
Assertions.assertThat(orderOptional.isEmpty()).isTrue();
}
@Test
void bizException(){
//given
Order order = new Order();
order.setUsername("잔고부족");
//when
try {
orderService.order(order);
} catch (NotEnoughMoneyException e) {
log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
}
//then
Order findOrder = orderRepository.findById(order.getId()).get();
Assertions.assertThat(findOrder.getPayStatus()).isEqualTo("대기");
}
}
NotEnoughMoneyException은 시스템에 문제가 발생한 것이 아니라, 비즈니스 로직 상의 문제 상황을 예외를 통해 알리는 역할을 한다.
마치 예외가 리턴 값처럼 사용되는 상황으로 이해할 수 있다.따라서 이 경우에는 트랜잭션을 커밋하는 것이 적합하다.
그렇지만, 비즈니스 상황에 따라 체크 예외의 경우에도 트랜잭션을 롤백하고 싶을 수 있는데 이럴 때 Spring의 rollbackFor 옵션을 사용하면 된다.