트랜잭션 프록시 우회
같은 대상 내부 호출·private 메서드·생성 및 PostConstruct 단계에서 @Transactional 인터셉터가 실행되지 않는 경로를 재현하고, 빈 분리와 orchestration 구조로 해결합니다.
애노테이션이 보이는데 트랜잭션이 시작되지 않는 가장 흔한 원인은 프록시를 지나지 않은 호출입니다.
같은 인스턴스의 this.inner()는 대상 내부에서 곧바로 실행되고, 생성자와 @PostConstruct도 프록시가 완성되기 전에 수행됩니다.
회원 게시판의 배치 초기화나 감사 저장이 조용히 자동 커밋으로 실행되지 않게 객체 생명주기와 호출 경계를 함께 그려야 합니다.
내부 호출의 프록시 우회
외부 클라이언트가 Spring 빈 참조를 호출하면 프록시가 메서드를 가로챕니다.
대상 메서드 안에서 다른 메서드를 부르면 Java의 일반적인 this 호출이므로 프록시로 되돌아가지 않습니다.
내부에 REQUIRES_NEW를 붙여도 새 트랜잭션을 시작할 인터셉터가 실행되지 않습니다.
package board.tx;
import javax.sql.DataSource;
import org.h2.jdbcx.JdbcDataSource;
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;
import org.springframework.transaction.support.TransactionSynchronizationManager;
public final class SelfInvocationProbe {
static class PostJobs {
public boolean outerCallsInner() {
return innerTransactionState();
}
@Transactional
public boolean innerTransactionState() {
return TransactionSynchronizationManager
.isActualTransactionActive();
}
}
@Configuration
@EnableTransactionManagement(proxyTargetClass = true)
static class Config {
@Bean
DataSource dataSource() {
var source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:self_call;DB_CLOSE_DELAY=-1");
return source;
}
@Bean
PlatformTransactionManager transactionManager(DataSource source) {
return new DataSourceTransactionManager(source);
}
@Bean
PostJobs postJobs() {
return new PostJobs();
}
}
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
PostJobs jobs = context.getBean(PostJobs.class);
System.out.printf("throughSelf=%s throughProxy=%s%n",
jobs.outerCallsInner(),
jobs.innerTransactionState());
}
}
}throughSelf=false throughProxy=trueSpring 설정이 고장 난 것이 아니라 두 호출 경로가 다릅니다.
디버그 로그에서 트랜잭션 이름이 없고 JDBC 자동 커밋이 true라면 빈 참조의 실제 클래스와 호출자를 먼저 확인합니다.
private·final 메서드
private 메서드는 외부 클라이언트가 호출할 수 없으므로 사용 사례 경계가 될 수 없습니다.
클래스 기반 프록시가 상속하더라도 private 메서드를 재정의할 수 없습니다.
final 메서드도 하위 클래스 가로채기를 막습니다.
애노테이션을 붙여 컴파일러 오류가 나지 않는 것과 어드바이스가 실행되는 것은 별개입니다.
Spring 7의 프록시 기능이 비-공개 메서드 일부를 지원하더라도, 트랜잭션 경계는 외부 협력자가 호출하는 명시적인 애플리케이션 메서드에 두는 편이 안전합니다. 인터페이스 프록시와 클래스 프록시를 바꿨을 때 의미가 달라지는 설계를 피합니다.
도우미 메서드는 애노테이션 없이 외부 트랜잭션에 참여하게 둡니다. 별도 전파가 정말 필요하면 도우미가 아니라 독립 책임의 빈으로 추출합니다. “애노테이션을 살리기 위해 공개”로 바꾸기보다 업무 경계가 분리되는 이유를 먼저 설명합니다.
생성자·PostConstruct 호출
컨테이너는 대상 인스턴스를 만들고 의존성을 주입한 뒤 초기화 콜백을 실행하고, 이후 빈 후처리기가 프록시를 반환합니다.
대상의 @PostConstruct가 자기 트랜잭션 메서드를 호출하면 앞의 내부 호출과 같은 경로입니다.
생성자에서 DB를 수정하는 것은 의존성 준비와 애플리케이션 시작 실패 복구까지 더 어렵게 만듭니다.
시작 마이그레이션은 Flyway·Liquibase처럼 목적에 맞는 도구에 맡깁니다.
캐시 준비 작업이나 초기 데이터 확인이 필요하면 ApplicationRunner 또는 애플리케이션 준비 이벤트 리스너가 완성된 다른 빈을 호출하게 합니다.
여러 인스턴스가 동시에 시작할 때 멱등성과 분산 잠금도 고려합니다.
초기화 실패를 무조건 삼키고 서버를 준비로 표시하면 첫 요청에서 늦게 장애가 드러납니다. 필수 스키마와 인증 정보 검사는 시작을 실패시키고, 선택 캐시 준비 작업은 성능 저하 상태와 재시도로 분리할 수 있습니다.
트랜잭션 워커 분리
아래 구조에서 스케줄러는 대상 ID를 선택하고 워커의 프록시 메서드를 하나씩 호출합니다. 각 게시글 정리가 독립 트랜잭션이므로 한 건의 충돌이 전체 배치 연결을 오래 잡지 않습니다.
package board.batch;
import java.util.List;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
public final class PostCleanupJob {
public static class CleanupCoordinator {
private final ExpiredPostQuery query;
private final CleanupWorker worker;
public CleanupCoordinator(
ExpiredPostQuery query,
CleanupWorker worker
) {
this.query = query;
this.worker = worker;
}
public JobResult run() {
int completed = 0;
int conflicted = 0;
for (long id : query.expiredIds(100)) {
try {
worker.cleanOne(id);
completed++;
} catch (StalePost failure) {
conflicted++;
}
}
return new JobResult(completed, conflicted);
}
}
public static class CleanupWorker {
private final PostCommands commands;
public CleanupWorker(PostCommands commands) {
this.commands = commands;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cleanOne(long postId) {
if (!commands.archiveIfExpired(postId)) {
throw new StalePost(postId);
}
}
}
public interface ExpiredPostQuery {
List<Long> expiredIds(int limit);
}
public interface PostCommands {
boolean archiveIfExpired(long postId);
}
public record JobResult(int completed, int conflicted) { }
public static final class StalePost extends RuntimeException {
public StalePost(long id) {
super("post is no longer expired: " + id);
}
}
}CleanupWorker는 Spring 빈이어야 하며 조정자는 주입받은 참조를 사용합니다.
new CleanupWorker로 만들면 다시 프록시가 없습니다.
REQUIRES_NEW는 연결을 추가로 요구할 수 있으므로 외부 조정자 자체에는 긴 트랜잭션을 두지 않습니다.
우회 기법의 위험
자기 프록시를 주입하거나 AopContext.currentProxy()로 다시 호출하는 우회책이 있습니다.
순환 의존성, 스레드-로컬 프록시 컨텍스트, 프레임워크 결합이 생기고 단위 테스트가 어려워집니다.
짧은 레거시 브리지로 제한하고 빈 분리를 기본 해결책으로 삼습니다.
프로그래밍 방식 트랜잭션도 대안입니다.
같은 클래스 안에서 작은 블록만 새 경계로 실행해야 하고 독립 빈이 과한 경우 TransactionTemplate를 필드로 주입해 콜백을 명시합니다.
이 방식은 내부 호출 영향을 받지 않지만 전파와 예외 정책을 코드로 읽어야 합니다.
AspectJ 위빙은 프록시가 아닌 바이트코드 조인 지점을 사용해 내부 호출도 가로채기할 수 있습니다. 빌드와 런타임 복잡도가 커지고 팀의 사고 모델이 달라지므로 프록시 한계를 피하려는 이유 하나만으로 도입하지 않습니다.
호출 경로별 검증
프록시 외부 호출과 자기 호출을 각각 실행해 활성, 읽기 전용, 트랜잭션 이름, 연결 동일성을 기록합니다.
REQUIRES_NEW 검증이라면 내부가 다른 연결과 독립 커밋을 쓰는지도 확인합니다.
로그 문자열만 보지 말고 롤백 뒤 행 결과를 함께 검사합니다.
시작 경로는 컨텍스트 새로 고침 단계에서 콜백이 실행됐는지, 준비 이벤트 이후 워커 프록시를 호출했는지 분리합니다. 애플리케이션이 여러 번 재시작되어도 초기 데이터가 중복되지 않게 고유 키와 업서트 정책을 둡니다.
연습 문제
한 서비스의 registerAll()이 내부 registerOne()을 호출하고 후자에 REQUIRES_NEW가 붙은 코드를 고치세요.
한 행 실패가 나머지 행을 막지 않아야 하며, 연결 풀을 고갈시키지 않는 배치 크기와 호출 구조를 제시하세요.
해설 보기
조정자에는 트랜잭션을 두지 않고 워커 빈이 각 행을 처리하면 동시에 외부 연결과 내부 연결을 잡지 않습니다. 실패 집계는 데이터베이스 트랜잭션과 분리된 메모리 결과로 유지합니다.
package board.batch;
import java.util.ArrayList;
import java.util.List;
public final class RegistrationBatch {
private final RegistrationWorker worker;
public RegistrationBatch(RegistrationWorker worker) {
this.worker = worker;
}
public BatchReport registerAll(List<String> titles) {
var failed = new ArrayList<String>();
int saved = 0;
for (String title : titles) {
try {
worker.registerOne(title);
saved++;
} catch (RuntimeException failure) {
failed.add(title);
}
}
return new BatchReport(saved, List.copyOf(failed));
}
public interface RegistrationWorker {
void registerOne(String title);
}
public record BatchReport(int saved, List<String> failedTitles) { }
}실제 워커 구현의 공개 메서드에 트랜잭션을 둡니다. 실패한 게시글 본문이 민감한 정보라면 보고서와 로그에는 해시 또는 내부 명령 ID를 기록합니다.