@Transactional 프록시
프록시 진입부터 트랜잭션 매니저와 JDBC 리소스 바인딩, 완료까지의 실제 경계를 실행 가능한 테스트로 확인합니다.
@Transactional은 메서드 본문에 커밋 코드를 삽입하지 않습니다. Spring이 만든 프록시로 들어온 호출을 트랜잭션 어드바이저가 가로채고, 선택된 PlatformTransactionManager가 자신이 소유한 리소스의 시작과 완료를 담당합니다.
따라서 애노테이션 위치만 보지 말고 호출이 프록시를 통과했는지, 어느 매니저가 선택됐는지, 어떤 리소스가 현재 스레드에 묶였는지를 함께 확인해야 합니다.
PROXY · ADVISOR · RESOURCE OWNERSHIP
트랜잭션은 프록시 진입부터 매니저가 소유한 리소스까지 이어진다
컨테이너 빈을 호출하면 어드바이저가 속성과 매니저를 선택하고, 그 매니저가 소유한 DataSource만 현재 경계에 바인딩한다. 대상 반환 뒤 완료 단계까지 성공해야 로컬 변경이 영속된다.
PROXY ENTRY
컨테이너가 준 빈 참조로 공개 사용 사례에 진입한다
빈 생성이나 주입만으로 트랜잭션이 시작되지는 않으며 실제 프록시 호출이 경계를 엽니다.
ADVISOR
속성과 이름이 지정된 매니저를 선택한다
readOnly와 timeout은 리소스가 지원하는 범위의 의도이며 절대적 강제 장치가 아닙니다.
OWNED RESOURCE
선택된 매니저가 자신의 DataSource만 바인딩한다
대상과 repository는 같은 로컬 경계에 참여하고 다른 DataSource는 자동으로 묶이지 않습니다.
COMPLETION
대상 반환 뒤 커밋 또는 롤백 완료까지 확인한다
프록시 여부와 활성 상태만이 아니라 경계 밖의 최종 행 결과로 영속적 성공을 검증합니다.
공개 애플리케이션 사용 사례를 로컬 경계로 두고, 매니저 이름과 리소스 소유권을 테스트한다. 원격 변경은 이 경계 밖의 별도 전달 구조가 소유한다.
프록시 진입이 경계를 만든다
컨테이너가 프록시 빈을 만들어 두었다고 데이터베이스 트랜잭션이 곧바로 시작되지는 않습니다. 호출자가 컨테이너에서 받은 참조의 공개 메서드를 호출할 때 어드바이저가 트랜잭션 속성을 찾고 매니저를 선택합니다.
일반적인 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로 만들지 않고 컨테이너가 제공한 빈을 사용합니다.
실제 프록시와 완료 결과를 검증한다
다음 테스트 구성은 두 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를 자동으로 원자화하지는 않습니다. 원격 변경은 로컬 경계 밖의 명시적인 전달 구조로 분리하고, 트랜잭션 안에서는 해당 매니저가 실제로 소유한 리소스만 다룹니다.
사용 사례가 영속 엔티티를 그대로 반환해 웹 계층에서 지연 로딩하게 만들기보다 경계 안에서 필요한 불변 결과를 완성해 반환합니다. 프록시 기술보다 먼저 업무 단위와 리소스 소유권을 결정해야 합니다.
점검 질문
- 호출자가 컨테이너에서 받은 프록시 참조를 사용하는가?
- 선택할 매니저가 둘 이상이면 이름을 명시했는가?
- 경계 안에서 실제로 바인딩되는 리소스를 테스트했는가?
- 대상 반환과 커밋 완료를 같은 성공으로 오해하지 않았는가?
- 원격 호출을 로컬 데이터베이스 트랜잭션 안에 오래 붙잡아 두지 않았는가?