본문으로 건너뛰기

안동민 개발노트

본문 시작

트랜잭션 전파 규칙

논리·물리 트랜잭션을 구분하고 REQUIRED의 rollback-only, REQUIRES_NEW의 독립 자원, NESTED의 savepoint 경계를 검증합니다.

트랜잭션 메서드가 두 번 호출됐다고 데이터베이스 트랜잭션도 두 개 생기는 것은 아닙니다.

전파 규칙은 현재 트랜잭션이 있을 때 다음 메서드가 어떤 물리 리소스를 사용할지 정합니다. REQUIRED는 기존 물리 트랜잭션에 참여하고, REQUIRES_NEW는 기존 리소스를 보류한 채 새 물리 트랜잭션을 시작하며, NESTED는 지원되는 경우 같은 물리 트랜잭션 안에 savepoint를 둡니다.

따라서 선택 기준은 “예외를 잡을 것인가”가 아니라 두 작업이 커밋 운명을 공유해야 하는가입니다.

REQUIRED, REQUIRES_NEW, NESTED 전파에서 논리 스코프와 물리 트랜잭션, rollback-only, 독립 커밋, savepoint의 완료 경계를 비교한 다이어그램

PROPAGATION · COMPLETION FATE

논리 호출 수보다 물리 완료 경계를 먼저 본다

REQUIRED는 한 커밋 운명을 공유하고, REQUIRES_NEW는 두 커밋을 분리하며, NESTED는 같은 커밋 안에서 savepoint까지만 되돌립니다.

Spring 트랜잭션 전파별 물리 완료 경계 REQUIRED 행은 외부와 내부 논리 스코프가 연결 A의 물리 트랜잭션 하나를 공유하며 내부 rollback-only가 외부 완료에서 UnexpectedRollbackException을 만든다. REQUIRES_NEW 행은 외부 연결 A를 보류하고 연결 B의 내부 트랜잭션을 독립 커밋한 뒤 A로 복귀한다. NESTED 행은 연결 A 안의 savepoint로 내부 실패만 되돌리지만 외부 롤백 시 모든 변경이 사라진다. REQUIRED 커밋 운명 공유 Outer logical new physical Tx A Inner logical same connection A rollback-only 예외를 잡아도 유지 Physical Tx A · connection A · outer completion UnexpectedRollback inner 정상 반환은 DB commit 완료가 아니다 REQUIRES_NEW 두 커밋은 독립 Outer Tx A connection A 보류 Inner Tx B connection B · commit 독립 결과 보존 outer rollback로 취소 안 됨 resume A · commit 또는 rollback 독립은 원자성 결합이 아니다 NESTED savepoint만 분리 Outer Tx A connection A savepoint S1 inner partial boundary rollback to S1 outer 계속 가능 Physical Tx A · outer가 최종 commit/rollback outer rollback 외부가 롤백하면 savepoint 뒤 변경도 모두 사라진다
물리 경계 호출 수와 트랜잭션 수를 분리해서 읽기

REQUIRED · 하나의 물리 트랜잭션

외부와 내부 논리 스코프가 연결 A를 공유합니다. 내부 rollback-only는 외부 완료 시 전체 롤백과 UnexpectedRollbackException으로 드러납니다.

REQUIRES_NEW · 두 물리 트랜잭션

외부 A를 보류하고 연결 B에서 내부를 커밋합니다. B의 커밋은 이후 A의 롤백으로 취소되지 않지만 두 결과가 원자적으로 결합되지는 않습니다.

NESTED · savepoint 하나

같은 연결 A 안에서 내부 실패만 savepoint까지 되돌립니다. 외부가 최종 롤백하면 중첩 변경도 함께 사라지며 매니저·드라이버 지원이 필요합니다.

  • 물리 완료 경계
  • rollback-only 전파
  • 보류 또는 부분 롤백

선택 질문: 함께 커밋할 것인가, 독립 커밋할 것인가, 같은 커밋 안에서 부분 취소할 것인가.


논리 스코프와 물리 트랜잭션

Spring의 트랜잭션 인터셉터는 호출된 메서드마다 논리 스코프를 엽니다. 각 스코프는 전파, 읽기 전용, 격리 수준, 타임아웃 같은 선언을 해석하고 종료 시점의 완료 판단을 맡습니다.

JDBC의 물리 트랜잭션은 실제 연결에서 자동 커밋을 끄고 마지막에 commit 또는 rollback하는 구간입니다. 같은 DataSource와 트랜잭션 매니저를 사용하는 두 REQUIRED 스코프는 보통 스레드에 바인딩된 연결 하나를 공유합니다.

이때 내부 메서드가 정상 반환해도 데이터베이스 커밋은 아직 일어나지 않습니다. 가장 바깥 경계가 물리 트랜잭션을 완료합니다. 진단할 때는 단순한 “활성 여부”가 아니라 다음을 함께 봅니다.

  • 현재 스코프가 새 물리 트랜잭션을 시작했는가
  • 내부와 외부가 같은 연결을 사용하는가
  • rollback-only가 표시됐는가
  • 가장 바깥 완료 뒤 어떤 행이 남았는가

isNewTransaction()은 첫 질문에 답하지만, 그 값 하나로 전체 커밋 운명을 설명할 수는 없습니다.


REQUIRED와 UnexpectedRollbackException

내부 REQUIRED가 런타임 예외로 끝나면 참여 중인 물리 트랜잭션이 rollback-only가 될 수 있습니다. 외부 메서드가 그 예외를 잡고 정상 반환하더라도 rollback-only 표시는 사라지지 않습니다.

가장 바깥 인터셉터가 커밋을 시도하는 순간 실제 결과는 롤백입니다. Spring은 정상 커밋처럼 보이게 두지 않고 호출자에게 UnexpectedRollbackException을 던집니다. 이 예외가 나타나는 경계는 내부 호출 지점이 아니라 외부 물리 트랜잭션의 완료 지점입니다.

핵심 상태와 함께 사라져야 하는 업무 이벤트 또는 outbox 행에는 이 공유 운명이 필요합니다. 반대로 내부 작업만 따로 남겨야 한다면 단순히 예외를 잡는 것으로는 부족합니다.


REQUIRES_NEW는 별도 커밋이지 결합 원자성이 아니다

REQUIRES_NEW는 외부 트랜잭션에 묶인 연결을 보류하고 다른 연결로 새 물리 트랜잭션을 시작합니다. 내부 커밋이 끝나면 외부 리소스를 다시 연결해 실행을 계속합니다.

내부 커밋은 이후 외부 롤백으로 취소되지 않습니다. 바로 그 때문에 실패 시도 감사처럼 외부 결과와 독립적으로 남아야 하는 기록에 사용할 수 있습니다. 그러나 두 커밋은 서로 독립이므로 둘을 함께 성공시키는 원자성 해결책은 아닙니다.

또한 외부가 연결 하나를 잡은 상태에서 내부가 하나를 더 요청합니다. 연결 풀 여유, 미커밋 데이터 가시성, 잠금 대기와 예외 전파를 함께 계산해야 합니다. 이 운영 경계는 다음 장에서 검증합니다.


NESTED는 같은 물리 트랜잭션의 savepoint다

NESTED는 지원되는 조합에서 기존 물리 트랜잭션 안에 savepoint를 만듭니다. 내부 실패를 savepoint까지 되돌리고 외부가 계속 실행할 수 있지만, 최종 커밋 권한은 여전히 외부 물리 트랜잭션에 있습니다.

따라서 외부가 끝내 롤백하면 savepoint 뒤에 남겨 둔 변경도 함께 사라집니다. 이는 내부 커밋이 외부와 독립인 REQUIRES_NEW와 반대입니다.

JDBC DataSourceTransactionManager와 savepoint를 지원하는 드라이버에서는 이 모델을 사용할 수 있습니다. JPA 트랜잭션 매니저와 영속성 컨텍스트 조합이 같은 의미를 제공한다고 가정해서는 안 됩니다. DB 행을 savepoint로 되돌려도 이미 변경한 메모리 엔티티 상태가 자동 복구된다는 보장도 없습니다.

NESTED는 작은 JDBC 배치의 부분 취소처럼 지원 범위와 허용 상태가 분명할 때만 선택하고, 실제 매니저·드라이버 통합 테스트로 닫습니다.


실행으로 경계를 고정한다

다음 테스트는 Java 25, Spring Boot 4.1.1이 관리하는 Spring Framework 7.0.9와 JUnit 6.0.3 기준입니다. 외부와 내부를 별도 빈으로 만들어 실제 프록시 경계를 통과시킵니다.

첫 테스트는 내부 REQUIRED 예외를 외부가 처리해도 최종 완료에서 UnexpectedRollbackException이 발생하고 모든 행이 사라짐을 검증합니다. 둘째 테스트는 NESTED 실패가 savepoint까지만 되돌아가 외부의 앞뒤 행은 커밋됨을 검증합니다.

src/test/java/board/tx/propagation/required/RequiredAndNestedBoundaryTest.java
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 후보같은 물리 트랜잭션의 savepoint
서로 다른 시스템에 결과를 전달outbox·보상·분산 설계로컬 전파만으로 원자성 없음

SUPPORTS는 있으면 참여하고 없으면 비트랜잭션으로 실행합니다. MANDATORY는 기존 경계를 요구하고, NEVER는 기존 경계가 있으면 거절합니다. NOT_SUPPORTED는 현재 트랜잭션을 보류하고 비트랜잭션으로 실행합니다.

전파 이름을 먼저 고르지 말고 허용 상태, 물리 리소스 수, 최종 완료 경계를 먼저 확정합니다.


연습 문제

게시글 등록, 포인트 차감, 실패 감사 세 작업을 설계하세요. 포인트 부족이면 게시글도 없어야 하고 실패 시도는 남아야 합니다.

다음 항목을 표로 답합니다.

  1. 각 호출의 논리 스코프 수와 물리 트랜잭션 수
  2. 동시에 점유할 수 있는 연결 수
  3. 외부 롤백 뒤 남는 행
  4. 감사 기록이 핵심 변경의 성공 사실을 주장해도 되는지

게시글과 포인트가 같은 데이터베이스라면 REQUIRED로 묶는 것이 자연스럽습니다. 실패 감사는 별도 빈의 REQUIRES_NEW 후보지만, 이는 감사와 핵심 변경을 원자적으로 묶지 않습니다. 감사에는 “커밋된 게시글”이 아니라 “실패 시도”라는 의미를 기록해야 합니다.