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

안동민 개발노트

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

@Transactional 프록시

트랜잭션 인터셉터의 실행 시점과 애노테이션 탐색·우선순위를 확인하고 적용 경계를 설계합니다.

@Transactional은 메서드 안에 커밋 코드를 삽입하는 문법이 아닙니다.

Spring 컨테이너가 대상 빈 앞에 프록시를 만들고, 프록시를 통과한 호출을 트랜잭션 인터셉터가 감싸는 선언입니다.

따라서 애노테이션의 위치만 보지 말고 실제 객체 동일성, 호출 방향, 트랜잭션 매니저 선택을 함께 확인해야 합니다.


트랜잭션 프록시 흐름

클라이언트가 프록시 메서드를 부르면 인터셉터가 트랜잭션 속성을 찾고 매니저에서 상태를 얻습니다.

그다음 대상을 호출하며, 정상 반환이면 커밋하고 롤백 대상 예외이면 롤백합니다.

커밋 자체가 실패할 수도 있으므로 대상 반환과 영속적 성공은 같은 시점이 아닙니다.

JDK 동적 프록시는 인터페이스를 중심으로 만들고 클래스 기반 프록시는 하위 클래스를 만듭니다.

Spring Boot의 일반적인 애플리케이션에서는 클래스 프록시를 자주 보지만 구현 방식에 기대어 설계하면 안 됩니다.

호출자는 컨테이너가 제공한 빈 참조를 사용하고, final 클래스·final 메서드처럼 하위 클래스 가로채기를 막는 형태를 피합니다.

프록시 생성이 곧 트랜잭션 시작을 뜻하지도 않습니다.

빈을 주입받거나 AopUtils.isAopProxy를 확인하는 동안에는 DB 트랜잭션이 없습니다.

애노테이션이 붙은 메서드를 외부에서 호출하는 순간 인터셉터가 동작합니다.


프록시·활성 상태 관찰

다음 구성은 H2 DataSource와 트랜잭션 매니저를 직접 조립하고, 사용 사례 안에서 현재 스레드의 트랜잭션 플래그를 반환합니다.

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

import javax.sql.DataSource;

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

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

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

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

    static class PostWriter {
        private final JdbcTemplate jdbc;

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

        @Transactional
        public boolean createSchemaAndReportState() {
            jdbc.execute("create table if not exists tx_probe(id bigint)");
            return TransactionSynchronizationManager
                    .isActualTransactionActive();
        }
    }

    public static void main(String[] args) {
        try (var context = new AnnotationConfigApplicationContext(Config.class)) {
            PostWriter bean = context.getBean(PostWriter.class);
            System.out.printf("proxy=%s activeInside=%s activeOutside=%s%n",
                    AopUtils.isAopProxy(bean),
                    bean.createSchemaAndReportState(),
                    TransactionSynchronizationManager
                            .isActualTransactionActive());
        }
    }
}
TransactionProxyProbe 실행
proxy=true activeInside=true activeOutside=false

활성 플래그는 현재 스레드의 관찰값입니다.

리액티브 트랜잭션은 Reactor 컨텍스트를 사용하므로 같은 검사법을 그대로 적용하지 않습니다.

이 교재의 JDBC 사용 사례는 스레드-결합된 매니저를 사용한다는 조건을 명시합니다.


애노테이션 탐색 규칙

클래스 수준 애노테이션은 여러 공개 사용 사례에 공통 기본값을 줄 수 있습니다.

메서드 수준 설정은 읽기 전용, 격리, 타임아웃처럼 특정 작업의 차이를 표현합니다.

인터페이스, 상위 클래스, 구현 위치를 섞으면 프록시 방식과 애노테이션 탐색 규칙을 이해하기 어려워집니다.

애플리케이션 서비스의 구체 공개 메서드에 의도를 보이게 두는 편이 읽기 쉽습니다.

readOnly=true는 데이터베이스마다 강제력이 다릅니다.

Hibernate 플러시 최적화와 연결 힌트로 사용될 수 있지만 쓰기가 언제나 물리적으로 거절된다는 보장은 없습니다.

읽기 복제본 라우팅에 활용한다면 트랜잭션 속성이 라우팅 키를 정하는 시점도 검증합니다.

타임아웃은 메서드 수행 전체의 경과 시간을 강제로 중단시키는 장치가 아닙니다.

트랜잭션 매니저가 남은 시간을 구문에 전달하고, 드라이버와 데이터베이스가 취소를 지원해야 합니다.

외부 HTTP 호출에는 별도 클라이언트 타임아웃이 필요합니다.


사용 사례 트랜잭션 경계

트랜잭션을 리포지토리 메서드마다 붙이면 여러 저장 동작을 하나의 원자 단위로 묶기 어렵습니다.

컨트롤러에 두면 요청 파싱과 뷰 렌더링, 느린 네트워크 작업까지 연결을 오래 잡을 수 있습니다.

애플리케이션 서비스의 공개 사용 사례가 보통 적절한 출발점입니다.

한 사용 사례가 DB 변경 뒤 외부 API를 호출할 때 단순히 큰 트랜잭션으로 묶어도 원자성이 생기지 않습니다.

원격 시스템은 로컬 롤백을 알지 못합니다.

아웃박스, 사가, 보상, 멱등성을 사용해 경계를 분리합니다.

애노테이션 범위는 하나의 트랜잭션 매니저가 실제로 보장할 수 있는 리소스까지만 포함합니다.

트랜잭션 메서드가 도메인 엔티티를 반환하고 컨트롤러에서 지연 연관을 읽으면 경계 밖 로딩 문제가 생깁니다.

사용 사례 안에서 필요한 투영을 완성하고 불변 DTO를 반환하는 편이 명시적입니다.

영속성 컨텍스트를 뷰 렌더링까지 열어 두는 OSIV에 기대면 웹 요청이 끝날 때까지 연결 사용이 늘어날 수 있습니다.


프록시·롤백 검증

단순히 메서드에 애노테이션이 붙었는지 리플렉션으로 확인하면 인터셉터 실행을 보장하지 못합니다.

실제 컨텍스트에서 빈을 꺼내 프록시 여부와 트랜잭션 플래그, 커밋 또는 롤백 후 행 수를 확인합니다.

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

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

import javax.sql.DataSource;

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

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

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

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

    static class RollbackWriter {
        private final JdbcTemplate jdbc;

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

        @Transactional
        public void writeThenFail(long id) {
            jdbc.update("insert into probe_note(id) values (?)", id);
            throw new IllegalStateException("force rollback");
        }
    }

    @Test
    void container에서_받은_service_호출은_예외와_함께_insert를_rollback한다() {
        try (var context = new AnnotationConfigApplicationContext(
                RollbackConfig.class)) {
            var service = context.getBean(RollbackWriter.class);
            var jdbc = new JdbcTemplate(context.getBean(DataSource.class));
            jdbc.execute("create table probe_note(id bigint primary key)");

            try {
                service.writeThenFail(7L);
            } catch (IllegalStateException expected) {
                assertThat(expected).hasMessage("force rollback");
            }

            Integer rows = jdbc.queryForObject(
                    "select count(*) from probe_note", Integer.class);
            assertThat(rows).isZero();
        }
    }
}

이 테스트는 구성과 대상을 한 파일 안에 포함해 별도 생략 없이 실행됩니다.

테스트 이름은 프록시 동작을 설명하며 애노테이션 존재만 검사하지 않습니다.


복수 매니저 소유권

DataSource와 트랜잭션 매니저가 둘 이상이면 이름 추론에 맡기지 않습니다.

@Transactional(transactionManager = "boardTransactionManager")처럼 리소스 소유자를 지정하거나 TransactionManagementConfigurer로 기본값을 결정합니다.

하나의 애노테이션이 두 독립 데이터베이스를 원자적으로 커밋하지는 않습니다.

관찰 시에는 트랜잭션 이름, 읽기 전용, 격리, 활성 플래그를 표본 추출된 디버그 로그나 메트릭 태그로 확인할 수 있습니다.

민감한 업무 파라미터를 태그에 넣으면 카디널리티와 개인 정보 문제가 생기므로 고정된 작업 이름을 사용합니다.


연습 문제

읽기 전용 주간 통계와 쓰기용 게시글 등록을 한 클래스에 구현하세요.

클래스 기본값과 메서드 재정의 중 하나의 정책을 고르고, 실제 프록시 빈에서 읽기 전용 플래그가 서로 다르게 관찰되는 테스트를 작성하세요.

해설 보기

아래처럼 클래스에 읽기 전용 기본값을 두고 명령만 재정의하면 쿼리가 늘어날 때 안전한 기본값을 제공합니다.

DB가 쓰기를 강제 차단하는지는 별도 통합 테스트가 필요합니다.

package board.tx;

import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Transactional(readOnly = true)
public class PostOperations {
    public boolean weeklySummaryIsReadOnly() {
        return TransactionSynchronizationManager
                .isCurrentTransactionReadOnly();
    }

    @Transactional(readOnly = false)
    public boolean registerIsReadOnly() {
        return TransactionSynchronizationManager
                .isCurrentTransactionReadOnly();
    }
}

예상 관찰값은 쿼리 true, 명령 false입니다.

인스턴스를 new PostOperations()로 직접 만들지 말고 트랜잭션 관리가 활성화된 컨텍스트에서 빈을 얻어 호출합니다.