에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/프레임워크 & 언어

[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결

반응형
[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결

[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결

"@Transactional을 분명히 붙였는데 트랜잭션이 없어요." 스프링에서 가장 많이 반복되는 질문이고, 답은 거의 항상 하나예요 — 프록시를 안 거쳤기 때문. 앞 편에서 본 대로 @Transactional의 실체는 프록시인데, 프록시가 못 끼어드는 호출 경로들이 있거든요.

그 대표 격인 자기호출(self-invocation)의 구조와 세 가지 해법을 보고, private·final·new 객체·다른 스레드까지 프록시가 무력화되는 경우의 전체 지도를 그려볼게요. @Cacheable·@Async·@Retryable 등 프록시 기반 애너테이션 전부에 똑같이 적용되는 이야기예요.

01. 자기호출, 프록시가 낄 자리가 없다

Spring 프록시 자기호출 함정. 외부 빈에서 호출하면 프록시가 트랜잭션을 시작하지만, 같은 클래스 안에서 this로 호출하면 프록시를 거치지 않아 Transactional이 있어도 그냥 메서드 호출이 된다
@Service
public class OrderService {

    public void process(List<Order> orders) {
        for (Order o : orders) {
            this.saveOrder(o);      // ❌ 자기호출 — 트랜잭션 안 걸림!
        }
    }

    @Transactional
    public void saveOrder(Order o) { ... }
}

외부에서 orderService.saveOrder()를 부르면 프록시가 가로채 트랜잭션을 열어요. 그런데 process() 안에서 this.saveOrder()를 부르면, 그건 실제 객체가 자기 자신의 메서드를 직접 부르는 것이에요. 프록시는 바깥에 있으니 낄 자리가 없죠. 애너테이션은 장식이 되고, 트랜잭션 없이 실행돼요.

고약한 건 에러가 안 난다는 거예요. 그냥 조용히 트랜잭션 없이 돌아요. 예외가 나도 롤백이 안 되고, 부분 저장이 일어나고 나서야 "어? 트랜잭션이?"가 되는 거죠. @Cacheable의 자기호출에서 캐시가 조용히 무시되던 것과 완전히 같은 구조예요.

02. 해법 셋, 구조 분리가 정석

자기호출 해법 3가지. 트랜잭션 단위를 별도 빈으로 분리하는 게 권장이고, 자신의 프록시를 주입받아 호출하는 자기 주입은 임시방편이며, TransactionTemplate은 코드로 경계를 명시한다

① 빈 분리 (권장)

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderTxService txService;

    public void process(List<Order> orders) {
        for (Order o : orders) {
            txService.saveOrder(o);   // ✅ 다른 빈 = 프록시 경유
        }
    }
}

트랜잭션 단위를 별도 빈으로 추출하면 호출이 자연스럽게 프록시를 거쳐요. 그리고 보통 이 분리가 설계에도 이로워요 — "여기부터 트랜잭션"이라는 경계가 클래스 구조에 드러나니까요. 자기호출 문제의 90%는 이걸로 푸는 게 맞아요.

② 자기 주입

자신의 프록시를 주입받아 self.saveOrder()로 부르는 방법이에요. 동작은 하는데, "자기를 주입받는" 어색함과 순환 참조 이슈가 있어서 임시방편 이상으로는 권하지 않아요.

③ TransactionTemplate

private final TransactionTemplate txTemplate;

public void process(Order o) {
    txTemplate.executeWithoutResult(status -> {
        // 이 블록이 트랜잭션 — 프록시와 무관
    });
}

애너테이션 대신 코드로 경계를 지정해요. 프록시 메커니즘과 무관하니 자기호출 문제가 원천적으로 없고, "트랜잭션이 어디서 시작하고 끝나는지"가 코드에 명시돼요. 루프 안에서 건별 트랜잭션을 돌리는 배치성 로직에 특히 잘 맞아요.

03. 프록시가 못 끼는 경우 전체 지도

Spring 프록시가 무력화되는 경우. 자기호출, private 메서드, final 메서드와 클래스, new로 만든 객체, 다른 스레드 실행이 있으며, 의심될 땐 isActualTransactionActive로 확인한다
  • 자기호출 — 위의 그 함정이에요.
  • private 메서드 — 프록시는 상속(CGLIB)이나 인터페이스 위임으로 만드는데, private은 오버라이드도 위임도 안 돼요. 애너테이션을 붙여도 무시돼요(친절하게도 경고조차 안 떠요).
  • final 메서드·클래스 — CGLIB이 상속으로 가로채야 하는데 final이면 오버라이드 불가예요. 단, final 메서드는 조용히 미적용이지만 final 클래스는 (인터페이스 없이 클래스 프록시 대상이면) 기동 시점에 프록시 생성 에러로 터져요. 조용히 넘어가는 쪽이 더 위험해요.
  • new로 만든 객체 — 스프링이 만든 빈이 아니면 프록시 자체가 없어요. 직접 new Service() 한 객체의 애너테이션은 전부 장식이에요.
  • 다른 스레드 — 트랜잭션은 ThreadLocal에 묶여 있어요. 트랜잭션 안에서 새 스레드(또는 @Async)로 넘긴 작업은 그 트랜잭션 밖이에요. 이건 다음다음 편(@Async)에서 깊게 다뤄요.
의심스러울 땐 추측하지 말고 확인해요 — TransactionSynchronizationManager.isActualTransactionActive()를 로그로 찍으면 "지금 이 코드가 트랜잭션 안인가"가 바로 나와요. 트랜잭션 미적용 버그는 조용해서, 이 한 줄 확인이 몇 시간을 아껴줘요. 테스트에서도 같은 방식으로 "이 메서드는 트랜잭션 안에서 돈다"를 단언할 수 있어요.

04. 자주 만나는 문제

@Transactional을 붙였는데 롤백이 안 돼요

순서대로 — ① 자기호출인가 ② private/final인가 ③ checked 예외인가(앞 편의 롤백 규칙) ④ 그 코드가 정말 스프링 빈인가. isActualTransactionActive로 확인부터요.

@Cacheable·@Retryable도 안 먹어요

같은 진단이에요. 프록시 기반 애너테이션은 전부 같은 한계를 공유해요. 하나가 안 먹는 구조면 다른 것도 안 먹어요.

리팩토링했더니 갑자기 트랜잭션이 사라졌어요

메서드를 같은 클래스 안으로 옮기면서 외부 호출이 자기호출로 바뀐 경우가 단골이에요. 클래스 합치기 리팩토링 후엔 트랜잭션 경계를 꼭 다시 봐요.

정리

@Transactional이 안 먹는 미스터리의 답은 거의 항상 하나예요 — "프록시를 안 거쳤다". 프록시가 못 끼어드는 경로를 외워두면 다섯 종류의 함정이 한 번에 풀려요.

  • 경로 — 자기호출, private, final, new 객체, 다른 스레드.
  • 정석 해법 — 트랜잭션 단위를 별도 빈으로 분리(또는 TransactionTemplate로 명시).
  • 확장 — 같은 한계가 @Cacheable·@Async·@Retryable 등 프록시 기반 애너테이션 전부에 적용돼요.

다음은 트랜잭션과 이벤트의 미묘한 타이밍이에요 — @TransactionalEventListener 편으로 이어져요.

출처: Spring Framework — Proxying Mechanisms · Spring — Method Visibility and @Transactional

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."