@Transactional 프록시
프록시 진입부터 트랜잭션 매니저와 JDBC 리소스 바인딩, 완료까지의 실제 경계를 실행 가능한 테스트로 확인합니다.
@Transactional은 메서드 본문에 커밋 코드를 끼워 넣지 않습니다. Spring이 만든 프록시로 들어온 호출을 트랜잭션 어드바이저(advisor)가 가로채고, 선택된 PlatformTransactionManager가 자신이 관리하는 자원의 시작과 완료를 맡습니다. 그래서 애노테이션이 어디에 붙었는지만 보지 말고, 호출이 프록시를 지났는지, 어느 관리자가 선택되었는지, 어떤 자원이 현재 스레드에 묶였는지를 함께 확인해야 합니다.
트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다
프록시 진입·manager 선택·연결 바인딩·같은 연결 SQL·완료로 자원과 경계가 변한다.
PROXY · ADVISOR · RESOURCE OWNERSHIP
프록시 경계가 선택한 매니저와 자원을 묶는다
공개 호출에서 advisor가 속성과 매니저를 선택합니다. 그 매니저는 자신이 소유한 DataSource를 바인딩하며 대상 반환 뒤 완료 단계가 이어집니다.
- 프록시 진입 → 매니저 선택
컨테이너가 준 빈 참조로 공개 사용 사례를 호출합니다. advisor가 트랜잭션 속성과 boardTransactionManager를 고릅니다.
- 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: boardTransactionManager | transaction 소유자가 결정됩니다. |
| 연결 바인딩과 SQL manager가 Board DB 연결을 바인딩하고 repository가 같은 연결을 씁니다. | 연결: 같은 Board DB · 업무: repository SQL | 업무가 관리된 자원에 참여합니다. |
| transaction 완료 manager가 경계의 완료와 자원 정리를 소유합니다. | 완료: manager 소유 · 연결: 관리 경계 | 업무 호출 뒤 연결 소유 범위가 끝납니다. |
트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다
프록시 진입·manager 선택·연결 바인딩·같은 연결 SQL·완료로 자원과 경계가 변한다.
PROXY · ADVISOR · RESOURCE OWNERSHIP
프록시 경계가 선택한 매니저와 자원을 묶는다
공개 호출에서 advisor가 속성과 매니저를 선택합니다. 그 매니저는 자신이 소유한 DataSource를 바인딩하며 대상 반환 뒤 완료 단계가 이어집니다.
- 프록시 진입 → 매니저 선택
컨테이너가 준 빈 참조로 공개 사용 사례를 호출합니다. advisor가 트랜잭션 속성과 boardTransactionManager를 고릅니다.
- 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: boardTransactionManager | transaction 소유자가 결정됩니다. |
| 연결 바인딩과 SQL manager가 Board DB 연결을 바인딩하고 repository가 같은 연결을 씁니다. | 연결: 같은 Board DB · 업무: repository SQL | 업무가 관리된 자원에 참여합니다. |
| transaction 완료 manager가 경계의 완료와 자원 정리를 소유합니다. | 완료: manager 소유 · 연결: 관리 경계 | 업무 호출 뒤 연결 소유 범위가 끝납니다. |
그림은 호출한 쪽이 컨테이너가 준 빈 참조로 공개 사용 사례 register()를 부르면, 프록시의 어드바이저가 트랜잭션 속성과 boardTransactionManager를 고르고, 그 관리자가 자신이 가진 Board DB의 연결을 현재 트랜잭션에 묶는 모습을 보여 줍니다. 사용 사례의 저장소는 같은 연결로 SQL을 실행합니다. 호출의 관계와 자원 관리의 관계는 다른 선으로 그려져 있으며, 대상이 반환한 뒤에도 최종 완료 단계가 남아 있습니다.
프록시에 들어올 때 트랜잭션이 생깁니다
컨테이너가 프록시 빈을 만들어 두었다고 데이터베이스 트랜잭션이 바로 시작되지는 않습니다. 호출한 쪽이 컨테이너에서 받은 참조의 공개 메서드를 부를 때, 어드바이저가 트랜잭션 속성을 찾고 관리자를 고릅니다. 일반적인 JDBC 트랜잭션의 순서는 다음과 같습니다.
- 프록시가 메서드와 클래스의 트랜잭션 속성을 해석합니다.
- 지정된 관리자가 트랜잭션을 시작하고, 자신의
DataSource자원을 묶습니다. - 대상 사용 사례가 같은 자원으로 작업합니다.
- 대상이 정상적으로 반환하면 커밋하고, 롤백 규칙에 맞는 예외가 빠져나오면 롤백합니다.
대상 메서드가 값을 돌려준 뒤에도 제약 조건의 검사나 flush 때문에 커밋이 실패할 수 있습니다. 대상이 반환한 순간과 영속적으로 성공한 순간은 같지 않습니다.
관리자와 자원의 소유를 코드에 드러냅니다
다음 서비스에는 두 DataSource가 주입되지만, register는 boardTransactionManager를 명시합니다. 이 테스트는 바깥에 기존 트랜잭션이 없는 상태에서 호출하며, 그 조건에서 게시판 자원은 현재 스레드에 묶이고 감사 자원은 묶이지 않습니다. 호출한 쪽이 이미 감사 트랜잭션을 열었는지는 별개의 조건이므로, 관리자를 골랐다고 다른 자원이 언제나 묶이지 않는다고 일반화하지 않습니다.
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로 직접 만들지 않고, 컨테이너가 준 빈을 씁니다.
더 깊이: 이름을 적지 않으면 관리자는 어떻게 골라질까
어드바이저가 관리자를 고르는 순서는 Spring Framework 7.0.10의 TransactionAspectSupport.determineTransactionManager에 정해져 있습니다. 먼저 @Transactional의 transactionManager(또는 value)에 값이 있으면, 그 값을 한정자나 빈 이름으로 가진 관리자를 씁니다. 값이 없으면 6.2부터는 대상 클래스에 붙은 타입 수준의 @Qualifier도 살펴봐서, 그 값과 맞는 관리자가 있으면 씁니다. @Transactional의 API 문서는 이 한정자를 구체 클래스에 붙이면 상위 클래스에서 물려받은 한정자 없는 트랜잭션 메서드에도 적용된다고 설명합니다.
둘 다 없으면 구성에서 지정한 기본 관리자를 쓰고, 그것도 없으면 빈 팩토리에서 TransactionManager 타입의 빈 하나를 찾아 캐시합니다. 이 마지막 단계는 타입으로 빈 하나를 요구하므로, 이 글처럼 관리자가 둘인데 @Primary도 이름도 없다면 애플리케이션이 시작할 때가 아니라 첫 트랜잭션 호출에서 실패합니다. 두 관리자를 함께 등록한 구성이라면, 이름을 적지 않은 트랜잭션 메서드 하나를 테스트에서 실제로 호출해 보아야 이 실패를 일찍 발견할 수 있습니다.
실제 프록시와 완료 결과를 검증합니다
다음 테스트 구성은 H2 데이터베이스 두 개와 관리자 두 개를 일부러 함께 등록합니다. 이름 추론에 맡기지 않고, 애노테이션이 고른 관리자와 자원만 활성화되는지 확인합니다.
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;
}
}
}리플렉션으로 애노테이션이 있는지만 검사해서는 프록시를 지났다는 것을 보여 줄 수 없습니다. 실제 빈이 프록시인지와 트랜잭션 안의 상태, 트랜잭션 밖의 행의 결과를 함께 검증해야 합니다.
속성은 관리자와 자원이 지원하는 만큼만 동작합니다
| 속성 | 인터셉터가 전하는 의도 | 따로 검증할 사실 |
|---|---|---|
readOnly | 읽기 전용 힌트와 flush 최적화 | 데이터베이스가 쓰기를 실제로 거절하는가 |
timeout | 관리자가 계산한 남은 작업 시간 | 드라이버와 데이터베이스가 취소를 지원하는가 |
transactionManager | 로컬 자원을 관리할 쪽의 명시 | 고른 관리자와 DataSource가 짝이 맞는가 |
readOnly=true는 모든 데이터베이스에서 쓰기를 강제로 막는 보안 장치가 아닙니다. timeout도 외부 HTTP 호출 전체를 멈추지 않으므로, 클라이언트의 타임아웃은 따로 둡니다.
공개 사용 사례를 로컬 트랜잭션의 범위로 둡니다
저장소 메서드마다 트랜잭션을 만들면 여러 저장 작업을 하나의 원자적 단위로 묶기 어렵고, 컨트롤러에 두면 요청 파싱과 느린 네트워크 작업까지 연결을 오래 쥡니다. 애플리케이션 서비스의 공개 사용 사례가 기본적인 출발점입니다.
로컬 트랜잭션 관리자 하나가 서로 독립된 두 데이터베이스나 원격 API를 저절로 원자적으로 묶어 주지는 않습니다. 원격 변경은 로컬 트랜잭션 밖의 명시적인 전달 구조로 나누고, 트랜잭션 안에서는 그 관리자가 실제로 가진 자원만 다룹니다.
사용 사례가 영속 엔티티를 그대로 돌려줘 웹 계층에서 지연 로딩하게 하기보다, 트랜잭션 안에서 필요한 불변 결과를 완성해 돌려줍니다. 프록시 기술보다 먼저 업무의 단위와 자원을 누가 관리하는지를 정해야 합니다.
정리
@Transactional은 컨테이너가 준 프록시 참조로 공개 메서드를 호출할 때만 어드바이저가 가로채 트랜잭션을 시작합니다.- 관리자가 여럿이면 이름을 명시하고, 고른 관리자가 자신의
DataSource만 현재 스레드에 묶는다는 것을 테스트로 확인합니다. - 대상이 값을 돌려준 뒤에도 커밋이 실패할 수 있으므로, 최종 성공은 프록시 호출이 끝난 뒤의 행으로 확인합니다.
readOnly와timeout은 관리자와 자원이 지원하는 만큼만 동작하며, 보안 장치나 HTTP 취소가 아닙니다.- 트랜잭션은 공개 사용 사례에 두고, 원격 호출이나 다른 데이터베이스는 로컬 트랜잭션이 원자적으로 묶어 주지 않습니다.
연습 문제
1. 관찰한 시점과 성공한 시점
바깥에 트랜잭션이 없는 상태에서 register 프록시를 호출합니다. 대상 안에서 transactionActive=true, 게시판 자원의 묶임은 참, 감사 자원의 묶임은 거짓으로 보였습니다. 대상이 결과를 돌려준 다음 관리자의 커밋이 제약 조건 오류로 실패했다고 가정하세요. 호출한 쪽이 정상 결과를 받았다고 말할 수 있나요? 이 관찰만으로 두 데이터베이스에 원자적으로 저장되었다고 할 수 있나요?
해설 보기
대상이 반환한 뒤에도 프록시의 완료 단계가 남아 있습니다. 그 단계가 실패하면 호출한 쪽은 프록시 호출의 실패를 받으므로, 정상 결과가 전달되었다고 확정할 수 없습니다. 세 가지 상태는 트랜잭션 안에서 고른 자원을 본 값일 뿐, 커밋이 성공했다는 뜻이 아닙니다. 감사 자원이 묶이지 않은 이 호출은 두 데이터베이스에 함께 원자적으로 저장했다는 뜻도 아닙니다. 최종 행은 프록시 호출이 끝난 뒤 해당 데이터베이스에서 다시 확인합니다.
2. 트랜잭션 설계를 점검하는 질문
새로 만든 서비스의 트랜잭션 설계를 검토하려고 합니다. 프록시와 관리자, 자원, 완료, 원격 호출의 관점에서 확인해야 할 질문을 다섯 가지로 정리해 보세요.
해설 보기
첫째, 호출한 쪽이 new로 만든 객체가 아니라 컨테이너가 준 프록시 참조를 쓰는지 확인합니다. 둘째, 관리자가 둘 이상이라면 transactionManager 이름을 명시했는지 봅니다. 셋째, 트랜잭션 안에서 실제로 묶이는 자원을 테스트로 확인했는지 봅니다. 넷째, 대상이 반환한 것과 커밋이 끝난 것을 같은 성공으로 오해하지 않고, 최종 행을 트랜잭션 밖에서 확인하는지 봅니다. 다섯째, 원격 호출을 로컬 데이터베이스 트랜잭션 안에서 오래 기다리지 않고, 아웃박스 같은 별도의 전달 구조로 나눴는지 봅니다.
다음 글 롤백 규칙에서는 어떤 예외가 롤백을 일으키고 어떤 예외는 커밋되는지, 그 규칙을 실행으로 확인합니다.