본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 예외와 트랜잭션 심화

롤백 규칙

Spring 트랜잭션의 기본 런타임 롤백·검사 커밋 규칙을 실행하고, rollbackFor·noRollbackFor를 업무 결과와 맞추며 커밋 시점 실패와 예외 삼키기의 위험을 구분합니다.

Spring의 기본 롤백 규칙은 비검사 예외와 Error에는 롤백, 검사 예외에는 커밋입니다. 이 기본값은 Java의 관습을 반영할 뿐 모든 업무에 맞는 진리가 아닙니다. 회원 게시판에서 보고서 파일 생성 실패가 검사 타입이어도 게시글 저장과 한 단위라면 롤백해야 하고, 등록 후 알림 실패가 런타임 타입이어도 핵심 행은 남겨야 할 수 있습니다. 먼저 어떤 상태가 함께 남아야 하는지를 정의한 뒤 규칙을 선택합니다.


타입 기반 기본 롤백

트랜잭션 인터셉터는 대상이 예외를 던지면 트랜잭션 속성의 rollbackOn을 호출합니다. RuntimeExceptionError는 기본적으로 true이고 검사 예외는 false입니다. 예외 처리되어 메서드가 정상 반환하면 인터셉터는 실패를 보지 못하므로 커밋을 시도합니다.

src/main/java/board/tx/RollbackRuleProbe.java
package board.tx;

import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

public final class RollbackRuleProbe {
    static final class ReportWriteFailure extends Exception {
        ReportWriteFailure() {
            super("report write failed");
        }
    }

    static final class InvalidPostState extends RuntimeException {
        InvalidPostState() {
            super("invalid post state");
        }
    }

    public static void main(String[] args) {
        var rule = new DefaultTransactionAttribute();
        System.out.printf("checked=%s runtime=%s error=%s%n",
                rule.rollbackOn(new ReportWriteFailure()),
                rule.rollbackOn(new InvalidPostState()),
                rule.rollbackOn(new AssertionError("broken invariant")));
    }
}
RollbackRuleProbe 실행
checked=false runtime=true error=true

예외 이름에 Business가 들어간다고 자동으로 커밋되지 않습니다. 검사·비검사 상속과 애노테이션 규칙이 실제 판정 입력입니다. API 검증처럼 트랜잭션 시작 전에 끝나는 오류는 롤백 규칙 자체가 실행되지 않을 수도 있습니다.

rollbackFor 규칙

검사 실패가 현재 로컬 DB 변경을 무효화해야 하면 rollbackFor를 사용합니다. 반대로 런타임 알림 실패에도 핵심 변경을 커밋하려면 noRollbackFor를 지정할 수 있습니다. 그러나 애노테이션 한 줄로 외부 시스템과 원자성이 생기지 않으며, 커밋할 상태가 사용자에게 허용되는지 먼저 설명해야 합니다.

클래스 이름 기반 규칙은 의존성이 클래스 경로에 없을 때 쓸 수 있지만 문자열 오타와 단순 이름 충돌 위험이 있습니다. 컴파일 가능한 타입 참조를 우선합니다. 넓은 Exception.class 규칙은 프로그래밍 결함과 모든 검사 타입을 묶으므로 정책 의도가 흐려집니다.

규칙이 여러 개 일치하면 가장 구체적인 예외 패턴을 선택합니다. 패키지 전체를 포함하지 않은 부분 문자열 패턴은 이름이 비슷한 다른 예외까지 잡을 수 있습니다. 계층을 작게 설계하고 테스트에서 각 말단 타입의 판정을 고정합니다.

커밋·롤백 결과 검증

다음 테스트는 검사 실패에 명시적인 롤백 규칙을 붙인 서비스를 Spring 컨텍스트에서 호출합니다. 메서드가 예외를 던졌다는 사실뿐 아니라 데이터베이스 행이 남지 않았다는 결과를 확인합니다.

src/test/java/board/tx/CheckedRollbackTest.java
package board.tx;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import javax.sql.DataSource;

import org.h2.jdbcx.JdbcDataSource;
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.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;

class CheckedRollbackTest {
    static final class ExportFailure extends Exception {
        ExportFailure() {
            super("export failed after insert");
        }
    }

    static class PostCommand {
        private final JdbcTemplate jdbc;

        PostCommand(DataSource source) {
            this.jdbc = new JdbcTemplate(source);
        }

        @Transactional(rollbackFor = ExportFailure.class)
        public void registerThenExport(long id) throws ExportFailure {
            jdbc.update("insert into post_note(id) values (?)", id);
            throw new ExportFailure();
        }
    }

    @Configuration
    @EnableTransactionManagement
    static class Config {
        @Bean
        DataSource dataSource() {
            var source = new JdbcDataSource();
            source.setURL("jdbc:h2:mem:checked_rule;DB_CLOSE_DELAY=-1");
            return source;
        }

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

        @Bean
        PostCommand postCommand(DataSource source) {
            return new PostCommand(source);
        }
    }

    @Test
    void checked_failure도_명시한_rule에_따라_row를_되돌린다() {
        try (var context = new AnnotationConfigApplicationContext(Config.class)) {
            var jdbc = new JdbcTemplate(context.getBean(DataSource.class));
            jdbc.execute("create table post_note(id bigint primary key)");
            var command = context.getBean(PostCommand.class);

            assertThatThrownBy(() -> command.registerThenExport(41L))
                    .isInstanceOf(ExportFailure.class);
            assertThat(jdbc.queryForObject(
                    "select count(*) from post_note", Integer.class))
                    .isZero();
        }
    }
}

같은 메서드에서 rollbackFor를 제거하면 검사 예외가 바깥으로 전달돼도 삽입은 커밋됩니다. 이 차이를 의도하지 않았다면 사용자에게 실패를 응답하면서 데이터는 저장된 놀라운 결과가 생깁니다.

예외 삼킨 정상 반환

서비스 내부에서 리포지토리 예외를 처리해 빈 결과를 반환하면 인터셉터는 정상 종료로 봅니다. 이미 참여한 내부 트랜잭션이 롤백 전용 상태를 표시했다면 외부 커밋에서 UnexpectedRollbackException이 발생할 수 있고, 단일 로컬 작업이 예외만 던진 뒤 표시하지 않았다면 일부 변경이 커밋될 수 있습니다.

복구하려면 실패한 변경을 되돌린 뒤 대체 상태가 일관적인지 확인해야 합니다. TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()로 수동 표시할 수 있지만 흐름이 숨겨집니다. 실패를 다시 던지거나 TransactionTemplate 콜백에서 명시적으로 상태를 다루는 편이 테스트하기 쉽습니다.

컨트롤러가 애플리케이션 실패를 예외 처리해 HTTP 응답으로 바꾸는 것은 트랜잭션 메서드 바깥이므로 괜찮습니다. 핵심은 프록시가 예외를 관찰한 뒤 롤백을 마쳤는지입니다. 예외 처리 위치가 인터셉터 안쪽인지 바깥쪽인지 호출 다이어그램으로 확인합니다.

커밋 단계 실패

JPA 지연 플러시, 데이터베이스 제약 조건, 동기화 콜백 때문에 대상 코드가 정상 실행된 뒤 커밋에서 예외가 날 수 있습니다. 서비스 메서드 안의 try·예외 처리는 프록시 바깥에서 발생한 커밋 예외를 잡지 못합니다. 호출자가 트랜잭션 프록시 호출 전체를 감싸야 합니다.

커밋 예외를 원래 업무 예외로 덮지 않습니다. 롤백도 실패할 수 있어 Spring은 애플리케이션 예외와 완료 실패의 관계를 보존합니다. 운영 로그에서 가장 구체적인 원인과 트랜잭션 결과를 함께 확인합니다.

afterCommit 콜백에서 예외가 나면 DB 커밋을 되돌릴 수 없습니다. 콜백 실패를 사용자에게 전체 실패처럼 보여 주면 재시도 시 중복이 생길 수 있습니다. 영속적 아웃박스를 트랜잭션 안에 저장하고 별도 발행기가 처리하는 방식이 더 분명합니다.

최종 상태 정책표

상황DB 변경외부 응답규칙 또는 구조
검증 실패시작하지 않음400트랜잭션 밖 입력 검사
낙관적 충돌롤백409런타임 기본값
검사 보고서 생성 실패함께 되돌림503rollbackFor
알림 발송 실패핵심 커밋202 또는 성공아웃박스 분리
프로그래밍 결함롤백500런타임 기본값 + 장애

업무 정책이 바뀌면 예외 상속만 몰래 바꾸지 말고 이 표와 통합 테스트를 같이 수정합니다. 같은 예외가 다른 사용 사례에서 서로 다른 상태 보존 요구를 가질 수 있으므로 애노테이션은 메서드 경계에 둡니다.

연습 문제

게시글 저장 뒤 추천 태그 계산이 실패하는 시나리오를 설계하세요. 추천은 선택 기능이지만 계산 결과 행을 같은 트랜잭션에 넣고 있습니다. 핵심 게시글을 보존할지 전체 롤백할지 선택하고, 예외 타입과 규칙, 사용자 응답, 재시도 위치를 코드로 표현하세요.

해설 보기

선택 기능이라면 추천 계산을 트랜잭션 밖으로 밀고 영속적 이벤트를 남기는 쪽이 핵심 저장과 잘 맞습니다. 같은 메서드에서 noRollbackFor로 억지 커밋하면 추천 행이 반쯤 기록됐는지 판단하기 어렵습니다.

package board.tx;

import org.springframework.transaction.annotation.Transactional;

public class CreatePostUseCase {
    private final PostStore posts;
    private final OutboxStore outbox;

    public CreatePostUseCase(PostStore posts, OutboxStore outbox) {
        this.posts = posts;
        this.outbox = outbox;
    }

    @Transactional
    public long register(String title) {
        long id = posts.insert(title);
        outbox.append("PostCreated", Long.toString(id));
        return id;
    }

    public interface PostStore {
        long insert(String title);
    }

    public interface OutboxStore {
        void append(String eventType, String aggregateId);
    }
}

추천 워커는 아웃박스 이벤트를 읽어 독립 트랜잭션으로 결과를 저장하고, 이벤트 ID 고유 제약 조건으로 재처리를 멱등하게 만듭니다.