본문 바로가기

@Transactional 프록시

프록시 진입부터 트랜잭션 매니저와 JDBC 리소스 바인딩, 완료까지의 실제 경계를 실행 가능한 테스트로 확인합니다.

@Transactional은 메서드 본문에 커밋 코드를 삽입하지 않습니다. Spring이 만든 프록시로 들어온 호출을 트랜잭션 어드바이저가 가로채고, 선택된 PlatformTransactionManager가 자신이 소유한 리소스의 시작과 완료를 담당합니다.

따라서 애노테이션 위치만 보지 말고 호출이 프록시를 통과했는지, 어느 매니저가 선택됐는지, 어떤 리소스가 현재 스레드에 묶였는지를 함께 확인해야 합니다.

트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다

프록시 진입·manager 선택·연결 바인딩·같은 연결 SQL·완료로 자원과 경계가 변한다.

PROXY · ADVISOR · RESOURCE OWNERSHIP

프록시 경계가 선택한 매니저와 자원을 묶는다

공개 호출에서 advisor가 속성과 매니저를 선택합니다. 그 매니저는 자신이 소유한 DataSource를 바인딩하며 대상 반환 뒤 완료 단계가 이어집니다.

트랜잭션 프록시와 매니저의 리소스 소유호출자는 프록시를 통해 공개 사용 사례에 진입한다. advisor는 boardTransactionManager를 선택하며 매니저는 Board DB 연결을 현재 경계에 바인딩한다. 사용 사례의 repository는 같은 연결로 SQL을 실행한다.호출자빈 참조Proxy · Advisor속성 + 매니저공개 사용 사례register()RepositorySQL선택선택된 매니저boardTransactionManager바인딩 · 완료Board DBowned DataSource
호출 경로와 게시판 리소스의 연결원desktop의6노드6관계와같다. 호출자→proxy→사용사례→repository의호출관계와proxy→선택manager→BoardDB의자원관리관계가구별된다. Repository는같은BoardDB를사용한다. 대상반환뒤최종완료가남으며 다른manager의기존바인딩은원 no-outer시험 밖의조건이다.호출자컨테이너 빈프록시 advisor선택 매니저게시판 자원사용 사례리포지토리Board DB같은 연결매니저 선택SQL 사용
  1. 프록시 진입 → 매니저 선택

    컨테이너가 준 빈 참조로 공개 사용 사례를 호출합니다. advisor가 트랜잭션 속성과 boardTransactionManager를 고릅니다.

  2. Board DB 자원 바인딩 → SQL

    선택된 매니저 소유의 연결에 repository가 참여합니다. 다른 DataSource는 자동 참여하지 않습니다.

완료 순서

RESOLVE → BEGIN → INVOKE → COMPLETE

대상 반환만으로 성공하지 않습니다. 커밋·롤백 완료와 최종 행 결과까지 확인합니다.

속성과 범위

readOnly는 읽기 의도이며 보안 장치가 아닙니다. timeout은 HTTP 취소가 아닙니다. 다른 DB·원격 API에는 별도 원자성 설계가 필요합니다.

공개 사용 사례를 로컬 경계로 삼고 프록시·매니저 이름·자원 소유권·완료 뒤 행 결과를 테스트합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 호출자는 프록시를 통해 공개 사용 사례에 진입한다. advisor는 boardTransactionManager를 선택하며 매니저는 Board DB 연결을 현재 경계에 바인딩한다. 사용 사례의 repository는 같은 연결로 SQL을 실행한다.
  • 원래 가로 도표의 6노드와 6관계와 같다. 호출자→proxy→사용 사례→repository의 호출 관계와 proxy→선택 manager→Board DB의 자원 관리 관계가 구별된다. Repository는 같은 Board DB를 사용한다. 대상 반환 뒤 최종 완료가 남으며 다른 manager의 기존 바인딩은 원래 no-outer 시험 밖의 조건이다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다
22원래 가로 도표의 6노드와 6관계와 같다. 호출자→proxy→사용 사례→repository의 호출 관계와 proxy→선택 manager→Board DB의 자원 관리 관계가 구별된다. Repository는 같은 Board DB를 사용한다. 대상 반환 뒤 최종 완료가 남으며 다른 manager의 기존 바인딩은 원래 no-outer 시험 밖의 조건이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
프록시와 manager

공개 사용 사례가 proxy를 통해 boardTransactionManager를 선택합니다.

진입: proxy · manager: boardTransactionManagertransaction 소유자가 결정됩니다.
연결 바인딩과 SQL

manager가 Board DB 연결을 바인딩하고 repository가 같은 연결을 씁니다.

연결: 같은 Board DB · 업무: repository SQL업무가 관리된 자원에 참여합니다.
transaction 완료

manager가 경계의 완료와 자원 정리를 소유합니다.

완료: manager 소유 · 연결: 관리 경계업무 호출 뒤 연결 소유 범위가 끝납니다.

트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다

프록시 진입·manager 선택·연결 바인딩·같은 연결 SQL·완료로 자원과 경계가 변한다.

PROXY · ADVISOR · RESOURCE OWNERSHIP

프록시 경계가 선택한 매니저와 자원을 묶는다

공개 호출에서 advisor가 속성과 매니저를 선택합니다. 그 매니저는 자신이 소유한 DataSource를 바인딩하며 대상 반환 뒤 완료 단계가 이어집니다.

트랜잭션 프록시와 매니저의 리소스 소유호출자는 프록시를 통해 공개 사용 사례에 진입한다. advisor는 boardTransactionManager를 선택하며 매니저는 Board DB 연결을 현재 경계에 바인딩한다. 사용 사례의 repository는 같은 연결로 SQL을 실행한다.호출자빈 참조Proxy · Advisor속성 + 매니저공개 사용 사례register()RepositorySQL선택선택된 매니저boardTransactionManager바인딩 · 완료Board DBowned DataSource
호출 경로와 게시판 리소스의 연결원desktop의6노드6관계와같다. 호출자→proxy→사용사례→repository의호출관계와proxy→선택manager→BoardDB의자원관리관계가구별된다. Repository는같은BoardDB를사용한다. 대상반환뒤최종완료가남으며 다른manager의기존바인딩은원 no-outer시험 밖의조건이다.호출자컨테이너 빈프록시 advisor선택 매니저게시판 자원사용 사례리포지토리Board DB같은 연결매니저 선택SQL 사용
  1. 프록시 진입 → 매니저 선택

    컨테이너가 준 빈 참조로 공개 사용 사례를 호출합니다. advisor가 트랜잭션 속성과 boardTransactionManager를 고릅니다.

  2. Board DB 자원 바인딩 → SQL

    선택된 매니저 소유의 연결에 repository가 참여합니다. 다른 DataSource는 자동 참여하지 않습니다.

완료 순서

RESOLVE → BEGIN → INVOKE → COMPLETE

대상 반환만으로 성공하지 않습니다. 커밋·롤백 완료와 최종 행 결과까지 확인합니다.

속성과 범위

readOnly는 읽기 의도이며 보안 장치가 아닙니다. timeout은 HTTP 취소가 아닙니다. 다른 DB·원격 API에는 별도 원자성 설계가 필요합니다.

공개 사용 사례를 로컬 경계로 삼고 프록시·매니저 이름·자원 소유권·완료 뒤 행 결과를 테스트합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 호출자는 프록시를 통해 공개 사용 사례에 진입한다. advisor는 boardTransactionManager를 선택하며 매니저는 Board DB 연결을 현재 경계에 바인딩한다. 사용 사례의 repository는 같은 연결로 SQL을 실행한다.
  • 원래 가로 도표의 6노드와 6관계와 같다. 호출자→proxy→사용 사례→repository의 호출 관계와 proxy→선택 manager→Board DB의 자원 관리 관계가 구별된다. Repository는 같은 Board DB를 사용한다. 대상 반환 뒤 최종 완료가 남으며 다른 manager의 기존 바인딩은 원래 no-outer 시험 밖의 조건이다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다
22원래 가로 도표의 6노드와 6관계와 같다. 호출자→proxy→사용 사례→repository의 호출 관계와 proxy→선택 manager→Board DB의 자원 관리 관계가 구별된다. Repository는 같은 Board DB를 사용한다. 대상 반환 뒤 최종 완료가 남으며 다른 manager의 기존 바인딩은 원래 no-outer 시험 밖의 조건이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
프록시와 manager

공개 사용 사례가 proxy를 통해 boardTransactionManager를 선택합니다.

진입: proxy · manager: boardTransactionManagertransaction 소유자가 결정됩니다.
연결 바인딩과 SQL

manager가 Board DB 연결을 바인딩하고 repository가 같은 연결을 씁니다.

연결: 같은 Board DB · 업무: repository SQL업무가 관리된 자원에 참여합니다.
transaction 완료

manager가 경계의 완료와 자원 정리를 소유합니다.

완료: manager 소유 · 연결: 관리 경계업무 호출 뒤 연결 소유 범위가 끝납니다.

프록시 진입이 경계를 만든다

컨테이너가 프록시 빈을 만들어 두었다고 데이터베이스 트랜잭션이 곧바로 시작되지는 않습니다. 호출자가 컨테이너에서 받은 참조의 공개 메서드를 호출할 때 어드바이저가 트랜잭션 속성을 찾고 매니저를 선택합니다.

일반적인 JDBC 경계의 순서는 다음과 같습니다.

  1. 프록시가 메서드와 클래스의 트랜잭션 속성을 해석합니다.
  2. 지정된 매니저가 트랜잭션을 시작하고 자신의 DataSource 리소스를 바인딩합니다.
  3. 대상 사용 사례가 같은 리소스를 사용해 작업합니다.
  4. 대상이 정상 반환하면 커밋하고, 롤백 규칙에 맞는 예외가 빠져나오면 롤백합니다.

대상 메서드가 값을 반환한 뒤에도 제약 조건 검사나 flush 때문에 커밋이 실패할 수 있습니다. 대상 반환과 영속적 성공은 같은 순간이 아닙니다.


매니저와 리소스 소유권을 코드에 드러낸다

다음 서비스에는 두 DataSource가 주입되지만 register 경계는 boardTransactionManager를 명시합니다. 이 테스트는 기존 외부 트랜잭션 없이 호출합니다. 그 조건에서 게시판 리소스가 현재 스레드에 바인딩되고 감사 리소스는 바인딩되지 않습니다. 기존 호출자가 감사 경계를 이미 열었는지는 별도 조건이므로, 매니저를 선택했다는 이유로 다른 리소스가 언제나 바인딩되지 않는다고 일반화하지 않습니다.

src/main/java/board/tx/proxy/boundary/BoardTransactionService.java
package board.tx.proxy.boundary;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

public class BoardTransactionService {
    private final DataSource boardDataSource;
    private final DataSource auditDataSource;
    private final JdbcTemplate boardJdbc;

    public BoardTransactionService(
            @Qualifier("boardDataSource") DataSource boardDataSource,
            @Qualifier("auditDataSource") DataSource auditDataSource
    ) {
        this.boardDataSource = boardDataSource;
        this.auditDataSource = auditDataSource;
        this.boardJdbc = new JdbcTemplate(boardDataSource);
    }

    @Transactional(transactionManager = "boardTransactionManager")
    public Observation register(long id, String title) {
        boardJdbc.update(
                "insert into proxy_post(id, title) values (?, ?)",
                id,
                title
        );
        return new Observation(
                TransactionSynchronizationManager.isActualTransactionActive(),
                TransactionSynchronizationManager.hasResource(boardDataSource),
                TransactionSynchronizationManager.hasResource(auditDataSource),
                TransactionSynchronizationManager.getCurrentTransactionName()
        );
    }

    public record Observation(
            boolean transactionActive,
            boolean boardResourceBound,
            boolean auditResourceBound,
            String transactionName
    ) { }
}

서비스 클래스와 경계 메서드는 클래스 기반 프록시가 재정의할 수 있도록 final이 아닙니다. 호출자는 이 클래스를 직접 new로 만들지 않고 컨테이너가 제공한 빈을 사용합니다.


실제 프록시와 완료 결과를 검증한다

다음 테스트 구성은 두 H2 데이터베이스와 두 매니저를 의도적으로 함께 등록합니다. 이름 추론에 맡기지 않고 애노테이션이 선택한 매니저와 리소스만 활성화되는지 확인합니다.

src/test/java/board/tx/proxy/boundary/TransactionBoundaryTest.java
package board.tx.proxy.boundary;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
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.beans.factory.annotation.Qualifier;
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.support.TransactionSynchronizationManager;

class TransactionBoundaryTest {
    private AnnotationConfigApplicationContext context;
    private JdbcTemplate boardJdbc;
    private JdbcTemplate auditJdbc;

    @BeforeEach
    void openContext() {
        context = new AnnotationConfigApplicationContext(BoundaryConfig.class);
        boardJdbc = new JdbcTemplate(context.getBean("boardDataSource", DataSource.class));
        auditJdbc = new JdbcTemplate(context.getBean("auditDataSource", DataSource.class));
        boardJdbc.execute("create table if not exists proxy_post(id bigint primary key, title varchar(120) not null)");
        auditJdbc.execute("create table if not exists proxy_audit(id bigint primary key)");
        boardJdbc.update("delete from proxy_post");
        auditJdbc.update("delete from proxy_audit");
    }

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

    @Test
    void proxy_entry_starts_and_completes_the_transaction() {
        var service = context.getBean(BoardTransactionService.class);

        assertTrue(AopUtils.isAopProxy(service));
        assertFalse(TransactionSynchronizationManager.isActualTransactionActive());

        var observation = service.register(41L, "proxy boundary");

        assertTrue(observation.transactionActive());
        assertTrue(observation.boardResourceBound());
        assertNotNull(observation.transactionName());
        assertTrue(observation.transactionName().endsWith(".register"));
        assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
        assertEquals(1, boardJdbc.queryForObject(
                "select count(*) from proxy_post",
                Integer.class
        ));
    }

    @Test
    void named_manager_binds_only_its_owned_data_source() {
        var service = context.getBean(BoardTransactionService.class);

        var observation = service.register(42L, "resource ownership");

        assertTrue(observation.boardResourceBound());
        assertFalse(observation.auditResourceBound());
        assertEquals(0, auditJdbc.queryForObject(
                "select count(*) from proxy_audit",
                Integer.class
        ));
    }

    @Configuration(proxyBeanMethods = false)
    @EnableTransactionManagement(proxyTargetClass = true)
    static class BoundaryConfig {
        @Bean("boardDataSource")
        DataSource boardDataSource() {
            return dataSource("b92_proxy_boundary_board");
        }

        @Bean("auditDataSource")
        DataSource auditDataSource() {
            return dataSource("b92_proxy_boundary_audit");
        }

        @Bean("boardTransactionManager")
        PlatformTransactionManager boardTransactionManager(
                @Qualifier("boardDataSource") DataSource dataSource
        ) {
            return new DataSourceTransactionManager(dataSource);
        }

        @Bean("auditTransactionManager")
        PlatformTransactionManager auditTransactionManager(
                @Qualifier("auditDataSource") DataSource dataSource
        ) {
            return new DataSourceTransactionManager(dataSource);
        }

        @Bean
        BoardTransactionService boardTransactionService(
                @Qualifier("boardDataSource") DataSource boardDataSource,
                @Qualifier("auditDataSource") DataSource auditDataSource
        ) {
            return new BoardTransactionService(boardDataSource, auditDataSource);
        }

        private static DataSource dataSource(String name) {
            var source = new JdbcDataSource();
            source.setURL("jdbc:h2:mem:" + name + ";DB_CLOSE_DELAY=-1");
            source.setUser("sa");
            source.setPassword("");
            return source;
        }
    }
}

리플렉션으로 애노테이션 존재만 검사해서는 프록시 진입을 증명할 수 없습니다. 실제 빈의 프록시 여부, 경계 안의 상태, 경계 밖의 행 결과를 함께 검증해야 합니다.

관찰 시점과 성공 시점을 나눠 보기 기존 외부 트랜잭션 없이 원 register 프록시를 호출합니다. 대상 안에서 transactionActive=true, 게시판 리소스 바인딩 true, 감사 리소스 바인딩 false를 관찰했습니다. 대상이 만든 결과를 반환한 다음, 매니저의 커밋이 제약 조건 오류로 실패했다고 가정하세요. 호출자가 정상 결과를 받았다고 말할 수 있나요? 이 관찰만으로 두 데이터베이스의 원자적 저장을 증명할 수 있나요?

진입 관찰과 최종 완료의 풀이

대상 반환 뒤에도 프록시의 완료 단계가 남아 있습니다. 그 단계가 실패하면 호출자는 프록시 호출의 실패를 받으며 정상 결과 전달을 확정할 수 없습니다. 세 상태는 경계 안에서 선택한 리소스를 관찰한 값이지 커밋 성공 증거가 아닙니다. 감사 리소스가 바인딩되지 않은 이 호출은 두 데이터베이스를 함께 원자적으로 저장했다는 증거도 아닙니다. 최종 행은 프록시 호출이 끝난 뒤 해당 데이터베이스에서 다시 확인합니다.


속성은 매니저와 리소스가 지원하는 만큼 동작한다

속성인터셉터가 전달하는 의도별도로 검증할 사실
readOnly읽기 전용 힌트와 flush 최적화데이터베이스가 쓰기를 실제 거절하는가
timeout매니저가 계산한 남은 작업 시간드라이버와 데이터베이스가 취소를 지원하는가
transactionManager로컬 리소스의 명시적 소유자선택된 매니저와 DataSource가 짝을 이루는가

readOnly=true는 모든 데이터베이스에서 쓰기를 강제 차단하는 보안 장치가 아닙니다. timeout도 외부 HTTP 호출 전체를 중단시키지 않으므로 클라이언트 타임아웃은 별도로 둡니다.


공개 사용 사례를 로컬 경계로 둔다

리포지토리 메서드마다 경계를 만들면 여러 저장 작업을 한 원자 단위로 묶기 어렵고, 컨트롤러에 경계를 두면 요청 파싱과 느린 네트워크 작업까지 연결을 오래 잡습니다. 애플리케이션 서비스의 공개 사용 사례가 기본 출발점입니다.

하나의 로컬 트랜잭션 매니저가 두 독립 데이터베이스나 원격 API를 자동으로 원자화하지는 않습니다. 원격 변경은 로컬 경계 밖의 명시적인 전달 구조로 분리하고, 트랜잭션 안에서는 해당 매니저가 실제로 소유한 리소스만 다룹니다.

사용 사례가 영속 엔티티를 그대로 반환해 웹 계층에서 지연 로딩하게 만들기보다 경계 안에서 필요한 불변 결과를 완성해 반환합니다. 프록시 기술보다 먼저 업무 단위와 리소스 소유권을 결정해야 합니다.


점검 질문

  • 호출자가 컨테이너에서 받은 프록시 참조를 사용하는가?
  • 선택할 매니저가 둘 이상이면 이름을 명시했는가?
  • 경계 안에서 실제로 바인딩되는 리소스를 테스트했는가?
  • 대상 반환과 커밋 완료를 같은 성공으로 오해하지 않았는가?
  • 원격 호출을 로컬 데이터베이스 트랜잭션 안에 오래 붙잡아 두지 않았는가?