트랜잭션 전파 규칙
논리·물리 트랜잭션을 구분하고 REQUIRED의 rollback-only, REQUIRES_NEW의 독립 자원, NESTED의 savepoint 경계를 검증합니다.
트랜잭션 메서드가 두 번 호출되었다고 데이터베이스 트랜잭션도 두 개 생기는 것은 아닙니다. 전파(propagation) 규칙은 지금 트랜잭션이 있을 때 다음 메서드가 어떤 물리 자원을 쓸지 정합니다. REQUIRED는 기존 물리 트랜잭션에 참여하고, REQUIRES_NEW는 기존 자원을 잠시 멈춘 채 새 물리 트랜잭션을 시작하며, NESTED는 지원되는 경우 같은 물리 트랜잭션 안에 저장점(savepoint)을 둡니다. 그래서 무엇을 고를지는 예외를 잡을지가 아니라, 두 작업이 커밋의 운명을 함께해야 하는지로 정합니다.
PROPAGATION · SHARED OR INDEPENDENT COMPLETION
세 전파 규칙은 커밋 운명을 다르게 묶는다
전제는 같은 DataSource의 JDBC 경계입니다. REQUIRED는 함께 완료하고, REQUIRES_NEW는 독립 완료하며, NESTED는 지원되는 savepoint로 부분 취소합니다.
- REQUIRED · 연결 A 하나
두 논리 범위가 물리 완료를 공유합니다. 내부 rollback-only는 외부가 예외를 잡아도 남아 외부 완료에서 전체 롤백과 UnexpectedRollbackException이 됩니다.
- REQUIRES_NEW · 연결 A와 B
외부 A를 보류하고 B를 독립 완료한 뒤 A를 재개합니다. B 커밋은 A 롤백으로 취소되지 않으며 Java 예외 전파는 별도입니다.
- NESTED · A의 savepoint
지원되는 매니저·드라이버에서 S1까지 내부 실패를 취소합니다. 최종 운명은 외부와 공유하며 외부 롤백이면 남은 변경도 사라집니다.
선택 질문
함께 사라져야 하나? 따로 남아야 하나? 부분 취소만 필요한가? 논리 호출 수보다 이 상태 요구를 먼저 정합니다.
NESTED의 지원 범위
JDBC savepoint 모델입니다. JPA 컨텍스트가 같은 의미를 제공하거나 메모리 엔티티까지 자동 복구된다고 가정하지 않습니다.
참여 중인 REQUIRED는 이미 시작된 물리 속성을 바꾸지 않습니다. 엄격한 불일치 검증은 매니저 설정과 통합 테스트로 확인합니다.
그림은 같은 DataSource의 JDBC 트랜잭션에서 세 전파 규칙을 나란히 비교합니다. REQUIRED는 바깥과 안쪽이 연결 A의 물리 트랜잭션을 함께 쓰므로, 안쪽의 롤백 전용 표시가 바깥이 끝날 때 전체 롤백과 UnexpectedRollbackException으로 드러납니다. REQUIRES_NEW는 A를 잠시 멈춘 동안 새 연결 B로 따로 커밋한 뒤 A를 다시 이어 가며, B의 커밋은 나중에 A가 롤백되어도 취소되지 않습니다. NESTED는 A 안의 저장점 S1까지만 되돌리고 바깥은 계속 실행할 수 있지만, 최종 운명은 바깥과 함께합니다. 세 경로는 서로 이어진 실행 순서가 아닙니다.
논리 범위와 물리 트랜잭션
Spring의 트랜잭션 인터셉터는 호출된 메서드마다 논리 범위(logical scope)를 엽니다. 범위마다 전파와 읽기 전용, 격리 수준, 타임아웃 같은 선언을 해석하고, 끝날 때 완료를 판단합니다.
JDBC의 물리 트랜잭션은 실제 연결에서 자동 커밋을 끄고, 마지막에 commit이나 rollback하는 구간입니다. 같은 DataSource와 트랜잭션 관리자를 쓰는 두 REQUIRED 범위는 보통 스레드에 묶인 연결 하나를 함께 씁니다.
이때 안쪽 메서드가 정상적으로 반환해도 데이터베이스 커밋은 아직 일어나지 않고, 가장 바깥의 범위가 물리 트랜잭션을 끝냅니다. 진단할 때는 단순히 활성인지가 아니라 다음을 함께 봅니다.
- 지금의 범위가 새 물리 트랜잭션을 시작했는가
- 안쪽과 바깥이 같은 연결을 쓰는가
- 롤백 전용으로 표시되었는가
- 가장 바깥이 끝난 뒤 어떤 행이 남았는가
isNewTransaction()은 첫 질문에 답하지만, 그 값 하나로 커밋의 운명 전체를 설명할 수는 없습니다.
REQUIRED와 UnexpectedRollbackException
안쪽 REQUIRED가 런타임 예외로 끝나면, 참여하던 물리 트랜잭션이 롤백 전용(rollback-only)이 될 수 있습니다. 바깥 메서드가 그 예외를 잡고 정상적으로 반환해도 롤백 전용 표시는 사라지지 않습니다.
가장 바깥의 인터셉터가 커밋을 시도하는 순간 실제 결과는 롤백입니다. Spring은 정상 커밋처럼 보이게 두지 않고, 호출한 쪽에 UnexpectedRollbackException을 던집니다. 이 예외가 나는 곳은 안쪽을 호출한 지점이 아니라, 바깥 물리 트랜잭션이 끝나는 지점입니다.
핵심 상태와 함께 사라져야 하는 업무 이벤트나 아웃박스 행에는 이렇게 운명을 함께하는 것이 필요합니다. 반대로 안쪽 작업만 따로 남겨야 한다면, 예외를 잡는 것만으로는 부족합니다.
REQUIRES_NEW는 따로 하는 커밋일 뿐 함께하는 원자성이 아닙니다
REQUIRES_NEW는 바깥 트랜잭션에 묶인 연결을 잠시 멈추고, 다른 연결로 새 물리 트랜잭션을 시작합니다. 안쪽 커밋이 끝나면 바깥 자원을 다시 연결해 실행을 이어 갑니다.
안쪽의 커밋은 나중에 바깥이 롤백되어도 취소되지 않습니다. 그래서 실패한 시도의 감사처럼, 바깥의 결과와 상관없이 남아야 하는 기록에 쓸 수 있습니다. 그러나 두 커밋은 서로 독립이므로, 둘을 함께 성공시키는 원자성의 해결책은 아닙니다.
또 바깥이 연결 하나를 쥔 상태에서 안쪽이 하나를 더 요청합니다. 연결 풀의 여유와 커밋되지 않은 데이터의 가시성, 잠금 대기, 예외 전파를 함께 계산해야 하며, 이 운영상의 문제는 다음 글에서 검증합니다.
NESTED는 같은 물리 트랜잭션의 저장점입니다
NESTED는 지원되는 조합에서 기존 물리 트랜잭션 안에 저장점을 만듭니다. 안쪽이 실패하면 저장점까지 되돌리고 바깥은 계속 실행할 수 있지만, 최종 커밋은 여전히 바깥 물리 트랜잭션이 정합니다. 그래서 바깥이 끝내 롤백하면 저장점 뒤에 남겨 둔 변경도 함께 사라지며, 이는 안쪽 커밋이 바깥과 독립인 REQUIRES_NEW와 반대입니다.
JDBC의 DataSourceTransactionManager와 저장점을 지원하는 드라이버에서는 이 모델을 쓸 수 있습니다. JPA 트랜잭션 관리자와 영속성 컨텍스트의 조합이 같은 의미를 준다고 가정해서는 안 되며, DB의 행을 저장점까지 되돌려도 이미 바뀐 메모리 엔티티의 상태가 저절로 되돌아간다는 보장도 없습니다. NESTED는 작은 JDBC 배치의 일부 취소처럼 지원 범위와 허용 상태가 분명할 때만 고르고, 실제 관리자와 드라이버로 하는 통합 테스트로 검증합니다.
더 깊이: 관리자마다 다른 두 가지 기본값
NESTED를 쓸 수 있는지는 관리자의 nestedTransactionAllowed 플래그가 먼저 정합니다. Spring Framework 7.0.10에서 DataSourceTransactionManager는 이 값을 true로 시작하지만, JpaTransactionManager는 false로 시작합니다. API 문서는 그 이유로, 저장점은 JDBC 연결에만 적용될 뿐 JPA EntityManager와 그 안에 캐시된 엔티티에는 적용되지 않으며 JPA 자체가 중첩 트랜잭션을 지원하지 않는다는 점을 듭니다. 플래그가 false인 관리자에서 기존 트랜잭션 안의 NESTED를 호출하면 NestedTransactionNotSupportedException이 나므로, 앞에서 말한 JPA의 의미 차이는 기본 설정에서는 실행 실패로 먼저 드러납니다.
REQUIRED에서 롤백 전용 표시가 생기는 것도 관리자의 기본값 때문입니다. AbstractPlatformTransactionManager의 globalRollbackOnParticipationFailure는 기본이 true여서, 참여한 범위가 예외로 실패하면 공유 트랜잭션 전체를 롤백 전용으로 표시하고 바깥은 더 이상 커밋을 고를 수 없습니다. 이 값을 false로 바꾸면 바깥이 롤백 여부를 결정하게 할 수 있지만, API 문서는 모든 참여 자원이 데이터 접근 실패 뒤에도 커밋을 이어 갈 수 있어야만 의미가 있고 Hibernate 세션이나 연속된 JDBC 변경은 대개 그렇지 않다고 경고합니다. 같은 문서가 권하는 해법이 바로 실패한 하위 범위만 저장점으로 되돌리는 NESTED입니다.
실행으로 확인합니다
다음 테스트는 Java 25와, Spring Boot 4.1.2가 관리하는 Spring Framework 7.0.10, JUnit 6.0.3을 기준으로 합니다. 바깥과 안쪽을 별도의 빈으로 만들어 실제 프록시를 지나게 합니다.
첫 테스트는 안쪽 REQUIRED의 예외를 바깥이 처리해도, 마지막에 UnexpectedRollbackException이 나고 모든 행이 사라지는지 검증합니다. 둘째 테스트는 NESTED의 실패가 저장점까지만 되돌아가, 바깥의 앞뒤 행은 커밋되는지 검증합니다.
package board.tx.propagation.required;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.UnexpectedRollbackException;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
class RequiredAndNestedBoundaryTest {
private AnnotationConfigApplicationContext context;
private JdbcTemplate jdbc;
@BeforeEach
void openContext() {
context = new AnnotationConfigApplicationContext(Config.class);
jdbc = context.getBean(JdbcTemplate.class);
jdbc.execute("drop table if exists tx_note");
jdbc.execute("create table tx_note(id bigint primary key, phase varchar(24))");
}
@AfterEach
void closeContext() {
context.close();
}
@Test
void requiredRollbackOnlyFailsAtTheOuterCompletionBoundary() {
var outer = context.getBean(OuterWork.class);
assertThrows(
UnexpectedRollbackException.class,
() -> outer.requiredThenRecover(10L));
assertEquals(0, jdbc.queryForObject(
"select count(*) from tx_note", Integer.class));
}
@Test
void nestedFailureRollsBackToSavepointAndOuterCanCommit() {
context.getBean(OuterWork.class).nestedThenContinue(20L);
assertEquals(
java.util.List.of("outer", "after"),
jdbc.queryForList(
"select phase from tx_note order by id", String.class));
}
public static class RequiredStep {
private final JdbcTemplate jdbc;
RequiredStep(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void writeThenFail(long id) {
jdbc.update(
"insert into tx_note(id, phase) values (?, 'required-inner')",
id);
throw new ExpectedStepFailure();
}
}
public static class NestedStep {
private final JdbcTemplate jdbc;
NestedStep(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional(propagation = Propagation.NESTED)
public void writeThenFail(long id) {
jdbc.update(
"insert into tx_note(id, phase) values (?, 'nested-inner')",
id);
throw new ExpectedStepFailure();
}
}
public static class OuterWork {
private final JdbcTemplate jdbc;
private final RequiredStep required;
private final NestedStep nested;
OuterWork(JdbcTemplate jdbc, RequiredStep required, NestedStep nested) {
this.jdbc = jdbc;
this.required = required;
this.nested = nested;
}
@Transactional
public void requiredThenRecover(long id) {
jdbc.update(
"insert into tx_note(id, phase) values (?, 'outer')", id);
try {
required.writeThenFail(id + 1);
} catch (ExpectedStepFailure expected) {
// The exception is handled, but the shared physical transaction
// remains rollback-only.
}
}
@Transactional
public void nestedThenContinue(long id) {
jdbc.update(
"insert into tx_note(id, phase) values (?, 'outer')", id);
try {
nested.writeThenFail(id + 1);
} catch (ExpectedStepFailure expected) {
jdbc.update(
"insert into tx_note(id, phase) values (?, 'after')",
id + 2);
}
}
}
static final class ExpectedStepFailure extends RuntimeException {
private static final long serialVersionUID = 1L;
}
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
static class Config {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:b92_required;DB_CLOSE_DELAY=-1");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
var manager = new DataSourceTransactionManager(dataSource);
manager.setNestedTransactionAllowed(true);
return manager;
}
@Bean
JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
@Bean
RequiredStep requiredStep(JdbcTemplate jdbc) {
return new RequiredStep(jdbc);
}
@Bean
NestedStep nestedStep(JdbcTemplate jdbc) {
return new NestedStep(jdbc);
}
@Bean
OuterWork outerWork(
JdbcTemplate jdbc,
RequiredStep required,
NestedStep nested
) {
return new OuterWork(jdbc, required, nested);
}
}
}참여할 때의 속성 검증
안쪽 REQUIRED가 바깥과 다른 격리 수준이나 읽기 전용 속성을 선언해도, 이미 시작된 물리 트랜잭션의 속성을 바꾸지는 못합니다. 기본 설정에서는 선언의 차이가 조용히 무시될 수 있습니다.
엄격해야 한다면 validateExistingTransaction=true로 참여할 때 속성이 맞지 않는 것을 빨리 드러내고, 통합 테스트로 확인합니다. 안쪽의 타임아웃 선언도 DB 타이머가 하나 더 생긴다는 뜻이 아니며, 바깥의 기한과 물리 자원의 수명이 전체 작업을 제한합니다. 느린 네트워크 호출을 트랜잭션 안에 두면 연결과 잠금을 쥐는 시간이 늘어나므로, 전파 애노테이션으로 이를 감추지 말고 외부 I/O의 위치를 다시 설계합니다.
허용할 상태의 조합으로 고릅니다
두 작업 A와 B의 결과를 먼저 표로 적습니다.
| 허용하는 상태 | 알맞은 출발점 | 물리적인 의미 |
|---|---|---|
| 함께 성공하거나 함께 실패 | REQUIRED | 하나의 물리 트랜잭션을 함께 씀 |
| B가 따로 남아야 함 | REQUIRES_NEW를 검토 | 바깥을 멈추고 새 연결로 새 커밋 |
| B의 실패만 일부 취소하고 최종 운명은 함께 | NESTED를 검토 | 같은 물리 트랜잭션의 저장점 |
| 서로 다른 시스템에 결과를 전달 | 아웃박스, 보상, 분산 설계 | 로컬 전파만으로는 원자성 없음 |
SUPPORTS는 트랜잭션이 있으면 참여하고 없으면 트랜잭션 없이 실행합니다. MANDATORY는 기존 트랜잭션을 요구하고, NEVER는 기존 트랜잭션이 있으면 거절하며, NOT_SUPPORTED는 지금의 트랜잭션을 멈추고 트랜잭션 없이 실행합니다. 전파의 이름부터 고르지 말고, 허용할 상태와 물리 자원의 수, 최종 완료 지점을 먼저 확정합니다.
정리
- 논리 범위는 메서드마다 열리지만, 물리 트랜잭션의 수와 커밋 시점은 전파 규칙과 가장 바깥 범위가 정합니다.
REQUIRED안쪽의 런타임 실패는 공유 트랜잭션을 롤백 전용으로 만들고, 바깥이 예외를 잡아도 마지막에UnexpectedRollbackException이 납니다.REQUIRES_NEW는 새 연결로 따로 커밋하므로 연결을 하나 더 쓰며, 바깥과 함께하는 원자성을 주지 않습니다.NESTED는 같은 물리 트랜잭션의 저장점으로 일부만 되돌리며, 바깥이 롤백하면 남긴 변경도 사라지고 JPA에서는 같은 의미를 기대하지 않습니다.- 참여하는
REQUIRED는 이미 시작된 트랜잭션의 속성을 바꾸지 못하므로, 엄격한 검증은validateExistingTransaction과 통합 테스트로 합니다.
연습 문제
1. 게시글과 포인트, 실패 감사
게시글 등록과 포인트 차감, 실패 감사의 세 작업을 설계하려고 합니다. 포인트가 부족하면 게시글도 없어야 하고, 실패한 시도는 남아야 합니다. 각 호출의 논리 범위 수와 물리 트랜잭션 수, 동시에 쥘 수 있는 연결 수, 바깥이 롤백된 뒤 남는 행, 감사 기록이 핵심 변경의 성공을 주장해도 되는지를 답하세요.
해설 보기
게시글과 포인트가 같은 데이터베이스라면 REQUIRED로 묶는 것이 자연스럽습니다. 실패 감사는 별도 빈의 REQUIRES_NEW를 검토할 수 있지만, 이것이 감사와 핵심 변경을 원자적으로 묶어 주지는 않습니다. 감사에는 커밋된 게시글이 아니라 실패한 시도라는 의미를 기록해야 합니다.
구체적으로 바깥의 등록 REQUIRED가 게시글을 삽입하고, 별도 포인트 빈의 REQUIRED가 부족 예외를 던졌다고 가정합니다. 바깥 대상은 그 실패를 잡아 별도 감사 빈의 REQUIRES_NEW로 실패한 시도 한 행을 저장한 뒤 원래의 실패를 다시 던지며, 감사 자체는 성공하고 모두 같은 DataSource를 씁니다. 이때 등록과 포인트, 감사의 논리 범위는 세 개입니다. 등록과 포인트는 물리 트랜잭션 A 하나를 함께 쓰고 감사는 독립된 B를 시작하므로, 물리 트랜잭션은 두 개입니다. 감사가 실행되는 순간에는 멈춰 둔 A와 지금의 B, 두 연결을 쥡니다. B가 커밋된 뒤 바깥이 원래 실패를 다시 던져 A가 롤백되므로, 게시글과 포인트의 변경은 남지 않고 실패한 시도의 감사 한 행만 남습니다.
감사까지 실패하거나 예외를 처리하는 순서를 바꾸면 최종 결과도 바뀌므로, REQUIRES_NEW라서 언제나 남는다고 조건을 빼먹지 않습니다.
2. NESTED 뒤에 바깥이 롤백되면
NESTED 테스트에서 바깥이 outer 행을 넣고, 안쪽 NESTED가 실패해 저장점까지 되돌아간 뒤, 바깥이 after 행을 더 넣었습니다. 그 뒤 바깥이 런타임 예외로 끝난다면 최종적으로 어떤 행이 남나요? 같은 상황에서 안쪽이 REQUIRES_NEW였다면 무엇이 달라지나요?
해설 보기
NESTED의 저장점은 같은 물리 트랜잭션 안에 있으므로, 바깥이 롤백되면 저장점 밖에 남겨 두었던 outer와 after 행이 모두 사라지고 남는 행은 없습니다. 안쪽이 REQUIRES_NEW였고 안쪽이 성공했다면 안쪽 행은 따로 커밋되어 바깥의 롤백 뒤에도 남지만, 이 문제처럼 안쪽이 실패했다면 안쪽 B도 롤백되므로 역시 남는 행이 없습니다. 어느 경우든 바깥의 행은 바깥의 완료를 따릅니다.
다음 글 REQUIRES_NEW의 독립 자원에서는 REQUIRES_NEW가 연결을 하나 더 쓰면서 생기는 풀 고갈과 가시성, 잠금 대기를 실행으로 확인합니다.