트랜잭션 프록시 우회
같은 인스턴스의 내부 호출이 프록시를 우회하는 이유를 재현하고 외부 협력 객체의 공개 경계로 재설계합니다.
애노테이션이 보이는데 트랜잭션이 시작되지 않는 가장 흔한 원인은 호출이 프록시를 지나지 않았기 때문입니다.
같은 인스턴스의 this.inner()는 대상 객체 안에서 바로 실행됩니다. inner()에 @Transactional이 있어도 두 번째 프록시 진입이 만들어지지 않으므로 새 어드바이스 경계가 생기지 않습니다.
SELF INVOCATION · PROXY ENTRY
this 호출은 새 advice를 만들지 않는다
같은 인스턴스의 내부 호출은 프록시로 되돌아가지 않습니다. 독립 책임이 필요하면 별도 worker 빈의 공개 진입점으로 호출합니다.
- Proxy outer() → 대상 this.inner()
외부 메서드에 트랜잭션 속성이 있을 때만 TX advice가 적용됩니다. 이 예제의 outer에는 속성이 없어 새 트랜잭션을 열지 않습니다. 내부 애노테이션은 새 경계를 만들지 않습니다. 외부 트랜잭션이 이미 있으면 그 경계 안에서 실행됩니다.
- Coordinator → Worker Proxy → cleanOne()
트랜잭션 없는 조정자가 항목마다 worker의 공개 메서드를 호출합니다. 충돌 항목만 롤백하고 조정자가 집계 후 다음 항목으로 갑니다.
하나의 업무 단위라면
바깥 공개 메서드가 경계를 소유하고 내부 helper는 함께 참여합니다. 자기 호출 때문에 기존 트랜잭션까지 사라지는 것은 아닙니다.
새 경계가 필요하다면
별도 worker의 공개 메서드를 호출합니다. 로컬 코드 블록 자체가 경계라면 TransactionTemplate을 검토합니다. 생성·초기화 호출은 최종 프록시 노출 전입니다.
프록시 기반 모델의 설명입니다. private·final 메서드의 재정의에 기대지 않고 외부에서 호출하는 공개 사용 사례를 경계로 둡니다.
외부 호출과 자기 호출은 경로가 다르다
외부 호출자는 컨테이너가 제공한 프록시 참조를 사용합니다. 프록시는 해당 메서드의 트랜잭션 속성을 해석한 뒤 대상을 호출합니다.
반면 대상 메서드가 같은 객체의 다른 메서드를 부르면 Java의 일반 메서드 호출입니다. 실행 흐름이 프록시로 되돌아가지 않으므로 내부 메서드의 애노테이션을 해석할 인터셉터가 없습니다.
이 차이는 설정 오류가 아닙니다. 프록시 밖에서 시작한 호출과 대상 안에서 끝난 호출이라는 객체 경로의 차이입니다.
경계가 필요하면 협력 객체로 분리한다
다음 코드는 우회되는 형태와 가장 단순한 재설계를 함께 정의합니다. 조정자는 같은 클래스의 내부 메서드를 호출하는 대신 주입받은 워커의 공개 메서드를 호출합니다.
package board.tx.proxy.selfcall;
import java.util.List;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public final class SelfInvocationServices {
private SelfInvocationServices() { }
public static class SelfCallingService {
public boolean outerCallsInner() {
return innerTransactionActive();
}
@Transactional
public boolean innerTransactionActive() {
return TransactionSynchronizationManager
.isActualTransactionActive();
}
}
public static class TransactionalWorker {
@Transactional
public WorkerObservation cleanOne(long postId) {
return new WorkerObservation(
postId,
TransactionSynchronizationManager
.isActualTransactionActive(),
TransactionSynchronizationManager
.getCurrentTransactionName()
);
}
}
public static final class CleanupCoordinator {
private final TransactionalWorker worker;
public CleanupCoordinator(TransactionalWorker worker) {
this.worker = worker;
}
public WorkerObservation runOne(long postId) {
return worker.cleanOne(postId);
}
public BatchResult runBatch(List<Long> postIds) {
boolean coordinatorTransactionActive =
TransactionSynchronizationManager
.isActualTransactionActive();
int completed = 0;
int conflicted = 0;
for (long postId : postIds) {
try {
worker.cleanOne(postId);
completed++;
} catch (CleanupConflict failure) {
conflicted++;
}
}
return new BatchResult(
completed,
conflicted,
coordinatorTransactionActive
);
}
}
public record WorkerObservation(
long postId,
boolean transactionActive,
String transactionName
) { }
public record BatchResult(
int completed,
int conflicted,
boolean coordinatorTransactionActive
) { }
public static final class CleanupConflict extends RuntimeException {
public CleanupConflict(long postId) {
super("cleanup conflict: " + postId);
}
}
}CleanupCoordinator는 순서와 입력 선택을 소유하고 트랜잭션 없이 항목을 순회합니다. 각 worker.cleanOne() 호출은 주입받은 워커 프록시를 통과해 한 항목만 소유하는 짧은 경계를 엽니다. 충돌한 워커 호출이 롤백된 뒤 조정자가 예외를 집계하므로 이미 끝난 항목과 다음 항목은 그 실패에 묶이지 않습니다. BatchResult는 성공 수와 충돌 수를 경계 밖에서 합칩니다.
TransactionalWorker가 독립된 데이터베이스 책임을 가지므로 빈 분리는 프록시를 다시 호출하기 위한 기교가 아니라 설계입니다.
두 호출 경로를 같은 컨텍스트에서 검증한다
테스트는 프록시 빈의 innerTransactionActive()를 외부에서 직접 호출한 결과와 outerCallsInner()가 자기 호출한 결과를 비교합니다. 이어서 조정자가 주입받은 워커 프록시로 진입하는 재설계를 검증합니다.
package board.tx.proxy.selfcall;
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 java.util.List;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
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.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
class SelfInvocationRedesignTest {
@Test
void same_instance_call_bypasses_the_second_proxy_entry() {
try (var context = new AnnotationConfigApplicationContext(ProxyConfig.class)) {
var service = context.getBean(
SelfInvocationServices.SelfCallingService.class
);
assertTrue(AopUtils.isAopProxy(service));
assertTrue(service.innerTransactionActive());
assertFalse(service.outerCallsInner());
}
}
@Test
void coordinator_enters_the_injected_worker_through_its_proxy() {
try (var context = new AnnotationConfigApplicationContext(ProxyConfig.class)) {
var worker = context.getBean(
SelfInvocationServices.TransactionalWorker.class
);
var coordinator = context.getBean(
SelfInvocationServices.CleanupCoordinator.class
);
var observation = coordinator.runOne(73L);
assertTrue(AopUtils.isAopProxy(worker));
assertTrue(observation.transactionActive());
assertNotNull(observation.transactionName());
assertTrue(observation.transactionName().endsWith(".cleanOne"));
}
}
@Test
void transactionless_coordinator_isolates_and_counts_worker_items() {
try (var context = new AnnotationConfigApplicationContext(ProxyConfig.class)) {
var coordinator = context.getBean(
SelfInvocationServices.CleanupCoordinator.class
);
var result = coordinator.runBatch(List.of(71L, 72L, 73L));
assertFalse(result.coordinatorTransactionActive());
assertEquals(2, result.completed());
assertEquals(1, result.conflicted());
}
}
static class ConflictWorker
extends SelfInvocationServices.TransactionalWorker {
@Override
@Transactional
public SelfInvocationServices.WorkerObservation cleanOne(long postId) {
if (postId == 72L) {
throw new SelfInvocationServices.CleanupConflict(postId);
}
return super.cleanOne(postId);
}
}
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
static class ProxyConfig {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:b92_proxy_selfcall;DB_CLOSE_DELAY=-1");
source.setUser("sa");
source.setPassword("");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
SelfInvocationServices.SelfCallingService selfCallingService() {
return new SelfInvocationServices.SelfCallingService();
}
@Bean
SelfInvocationServices.TransactionalWorker transactionalWorker() {
return new ConflictWorker();
}
@Bean
SelfInvocationServices.CleanupCoordinator cleanupCoordinator(
SelfInvocationServices.TransactionalWorker worker
) {
return new SelfInvocationServices.CleanupCoordinator(worker);
}
}
}이 테스트는 애노테이션 존재를 검사하지 않고 호출 경로별 실제 트랜잭션 활성 상태를 관찰합니다. 배치 테스트는 가운데 항목의 충돌 뒤에도 다음 항목이 실행되어 성공 2건과 충돌 1건이 집계되는지 확인합니다.
이 worker는 트랜잭션 활성 상태와 이름을 반환하는 프로브입니다. completed=2는 두 호출이 예외 없이 완료됐다는 수이며, 두 게시글을 실제로 정리한 행 수가 아닙니다. 데이터 변경을 추가하는 실습에서는 DML과 경계 밖의 최종 행 검증도 함께 넣어야 합니다.
조정자에 외부 경계를 추가하면 무엇이 바뀌나요? 원 테스트는 트랜잭션 없는 조정자가 71·72·73을 호출합니다. 이번에는 같은 DataSource의 외부 REQUIRED 프록시가 이미 트랜잭션을 연 상태에서 같은 조정자를 호출한다고 가정하세요. worker는 원 코드처럼 기본 REQUIRED이며 72에서 런타임 실패가 나고 조정자가 이를 잡습니다. 별도 worker 빈이라는 이유만으로 항목별 독립 커밋이 유지될까요?
프록시 진입과 물리 독립성의 풀이
새 worker 프록시 진입은 여전히 있지만 REQUIRED는 기존 외부 물리 트랜잭션에 참여합니다. 조정자의 활성 상태도 이제 true입니다. 내부 72 실패가 공유 경계를 rollback-only로 표시하면 조정자가 계속 순회해도 외부 정상 커밋을 보장하지 못합니다. 각 성공 호출의 completed 집계는 최종 DB 커밋 집계와 다릅니다.
원 테스트의 항목별 경계는 조정자뿐 아니라 호출 진입 시 기존 트랜잭션이 없다는 전제에 달려 있습니다. 별도 빈은 advice를 통과하게 하고, REQUIRED·REQUIRES_NEW 같은 전파는 물리 경계의 관계를 결정합니다. 두 선택을 분리해 설명하세요.
정말 로컬 블록이라면 TransactionTemplate을 선택할 수 있다
별도 협력 객체의 책임이 아니라 같은 사용 사례 안의 작은 로컬 블록 자체가 경계여야 한다면 TransactionTemplate 콜백으로 범위를 명시할 수 있습니다. 콜백은 트랜잭션 매니저를 직접 사용하므로 자기 호출에 기대지 않습니다.
대신 애노테이션 기반 선언형 객체 경계의 간결함을 포기하고 애플리케이션 코드가 Spring 트랜잭션 API와 전파·예외 정책에 직접 결합됩니다. 독립 책임이면 워커 빈을 우선하고, 코드 블록 자체가 의도한 단위일 때만 이 선택을 사용합니다.
private·final·비공개 메서드를 경계로 삼지 않는다
클래스 기반 프록시는 대상 메서드를 재정의해 가로챕니다. private 메서드는 외부 사용 사례가 아니며 하위 클래스에서 재정의할 수 없습니다. final 메서드도 재정의를 막습니다.
비공개 메서드 지원 범위는 프록시 방식과 설정에 따라 달라질 수 있으므로 “모든 비공개 메서드는 언제나 동작하지 않는다”라고 단정할 필요는 없습니다. 대신 트랜잭션 경계를 외부 협력자가 호출하는 명시적인 공개 애플리케이션 메서드에 둡니다.
도우미 메서드는 애노테이션 없이 바깥 경계에 참여하게 두고, 별도의 업무 경계가 필요할 때만 독립 책임의 빈으로 추출합니다. 애노테이션을 살리기 위해 메서드 가시성만 바꾸는 것은 설계 이유가 아닙니다.
AspectJ 위빙은 프록시와 다른 실행 모델이어서 자기 호출 조인 지점을 다룰 수 있지만, 일반 프록시 모드의 동작을 바꾸지는 않습니다. 이 장의 문제를 해결하려고 별도 빌드·런타임 모델을 도입하지 않습니다.
생성과 초기화 단계에는 프록시 진입이 없다
대상 인스턴스의 생성자와 초기화 콜백은 최종 프록시 참조를 애플리케이션에 노출하기 전에 실행됩니다. 그 단계에서 자기 트랜잭션 메서드를 호출해도 같은 인스턴스 내부 호출입니다.
스키마 변경은 Flyway나 Liquibase처럼 목적에 맞는 도구가 소유해야 합니다. 시작 뒤 수행해야 하는 데이터 작업은 완성된 다른 빈을 호출하는 ApplicationRunner 같은 외부 진입점에 두고, 여러 인스턴스가 함께 시작해도 안전하도록 멱등성을 설계합니다.
가장 단순한 수정 순서
- 호출자가 실제 Spring 빈 참조를 쓰는지 확인합니다.
- 대상 내부의
this호출과 생성·초기화 호출을 찾습니다. - 두 메서드가 하나의 업무 경계라면 내부 애노테이션을 제거하고 바깥 경계에 참여시킵니다.
- 독립 경계가 필요한 별도 책임이라면 협력 객체로 추출하고 주입받은 프록시의 공개 메서드를 호출합니다.
- 외부 호출과 자기 호출을 각각 실행해 활성 상태와 최종 데이터 결과를 고정합니다.
자기 프록시 주입이나 프록시 컨텍스트 조회 같은 호환 우회 경로를 추가하지 않습니다. 호출 구조를 드러내는 객체 분리가 더 작고 오래가는 해결책입니다.