Spring에서 데이터베이스 트랜잭션은 @Transactional로 관리됩니다. 이 애노테이션은 AOP 기반 프록시가 트랜잭션 경계를 만들어 주는 방식으로 동작하며, 애플리케이션은 JPA를 통해 DB에 접근합니다.
이렇게 여러 구성 요소가 겹쳐 DB 트랜잭션을 사용하다 보니 실제 동작을 정확히 이해하지 못한 채 무작정 @Transactional을 붙이거나 흐름을 파악하지 못해 시행착오를 겪는 경우가 많습니다.
그래서 이번 글에서는 로그를 확인하면서 프레임워크가 트랜잭션을 실제로 어떻게 시작·합류·커밋·롤백하는지 살펴보려고 합니다.
TL;DR
- 결론: REQUIRED → REQUIRED에서 내부 RuntimeException(unchecked exception)은 바깥까지 전파되어 전체 롤백된다.
가장 보편적으로 사용하는 전파 옵션(REQUIRED)의 로그를 살펴보겠습니다.
실험 환경
- Spring Boot 3.5, Hibernate 6.6, Java 21
- 로깅 레벨 설정
아래와 같이 로깅 레벨을 설정해 트랜잭션 인터셉터(AOP), 트랜잭션 매니저(JpaTransactionManager), 그리고 Hibernate의 SQL/파라미터 바인딩 로그를 확인합니다.
logging:
level:
# @Transactional AOP 인터셉터: 경계 진입/종료/예외 흐름
# 예: "Getting transaction for ...", "Completing transaction for ..."
org.springframework.transaction.interceptor: TRACE
# Hibernate가 실제로 실행한 SQL
# 예: "select ...", "update wallet set ..."
org.hibernate.SQL: DEBUG
# Hibernate 6 파라미터 바인딩 값
org.hibernate.orm.jdbc.bind: TRACE
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
# JPA 트랜잭션 매니저: BEGIN/COMMIT/ROLLBACK, rollback-only, 참여/중단/재개(suspend/resume)
# 예: "Creating new transaction ...", "Initiating transaction rollback"
org.springframework.orm.jpa.JpaTransactionManager: DEBUG
테스트 시나리오
실제 코드
- OuterService: @Transactional(REQUIRED)
- 내부에서 InnerService(required_addAndThrowRuntime) 호출
- 기존값 0에 +10 수행
- InnerService: @Transactional(REQUIRED)
- +100 후, RuntimeException 던짐 → 전체 롤백 기대0
public class OuterService {
@Transactional // REQUIRED
public void outer_calls_required_then_runtime(Long id) {
repo.findById(id).orElseThrow().plus(10);
inner.required_addAndThrowRuntime(id, 100); // 전체 롤백 기대
}
}
public class InnerService {
@Transactional(propagation = Propagation.REQUIRED)
public void required_addAndThrowRuntime(Long id, long amt) {
repo.findById(id).orElseThrow().plus(amt);
em.flush(); // DB까지 즉시 반영 시도
throw new RuntimeException("inner required boom");
}
}
테스트 코드
@Test
@DisplayName("REQUIRED→REQUIRED에서 RuntimeException 전파시 전체 롤백")
void required_required_runtime_rolls_back_all() {
// Arrange
Long id = wallet.getId();
assertThat(wallet.getBalance()).isEqualTo(0);
// Act
assertThrows(RuntimeException.class, () -> outerService.outer_calls_required_then_runtime(id));
// Assert
Wallet w = walletRepository.findById(id).orElseThrow();
assertThat(w.getBalance()).isEqualTo(0); // +10, +100 모두 롤백
}
핵심 로그로 보는 실제 동작
플로우 차트로 살펴보기
아래는 로그 메세지를 플로우차트로 그려보았는데요. 로그 발생 주체(인터셉터, 트랜잭션 매니저, Hibernate 등) 를 기준으로 흐름을 그려보았습니다.
로그 메세지 확인
로그의 필요한 부분만 발췌한 축약본입니다.
# 바깥 트랜잭션 시작(@Transactional REQUIRED)
DEBUG JpaTransactionManager : Creating new transaction ... OuterService.outer_calls_required_then_runtime
DEBUG JpaTransactionManager : Opened new EntityManager [...]
TRACE TransactionInterceptor: Getting transaction for [OuterService...]
# 같은 트랜잭션 합승(REQUIRED→REQUIRED)
DEBUG JpaTransactionManager : Found thread-bound EntityManager [...]
DEBUG JpaTransactionManager : Participating in existing transaction
# 내부 로직에서 실제 UPDATE (flush 발생) — balance=110
DEBUG org.hibernate.SQL : update wallet set balance=?, owner=? where id=?
TRACE ...jdbc.bind : binding parameter (1:BIGINT) <- [110]
# 내부에서 RuntimeException 발생 → 전체 롤백 예약
TRACE TransactionInterceptor: Completing transaction for [InnerService...] after exception: RuntimeException
DEBUG JpaTransactionManager : Participating transaction failed - marking existing transaction as rollback-only
DEBUG JpaTransactionManager : Setting JPA transaction ... rollback-only
# 롤백 수행
DEBUG JpaTransactionManager : Initiating transaction rollback
DEBUG JpaTransactionManager : Rolling back JPA transaction on EntityManager [...]
- 바깥 트랜잭션 시작
JpaTransactionManager가 새 트랜잭션을 만들고(Creating new transaction), EntityManager를 열며, TransactionInterceptor가 경계 진입을 잡습니다. - 동일 트랜잭션 합승(REQUIRED → REQUIRED)
같은 스레드에 바인딩된 EntityManager를 재사용하고(Found thread-bound EntityManager), 내부 호출/리포지토리 작업이 기존 트랜잭션에 참여합니다(Participating in existing transaction). - 실제 DB 변경 시도(플러시)
하이버네이트가 UPDATE wallet ... SQL과 바인딩 값을 찍습니다 → flush가 발생해 balance=110이 DB에 반영 시도됨. - 내부에서 RuntimeException 발생 → 롤백 예약
인터셉터가 예외 종료를 기록하고(after exception), 매니저가 트랜잭션을 rollback-only로 표시합니다. - 실제 롤백 수행
Initiating transaction rollback / Rolling back ... 로 전체 트랜잭션 롤백 완료 → 앞서 나간 UPDATE 효과는 되돌려집니다.
UPDATE가 “한 번(=110)”만 찍혔을까?
- 같은 트랜잭션(+동일 EntityManager)에서 Outer의 +10과 Inner의 +100이 동일 영속성 컨텍스트에 반영됨.
- flush 시점(로그 확인을 위해 내부 로직에서 강제)에 더티 체킹으로 합산된 값(110) 이 한 번에 UPDATE로 나간 것을 확인.
- 하지만 mark rollback-only → rollback으로 인해 최종 결과는 0.
기본 전파 옵션인 REQUIRED의 로그를 먼저 살펴보았는데요. 다른 전파 옵션의 동작도 같은 방식으로 해석할 수 있습니다. 예컨대 REQUIRES_NEW에서는 Suspending current transaction / Resuming suspended transaction 로그를, NESTED에서는 savepoint 관련 로그를 확인할 수 있습니다.
'Spring Framework > Spring' 카테고리의 다른 글
Spring에서 Redis 사용하기 (RedisTemplate) (3) | 2025.08.17 |
---|---|
@Transactional 에 대한 것 (2) | 2025.08.08 |
스프링이 자바 빈 등록하는 방법 (1) | 2025.06.07 |
Spring Event 를 활용한 예약 푸쉬 발송 (0) | 2023.04.05 |
Spring Security 디버깅 방법 (0) | 2023.03.30 |