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

안동민 개발노트

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

트랜잭션 전파 규칙

여러 @Transactional 메서드가 하나의 물리 트랜잭션에 참여할 때 각 논리 스코프의 롤백 전용 표식을 추적하고, 필수·REQUIRES_NEW·NESTED 선택을 자원과 실패 격리 기준으로 비교합니다.

트랜잭션 메서드가 두 번 호출됐다고 데이터베이스 트랜잭션도 항상 두 개 생기지는 않습니다. REQUIRED는 기존 물리 트랜잭션에 참여하면서 메서드마다 논리 스코프를 만듭니다. 안쪽 스코프가 롤백 전용 상태를 표시하면 바깥 코드가 예외를 처리해도 하나뿐인 물리 트랜잭션은 커밋될 수 없습니다. 이 사실을 모르면 메서드는 정상 반환했는데 마지막에 UnexpectedRollbackException이 나타나는 현상을 설명하기 어렵습니다.


물리 트랜잭션

JDBC에서는 연결의 자동 커밋을 끄고 커밋 또는 롤백하는 구간이 물리 트랜잭션입니다. Spring 트랜잭션 매니저는 현재 스레드와 DataSource에 묶인 리소스 보관 객체를 관리합니다. 여러 리포지토리가 DataSourceUtils를 통해 같은 연결을 얻으면 하나의 원자 단위에 참여합니다.

논리 트랜잭션은 인터셉터가 메서드별 속성과 완료 판단을 관리하는 스코프입니다. 외부와 내부가 모두 REQUIRED이면 논리 스코프는 둘이어도 물리 리소스는 하나입니다. 내부가 성공해도 아직 데이터베이스 커밋은 일어나지 않고 외부 종료까지 기다립니다.

isNewTransaction()은 현재 상태가 새 물리 트랜잭션을 시작했는지 알려 줍니다. 활성 플래그만 보면 참여와 신규를 구별하지 못합니다. 진단 테스트에서는 연결 동일성, 신규 여부, 롤백 전용 상태, 최종 행을 함께 봅니다.

REQUIRED와 롤백 전용 상태

다음 실행 예제는 외부 서비스가 내부 런타임 예외를 예외 처리하고 정상 문자열을 반환하도록 구성합니다. 두 빈으로 나누어 내부 프록시가 실제로 호출되게 했습니다.

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

import javax.sql.DataSource;

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

public final class RequiredRollbackProbe {
    static class InnerWriter {
        private final JdbcTemplate jdbc;

        InnerWriter(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }

        @Transactional
        public void appendThenFail(long id) {
            jdbc.update("insert into tx_note(id, kind) values (?, 'inner')", id);
            throw new IllegalStateException("inner rejected");
        }
    }

    static class OuterWriter {
        private final JdbcTemplate jdbc;
        private final InnerWriter inner;

        OuterWriter(JdbcTemplate jdbc, InnerWriter inner) {
            this.jdbc = jdbc;
            this.inner = inner;
        }

        @Transactional
        public String write(long id) {
            jdbc.update("insert into tx_note(id, kind) values (?, 'outer')", id);
            try {
                inner.appendThenFail(id + 1);
            } catch (IllegalStateException expected) {
                return "outer tried to recover";
            }
            return "unreachable";
        }
    }

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

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

        @Bean
        JdbcTemplate jdbcTemplate(DataSource source) {
            return new JdbcTemplate(source);
        }

        @Bean
        InnerWriter innerWriter(JdbcTemplate jdbc) {
            return new InnerWriter(jdbc);
        }

        @Bean
        OuterWriter outerWriter(JdbcTemplate jdbc, InnerWriter inner) {
            return new OuterWriter(jdbc, inner);
        }
    }

    public static void main(String[] args) {
        try (var context = new AnnotationConfigApplicationContext(Config.class)) {
            var jdbc = context.getBean(JdbcTemplate.class);
            jdbc.execute("create table tx_note(id bigint primary key, kind varchar(20))");
            try {
                context.getBean(OuterWriter.class).write(10L);
            } catch (UnexpectedRollbackException failure) {
                Integer rows = jdbc.queryForObject(
                        "select count(*) from tx_note", Integer.class);
                System.out.printf("completion=%s rows=%d%n",
                        failure.getClass().getSimpleName(), rows);
            }
        }
    }
}
RequiredRollbackProbe 실행
completion=UnexpectedRollbackException rows=0

외부가 예외 처리했는데도 두 삽입이 모두 사라집니다. Spring은 정상 커밋처럼 속이는 대신 롤백됐다는 사실을 마지막 호출자에게 알려 줍니다.

트랜잭션 전파 정책

REQUIRED는 있으면 참여하고 없으면 새로 만듭니다. REQUIRES_NEW는 기존 리소스를 일시 중단하고 독립 물리 트랜잭션을 시작합니다. SUPPORTS는 있으면 참여하지만 없으면 비트랜잭션으로 실행합니다. MANDATORY는 기존 경계를 요구하고, NEVER는 있으면 거절합니다. NOT_SUPPORTED는 현재 트랜잭션을 일시 중단한 채 비트랜잭션으로 실행합니다.

선택 기준은 예외를 잡고 싶은지가 아니라 커밋 운명을 공유해야 하는지입니다. 핵심 행과 아웃박스 이벤트는 함께 성공해야 하므로 필수가 맞습니다. 실패하더라도 남겨야 하는 진단 감사는 REQUIRES_NEW 후보지만 연결과 일관성 비용이 있습니다. 조회 도우미에 SUPPORTS를 붙여 트랜잭션이 없을 때 여러 쿼리의 스냅샷 일관성이 필요 없다는 계약을 표현할 수 있습니다.

REQUIRES_NEW의 내부 커밋은 외부 롤백으로 되돌릴 수 없습니다. 내부 트랜잭션이 외부 트랜잭션의 미커밋 행을 조회하려 하면 격리 때문에 보지 못하거나 잠금을 기다립니다. 독립이라는 장점이 곧 데이터 가시성 제약입니다.

NESTED·savepoint

NESTED는 보통 하나의 물리 트랜잭션 안에서 저장점을 만들고 내부 실패만 그 지점까지 되돌립니다. 외부 커밋이 실패하면 중첩 변경도 함께 사라집니다. 독립 커밋이 필요한 REQUIRES_NEW와 의미가 다릅니다.

JDBC DataSourceTransactionManager는 저장점을 지원하는 드라이버에서 중첩을 사용할 수 있습니다. JPA 매니저와 리소스 조합은 같은 방식으로 지원하지 않을 수 있습니다. 애노테이션을 붙이기 전에 매니저 기능과 nestedTransactionAllowed 설정을 통합 테스트로 확인합니다.

저장점 롤백 뒤 메모리 기반 엔티티 상태가 자동으로 이전 값으로 돌아간다는 보장도 없습니다. 영속성 컨텍스트와 DB 행이 어긋날 수 있어 JPA에서 무심코 쓰기 어렵습니다. 작은 JDBC 배치에서 부분 실패를 허용하는 경우처럼 범위를 명확히 제한합니다.

참여 속성 호환성

내부 필수가 외부와 다른 격리가나 읽기 전용을 요청해도 기존 물리 트랜잭션의 속성을 바꾸지 못합니다. 기본 설정은 선언 차이를 조용히 무시할 수 있습니다. validateExistingTransaction=true를 켜면 읽기 전용나 격리 불일치를 빠르게 발견할 수 있습니다.

타임아웃도 참여 스코프마다 독립 DB 타이머가 생긴다는 뜻이 아닙니다. 외부 기한이 전체 리소스 수명을 제한하고 내부는 남은 시간 안에서 실행됩니다. 느린 외부 호출을 트랜잭션 내부에 두면 풀 연결과 잠금을 불필요하게 점유합니다.

트랜잭션 이름은 보통 완전한 클래스 이름과 메서드에서 만들어집니다. 중첩 호출별 이름을 메트릭 태그로 무한 생성하지 말고 고정된 작업 그룹을 사용합니다. 전파별 활성·신규·롤백 전용 상태는 디버그 진단에 유용합니다.

상태 그래프 설계

두 작업 A와 B의 결과를 네 칸으로 적습니다. 둘 다 성공, A만 성공, B만 성공, 둘 다 실패 중 업무상 허용되는 상태를 표시합니다. 오직 함께 성공·함께 실패만 허용되면 필수 공유 트랜잭션이 자연스럽습니다. B가 A 실패와 무관하게 반드시 남아야 하면 독립 리소스 또는 REQUIRES_NEW를 검토합니다.

데이터베이스가 둘이면 같은 로컬 매니저의 전파만으로 원자성을 보장할 수 없습니다. XA를 사용할지, 아웃박스와 최종 일관성을 사용할지 별도 아키텍처 결정이 필요합니다. 애노테이션 이름이 같아도 리소스 경계가 다르면 보장 수준이 달라집니다.

연습 문제

게시글 등록, 포인트 차감, 감사 기록 세 작업의 전파를 설계하세요. 포인트 부족이면 게시글도 없어야 하고, 실패 감사는 남아야 합니다. 각 작업의 물리 트랜잭션 수, 연결 최대 점유 수, 외부 롤백 시 남는 행을 표로 설명하세요.

해설 보기

게시글과 포인트가 같은 데이터베이스라면 외부 필수에서 함께 변경합니다. 실패 감사는 별도 빈의 REQUIRES_NEW로 기록할 수 있지만, 외부가 연결 하나를 잡은 채 내부가 두 번째를 요청한다는 풀 조건을 반영합니다.

package board.tx;

import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

public class FailureAuditWriter {
    private final AuditStore store;

    public FailureAuditWriter(AuditStore store) {
        this.store = store;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(long authorId, String code) {
        store.insert(authorId, code);
    }

    public interface AuditStore {
        void insert(long authorId, String code);
    }
}

감사가 반드시 유실되면 안 되고 DB 장애까지 견뎌야 한다면 같은 DB의 REQUIRES_NEW만으로 충분하지 않습니다. 영속적 메시지나 별도 추가 전용 수집 대상을 검토합니다.