[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결
![[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결](https://blog.kakaocdn.net/dna/TYw2f/dJMcabRWY3F/AAAAAAAAAAAAAAAAAAAAABUcLLSe_tj-uMwBOj3uIOuDu2b-O2BplLsRTWiU5Vn8/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1788188399&allow_ip=&allow_referer=&signature=IQkVNOoqvBqIuysqeSccrBDXzN0%3D)
[Spring] @Transactional이 안 먹는 이유: 프록시 자기호출, private, 내부 호출 해결
"@Transactional을 분명히 붙였는데 트랜잭션이 없어요." 스프링에서 가장 많이 반복되는 질문이고, 답은 거의 항상 하나예요 — 프록시를 안 거쳤기 때문. 앞 편에서 본 대로 @Transactional의 실체는 프록시인데, 프록시가 못 끼어드는 호출 경로들이 있거든요.
그 대표 격인 자기호출(self-invocation)의 구조와 세 가지 해법을 보고, private·final·new 객체·다른 스레드까지 프록시가 무력화되는 경우의 전체 지도를 그려볼게요. @Cacheable·@Async·@Retryable 등 프록시 기반 애너테이션 전부에 똑같이 적용되는 이야기예요.
01. 자기호출, 프록시가 낄 자리가 없다

@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. 해법 셋, 구조 분리가 정석

① 빈 분리 (권장)
@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. 프록시가 못 끼는 경우 전체 지도

- 자기호출 — 위의 그 함정이에요.
- 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
'백엔드 > 프레임워크 & 언어' 카테고리의 다른 글
| [Spring] 애너테이션이 안 먹을 때 트러블슈팅: 트랜잭션, 캐시, 비동기 증상별 진단 가이드 (0) | 2026.08.05 |
|---|---|
| [Spring] 순환 참조 해결과 빈 초기화 함정: 생성자 주입, @Lazy, @PostConstruct 주의점 (1) | 2026.08.04 |
| [Spring] @Async 함정 정리: 스레드풀 설정, 예외 증발, 트랜잭션·컨텍스트 미전파 (0) | 2026.08.03 |
| [Spring] @TransactionalEventListener 정리: AFTER_COMMIT, 이벤트와 트랜잭션 타이밍 (0) | 2026.07.31 |
| [Spring] @Transactional 동작 원리: 전파(propagation), 롤백 규칙, REQUIRES_NEW (0) | 2026.07.29 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."