Spring을 이용하여 코드를 작성하면서 @Transactional을 이용해본적 없는 개발자는 없을 것입니다.
@Transactional을 선언하면 어플리케이션에서 어노테이션 하나로 DB에 트랜잭션을 적용할 수 있으며
해당 어노테이션을 통해 트랜잭션 전파유형, 롤백규칙, 격리 수준 등을 커스텀하게 설정할 수 있는 아주 흔히 쓰이며 유용성 높은 어노테이션입니다.
그런데 코드를 작성하다 문득, @Transactional을 사용을 자주하지만 내부적으로 동작원리를 잘 모르고 있는 것 같았습니다. 그 계기는 하나의 클래스에서 @Transactional이 선언되어있는 메서드가 동일한 클래스의 @Transacitional이 있는 내부 메서드를 호출하여 코드를 작성한 과거의 저를 발견했기 때문입니다.
이 상황은 무엇이 잘못되었을까요?
이번 기회에 Spring에서의 Transactiona의 동작원리 및 @Transactional의 동작방식에 대해서 정리해보고자 합니다.
@Transactional 이해에 앞서 Spring 자체에서 트랜잭션을 어떠한 관점에서 보고 어떻게 다루는지를 이해할 필요가 있습니다.
스프링은 다음의 3가지 방식으로 트랜잭션을 다룹니다.
TransactionSynchronizationManager를 통해 트랜잭션 동기화를 실행합니다.
TransactionSynchronizationManager는 내부에 트랜잭션 관련된 정보를 ThreadLocal을 이용하여 저장하여 동일한 스레드 내에서 같은 정보를 공유할 수 있게끔 구성합니다.
작업 쓰레드마다 각 ThreadLocal에 트랜잭션 관련 정보를 저장하기 때문에 멀티 쓰레드 환경에서 충돌이 발생할 여지가 없습니다.
하단 그림은 TransactionSynchronizationManager의 코드

또한 PlatformTransactionManager의 각 구현체에서 TransactionSynchronizationManager를 아래와 같은 방식으로 이용하여 하나의 쓰레드 내에서 유지할 수 있습니다.
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { DataSourceTransactionObjecttxObject = (DataSourceTransactionObject) transaction;
Connection con =DataSourceUtils.getConnection(this.dataSource); txObject.setConnectionHolder(new ConnectionHolder(con));
// ThreadLocal에 트랜잭션 동기화 TransactionSynchronizationManager.bindResource(this.dataSource,txObject.getConnectionHolder());
}

public void addUser(User user) {
TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
userRepository.save(user) // 비즈니스 로직
this.transactionManager.commit(status);
} catch (Exception e) {
this.transactionManager.rollback(status);
throw e
}
}
위와 같은 코드에서 비즈니스로직과 Transaction 관련 코드가 뒤섞여있는데, 이를 @Transactional이라는 선언형 어노테이션을 통해 비즈니스 로직에 집중할 수 있습니다.
@Transactional은 Spring의 AOP를 사용해서 프록시 객체를 생성하는데, 해당 프록시 객체가 트랜잭션과 관련된 로직을 담당하게 함으로써 역할과 책임을 명확히 분리할 수 있습니다.
@Transacional 어노테이션은 그렇다면 어떻게 동작할까요?
스프링 AOP 개념의 이해가 본 글의 목적이 아닌 관계로 간단히만 설명하고 넘어가겠습니다.
전술하였듯, 해당 어노테이션은 스프링의 AOP 기술을 이용한 프록시 객체를 사용하는데요, 간략하게 알아보자면 과정은 다음과 같습니다.
@Transactional
public void addUser(User user) {
userRepository.save(user) // 비즈니스 로직
}
동일한 기능을 하는 로직이지만, @Transactional 어노테이션 추가로 인해 비즈니스로직에만 집중할 수 있습니다.
Spring에서 @Transactional은 UnCheckedException에 대해서 예외가 발생하면 자동적으로 RollBack이 되게끔 구성되어있습니다.
한 예로 Spring의 data접근과 관련되어 발생하게 되는 예외(예를 들어 SqlException)는 내부적으로 UnCheckedException 계열(DataAccessException)으로 전환해서 다시 예외를 내던지는 방식으로 작성이 되어있고 이는 Spring의 기본적 예외처리 관련된 철학의 일환이라고 볼 수 있습니다.
이는 UnCheckedException의 경우에 프로그래밍적 오류나 논리적으로 복구 불가능한 오류 혹은 개발자가 처리할 수 없는 것 등에 해당되는 경우가 많기에 롤백되게끔 스프링에서 설계를 해뒀기 때문입니다.
물론 해당 옵션은 기본값이지만, @Transactional의 속성 값으로 ‘rollBackFor’라는 값이나 ‘noRollbackFor’에 특정 예외를 설정해줌으로써 해당 예외에만 롤백을 적용하거나 적용하지 않는 방식으로 커스텀하게 작성할 수도 있습니다.
트랜잭션 전파는 트랜잭션의 경계에서 이미 진행중인 트랜잭션이 있거나 없을때 어떻게 동작할 것인지를 결정하는 것을 의미한다. 이미 기 실행중이던 트랜잭션 A가 존재하고 B라는 새로운 작업의 시작으로 B작업에 대한 트랜잭션을 어떻게 다룰까와 같은 것이 트랜잭션 전파에서 다루는 내용입니다.
해당 개념을 다루기 전에 물리트랜잭션과 논리트랜잭션에 대한 간단한 개념에의 이해가 요구됩니다.
트랜잭션 전파에서 물리적 트랜잭션과 논리적 트랜잭션의 개념이 상이한데 각각의 내용은 다음과 같습니다.
@Transactional 선언시, 트랜잭션 전파에 줄 수 있는 옵션은 다음과 같습니다.
REQUIRED
REQUIRED_NEW
SUPPORTS
NOT_SUPPORTED
NEVER
NESTED
MANDATORY
예를 들자면 다음과 같습니다.
@Service
@RequiredArgsConstructor
public class UserServiceA{
private final UserServiceB userServiceB;
@Transactional
public void methodA(){
userServiceB.methodB();
}
}
---
@Service
public class UserServiceB{
@Transactional(propagation = Propagtion.REQUIRED_NEW)
public void methodB(){
//.. someLogic
}
}
위와 같은 예제에서 methodA가 methodB를 호출하지만 트랜잭션 전파 옵션으로 인해 서로 다른 물리적 트랜잭션에 속하고 논리적 트랜잭션 범위 역시 각각 다르게 됩니다.
처음의 배경부분에서 설명했던 부분으로 돌아가보겠습니다.
아래의 코드에서 methodA와 methodB의 (논리적으로) 트랜잭션이 각각 수행될까요?
@Service
public class ExampleService {
@Transactional
public void methodA() {
System.out.println("methodA: 트랜잭션 시작");
methodB(); // 내부 호출
}
@Transactional
public void methodB() {
System.out.println("methodB: 별도의 트랜잭션 시작");
}
}
정답은 ‘아니오’입니다.
이는 프록시 객체의 동작방식에 기인합니다.
스프링은 AOP를 통해 프록시 객체를 생성합니다.
스프링은 @Transactional이 선언된 Bean의 경우 해당 Bean의 프록시 객체를 별도로 생성하여, POJO로 생성된 Bean을 프록시 객체가 감싸게 됩니다.
@Transactional이 선언되어있는 메서드가 호출될 경우, Advice를 실행하게 되는데 이 동작에서
프록시 객체는 ‘외부 호출’만 가로채게 됩니다. 즉, 동일 클래스의 내부 호출 등은 프록시 객체의 동작대상에 포함되지 않습니다.
따라서 methodB()의 경우 프록시 객체를 거치지 않고 실제 객체의 메서드가 호출되게 됩니다.
즉 본 코드의 의도는 methodA와 methodB의 논리 트랜잭션을 각각 설정해주고자 함이었을 겁니다.
그렇다면 본 의도에 맞게 , 문제를 해결하려면 어떻게 해야할까요?
@Service
public class MethodAService {
private final MethodBService methodBService;
public MethodAService(MethodBService methodBService) {
this.methodBService = methodBService;
}
@Transactional
public void methodA() {
System.out.println("methodA: 트랜잭션 시작");
methodBService.methodB(); // 프록시를 통해 호출
}
}
@Service
public class MethodBService {
@Transactional
public void methodB() {
System.out.println("methodB: 별도의 트랜잭션 시작");
}
}
위와 같이 변경하면, methodA와 methodB가 각각 프록시 객체를 통해 개별적인 논리적 트랜잭션이 적용될 수 있습니다.