같은 클래스 안에서 자기 메서드를 직접 호출하면 프록시를 거치지 않아, @Transactional 같은 애노테이션이 조용히 무시되는 현상.
왜 안 걸리나
@Service
class OrderService {
public void outer() {
inner(); // ← this.inner() 다. 프록시가 아니라 원본을 직접 부른다
}
@Transactional
public void inner() { ... } // 트랜잭션이 시작되지 않는다
}
호출자 → [프록시] → 원본.outer()
│
└ this.inner() ← 프록시를 우회한다
(프록시는 outer 진입만 가로챘다)
프록시는 바깥에서 들어오는 호출만 가로챈다. 객체 내부의 호출은 볼 수 없다.
확인하는 법
// 트랜잭션이 실제로 걸렸는지
System.out.println(TransactionSynchronizationManager.isActualTransactionActive()); // false
// 로그로 보기
// logging.level.org.springframework.transaction.interceptor=TRACE
로그에 Getting transaction for [...]이 안 찍히면 프록시를 안 탄 것이다.
해결하는 세 가지
// ① 클래스를 분리한다 (권장 — 책임도 함께 나뉜다)
@Service
class OrderService {
private final OrderTxService tx;
public void outer() { tx.inner(); } // 다른 빈이므로 프록시를 탄다
}
// ② 자기 자신을 주입받는다 (순환 참조라 @Lazy 가 필요하다)
@Service
class OrderService {
@Lazy private final OrderService self;
public void outer() { self.inner(); }
}
// ③ AopContext 로 현재 프록시를 꺼낸다 (설정이 필요하고 가독성이 나쁘다)
// @EnableAspectJAutoProxy(exposeProxy = true)
public void outer() { ((OrderService) AopContext.currentProxy()).inner(); }
①이 정석이다. ②③은 문제를 덮는 쪽에 가깝고, 특히 ③은 코드를 읽는 사람에게 의도가 전달되지 않는다.
같은 원인의 형제 증상
@Cacheable 이 안 먹는다 @Async 가 동기로 돈다 @Retryable 이 재시도하지 않는다
- @PreAuthorize 가 통과된다 — ← 보안 사고가 된다
전부 프록시를 안 탄 것이 원인이다. 하나를 이해하면 나머지가 같이 풀린다.
면접 함정
- ❌ "private 메서드에 @Transactional을 붙이면 된다" → 프록시가 오버라이드할 수 없어 역시 무시된다.
- ❌ "예외가 나니까 안 걸린 걸 알 수 있다" → 아무 예외도 안 난다. 트랜잭션 없이 그냥 실행된다.
왜 예외가 안 나는가
프록시는 '메서드 호출을 가로채는' 방식이다
- 가로챌 기회가 없으면 그냥 원본이 실행될 뿐이다
- 애노테이션은 메타데이터일 뿐 그 자체로 아무 동작도 하지 않는다
그래서 컴파일 오류도 런타임 예외도 나지 않는다 "조용히 아무 일도 안 일어난다" 가 이 함정이 위험한 이유다
테스트로 잡는다
@Test
void 트랜잭션이_실제로_걸리는지() {
assertThatThrownBy(() -> service.outerThatShouldRollback())
.isInstanceOf(RuntimeException.class);
assertThat(repo.count()).isZero(); // 롤백됐어야 한다
}
"애노테이션을 붙였다"가 아니라 "실제로 롤백되는가"를 테스트해야 이 유형의 버그가 잡힌다.
정적 분석으로 예방하기
ArchUnit · 커스텀 린트 규칙으로
- "같은 클래스 안에서 @Transactional 메서드를 호출하지 않는다" 를 검사할 수 있다 리뷰에만 의존하면 반드시 새어 나간다