이번 글에서는 스프링에서 트랜잭션이 둘 이상 있을 때 어떻게 동작하는지, 트랜잭션 전파(transaction propagation)에 대해 정리해보겠습니다.
이전 글에서 언급하지 않은 내용이 있는데,TransactionManager 라는 것입니다. 이전 글에서는 우리는 여러 데이터베이스와의 연결을 통합한 표준 인터페이스인 JDBC에 대해 살펴보았습니다. JDBC 덕분에 어느 데이터베이스를 사용하는지와 무관하게 같은 인터페이스로 데이터베이스 접근 로직을 작성할 수 있었죠. 이렇게 JDBC API를 직접 사용하는 것 이외에도 그 이상의 추상화를 제공하는 다양한 데이터베이스 접근 기술들이 있습니다. JDBC를 기반으로 순수 JDBC만 사용할 때의 여러 문제들을 해결해주는 솔루션입니다. MyBatis, JPA(Hibernate), QueryDSL 등이 그 예입니다. 이런 다양한 DB 접근 기술들은 모두 서로 다른 방식으로 트랜잭션을 다룹니다. JDBC가 등장하기 전과 똑같은 문제가 발생했죠. 데이터베이스 접근 기술을 변경하는 것 때문에, 서비스 계층을 수정해야 하는 경우가 발생합니다.
Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 트랜잭션 시작
// -----비즈니스 로직 들어갈 자리-----
conn.commit(); // 트랜잭션 성공
} catch (Exception e) {
if (conn != null) conn.rollback(); // 실패 시 롤백
e.printStackTrace();
} finally {
if (conn != null) conn.close(); // 커넥션 반환
}
@Service
public class TransferService {
@PersistenceContext
private EntityManager em;
@Autowired
private EntityManagerFactory emf;
public void transfer(Long fromId, Long toId, int amount) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin(); // 트랜잭션 시작
// -----비즈니스 로직 들어갈 자리-----
tx.commit(); // 커밋
} catch (Exception e) {
if (tx.isActive()) {
tx.rollback(); // 실패 시 롤백
}
throw e;
} finally {
em.close(); // EntityManager 정리
}
}
}
이런 문제를 해결하기 위해 스프링은 추상화된 인터페이스를 또 한 번 제공합니다.

PlatformTransactionManager는 TransactionManager를 상속받는 친구입니다. 각 데이터베이스 접근 방식은 이 인터페이스를 구현하여 일관된 트랜잭션 관리 방식을 제공합니다. 각 PlatformTransactionManager의 구현체는 내부적으로 TransactionSynchronizationManager(트랜잭션 동기화 매니저)에 트랜잭션 정보를 저장(바인딩)하여 각 요청을 처리하는 스레드에서 트랜잭션 정보를 꺼내쓸 수 있도록 합니다. (이 부분에 대해 궁금하신 분은 ThreadLocal에 대해 더 알아보시면 좋습니다)
이렇게 스프링이 제공하는 PlatformTransactionManager를 사용하면 데이터베이스 접근 방법에 따른 트랜잭션 관리 차이에 신경쓰지 않고 서비스 계층에서 트랜잭션 관리 기능을 사용할 수 있습니다. 스프링부트는 어떤 트랜잭션 매니저 구현체가 주입되어야 하는지도 자동으로 파악하여 등록해줍니다.
@Service
public class SomeService {
private final PlatformTransactionManager transactionManager;
public SomeService(PlatformTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
public void doSomething() {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(def);
try {
// ----- 비즈니스 로직 -----
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}
서비스 클래스, 즉, 클라이언트에서 트랜잭션 매니저를 통해서 트랜잭션을 생성하고, 커밋하고, 롤백할 수 있다는 사실은 확인을 했습니다. 이는 앞서 살펴본 @Transactional 어노테이션을 사용한 선언적 트랜잭션 관리 방식에서도 마찬가지입니다. (TransactionInterceptor가 내부적으로 트랜잭션 매니저를 통해 트랜잭션을 시작/커밋/롤백합니다)
실제로 트랜잭션 매니저에 의해 트랜잭션이 시작하고 커밋 혹은 롤백되는 것을 눈으로 한번 살펴보겠습니다. (아래 예제는 김영한님의 스프링 DB 2편 - 데이터 접근 활용 기술 강의에서 가져왔습니다)
@Slf4j
@SpringBootTest
public class BasicTxTest {
@Autowired
PlatformTransactionManager txManager;
@TestConfiguration
static class Config {
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@Test
void commit() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 커밋 시작");
txManager.commit(status);
log.info("트랜잭션 커밋 완료");
}
@Test
void rollback() {
log.info("트랜잭션 시작");
TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션 롤백 시작");
txManager.rollback(status);
log.info("트랜잭션 롤백 완료");
}
}


테스트 설정을 위해 주입한 대로 DataSourceTransactionManager에 의해서 트랜잭션이 관리가 되며, 기본 설정상 커넥션 풀(Hikari)을 사용하기 때문에 트랜잭션 시작 시 커넥션 풀에서 커넥션을 얻어와서 커밋 혹은 롤백을 하고 다시 반납을 합니다.
커넥션 풀(Hikari Pool)은 데이터베이스와의 커넥션을 미리 정해진 개수만큼 만들어두고 트랜잭션을 이용할 때 하나씩 꺼내서 사용하고 풀에 반납하도록 합니다. 이렇게 커넥션 풀에서 커넥션을 꺼낼 때, 물리 커넥션은 동일하지만 프록시 커넥션이라는 것을 매번 새로 만들어 주고, 반납 시 파괴합니다. 즉, 물리 커넥션 이름이 같더라도 프록시 커넥션 하나를 하나의 실제 커넥션으로 보셔야 합니다. 만약 커넥션 풀을 사용하지 않는 경우 실제 물리 커넥션을 매번 만들고 없애고 합니다.
트랜잭션 처리가 필요한 비즈니스 로직 메서드에 트랜잭션을 겁니다. 트랜잭션을 시작하고 비즈니스 로직을 처리하고(DB에 생성, 업데이트 등등) 트랜잭션을 끝내는 단순한 메서드는 하나의 커넥션을 이용하고 다시 반납(혹은 연결 해제)합니다. 이 과정은 연속적으로 일어나도 별 상관이 없습니다.
@Test
void double_commit() {
log.info("트랜잭션1 시작");
TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션1 커밋 시작");
txManager.commit(tx1);
log.info("트랜잭션2 시작");
TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("트랜잭션2 커밋 시작");
txManager.commit(tx2);
}

각 커넥션이 서로 다른 커넥션이니까 당연합니다.
문제는 다음과 같은 상황에 발생합니다.
현재 실행 중인 트랜잭션이 있는 상태에서 새로운 트랜잭션 메서드를 호출할 때.
쉽게 말해서 @Transactional이 붙은 메서드 안에서 또 @Transactional이 붙은 다른 메서드를 호출하면 어떻게 해야할까요? 둘 중 하나가 예외가 발생하면, 둘 다 롤백을 해야할까요? 아니면 성공한 녀석은 커밋하고 실패한 녀석만 롤백해야 할까요?
이처럼 동일 트랜잭션으로 합쳐져서(외부 트랜잭션을 그대로 이어받아서) 처리되도록 하는 것을 기존 트랜잭션에 '참여한다'라고 표현하며, 트랜잭션이 중첩되었을 때 기존 트랜잭션에 참여할지, 새로운 트랜잭션을 만들지 결정하는 설정을 트랜잭션 전파(Transaction Propagation)라고 합니다.
@Test
void inner_commit() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(
new DefaultTransactionAttribute()); // 외부 트랜잭션 시작
log.info("outer.isNewTransaction()={}",
outer.isNewTransaction()); // 이미 진행 중인 트랜잭션에 참여했는지 여부 확인하는 메서드
log.info("내부 트랜잭션 시작");
// 외부 트랜잭션이 끝나지 않았는데 트랜잭션이 시작
TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
log.info("inner.isNewTransaction()={}", inner.isNewTransaction());
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
}

트랜잭션 매니저에 의해서 시작한 외부(outer) 트랜잭션이 커밋되기 전에 내부(inner) 트랜잭션이 시작되었습니다. 외부 트랜잭션의 경우 신규 트랜잭션이기 때문에 isNewTransaction=true로 표시됩니다. 그러나 내부 트랜잭션은 isNewTransaction=false로 표시됩니다. 이렇게 된 경우를 보고 내부 트랜잭션이 외부 트랜잭션(기존 트랜잭션)에 참여했다고 합니다.
위 예제에서는 둘 다 성공적으로 커밋이 되었습니다. 외부 트랜잭션이 커밋되고 나서야 트랜잭션 매니저가 트랜잭션을 커밋하는 것을 볼 수 있습니다(파란 부분). 외부 트랜잭션과 내부 트랜잭션이라는 논리 트랜잭션이 하나의 물리 트랜잭션으로 묶인 것입니다.
log.info("내부 트랜잭션 커밋");
txManager.commit(inner);
log.info("외부 트랜잭션 커밋");
txManager.commit(outer);
우리가 알기로 트랜잭션에서 커밋 혹은 롤백을 하면 해당 트랜잭션은 끝납니다. 그런데 스프링은 어떻게 외부 트랜잭션과 내부 트랜잭션을 묶어서 커밋을 두 번 하는데, 실제로는 한 번만 일어나게 할까요?
스프링은 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부(논리) 트랜잭션이 실제 물리 트랜잭션을 관리하도록 합니다. PlatformTransactionManager는 getTransaction()이 호출될 때, 현재 진행 중인 트랜잭션이 있는지 여부와 그 전파 속성에 따라 새로운 트랜잭션을 생성할지 결정합니다. 트랜잭션 전파의 기본 속성은 REQUIRED이며 이는 새 트랜잭션이 기존의 트랜잭션에 참여하도록 하는 속성입니다. 기존 로그 사진에서 새롭게 표시한 초록색 부분에서 확인할 수 있습니다.

이후에 저 전파 속성은 더 자세히 설명하겠지만, 기본 설정은 REQUIRED이며, 이는 트랜잭션 전파를 어떻게 할지에 대한 하나의 선택 사항이며, 트랜잭션 매니저로 하여금 논리적 커밋이 여러 번 일어나도, 물리적 커밋 혹은 롤백은 한 번만 일어나게 판단하도록 한다는 부분을 이해하시면 됩니다.
만약 내부 트랜잭션에서 롤백이 일어나면 어떻게 되는지 살펴보겠습니다.
@Test
void innerRollback() {
log.info("외부 트랜잭션 시작");
TransactionStatus outer = txManager.getTransaction(new
DefaultTransactionAttribute());
log.info("내부 트랜잭션 시작");
TransactionStatus inner = txManager.getTransaction(new
DefaultTransactionAttribute());
log.info("내부 트랜잭션 롤백");
txManager.rollback(inner);
log.info("외부 트랜잭션 커밋");
assertThatThrownBy(() -> txManager.commit(outer))
.isInstanceOf(UnexpectedRollbackException.class);
}


내부 트랜잭션이 롤백을 하자 기존 트랜잭션을 rollback-only로 마킹했다는 로그가 찍힙니다. 외부 트랜잭션의 커밋 시도에서는 현재 트랜잭션이 rollback-only로 마킹되어 있기 때문에 롤백을 진행한다고 나옵니다. 내부 트랜잭션이 롤백되었기 때문에 현재 물리 트랜잭션이 롤백되는 것입니다.
지금까지 본 전파 옵션은 기본 설정인 REQUIRED입니다. 이 외에도 여러가지 전파 옵션이 있습니다.
@Transactional(propagation = Propagation.REQUIRED)
public void someMethod() {
// 트랜잭션 존재 시 참여, 없으면 새로 시작
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logHistory() {
// 별도의 트랜잭션으로 처리됨
}
@Transactional(propagation = Propagation.SUPPORTS)
public void readOnlyMethod() {
// 트랜잭션 유무에 따라 유동적으로 동작
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void noTransactionNeeded() {
// 트랜잭션 일시 정지 후 실행
}
IllegalTransactionStateException)가 발생합니다.@Transactional(propagation = Propagation.MANDATORY)
public void mustRunInTransaction() {
// 트랜잭션 없으면 예외 발생
}
@Transactional(propagation = Propagation.NEVER)
public void mustNotRunInTransaction() {
// 트랜잭션이 있으면 예외 발생
}
REQUIRED 처럼 동작합니다.@Transactional(propagation = Propagation.NESTED)
public void nestedOperation() {
// Savepoint를 기준으로 롤백이 가능함
}