본문으로 건너뛰기

안동민 개발노트

본문 시작

롤백 규칙

밖으로 빠져나온 예외에 적용되는 기본 규칙과 rollbackFor·noRollbackFor의 실제 커밋 결과를 검증합니다.

Spring의 기본 롤백 규칙은 밖으로 빠져나온 RuntimeExceptionError에는 롤백, 밖으로 빠져나온 검사 예외에는 커밋입니다.

이 기본값은 Java 예외 분류의 관습이지 업무 불변식 자체가 아닙니다. 어떤 데이터가 함께 남아야 하는지 먼저 정한 뒤 rollbackFor 또는 noRollbackFor로 경계의 정책을 좁혀야 합니다.

밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다

EXCEPTION ESCAPE · RULE · COMPLETION

밖으로 나온 예외와 가장 구체적인 규칙이 완료 결정을 만든다

대상 안에서 처리해 정상 반환한 실패는 인터셉터가 보지 못한다. 예외가 빠져나오면 명시 규칙을 먼저 적용하고, 일치하는 규칙이 없을 때만 RuntimeException·Error 롤백과 검사 예외 커밋이라는 기본값을 쓴다.

Spring 트랜잭션 롤백 규칙의 완료 결정 흐름 정상 반환은 커밋을 시도한다. 밖으로 나온 예외는 rollbackFor 또는 noRollbackFor의 구체 규칙을 먼저 따르고, 명시 규칙이 없으면 런타임 예외와 Error는 롤백하며 검사 예외는 커밋한다. 대상 메서드 종료 return or throw 예외가 밖으로 빠져나왔는가? 구체 명시 규칙이 일치하는가? rollbackFor / noRollbackFor 기본 타입은 무엇인가? unchecked or checked COMMIT 시도 정상 반환 · checked · noRollbackFor ROLLBACK RuntimeException · Error · rollbackFor EXIT NO · NORMAL RETURN YES noRollbackFor rollbackFor NO RULE CHECKED RUNTIMEEXCEPTION · ERROR
  1. 인터셉터가 본 종료

    정상 반환

    적용 규칙 · 예외 규칙 없음

    완료 · 커밋 시도

  2. 인터셉터가 본 종료

    검사 예외 escape

    적용 규칙 · 기본값

    완료 · 커밋 시도

  3. 인터셉터가 본 종료

    RuntimeException / Error escape

    적용 규칙 · 기본값

    완료 · 롤백

  4. 인터셉터가 본 종료

    검사 예외 escape

    적용 규칙 · 구체 rollbackFor

    완료 · 롤백

  5. 인터셉터가 본 종료

    RuntimeException escape

    적용 규칙 · 구체 noRollbackFor

    완료 · 커밋 시도

예외 이름이 아니라 프록시 밖으로 실제 전달된 타입과 가장 구체적인 규칙을 테스트한다. 이미 커밋된 뒤의 외부 전달 실패는 이 롤백 규칙으로 되돌릴 수 없다.


인터셉터가 관찰한 종료가 판정 입력이다

트랜잭션 인터셉터는 대상 호출이 끝날 때 다음 두 입력을 봅니다.

  • 정상 반환이면 커밋을 시도합니다.
  • 예외가 빠져나오면 가장 구체적으로 일치하는 롤백 규칙을 적용합니다.

따라서 대상 메서드 안에서 예외를 잡고 정상 값을 반환하면 그 예외는 기본 롤백 판정에 도달하지 않습니다. 반대로 호출자에서 예외를 HTTP 응답으로 바꾸는 것은 프록시가 먼저 예외를 관찰하고 완료한 뒤이므로 경계 의미를 흐리지 않습니다.

기본 규칙은 다음과 같습니다.

밖으로 빠져나온 값기본 완료
RuntimeException 하위 타입롤백
Error 하위 타입롤백
검사 예외커밋
정상 반환커밋 시도

예외 이름에 Business가 들어가는지, 메시지에 특정 단어가 있는지는 기본 판정 입력이 아닙니다.


상태 요구를 타입 규칙으로 명시한다

다음 서비스는 같은 삽입 뒤 서로 다른 실패를 밖으로 전달합니다. 일곱 메서드의 차이는 예외가 실제로 빠져나오는 방식과 애노테이션 규칙뿐입니다.

src/main/java/board/tx/proxy/rollback/RollbackPolicyService.java
package board.tx.proxy.rollback;

import javax.sql.DataSource;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;

public class RollbackPolicyService {
    private final JdbcTemplate jdbc;

    public RollbackPolicyService(DataSource dataSource) {
        this.jdbc = new JdbcTemplate(dataSource);
    }

    @Transactional
    public void defaultChecked(long id) throws ReportExportFailure {
        insert(id, "default-checked");
        throw new ReportExportFailure();
    }

    @Transactional
    public void defaultRuntime(long id) {
        insert(id, "default-runtime");
        throw new NotificationFailure();
    }

    @Transactional
    public void defaultError(long id) {
        insert(id, "default-error");
        throw new AssertionError("broken invariant");
    }

    @Transactional(rollbackFor = ReportExportFailure.class)
    public void rollbackChecked(long id) throws ReportExportFailure {
        insert(id, "rollback-checked");
        throw new ReportExportFailure();
    }

    @Transactional(noRollbackFor = NotificationFailure.class)
    public void commitRuntime(long id) {
        insert(id, "commit-runtime");
        throw new NotificationFailure();
    }

    @Transactional(
            rollbackFor = Exception.class,
            noRollbackFor = NotificationFailure.class
    )
    public void specificNoRollbackWins(long id) {
        insert(id, "specific-no-rollback");
        throw new NotificationFailure();
    }

    @Transactional
    public boolean catchRuntimeAndReturn(long id) {
        insert(id, "caught-runtime");
        try {
            throw new NotificationFailure();
        } catch (NotificationFailure handled) {
            return false;
        }
    }

    private void insert(long id, String scenario) {
        jdbc.update(
                "insert into rollback_note(id, scenario) values (?, ?)",
                id,
                scenario
        );
    }

    public static final class ReportExportFailure extends Exception {
        public ReportExportFailure() {
            super("report export failed");
        }
    }

    public static final class NotificationFailure extends RuntimeException {
        public NotificationFailure() {
            super("notification failed");
        }
    }
}

rollbackFor는 검사 예외여도 현재 데이터베이스 변경과 함께 무효화해야 한다는 뜻입니다. noRollbackFor는 런타임 예외가 빠져나와도 현재 데이터베이스 상태가 유효하므로 커밋하겠다는 강한 정책입니다. specificNoRollbackWins()에서는 Exception 규칙과 NotificationFailure 규칙이 모두 일치하지만, 말단 타입에 더 가까운 noRollbackFor가 선택됩니다. 외부 시스템과 원자성이 생긴다는 뜻은 아닙니다.


예외와 행 결과를 함께 테스트한다

다음 테스트는 예외가 호출자에게 전달되는지와 최종 행 수를 모두 고정합니다. 메서드가 실패했다는 주장만으로는 커밋과 롤백을 구분할 수 없습니다.

src/test/java/board/tx/proxy/rollback/RollbackPolicyTest.java
package board.tx.proxy.rollback;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

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.aop.support.AopUtils;
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.annotation.EnableTransactionManagement;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

class RollbackPolicyTest {
    private AnnotationConfigApplicationContext context;
    private JdbcTemplate jdbc;
    private RollbackPolicyService service;

    @BeforeEach
    void openContext() {
        context = new AnnotationConfigApplicationContext(RollbackConfig.class);
        var dataSource = context.getBean(DataSource.class);
        jdbc = new JdbcTemplate(dataSource);
        jdbc.execute("create table if not exists rollback_note(id bigint primary key, scenario varchar(80) not null)");
        jdbc.update("delete from rollback_note");
        service = context.getBean(RollbackPolicyService.class);
    }

    @AfterEach
    void closeContext() {
        context.close();
    }

    @Test
    void framework_default_is_runtime_and_error_rollback_checked_commit() {
        var rule = new DefaultTransactionAttribute();

        assertFalse(rule.rollbackOn(new RollbackPolicyService.ReportExportFailure()));
        assertTrue(rule.rollbackOn(new RollbackPolicyService.NotificationFailure()));
        assertTrue(rule.rollbackOn(new AssertionError("broken invariant")));
    }

    @Test
    void escaping_checked_exception_commits_by_default() {
        assertThrows(
                RollbackPolicyService.ReportExportFailure.class,
                () -> service.defaultChecked(11L)
        );

        assertEquals(1, rows());
    }

    @Test
    void escaping_runtime_exception_rolls_back_by_default() {
        assertThrows(
                RollbackPolicyService.NotificationFailure.class,
                () -> service.defaultRuntime(12L)
        );

        assertEquals(0, rows());
    }

    @Test
    void escaping_error_rolls_back_through_the_transaction_proxy() {
        assertTrue(AopUtils.isAopProxy(service));
        assertThrows(
                AssertionError.class,
                () -> service.defaultError(16L)
        );

        assertEquals(0, rows());
    }

    @Test
    void rollback_for_rolls_back_the_named_checked_exception() {
        assertThrows(
                RollbackPolicyService.ReportExportFailure.class,
                () -> service.rollbackChecked(13L)
        );

        assertEquals(0, rows());
    }

    @Test
    void no_rollback_for_commits_the_named_runtime_exception() {
        assertThrows(
                RollbackPolicyService.NotificationFailure.class,
                () -> service.commitRuntime(14L)
        );

        assertEquals(1, rows());
    }

    @Test
    void specific_no_rollback_rule_beats_the_matching_broad_rollback_rule() {
        assertThrows(
                RollbackPolicyService.NotificationFailure.class,
                () -> service.specificNoRollbackWins(17L)
        );

        assertEquals(1, rows());
    }

    @Test
    void caught_exception_becomes_normal_return_to_the_interceptor() {
        assertFalse(service.catchRuntimeAndReturn(15L));

        assertEquals(1, rows());
    }

    private int rows() {
        return jdbc.queryForObject(
                "select count(*) from rollback_note",
                Integer.class
        );
    }

    @Configuration(proxyBeanMethods = false)
    @EnableTransactionManagement(proxyTargetClass = true)
    static class RollbackConfig {
        @Bean
        DataSource dataSource() {
            var source = new JdbcDataSource();
            source.setURL("jdbc:h2:mem:b92_proxy_rollback;DB_CLOSE_DELAY=-1");
            source.setUser("sa");
            source.setPassword("");
            return source;
        }

        @Bean
        PlatformTransactionManager transactionManager(DataSource dataSource) {
            return new DataSourceTransactionManager(dataSource);
        }

        @Bean
        RollbackPolicyService rollbackPolicyService(DataSource dataSource) {
            return new RollbackPolicyService(dataSource);
        }
    }
}

검사 예외 테스트에서도 예외는 호출자에게 그대로 전달됩니다. 차이는 전달 여부가 아니라 트랜잭션 완료 결정입니다.


규칙은 좁고 구체적으로 둔다

rollbackFor = Exception.class처럼 넓은 규칙은 프로그래밍 결함과 모든 검사 타입을 한 정책으로 묶습니다. rollbackForClassName 같은 문자열 규칙은 타입이 클래스 경로에 없을 때만 필요하며 오타와 이름 충돌을 컴파일러가 잡지 못합니다.

컴파일 가능한 구체 타입을 우선하고, 같은 메서드에 여러 규칙이 겹치면 각 말단 예외 타입의 행 결과를 테스트합니다. 예외 상속 구조를 바꾸면서 트랜잭션 정책이 몰래 변하지 않게 정책표와 테스트를 함께 수정해야 합니다.

상황예외가 경계 밖으로 나가는가의도한 DB 결과표현
낙관적 충돌롤백런타임 기본값
검사 보고서 생성 실패함께 롤백구체 rollbackFor
핵심 상태가 유효한 알림 실패커밋구체 noRollbackFor
내부에서 복구해 정상 반환아니요커밋 시도복구된 상태를 별도 검증

커밋 뒤 실패는 롤백 규칙으로 해결하지 않는다

지연 flush나 제약 조건 때문에 대상 코드가 정상 반환한 뒤 커밋 단계에서 실패할 수 있습니다. 대상 메서드 내부의 try 블록은 프록시가 수행하는 커밋 실패를 감싸지 못하므로 호출자는 프록시 호출 전체의 실패를 처리해야 합니다.

이미 커밋된 뒤 실행되는 콜백 실패는 데이터베이스를 되돌리지 못합니다. 외부 전달이 핵심 저장과 독립적이라면 로컬 데이터와 전달할 사실을 같은 트랜잭션에 기록하고 비동기 경계에서 발행하는 구조를 사용합니다. 커밋 뒤 드러난 프로그래밍 결함은 롤백 규칙으로 숨기지 말고 incident로 기록한 뒤 필요한 보상을 별도 작업으로 실행합니다. 그 구조의 원자성·재시도·멱등성은 뒤의 전파와 아웃박스 절에서 다룹니다.


점검 질문

  • 롤백 규칙이 보는 예외가 실제로 프록시 밖으로 빠져나오는가?
  • 예외 전달과 최종 행 결과를 함께 검증했는가?
  • rollbackFornoRollbackFor가 구체 업무 상태를 설명하는가?
  • 외부 시스템 실패를 로컬 애노테이션 한 줄로 원자화한다고 오해하지 않았는가?
  • 커밋 이후 실패를 롤백 규칙이 아니라 별도 전달 구조로 소유했는가?