본문 바로가기

트랜잭션 프록시 우회

같은 인스턴스의 내부 호출이 프록시를 우회하는 이유를 재현하고 외부 협력 객체의 공개 경계로 재설계합니다.

애노테이션이 보이는데도 트랜잭션이 시작되지 않는 가장 흔한 원인은, 호출이 프록시를 지나지 않았기 때문입니다. 같은 인스턴스의 this.inner()는 대상 객체 안에서 바로 실행됩니다. inner()에 @Transactional이 있어도 프록시에 두 번째로 들어오는 일이 없으므로, 새 어드바이스가 적용되지 않습니다.

SELF INVOCATION · PROXY ENTRY

this 호출은 새 advice를 만들지 않는다

같은 인스턴스의 내부 호출은 프록시로 되돌아가지 않습니다. 독립 책임이 필요하면 별도 worker 빈의 공개 진입점으로 호출합니다.

같은 인스턴스 호출과 별도 worker 프록시 호출첫 행은 proxy outer 진입 뒤 같은 대상의 this.inner를 호출하므로 내부 애노테이션을 해석하는 새 advice가 없다. 둘째 행은 트랜잭션 없는 조정자가 주입받은 별도 worker proxy를 호출해 항목당 짧은 트랜잭션 경계를 연다.같은 인스턴스 · 프록시 재진입 없음Proxy · outer()TX advice는 조건부대상 outer()→ this.inner()inner() 내부 호출새 advice 없음별도 worker 빈 · 공개 메서드로 진입Coordinator트랜잭션 없는 항목 반복Worker Proxy공개 cleanOne()Worker 대상항목당 짧은 경계
자기 호출과 협력 proxy의 두 경로서로잇지않는두비교경로의6노드4간선이다. 위는proxy outer→targetouter→thisinner로새advice가없다. 아래는기존TX없는coordinator→workerproxy→target으로항목별REQUIRED경계를연다. 기존외부TX가있다면아래도그물리경계에참여하며별도빈자체가독립commit을보장하지않는다.같은 인스턴스협력 프록시프록시 outeradvice 조건부대상 outerthis.inner새 advice 없음조정자기존 TX 없음worker 프록시REQUIRED 진입worker 대상항목별 경계
  1. Proxy outer() → 대상 this.inner()

    외부 메서드에 트랜잭션 속성이 있을 때만 TX advice가 적용됩니다. 이 예제의 outer에는 속성이 없어 새 트랜잭션을 열지 않습니다. 내부 애노테이션은 새 경계를 만들지 않습니다. 외부 트랜잭션이 이미 있으면 그 경계 안에서 실행됩니다.

  2. Coordinator → Worker Proxy → cleanOne()

    트랜잭션 없는 조정자가 항목마다 worker의 공개 메서드를 호출합니다. 충돌 항목만 롤백하고 조정자가 집계 후 다음 항목으로 갑니다.

하나의 업무 단위라면

바깥 공개 메서드가 경계를 소유하고 내부 helper는 함께 참여합니다. 자기 호출 때문에 기존 트랜잭션까지 사라지는 것은 아닙니다.

새 경계가 필요하다면

별도 worker의 공개 메서드를 호출합니다. 로컬 코드 블록 자체가 경계라면 TransactionTemplate을 검토합니다. 생성·초기화 호출은 최종 프록시 노출 전입니다.

프록시 기반 모델의 설명입니다. private·final 메서드의 재정의에 기대지 않고 외부에서 호출하는 공개 사용 사례를 경계로 둡니다.

그림은 서로 이어지지 않는 두 경로를 비교합니다. 위쪽은 프록시의 outer()로 들어온 뒤 같은 대상 안에서 this.inner()를 부르므로, 안쪽 애노테이션을 해석할 새 어드바이스가 없는 경로입니다. 이 예제의 outer에는 트랜잭션 속성이 없어 새 트랜잭션도 열리지 않습니다. 아래쪽은 기존 트랜잭션이 없는 조정자가 주입받은 별도의 워커 프록시를 호출해, 항목마다 짧은 REQUIRED 트랜잭션을 여는 경로입니다. 바깥에 이미 트랜잭션이 있다면 아래쪽도 그 물리 트랜잭션에 참여하므로, 별도의 빈이라는 것만으로 따로 커밋된다고 보장하지는 않습니다.


바깥 호출과 자기 호출은 경로가 다릅니다

바깥에서 호출하는 쪽은 컨테이너가 준 프록시 참조를 씁니다. 프록시는 그 메서드의 트랜잭션 속성을 해석한 뒤 대상을 호출합니다. 반면 대상 메서드가 같은 객체의 다른 메서드를 부르는 것은 Java의 평범한 메서드 호출입니다. 실행 흐름이 프록시로 되돌아가지 않으므로, 안쪽 메서드의 애노테이션을 해석할 인터셉터가 없습니다.

이 차이는 설정의 오류가 아닙니다. 프록시 밖에서 시작한 호출과, 대상 안에서 끝난 호출이라는 객체 경로의 차이입니다.


트랜잭션이 따로 필요하면 협력 객체로 나눕니다

다음 코드는 우회되는 형태와 가장 단순한 재설계를 함께 정의합니다. 조정자는 같은 클래스의 안쪽 메서드를 부르는 대신, 주입받은 워커의 공개 메서드를 호출합니다.

src/main/java/board/tx/proxy/selfcall/SelfInvocationServices.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가 따로 데이터베이스 작업을 맡으므로, 빈을 나누는 것은 프록시를 다시 호출하기 위한 요령이 아니라 설계입니다. 같은 빈을 자기 자신에게 주입받아 this 대신 그 참조로 안쪽 메서드를 부르는 자기 주입(self injection)이나, 프록시 노출(exposeProxy)을 켠 뒤 AopContext.currentProxy()로 지금 호출을 처리하는 프록시를 꺼내 부르는 프록시 컨텍스트 조회도 호출을 프록시로 다시 보낼 수는 있습니다. 하지만 Spring 레퍼런스는 자기 호출이 생기지 않게 코드를 고치는 방법을 먼저 권하고, AopContext.currentProxy()는 클래스를 Spring AOP에 직접 묶으므로 마지막 수단으로만 소개합니다. 이 글이 두 우회를 쓰지 않고 협력 객체로 나누는 이유이며, 두 방법의 비용은 내부 호출과 프록시에서 다시 비교합니다.


두 호출 경로를 같은 컨텍스트에서 검증합니다

테스트는 프록시 빈의 innerTransactionActive()를 바깥에서 직접 호출한 결과와, outerCallsInner()가 자기 호출한 결과를 비교합니다. 이어서 조정자가 주입받은 워커 프록시로 들어가는 재설계를 검증합니다.

src/test/java/board/tx/proxy/selfcall/SelfInvocationRedesignTest.java
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건이 집계되는지 확인합니다.

이 워커는 트랜잭션의 활성 상태와 이름을 돌려주는 프로브입니다. completed=2는 두 호출이 예외 없이 끝났다는 수일 뿐, 두 게시글을 실제로 정리한 행 수가 아닙니다. 데이터 변경을 더하는 실습에서는 DML과, 트랜잭션 밖의 최종 행 검증도 함께 넣어야 합니다.


정말 로컬 블록이라면 TransactionTemplate을 고를 수 있습니다

별도 협력 객체의 일이 아니라, 같은 사용 사례 안의 작은 로컬 블록 자체가 트랜잭션이어야 한다면 TransactionTemplate 콜백으로 범위를 명시할 수 있습니다. 콜백은 트랜잭션 관리자를 직접 쓰므로 자기 호출에 기대지 않습니다.

대신 애노테이션 기반 선언의 간결함을 포기하고, 애플리케이션 코드가 Spring 트랜잭션 API와 전파, 예외 정책에 직접 묶입니다. 따로 맡을 일이라면 워커 빈을 먼저 고르고, 코드 블록 자체가 의도한 단위일 때만 이 방법을 씁니다.


private이나 final, 비공개 메서드에 트랜잭션을 두지 않습니다

클래스 기반 프록시는 대상 메서드를 재정의해 가로챕니다. private 메서드는 바깥의 사용 사례가 아니고 하위 클래스에서 재정의할 수 없으며, final 메서드도 재정의를 막습니다.

public이 아닌 protected나 패키지 가시성 메서드를 어디까지 지원하는지는 프록시 방식과 설정에 따라 달라지므로, 이런 메서드가 언제나 동작하지 않는다고 단정할 필요는 없습니다. 대신 트랜잭션은 바깥의 협력자가 호출하는 명시적인 공개 애플리케이션 메서드에 둡니다. 도우미 메서드는 애노테이션 없이 바깥 트랜잭션에 참여하게 두고, 별도의 업무 단위가 필요할 때만 따로 맡는 빈으로 꺼냅니다. 애노테이션을 살리려고 메서드의 가시성만 바꾸는 것은 설계의 이유가 되지 않습니다.

AspectJ 위빙은 프록시와 다른 실행 모델이라 자기 호출의 조인 포인트도 다룰 수 있지만, 일반 프록시 모드의 동작을 바꾸지는 않습니다. 이 글의 문제를 풀려고 별도의 빌드와 런타임 모델을 도입하지 않습니다.

더 깊이: 메서드 가시성 규칙은 프록시 방식과 버전에 따라 다릅니다

앞에서 protected나 패키지 가시성 메서드의 지원 범위가 설정에 따라 달라질 수 있다고 한 것은 Spring Framework 6.0의 변경 때문입니다. Spring 레퍼런스에 따르면 6.0부터 클래스 기반 프록시에서는 protected와 패키지 가시성 메서드도 기본으로 트랜잭션이 적용되고, 5.3까지는 공개 메서드만 적용되었습니다. 반면 인터페이스 기반 프록시의 트랜잭션 메서드는 언제나 public이면서 프록시가 구현한 인터페이스에 선언되어 있어야 합니다. 두 방식 모두 프록시를 지나 들어온 바깥 호출만 가로챈다는 점은 같습니다.

메서드클래스 기반 프록시(Spring Boot 기본)인터페이스 기반 프록시
public적용프록시 인터페이스에 선언된 경우만 적용
protected, 패키지 가시성6.0부터 적용, 5.3까지는 무시적용되지 않음
private, final재정의할 수 없어 적용되지 않음적용되지 않음

프록시 방식에 따라 결과가 달라지는 것이 싫다면, 레퍼런스가 안내하는 대로 AnnotationTransactionAttributeSource(true)를 TransactionAttributeSource 빈으로 등록해 예전처럼 공개 메서드만 보게 할 수 있습니다. 애노테이션을 어디에 붙였는지도 결과에 영향을 줍니다. 같은 메서드에 대해서는 가장 구체적인 위치의 설정이 이기므로 메서드의 애노테이션이 클래스의 애노테이션보다 우선합니다. 또 인터페이스 메서드에 붙인 애노테이션은 5.0부터 두 프록시 방식 모두에서 인식되지만, Java의 애노테이션은 인터페이스에서 상속되지 않아 AspectJ 모드에서는 무시됩니다. 레퍼런스가 구체 클래스의 메서드에 애노테이션을 붙이라고 권하는 이유입니다.


생성과 초기화 단계에는 프록시를 거치는 호출이 없습니다

대상 인스턴스의 생성자와 초기화 콜백은 최종 프록시 참조가 애플리케이션에 노출되기 전에 실행됩니다. 그 단계에서 자신의 트랜잭션 메서드를 불러도 같은 인스턴스 안의 호출입니다.

스키마 변경은 Flyway나 Liquibase처럼 목적에 맞는 도구가 맡아야 합니다. 시작한 뒤 해야 하는 데이터 작업은, 완성된 다른 빈을 호출하는 ApplicationRunner 같은 바깥의 진입점에 두고, 여러 인스턴스가 함께 시작해도 안전하도록 멱등하게 설계합니다.


정리

  • 같은 인스턴스의 this 호출은 프록시를 다시 지나지 않으므로, 안쪽 메서드의 @Transactional은 새 어드바이스를 만들지 않습니다.
  • 따로 트랜잭션이 필요한 일은 별도의 협력 빈으로 나누고, 주입받은 프록시의 공개 메서드로 호출합니다.
  • 별도의 빈은 어드바이스를 지나게 할 뿐, 물리 트랜잭션이 따로 커밋되는지는 바깥 트랜잭션의 유무와 전파 속성이 정합니다.
  • 코드 블록 자체가 트랜잭션 단위라면 TransactionTemplate을 쓸 수 있지만, Spring 트랜잭션 API에 직접 묶입니다.
  • private이나 final 메서드, 생성과 초기화 단계의 호출에 트랜잭션을 기대지 않고, 자기 주입 같은 우회도 쓰지 않습니다.

연습 문제

1. 조정자에 바깥 트랜잭션을 더하면

테스트에서는 트랜잭션이 없는 조정자가 71, 72, 73을 호출합니다. 이번에는 같은 DataSource의 바깥 REQUIRED 프록시가 이미 트랜잭션을 연 상태에서 같은 조정자를 호출한다고 가정합니다. 워커는 기본값인 REQUIRED이고, 72에서 런타임 실패가 나며 조정자가 이를 잡습니다. 별도의 워커 빈이라는 이유만으로 항목마다 따로 커밋되는 것이 유지될까요?

해설 보기

워커 프록시에 새로 들어가는 것은 여전하지만, REQUIRED는 기존 바깥 물리 트랜잭션에 참여합니다. 조정자 안에서도 트랜잭션이 활성 상태입니다. 72의 실패가 공유 트랜잭션을 롤백 전용으로 표시하면, 조정자가 계속 순회해도 바깥이 정상적으로 커밋된다고 보장할 수 없습니다. 성공한 호출의 completed 집계는 최종 DB 커밋의 집계와 다릅니다.

테스트에서 항목마다 트랜잭션이 따로 생긴 것은, 조정자뿐 아니라 호출이 시작될 때 기존 트랜잭션이 없다는 전제에 달려 있습니다. 별도의 빈은 어드바이스를 지나게 하고, REQUIRED나 REQUIRES_NEW 같은 전파가 물리 트랜잭션의 관계를 정합니다. 두 선택을 나눠 설명해야 합니다.

2. 가장 단순한 수정 순서

애노테이션이 있는데 트랜잭션이 시작되지 않는다는 보고를 받았습니다. 원인을 찾고 고치는 순서를 다섯 단계로 정리해 보세요.

해설 보기

먼저 호출한 쪽이 실제 Spring 빈 참조를 쓰는지 확인합니다. 다음으로 대상 안의 this 호출과, 생성이나 초기화 단계의 호출을 찾습니다. 두 메서드가 하나의 업무 단위라면 안쪽의 애노테이션을 지우고 바깥 트랜잭션에 참여시킵니다. 따로 트랜잭션이 필요한 일이라면 협력 객체로 꺼내고, 주입받은 프록시의 공개 메서드를 호출합니다. 마지막으로 바깥 호출과 자기 호출을 각각 실행해 활성 상태와 최종 데이터의 결과를 테스트로 고정합니다.

자기 주입이나 프록시 컨텍스트 조회 같은 우회 경로는 더하지 않습니다. 호출 구조를 드러내는 객체 분리가 더 작고 오래가는 해결책입니다.

다음 글 트랜잭션 전파 규칙에서는 바깥 트랜잭션이 있을 때 안쪽 호출이 참여할지, 새로 열지를 정하는 전파 속성을 실행으로 확인합니다.