본문으로 건너뛰기

안동민 개발노트

본문 시작

트랜잭션 프록시 우회

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

애노테이션이 보이는데 트랜잭션이 시작되지 않는 가장 흔한 원인은 호출이 프록시를 지나지 않았기 때문입니다.

같은 인스턴스의 this.inner()는 대상 객체 안에서 바로 실행됩니다. inner()@Transactional이 있어도 두 번째 프록시 진입이 만들어지지 않으므로 새 어드바이스 경계가 생기지 않습니다.

자기 호출은 대상 안에서 끝나고 협력 객체 호출은 새 프록시로 들어간다

SELF INVOCATION · PROXY ENTRY · REDESIGN

자기 호출은 대상 안에서 끝나고 협력 객체 호출은 새 프록시로 들어간다

같은 인스턴스의 this.inner()는 프록시로 되돌아가지 않아 내부 애노테이션을 해석할 두 번째 어드바이스가 없다. 독립 경계가 필요하면 책임을 worker 빈으로 분리하고 주입받은 프록시의 공개 메서드로 진입한다.

자기 호출 우회와 협력 객체 프록시 진입의 차이 위 경로에서는 프록시를 지난 호출이 같은 대상의 outer에서 this.inner로 이어져 내부 트랜잭션 어드바이스가 실행되지 않는다. 아래 경로에서는 트랜잭션 없는 coordinator가 항목마다 주입받은 worker 프록시를 호출해 짧은 cleanOne 경계를 열고, 충돌을 집계한 뒤 다음 항목을 계속 처리한다. BYPASS · SAME INSTANCE REDESIGN · SEPARATE COLLABORATOR 외부 호출자 bean reference Proxy outer() entry 같은 대상 인스턴스 outer() → this.inner() 내부 호출은 프록시로 되돌아가지 않음 두 번째 advice 없음 @Transactional not read Coordinator no tx · item loop Worker Proxy 공개 경계의 속성 해석 injected collaborator Worker.cleanOne() short boundary · one item Transaction active success/conflict · continue CALL INVOKE SKIP CALL ADVISE BEGIN BEAN LIFECYCLE CONSTRUCT → INJECT → INITIALIZE → PROXY EXPOSED ELIGIBLE ENTRY 외부 협력자가 호출하는 재정의 가능한 공개 메서드에 경계를 둔다 LOCAL BLOCK TransactionTemplate는 범위를 명시하지만 코드를 트랜잭션 API에 결합한다

BYPASS

같은 인스턴스에서 this.inner()를 호출한다

  1. 외부 호출은 빈 프록시를 한 번 지난다.
  2. 대상 outer가 같은 대상의 inner를 직접 호출한다.
  3. 두 번째 프록시 진입이 없어 inner 애노테이션은 새 경계를 만들지 않는다.

REDESIGN

독립 책임을 worker 빈으로 추출한다

  1. 트랜잭션 없는 Coordinator가 항목마다 주입받은 worker 참조를 호출한다.
  2. Worker 프록시가 공개 cleanOne에 한 항목짜리 짧은 경계를 연다.
  3. 충돌은 그 항목에서 롤백하고 성공·충돌 수를 집계한 뒤 다음 항목을 계속한다.

생성자와 초기화 콜백은 최종 프록시가 노출되기 전이므로 자기 호출의 경계가 아니다. private·final 메서드 대신 외부에서 호출 가능한 명시적 사용 사례를 경계로 삼는다.

독립 책임이 아닌 로컬 코드 블록 자체가 경계라면 TransactionTemplate를 쓸 수 있다. 범위가 명시되는 대신 선언형 객체 경계를 잃고 코드가 트랜잭션 API에 결합된다.

두 메서드가 하나의 업무 단위라면 바깥 경계에 함께 참여시킨다. 배치는 트랜잭션 없는 조정자가 항목별 워커 경계를 호출해 이전 실패를 다음 항목과 분리하고 결과를 집계한다. 별도 책임이 아니라 로컬 블록이 경계일 때만 TransactionTemplate를 쓴다.


외부 호출과 자기 호출은 경로가 다르다

외부 호출자는 컨테이너가 제공한 프록시 참조를 사용합니다. 프록시는 해당 메서드의 트랜잭션 속성을 해석한 뒤 대상을 호출합니다.

반면 대상 메서드가 같은 객체의 다른 메서드를 부르면 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가 독립된 데이터베이스 책임을 가지므로 빈 분리는 프록시를 다시 호출하기 위한 기교가 아니라 설계입니다.


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

테스트는 프록시 빈의 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건이 집계되는지 확인합니다.


정말 로컬 블록이라면 TransactionTemplate를 선택할 수 있다

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

대신 애노테이션 기반 선언형 객체 경계의 간결함을 포기하고 애플리케이션 코드가 Spring 트랜잭션 API와 전파·예외 정책에 직접 결합됩니다. 독립 책임이면 워커 빈을 우선하고, 코드 블록 자체가 의도한 단위일 때만 이 선택을 사용합니다.


private·final·비공개 메서드를 경계로 삼지 않는다

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

비공개 메서드 지원 범위는 프록시 방식과 설정에 따라 달라질 수 있으므로 “모든 비공개 메서드는 언제나 동작하지 않는다”라고 단정할 필요는 없습니다. 대신 트랜잭션 경계를 외부 협력자가 호출하는 명시적인 공개 애플리케이션 메서드에 둡니다.

도우미 메서드는 애노테이션 없이 바깥 경계에 참여하게 두고, 별도의 업무 경계가 필요할 때만 독립 책임의 빈으로 추출합니다. 애노테이션을 살리기 위해 메서드 가시성만 바꾸는 것은 설계 이유가 아닙니다.

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


생성과 초기화 단계에는 프록시 진입이 없다

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

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


가장 단순한 수정 순서

  1. 호출자가 실제 Spring 빈 참조를 쓰는지 확인합니다.
  2. 대상 내부의 this 호출과 생성·초기화 호출을 찾습니다.
  3. 두 메서드가 하나의 업무 경계라면 내부 애노테이션을 제거하고 바깥 경계에 참여시킵니다.
  4. 독립 경계가 필요한 별도 책임이라면 협력 객체로 추출하고 주입받은 프록시의 공개 메서드를 호출합니다.
  5. 외부 호출과 자기 호출을 각각 실행해 활성 상태와 최종 데이터 결과를 고정합니다.

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