IT·리눅스/Java

@Transactional의 함정 - 프록시와 self-invocation

jmineekim 2026. 6. 2. 10:00

스프링 처음 배울 때 @Transactional 어노테이션 한 줄이면 다 된다고 배웁니다. 그래서 결제 메서드 위에 @Transactional 붙여 두고 잘 돌리고 있었는데, 어느 날 운영에서 "환불 처리가 일부만 반영되고 다른 부분은 그대로다"는 알람이 옵니다. 분명 예외가 던져졌는데 롤백이 안 된 거예요. 이런 케이스가 한두 가지가 아닙니다. private 메서드, self-invocation, checked exception, 모두 @Transactional을 무력화시키는 함정이에요. 오늘은 이 어노테이션이 어떻게 동작하는지부터 시작해서, 신입이 가장 많이 당하는 함정들을 정리해 볼게요.

개념 / 원리

핵심은 @Transactional스프링 AOP 프록시 기반으로 동작한다는 점입니다. 빈을 그대로 주입하는 게 아니라, 그 빈을 감싼 프록시 객체를 주입해요. 호출이 들어오면 프록시가 먼저 받아서 트랜잭션을 열고, 진짜 빈을 호출한 다음, 끝나면 커밋(또는 예외 시 롤백)합니다.

비유하자면 카페에서 손님이 카운터(프록시)에 주문하면, 카운터가 주방(실제 빈)에 전달하고, 음식이 나오면 다시 카운터를 거쳐 손님에게 가는 구조예요. 그래서 "카운터를 거치지 않은 호출"은 트랜잭션이 안 걸립니다. 이게 모든 함정의 출발점이에요.

스프링이 프록시를 만드는 방식은 두 가지입니다.

  • JDK Dynamic Proxy: 인터페이스를 구현한 동적 클래스를 만들어 끼움. 대상 빈이 인터페이스를 구현해야 가능.
  • CGLIB Proxy: 대상 클래스를 상속해서 자식 클래스를 만들어 끼움. 인터페이스 없어도 가능하지만 final 클래스나 final/private 메서드는 못 가로챔.

스프링 부트 2.x 이후로는 CGLIB이 기본인데, private 메서드는 어떤 방식으로도 프록시 못 한다는 한계가 그대로 남아 있습니다.

실전 예제

가장 흔한 self-invocation 함정부터 봅시다.

@Service
public class OrderService {

    @Transactional
    public void placeOrder(Order order) {
        save(order);
        sendNotification(order);  // 같은 클래스 내부 호출
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Order order) {
        // 새 트랜잭션을 기대하지만 실제로는 안 만들어짐
        notificationRepository.save(...);
    }
}

placeOrder 안에서 sendNotification을 호출했는데, 이건 프록시를 거치지 않고 this.sendNotification 으로 직접 호출됩니다. 카운터를 거치지 않은 호출이라 REQUIRES_NEW도 무시되고, 기존 트랜잭션에 그대로 묶입니다.

해결하려면 자기 자신을 빈으로 주입받아 호출하거나, 메서드를 다른 빈으로 분리합니다.

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    @Transactional
    public void placeOrder(Order order) {
        save(order);
        notificationService.send(order);  // 다른 빈 → 프록시 거침
    }
}

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void send(Order order) {
        notificationRepository.save(...);
    }
}

두 번째로 자주 만나는 함정이 예외 종류에 따른 롤백 차이입니다. 스프링은 기본적으로 RuntimeExceptionError만 롤백하고, checked exception(IOException 같은)은 커밋합니다.

@Transactional
public void process() throws IOException {
    repository.save(new Order());
    if (something) {
        throw new IOException("network error");
    }
    // checked exception이라 이 메서드는 커밋된 채로 끝남
}

직관과 정반대죠. checked exception에서도 롤백시키려면 rollbackFor를 명시합니다.

@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
    // ...
}

주의할 점

1) private 메서드에 @Transactional은 무력입니다. 어떤 프록시 방식이든 private은 가로채지 못합니다. 컴파일은 되지만 트랜잭션은 안 열려요. IDE 경고도 잘 안 떠서 디버깅이 쉽지 않습니다. 반드시 public(또는 최소 protected)이어야 합니다.

2) self-invocation은 무조건 안 된다고 기억하세요. 위 예제처럼 같은 클래스 안 메서드를 부르면 프록시가 안 끼어들어서 @Transactional이 무시됩니다. 우회법은 (a) 다른 빈으로 분리, (b) ApplicationContext로 자신의 프록시 가져오기, (c) AopContext.currentProxy() 사용 등이 있지만 (a)가 가장 깔끔합니다.

3) checked exception은 기본 롤백 대상이 아닙니다. JDBC 시절 관행이 그대로 남아 있는 건데, 직관과 반대라 정말 자주 사고가 납니다. 팀 컨벤션으로 @Transactional(rollbackFor = Exception.class)를 기본으로 두는 것도 한 가지 방법입니다.

4) readOnly = true는 hint일 뿐 강제력은 약합니다. 일부 드라이버나 JPA 구현에서는 최적화에 활용하지만, "이 메서드에서 UPDATE 했다고 막아주는" 안전장치는 아닙니다. INSERT/UPDATE가 그대로 실행될 수 있으니 코드 리뷰 시 주의하세요.

5) JPA의 flush 타이밍을 잊지 마세요. @Transactional 메서드 안에서 repository.save()만 호출하고 끝내면, 실제 INSERT는 트랜잭션 커밋 직전(또는 flush 시점)에 일어납니다. 메서드 중간에 native 쿼리로 같은 데이터를 조회하면 아직 DB에 없어서 안 보일 수 있어요. 저는 이걸로 디버깅 반나절 한 적이 있습니다.

6) 트랜잭션이 안 도는지 확인하는 가장 빠른 방법. TransactionSynchronizationManager.isActualTransactionActive()를 메서드 안에서 출력해 보세요. false면 트랜잭션 자체가 안 열린 것 — 위 함정 중 하나에 걸린 겁니다.

마무리

정리하면 세 줄입니다.

  • @Transactional은 프록시로 동작한다 — 프록시를 거치지 않은 호출(private, self-invocation)은 무시된다.
  • 기본은 RuntimeException만 롤백한다. checked exception 롤백하려면 rollbackFor 명시.
  • 의심될 땐 TransactionSynchronizationManager.isActualTransactionActive()로 실제 트랜잭션이 떠 있는지부터 확인.
반응형