트랜잭션 프록시 누락 원인과 해결

증상이 보이는 순간

주문 저장 뒤 포인트 적립 메서드에 @Transactional을 붙였는데 예외가 나도 일부 데이터가 남으면 같은 서비스 내부 호출부터 본다. place() 안에서 this.recordPoint()를 부르면 호출은 스프링 프록시를 통과하지 않는다. 애너테이션 문제가 아니라 트랜잭션 인터셉터의 진입점이 사라진 상태다.

@Service
class OrderService {
    public void place(Long id) {
        saveOrder(id);
        this.recordPoint(id); // 같은 객체 내부 호출
    }

    @Transactional
    public void recordPoint(Long id) {
        savePoint(id);
        throw new IllegalStateException();
    }
}

위 코드는 자연스러워 보이지만 recordPoint()가 프록시 바깥에서 직접 실행된다. 그래서 트랜잭션 생성과 롤백 규칙이 스프링 AOP 체인에 잡히지 않는다.

원인은 프록시 경계다

Spring Framework의 기본 트랜잭션 모드는 프록시 기반이다. 공식 문서도 프록시로 들어오는 외부 호출만 가로채며, 같은 대상 객체 안의 self-invocation은 @Transactional이 붙어도 실제 트랜잭션으로 이어지지 않는다고 설명한다. 그래서 해결의 핵심은 애너테이션 추가가 아니라 호출 경계를 다시 만드는 것이다.

서비스를 나누어 해결하기

가장 단순한 수정은 트랜잭션 작업을 다른 스프링 빈으로 분리하는 것이다. 외부 빈에서 PointService.record()를 호출하면 프록시를 거치므로 @Transactional이 적용된다.

@Service
class OrderFacade {
    private final OrderService orders;
    private final PointService points;

    public void place(Long id) {
        orders.saveOrder(id);
        points.record(id);
    }
}

@Service
class PointService {
    @Transactional
    public void record(Long id) {
        savePoint(id);
        throw new IllegalStateException();
    }
}

이 구조는 트랜잭션 경계를 코드 구조로 드러낸다. 주문 저장과 포인트 적립의 책임도 분리되어 테스트에서 롤백 대상을 확인하기 쉽다. 두 작업이 하나의 원자적 단위라면 상위 유스케이스 메서드에 트랜잭션을 둔다.

작은 범위는 TransactionTemplate

메서드 분리가 어색한 작은 범위라면 TransactionTemplate으로 트랜잭션 블록을 명시할 수 있다. 스프링 API에 의존하지만 내부 호출 문제를 피하고 실행 범위를 바로 읽을 수 있다.

tx.executeWithoutResult(status -> {
    savePoint(memberId);
    auditPoint(memberId);
});

검증은 실패 케이스를 먼저 둔다. ./gradlew test --tests '*TxProxyTest'로 예외 발생 뒤 포인트 건수가 늘지 않는지 확인하면 프록시 경계가 복구됐는지 판단할 수 있다.

통과해도 내부의 다른 @Transactional, @PostConstruct, 별도 스레드 로직은 확인한다. 핵심은 필요한 호출이 스프링 프록시 경계를 실제로 지나가게 만드는 것이다.

댓글