📍 배경
인프런 강의 중 김영한님의 "스프링 DB 2편" 을 공부하던 중, 트랜잭션 전파에 대해 공부하다가 각 전파 속성별로 실습을 하고 싶다는 생각을 했습니다.
실무에서는 `REQUIRED` , `REQUIRES_NEW` 를 주로 사용한다고 하지만 각 속성별로 어떤 시나리오에서 사용하게 되는지 가정을 해보고 직접 코드를 작성해보며 각 속성별의 차이를 이해하기 위해 실습을 진행하였습니다.
📍 트랜잭션 전파란?
트랜잭션이 둘 이상 있을 때 어떻게 동작할지 결정하는 것을 트랜잭션 전파라고 합니다.
예를 들어, 트랜잭션이 이미 진행 중인 상태에서 새로운 트랜잭션 메서드를 호출했을 때 기존 트랜잭션에 참여할지, 새로운 트랜잭션을 생성할지, 트랜잭션 없이 실행할지 등을 결정하는 것이 전파 속성입니다.
트랜잭션을 이해하기 위한 개념들
전파 속성을 제대로 이해하려면 물리 트랜잭션과 논리 트랜잭션 , 외부 트랜잭션과 내부 트랜잭션이라는 개념을 알아야합니다.
언뜻 다른 분류 기준처럼 보이지만, 사실은 같은 내용을 이해하기 쉽게 다른 관점에서 설명한 것입니다.
외부 트랜잭션과 내부 트랜잭션
- 외부 트랜잭션
- 처음 시작한 트랜잭션 (`isNewTransaction=True`)
- 상대적으로 밖에 있기 때문에 외부 트랜잭션이라 칭함
- 내부 트랜잭션
- 외부에 트랜잭션이 수행되고 있는데 도중에 호출됨
- 내부에 있는 것처럼 보여서 내부 트랜잭션이라 칭함
외부 트랜잭션과 내부 트랜잭션은 처음 시작한 트랜잭션이냐 아니냐로 구분지어서 칭하게 됩니다.
물리 트랜잭션과 논리 트랜잭션
- 물리 트랜잭션
- 실제 데이터베이스에 적용되는 트랜잭션
- 데이터베이스 커넥션을 통해 실행되는 실제 commit, rollback
- 하나의 물리 트랜잭션만 존재
- 논리 트랜잭션
- 스프링이 관리하는 트랜잭션 단위
- @Transactional이 적용된 각각의 메서드
- 여러 개의 논리 트랜잭션이 하나의 물리 트랜잭션에 참여할 수 있음
원칙
- 모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋됨
- 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백됨
물리 트랜잭션과 논리 트랜잭션은 실제로 commit, rollback이 수행되는 트랜잭션이 누구냐에 따라 달라집니다.
쉽게 정리하자면
- 처음 시작하는 외부 트랜잭션 = 물리 트랜잭션
- 이후 참여하는 내부 트랜잭션 = 논리 트랜잭션
다만 이후에 나타나는 논리 트랜잭션이 내부 트랜잭션으로 참여할지, 아니면 새로운 외부 트랜잭션(물리 트랜잭션)으로 수행하게 될지는 전파 옵션에 따라 달라집니다.
📍 실습 환경 설정
기본 환경
- Spring Boot 3.4
- JPA
- H2 Database
- JUnit 5
의존성 설정 (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
트랜잭션 로그 활성화 (application.properties)
spring.datasource.url=[로컬DB주소]
spring.datasource.username=[로컬DB사용자]
spring.datasource.password=
# ===== Database Configuration =====
# JPA/Hibernate DDL Strategy
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true
# ===== Logging Configuration =====
# Transaction Logging - 트랜잭션 시작, 커밋, 롤백, 참여 확인
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
# JPA Logging
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
# SQL Logging
logging.level.org.hibernate.SQL=DEBUG
이 설정을 통해 트랜잭션의 시작, 커밋, 롤백, 참여 등의 동작을 콘솔에서 확인할 수 있습니다.
전파 속성별 시나리오
각 전파 속성을 이해하기 쉽게 물류 창고를 배경으로 시나리오를 만들었습니다.
물류 창고의 작업대에서 상품을 포장하는 작업을 메인 트랜잭션으로 두고, 포장 과정에서 발생하는 라벨 부착, SMS 알림발송, 실패 로그 기록 등의 부가 작업들이 각 전파 속성에 따라 어떻게 다르게 처리되는지 실습해보았습니다.
📍 전파 속성 6가지 실습
이번 실습에서 사용하는 H2 데이터베이스는 NESTED(Savepoint) 기능을 지원하지 않습니다.
따라서 NESTED 전파 속성은 실습에서 생략하고, 나머지 6가지 속성을 다루겠습니다.
NESTED는 Oracle, PostgreSQL, MySQL 등에서 사용 가능합니다.
1. REQUIRED (기본값)
기존 트랜잭션이 있으면 참여하고, 없으면 새로운 트랜잭션을 생성합니다. 가장 일반적으로 사용되는 전파 속성입니다.
사용 시나리오
메인 작업대에서 작업 중이면 같은 작업대 사용, 작업대가 비어있으면 새로 설치합니다.
1. 포장 작업대에서 포장 진행 (외부 트랜잭션)
2. 같은 작업대에서 라벨 부착 작업 (내부 트랜잭션 - 같은 트랜잭션 참여)
3. 모든 작업이 하나의 트랜잭션으로 처리
4. 하나라도 실패하면 전체 롤백
구현 코드
PackageService (외부 트랜잭션)
public class PackageService {
private final PackageRepository packageRepository;
private final WorkLogService workLogService;
// ========== REQUIRED ==========
@Transactional(propagation = Propagation.REQUIRED)
public Package processRequired(String trackingNumber) {
logTx("PackageService.processRequired - START");
Package pkg = new Package(trackingNumber, "서울");
// 라벨 부착 - 같은 트랜잭션
workLogService.attachLabelRequired(trackingNumber);
pkg.complete();
packageRepository.save(pkg);
log.info("포장 작업 완료");
logTx("PackageService.processRequired - END");
return pkg;
}
PackageService는 처음으로 호출되는 서비스로 @Transactional 어노테이션이 있으면 외부 트랜잭션이 시작됩니다.
`@Transactional`의 기본 전파 옵션은 `REQUIRED`이므로 생략할 수 있지만, 명시성을 위해 `@Transactional(propagation = Propagation.REQUIRED)`로 작성하였습니다.
WorkLogService (내부 트랜잭션)
public class WorkLogService {
private final WorkLogRepository workLogRepository;
@Transactional(propagation = Propagation.REQUIRED)
public void attachLabelRequired(String trackingNumber) {
logTx("WorkLogService.attachLabelRequired - START");
WorkLog workLog = new WorkLog(trackingNumber, "작업자A", "라벨 부착", null);
workLogRepository.save(workLog);
log.info("라벨 부착 완료");
logTx("WorkLogService.attachLabelRequired - END");
}
외부 트랜잭션에서 호출되는 내부 트랜잭션입니다. 전파 옵션은 `REQUIRED`로 기존 트랜잭션에 참여하게 됩니다.
테스트 코드 및 결과
@Test
@DisplayName("REQUIRED: 같은 트랜잭션으로 진행된다")
void processRequired_Success() {
// given
String trackingNumber = "REQ-001";
// when
Package result = packageService.processRequired(trackingNumber);
// then
assertThat(result.getStatus()).isEqualTo("COMPLETED");
List<Package> packages = packageRepository.findAll();
assertThat(packages).hasSize(1);
assertThat(packages.get(0).getTrackingNumber()).isEqualTo(trackingNumber);
// WorkLog도 함께 저장되었는지 확인
assertThat(workLogRepository.findByTrackingNumber(trackingNumber).stream().count()).isEqualTo(1);
}


노란색으로 하이라이팅 한 부분은 트랜잭션 로그 입니다
`packageService.processRequired()` 호출로 인해 `Creating new transaction with name ...` 으로 트랜잭션이 시작됩니다. 세션 번호를 확인하면 동일한 것을 볼 수 있습니다.
그 이후, `workService.attachLabelRequired()` 호출로 내부 트랜잭션이 기존 트랜잭션에 참여하게 됩니다.
그리고서 또 트랜잭션 참여가 보이는데 더 호출하는 서비스가 없는데 왜 보일까 싶죠?
그 이유는 JPA Repository 메서드는 기본적으로 `@Transactional` 이 적용되어 있어, 진행 중인 트랜잭션이 있으면 자동으로 참여합니다.
결과적으로 `Participating in existing transaction` 로그가 3번 보이게 됩니다
- `workService.attachLabelRequired()` 메서드 1번
- JPA 쿼리 2번
2. REQUIRES_NEW
항상 새로운 외부 트랜잭션을 생성합니다. 기존 트랜잭션이 있으면 보류(suspend)하고 완전히 독립적인 새 트랜잭션을 시작합니다.
사용 시나리오
케이스 1: 메인 트랜잭션 성공 ✅ + 독립 트랜잭션 실패 ❌
포장 완료 + SMS 발송 실패
1. 포장 작업대에서 포장 완료 (메인 트랜잭션) ✅
2. 독립 작업대에서 SMS 발송 시도 (독립 트랜잭션 - REQUIRES_NEW)
3. 통신사 서버 오류로 SMS 발송 실패 ❌ -> 재시도 로직으로 처리 필요
-----------------------------------------------------------
케이스 2: 메인 트랜잭션 실패 ❌ + 독립 트랜잭션 성공 ✅
포장 실패 + 작업 로그 기록
1. 포장 작업 중 물품 손상 발견 (메인 트랜잭션) ❌
2. 독립 작업대에서 실패 로그 기록 (독립 트랜잭션 - REQUIRES_NEW)
작업자: 홍길동
실패 사유: "물품 손상 - 액정 파손" ✅
구현 코드
PackageService (메인 트랜잭션)
// ========== REQUIRES_NEW - 케이스1: 메인 성공 + 독립 실패 ==========
@Transactional
public Package processRequiresNewCase1(String trackingNumber) {
logTx("PackageService.processRequiresNewCase1 - START");
Package pkg = new Package(trackingNumber, "부산");
pkg.complete();
packageRepository.save(pkg);
log.info("포장 작업 완료");
try {
// SMS 발송 (REQUIRES_NEW) - 독립 트랜잭션
// 하지만 통신사 오류로 런타임 예외 발생
workLogService.sendSmsRequiresNew(trackingNumber);
} catch (RuntimeException e) {
// 나중에 배치로 알림 재발송 로직 실행
log.error("알림 발송 실패했지만 포장은 완료됨: {}", e.getMessage());
}
logTx("PackageService.processRequiresNewCase1 - END");
return pkg;
}
// ========== REQUIRES_NEW - 케이스2: 메인 실패 + 독립 성공 ==========
@Transactional
public Package processRequiresNewCase2(String trackingNumber) {
logTx("PackageService.processRequiresNewCase2 - START");
Package pkg = new Package(trackingNumber, "대구");
log.info("포장 시작");
if (trackingNumber.contains("손상")) {
// 물품 손상 발견!
log.error("물품 손상 발견! 포장 불가 - 포장 중단");
workLogService.logAttemptRequiresNew(trackingNumber, "홍길동", "포장 실패", "물품 손상 - 액정 파손");
throw new IllegalStateException("물품 손상 발견");
}
return pkg;
}
WorkLogService (독립 트랜잭션)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendSmsRequiresNew(String trackingNumber) {
logTx("WorkLogService.sendSmsRequiresNew - START");
WorkLog workLog = new WorkLog(trackingNumber, "시스템", "SMS발송시도", null);
workLogRepository.save(workLog);
// SMS 발송 실패 시물레이션
log.error("통신사 서버 오류!!");
throw new IllegalStateException("SMS 발송 실패 - 통신사 오류");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttemptRequiresNew(String trackingNumber, String worker, String action, String failReason) {
logTx("WorkLogService.logAttemptRequiresNew - START");
WorkLog workLog = new WorkLog(trackingNumber, worker, action, failReason);
workLogRepository.save(workLog);
log.info("작업 로그 기록 - 직원: {} 작업 : {} 실패사유: {}", worker, action, failReason);
logTx("WorkLogService.logAttemptRequiresNew - END");
}
테스트 코드 및 결과
케이스 1:
@Test
@DisplayName("REQUIRES_NEW 케이스1: 메인 성공, 독립 실패 - 메인 트랜잭션만 커밋된다")
void processRequiresNewCase1_ParentSuccessChildFail() {
// given
String trackingNumber = "NEW-001";
// when
Package result = packageService.processRequiresNewCase1(trackingNumber);
// then
// 메인 트랜잭션은 성공
assertThat(result.getStatus()).isEqualTo("COMPLETED");
List<Package> packages = packageRepository.findByTrackingNumber(trackingNumber);
assertThat(packages).hasSize(1);
// 독립 트랜잭션(SMS)은 실패했으므로 WorkLog가 저장되지 않음
assertThat(workLogRepository.findByTrackingNumber(trackingNumber).stream().count()).isEqualTo(0);
}


위 로그에서 노란색 하이라이팅으로 된 부분이 메인 트랜잭션(세션 - 1911044590) 이고 초록색 하이라이팅 된 부분이 독립 트랜잭션(세션 - 575061969)입니다.
세션 번호가 서로 다른 것을 확인할 수 있는데, 이는 완전히 독립된 2개의 물리 트랜잭션이 생성되었음을 의미합니다.
로그를 쭉 보시면 같은 줄에 노란색 하이라이팅 다음으로 초록색 하이라이팅이 이어진 부분이 있는데요(`Suspending current transaction, creating new transaction with name ...`)
그 부분은 `workLogService.sendSmsRequiresNew()` 호출 시점에 메인 트랜잭션을 일시정지 하고 새로운 독립 트랜잭션을 생성합니다.
런타임예외로 인해 독립 트랜잭션에서는 롤백이 된 후 세션이 닫히는걸 볼 수 있고 그 이후에 멈춰 있던 메인 트랜잭션이 수행됩니다 .(`Resuming suspended transaction after completion of inner transaction`)
메인 트랜잭션은 예외를 try-catch 구문으로 잡았기 때문에 정상적으로 완료되어 커밋이 된 후에 세션이 닫힙니다.
케이스 2:
@Test
@DisplayName("REQUIRES_NEW 케이스2: 메인 실패, 독립 성공 - 독립트랜잭션만 커밋된다")
void processRequiresNewCase2_ParentFailChildSuccess() {
// given
String trackingNumber = "NEW-손상-002";
// when & then
assertThatThrownBy(() -> packageService.processRequiresNewCase2(trackingNumber))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("물품 손상 발견");
// 메인 트랜잭션은 롤백되어 Package가 저장되지 않음
List<Package> packages = packageRepository.findByTrackingNumber(trackingNumber);
assertThat(packages.size()).isEqualTo(0);
// 독립 트랜잭션(REQUIRES_NEW)은 성공하여 WorkLog는 저장됨
long workLogCount = workLogRepository.findByTrackingNumber(trackingNumber).stream().count();
assertThat(workLogCount).isEqualTo(1);
}


동일한 REQUIRES_NEW 전파 옵션이지만 이번에는 메인 트랜잭션은 롤백되고 독립 트랜잭션은 커밋되는 시나리오입니다.
위 로그에서는 노란색 하이라이팅으로 된 부분이 메인 트랜잭션(세션 - 712102960) 이고 초록색 하이라이팅 된 부분이 독립 트랜잭션(세션 - 201214457)입니다.
흐름은 앞서 했던 테스트 로그와 비슷하지만, 독립 트랜잭션에서는 커밋되고 메인 트랜잭션에서는 런타임 예외로 인해 롤백되는 로그를 확인 할 수 있습니다.
이로 인해, 메인 작업이 실패하더라도 독립 트랜잭션으로 기록한 실패 로그는 보존됩니다. 이것이 감사(Audit) 로깅에서 REQUIRES_NEW를 사용하는 이유입니다.
3. SUPPORTS
트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행합니다.
사용 시나리오
케이스 1: 포장 작업 중 바코드 스캔 (트랜잭션 ✅)
1. 포장 작업 시작 (외부 트랜잭션 있음)
2. 바코드 스캔 (SUPPORTS - 외부 트랜잭션 참여)
3. 포장 완료
결과: 포장 실패 시 스캔 기록도 함께 롤백
-----------------------------------------------
케이스 2: 단순 재고 확인용 스캔 (트랜잭션 ❌)
1. 재고 확인을 위한 단순 스캔
2. 바코드 스캔 (SUPPORTS - 트랜잭션 없이 조회)
결과: DB 커넥션 낭비 없이 빠른 조회
구현 코드
PackageService (외부 트랜잭션)
// ========== SUPPORTS - 케이스1: 트랜잭션 O ==========
@Transactional
public Package processSupportsCase1(String trackingNumber) {
logTx("PackageService.processSupportsCase1 - START");
Package pkg = new Package(trackingNumber, "인천");
workLogService.scanBarcodeSupports(trackingNumber);
log.info("### 포장 시작 - JPA 트랜잭션 수행");
pkg.complete();
packageRepository.save(pkg);
logTx("PackageService.processSupportsCase1 - END");
return pkg;
}
// ========== SUPPORTS - 케이스2: 트랜잭션 X ==========
public void processSupportsCase2(String trackingNumber) {
logTx("PackageService.processSupportsCase2 - START 트랜잭션 없음");
log.info("### 단순 스캔만 수행");
workLogService.scanBarcodeSupports(trackingNumber);
logTx("PackageService.processSupportsCase2 - END");
}
두 메서드의 유일한 차이는 `@Transactional` 어노테이션의 유무입니다.
동일한 `workLogService.scanBarcodeSupports()` 메서드를 호출하지만, 호출하는 메서드에 트랜잭션이 있는지 없는지에 따라 동작이 달라지는 것을 확인할 수 있습니다.
WorkLogService (내부 트랜잭션)
@Transactional(propagation = Propagation.SUPPORTS)
public void scanBarcodeSupports(String trackingNumber) {
logTx("WorkLogService.scanBarcodeSupports - START");
log.info("@@@ [내부 트랜잭션 - SUPPORTS] @@@");
log.info("@@@ 바코드 스캔: {}", trackingNumber);
logTx("WorkLogService.scanBarcodeSupports - END");
}
테스트 코드 및 결과
케이스 1 :
@Test
@DisplayName("SUPPORTS 케이스1: 트랜잭션이 있으면 참여한다")
void processSupportsCase1_WithTransaction() {
// given
String trackingNumber = "SUP-001";
// when
Package result = packageService.processSupportsCase1(trackingNumber);
// then
assertThat(result.getStatus()).isEqualTo("COMPLETED");
assertThat(packageRepository.count()).isEqualTo(1);
}

`packageService.processSupportsCase1()` 호출로 생성된 외부 트랜잭션이 처음부터 끝까지 하나의 세션(세션 - 1874361283)으로 수행되는 것을 확인할 수 있습니다.
`workLogService.scanBarcodeSupports()` 호출 시점에 SUPPORT 트랜잭션 전파 옵션의 특성으로 기존에 있는 트랜잭션에 참여하는것을 로그로 확인할 수 있습니다.
보라색으로 하이라이팅 된 부분을 보시면 트랜잭션 활성 상태를 확인하기 위해 `TransactionSynchronizationManager.isActualTransactionActive()` 를 통해 로그를 남겨놓았습니다.
로그를 보시면 Active: true로 트랜잭션이 살아 있는것을 확인 할 수 있습니다.
케이스 2 :
@Test
@DisplayName("SUPPORTS 케이스2: 트랜잭션이 없으면 트랜잭션 없이 실행")
void processSupportsCase2_WithoutTransaction() {
// given
String trackingNumber = "SUP-002";
// when
packageService.processSupportsCase2(trackingNumber);
// then
// 트랜잭션이 없으므로 DB 작업이 없음
assertThat(packageRepository.count()).isEqualTo(0);
assertThat(workLogRepository.count()).isEqualTo(0);
}

외부 트랜잭션이 없는 상태에서 `Propagation.SUPPORTS` 메서드를 호출하면 트랜잭션 없이 실행되므로 JpaTransactionManager 관련 로그가 보이지 않습니다.
그리고 보란색으로 하이라이팅 된 부분을 보시면 `TransactionSynchronizationManager.isActualTransactionActive()` 결과가Active : false 입니다. 이는 현재 트랜잭션이 비활성 상태 임을 의미합니다.
4. NOT_SUPPORTED
트랜잭션을 사용하지 않습니다. 기존 트랜잭션이 있으면 중단하고 트랜잭션 없이 실행합니다.
사용 시나리오
시나리오: 무거운 상자 계량
1. 포장 작업 중 (트랜잭션 진행 중)
2. 무게 측정 필요 → 작업대에서 내려놓음 (트랜잭션 중단)
3. 별도 계량 장비로 측정 (100ms 소요)
4. 측정 완료 후 작업대로 복귀 (트랜잭션 재개)
구현 코드
PackageService (외부 트랜잭션)
// ========== NOT_SUPPORTED ==========
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void processNotSupported(String trackingNumber) {
logTx("PackageService.processNotSupported - START");
log.info("무게 측정 중 ... (트랜잭션이 있는 경우, 트랜잭션 중단)");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
log.info("@@@ {} - 측정 완료 : 2.5kg", trackingNumber);
logTx("PackageService.processNotSupported - END");
}
이전 실습코드는 외부트랜잭션/메인트랜잭션은 `PackageService` 에서 구현하고 내부트랜잭션/독립트랜잭션은 `WorkLogService`에서 구현하였습니다.
MANDANTORY 부터 실습 코드는 트랜잭션의 유무에 따라 동작이 달라지는 특성이 있습니다.
그로 인해 비교적 간단한 검증이므로 테스트 메서드에서 `@Transactional`을 직접 적용해 동일한 서비스 메서드를 트랜잭션 유/무 환경에서 호출하여 차이 비교해보겠습니다.
테스트 코드 및 결과
케이스 1 :
@Test
@Transactional
@DisplayName("NOT_SUPPORTED: 트랜잭션이 있어도 중단하고 실행")
void processNotSupported_WithTransaction() {
// given
String trackingNumber = "NS-001";
// when
packageService.processNotSupported(trackingNumber);
}

이번에도 테스트 메서드에 `@Transactional`이 붙어있기 때문에, 트랜잭션은 테스트 메서드부터 시작됩니다. 로그에서 트랜잭션 이름을 확인해보면 테스트 메서드명과 동일한 것을 볼 수 있습니다.
`PackageService.processNotSupported()` 호출 시점에 `NOT_SUPPORT` 트랜잭션 전파 옵션의 특성으로 기존에 진행 중이던 트랜잭션이 중단되는 걸 확인 할 수 있습니다. (Suspending currnet transaction)
그리고 보란색으로 하이라이팅 된 부분을 보시면 트랜잭션이 비활성화 상태입니다.
`PackageService.processNotSupported()` 메서드가 종료되고 나면 중단되었던 트랜잭션이 재게됩니다 (Resuming suspend transaction...)
케이스 2:
@Test
@DisplayName("NOT_SUPPORTED: 트랜잭션이 없이 실행")
void processNotSupported_WithoutTransaction() {
// given
String trackingNumber = "NS-002";
// when
packageService.processNotSupported(trackingNumber);
}

트랜잭션 없이 실행되므로 JpaTransactionManager 관련 로그가 보이지 않습니다.
그리고 보란색으로 하이라이팅 된 부분을 보시면 `TransactionSynchronizationManager.isActualTransactionActive()` 결과가Active : false 입니다. 이는 현재 트랜잭션이 비활성 상태 임을 의미합니다.
5. MANDATORY
반드시 기존 트랜잭션이 있어야 합니다. 없으면 IllegalTransactionStateException 예외가 발생합니다.
사용 시나리오
위험물은 반드시 안전 절차(트랜잭션)를 거쳐야 함
작업대(트랜잭션) 없이 위험물 처리 시도 시 거부
구현 코드
PackageService (외부 트랜잭션)
// ========== MANDATORY ==========
@Transactional(propagation = Propagation.MANDATORY)
public Package processMandatory(String trackingNumber) {
logTx("PackageService.processMandatory - START");
Package pkg = new Package(trackingNumber, "광주");
pkg.complete();
log.info("### 위험물 검수 작업대 필수 - JPA 트랜잭션 수행");
packageRepository.save(pkg);
logTx("PackageService.processMandatory - END");
return pkg;
}
테스트 코드 및 결과
케이스 1 :
@Test
@Transactional
@DisplayName("MANDATORY: 트랜잭션이 있는경우 수행")
void processMandatory_WithTransaction() {
// given
String trackingNumber = "MAN-001";
// when
Package result = packageService.processMandatory(trackingNumber);
// then
assertThat(result.getStatus()).isEqualTo("COMPLETED");
assertThat(packageRepository.count()).isEqualTo(1);
}

테스트 메서드에 `@Transactional`이 붙어있기 때문에, 트랜잭션은 테스트 메서드부터 시작됩니다. 로그를 확인해보면 트랜잭션 이름이
`TransactionPropagationTest.processMandatory_WithTransaction` 로 표시 되는 것을 볼 수 있습니다.
이후 세션 번호를 보시면 동일한 트랜잭션이 쭉 이어지는 것을 확인할 수 있습니다.
`PackageService.processMandatory()` 메서드가 MANDATORY 전파 옵션으로 설정되어 있어 기존 트랜잭션에 참여하게 되고, 메서드 수행이 완료된 후에도 처음에 생성된 세션 번호가 그대로 유지됩니다.
흥미로운 점은 테스트 코드의 검증 단계에서도 동일한 트랜잭션이 계속된다는 것입니다. `assertThat(packageRepository.count())`를 실행하면 패키지의 개수를 세기 위해 SELECT 쿼리가 수행되는데, 이 쿼리 역시 처음 생성된 트랜잭션에 참여하여 실행되는 것을 로그로 확인할 수 있습니다. 결과적으로 테스트 메서드 시작부터 검증까지 모든 작업이 하나의 물리 트랜잭션으로 처리되며, MANDATORY는 기존 트랜잭션이 있으면 정상적으로 수행됩니다.
케이스 2 :
@Test
@DisplayName("MANDATORY: 트랜잭션이 없으면 예외 발생")
void processMandatory_WithoutTransaction_ThrowsException() {
// given
String trackingNumber = "MAN-002";
// when & then
assertThatThrownBy(() -> packageService.processMandatory(trackingNumber))
.isInstanceOf(IllegalTransactionStateException.class);
}

`packageService.processMandatory()`를 호출하는 테스트 메서드에서 `@Transactional`이 없으므로 메서드 호출 즉시 `IllegalTransactionStateException` 예외가 발생합니다.
이 예외는 실제 비즈니스 로직이 실행되기 전, 스프링의 트랜잭션 인터셉터 단계에서 발생하기 때문에 JpaTransactionManager 관련 로그는 전혀 출력되지 않습니다.
그래서 테스트가 통과됐다는 이미지를 첨부합니다 ^^;
6. NEVER
트랜잭션을 허용하지 않습니다. 기존 트랜잭션이 있으면 IllegalTransactionStateException 예외가 발생합니다.
사용 시나리오
시나리오: 물류 센터 실시간 재고 모니터
벽면 모니터에 1초마다 자동 갱신
포장 작업대(트랜잭션) 필요 없음
구현 코드
PackageService (외부 트랜잭션)
// ========== NEVER ==========
@Transactional(propagation = Propagation.NEVER)
public void processNever() {
logTx("PackageService.processNever - START");
long total = packageRepository.count();
log.info("@@@ 대시보드 조회: 총 {}개", total);
logTx("PackageService.processNever - END");
}
테스트 코드 및 결과
케이스 1 :
@Test
@Transactional
@DisplayName("NEVER: 트랜잭션이 있는경우 예외발생")
void processNever_WithTransaction_ThrowsException() {
// when & then
assertThatThrownBy(() -> packageService.processNever())
.isInstanceOf(IllegalTransactionStateException.class);
}

테스트 메서드에 `@Transactional`이 붙어있어 트랜잭션이 존재하는 상태입니다.
`NEVER` 전파 옵션은 트랜잭션이 있으면 안 되므로, `packageService.processNever()` 호출 즉시 `IllegalTransactionStateException` 예외가 발생합니다.
MANDATORY 전파옵션에서 트랜잭션이 없을 때와 동일한 예외가 발생하는 것을 확인 할 수 있습니다.
그래서 이번에도 실제 비즈니스 로직 실행 전에 예외가 발생하므로 트랜잭션 로그는 출력되지 않았으며 테스트가 통과됐다는 이미지를 첨부합니다 ^^;
케이스 2:
@Test
@DisplayName("NEVER: 트랜잭션이 없을 때만 실행 가능")
void processNever_WithoutTransaction() {
// when
packageService.processNever();
// then
// 단순 조회만 수행
assertThat(packageRepository.count()).isEqualTo(0);
}

테스트 메서드에 `@Transactional`이 붙어 있지 않아 트랜잭션 없이 `PackageService.processNever()` 가 수행될 수 있었습니다.
로그를 보시면 테스트 메서드에 트랜잭션이 없는데도 트랜잭션이 생성되는 것을 확인할 수 있는데요.
이는 JPA Repository 메서드는 기본적으로 `@Transactional` 이 적용되어 있기 때문입니다.
`packageRepository.count()` 호출 시점에 JPA Repository의 트랜잭션이 생성되어 해당 쿼리만 수행하고 즉시 커밋 후 종료됩니다.
중요한 점은 `PackageService.processNever()` 메서드 자체에는 활성화된 트랜잭션이 없습니다!
메서드가 종료된 이후에 보여지는 트랜잭션 생성 로그는 테스트 코드의 검증단계에서 수행되는 쿼리로 인해 생긴 트랜잭션입니다.
📍 배운점
실습 코드를 작성하는 것보다 블로그 포스팅을 작성하는 데 시간이 훨씬 더 오래 걸렸습니다. 하지만 그만큼 트랜잭션 전파에 대해 깊이 이해할 수 있었던 시간이었습니다.
1. JpaTransactionManager 로그의 유용성
`logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG` 설정을 통해 현재 트랜잭션이 어떻게 적용되는지 명확하게 확인할 수 있었습니다. 특히 로그에 표시되는 세션 번호가 매우 유용했는데, 세션 번호를 통해 서로 다른 물리 트랜잭션인지 같은 트랜잭션인지 직관적으로 파악할 수 있었습니다.
앞으로 개발 환경에서 트랜잭션 동작이 의도대로 작동하는지 확인하고 싶을 때마다 이 프로퍼티 설정을 켜서 활용할 수 있을 것 같습니다. 특히 복잡한 비즈니스 로직에서 트랜잭션 경계를 파악하거나, 예상치 못한 롤백이 발생했을 때 원인을 추적하는 데 큰 도움이 될 것입니다.
2. REQUIRED와 REQUIRES_NEW만으로도 충분한 이유
6가지 전파 옵션을 모두 실습해보니, 왜 실무에서 `REQUIRED`와 `REQUIRES_NEW`만 주로 사용하는지 이해가 갔습니다.
- REQUIRED: 대부분의 비즈니스 로직은 하나의 트랜잭션으로 묶여 원자성을 보장해야 합니다
- REQUIRES_NEW: 로깅, 감사, 알림처럼 독립적으로 커밋되어야 하는 작업에 사용
나머지 전파 옵션들(`SUPPORTS`, `MANDATORY`, `NOT_SUPPORTED`, `NEVER`)이 제공하는 기능은 대부분 `REQUIRED`와 `REQUIRES_NEW`의 조합, 그리고 트랜잭션 유무 제어로 충분히 구현할 수 있습니다. 오히려 너무 다양한 전파 옵션을 사용하면 코드의 복잡도만 높아질 수 있겠다는 생각이 들었습니다.
3. 비즈니스 요구사항이 전파 속성을 결정한다
기술적으로 가능한 것과 비즈니스적으로 필요한 것은 다릅니다. 전파 옵션을 선택할 때는 항상 비즈니스 관점에서 질문해야겠다고 생각했습니다.
- 이 작업이 실패하면 다른 작업도 취소되어야 하는가?
- 이 기록은 메인 작업이 실패해도 남아야 하는가?
- 이 조회 작업은 트랜잭션이 반드시 필요한가?
앞으로도 기술보다 비즈니스 요구사항을 먼저 명확히 하고, 그에 맞는 전파 옵션을 선택하도록 하겠습니다.
'Spring' 카테고리의 다른 글
| @SpringBootTest, 순수 스프링 컨테이너, @TestConfiguration 정리 (5) | 2025.08.18 |
|---|---|
| Spring Cloud Gateway와 WebFlux 관계 이해하기 (2편) (1) | 2025.06.29 |
| Spring Cloud Gateway와 WebFlux 관계 이해하기 (1편) (0) | 2025.05.06 |
| Spring Cloud Gateway에서 라우팅 설정 방법 (0) | 2025.03.17 |
| Spring Cloud Gateway 동적 라우팅 추가시 Lambda 표현식 (0) | 2025.03.13 |