안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.
스프링 부트(Spring Boot)로 백엔드를 개발하다 보면 특정 로직이 실패했을 때 데이터베이스를 이전 상태로 되돌리기 위해 @Transactional 어노테이션을 숨 쉬듯이 사용하게 됩니다.
그런데 개발 서버에서 테스트를 하던 중 등골이 서늘해지는 상황을 마주할 때가 있습니다. 명백하게 데이터베이스 저장 도중 런타임 예외(RuntimeException)가 발생해서 에러 로그가 시뻘겋게 찍혔는데, DB를 확인해 보니 데이터가 롤백(Rollback)되지 않고 버젓이 저장되어 있는 기현상입니다.
"어? 분명히 메서드 위에 @Transactional을 예쁘게 붙여놨는데 왜 롤백이 안 된 거지?"
오늘은 실무에서 백엔드 개발자들이 가장 많이 당하는 함정이자, 면접 단골 질문이기도 한 '스프링 AOP 프록시 내부 호출(Self-Invocation)' 문제의 원인을 아주 객관적으로 파헤치고, 이 어이없는 트러블슈팅을 단숨에 해결하는 베스트 프랙티스를 정리해 드립니다.
1. 실전 트러블슈팅: 완벽해 보이지만 완벽하게 망가진 코드
문제가 발생하는 아주 전형적인 실무 코드 패턴을 먼저 살펴보겠습니다.
주문(Order)을 생성하는 핵심 서비스 로직이 있고, 코드를 깔끔하게 유지하기 위해 실제 DB에 저장하는 로직을 같은 클래스 내의 다른 메서드로 분리했습니다.
Java
@Service
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
// 🚨 외부에서 호출되는 진입점 메서드 (트랜잭션 없음)
public void createOrder(OrderRequest request) {
System.out.println("주문 로직 시작");
// 🚨 같은 클래스 내부에 있는 트랜잭션 메서드를 직접 호출! (Self-Invocation)
this.saveOrderData(request);
System.out.println("주문 로직 끝");
}
// 💡 롤백을 위해 트랜잭션을 걸어둔 내부 메서드
@Transactional
public void saveOrderData(OrderRequest request) {
orderRepository.save(new Order(request));
// 의도적으로 런타임 에러 발생! -> 롤백이 되어야 정상입니다.
throw new RuntimeException("DB 저장 중 강제 에러 발생!");
}
}
여러분의 의도대로라면 saveOrderData() 내부에서 에러가 터졌으니 방금 저장한 주문 데이터는 롤백되어 DB에서 사라져야 합니다.
하지만 이 코드를 실행해 보면, 에러는 에러대로 터지고 DB에는 주문 데이터가 버젓이 커밋(Commit)되어 남아있습니다. 트랜잭션이 아예 시작조차 되지 않은 것입니다.
2. 도대체 왜 안 될까? (스프링 AOP 프록시의 배신)
이 미스터리를 풀려면 스프링이 @Transactional이나 @Async, @Cacheable 같은 마법의 어노테이션들을 어떻게 동작시키는지(AOP 원리)를 이해해야 합니다.
스프링은 서버가 켜질 때, @Transactional이 붙은 클래스를 발견하면 원본 클래스 대신 '프록시(Proxy) 가짜 객체'를 하나 만들어치웁니다.
외부(컨트롤러 등)에서 OrderService를 호출하면, 사실 원본 객체가 아니라 프록시 객체가 호출을 가로챕니다. 프록시는 "아하, 트랜잭션을 먼저 열고, 원본 메서드를 실행한 다음, 결과에 따라 커밋이나 롤백을 해줘야지!"라고 똑똑하게 동작하죠.
내부 호출(Self-Invocation)이 발생하는 순간
문제는 원본 객체의 createOrder() 메서드 안에서 this.saveOrderData()를 호출할 때 발생합니다.
자바 문법에서 this는 원본 객체 자기 자신을 가리킵니다. 프록시 객체가 아닙니다!
외부에서 들어올 때는 프록시가 문지기 역할을 해줬지만, 이미 성벽 안(원본 객체 내부)으로 들어온 상태에서 자기들끼리(this) 메서드를 호출하면 프록시라는 문지기를 거치지 않고 다이렉트로 실행되어 버립니다.
문지기를 거치지 않았으니 트랜잭션을 열어줄 사람도, 롤백을 해줄 사람도 없는 순수한 자바 코드가 덩그러니 실행된 것이죠.

💡 [데브프리의 심층 면접 꿀팁: AOP 프록시의 또 다른 함정, 'private' 메서드는 어떨까요?]
면접관이 "만약 트랜잭션을 걸어둔 saveOrderData() 메서드를 외부에서 직접 호출하지 못하게 접근 제어자를 private으로 바꾸면 어떻게 되나요?"라고 묻는다면 100점짜리 압박 질문입니다!
정답은 "프록시 자체가 적용되지 않아 트랜잭션이 쥐도 새도 모르게 무시된다!"입니다.
스프링 AOP(CGLIB 기반)는 원본 클래스를 '상속'받아 가짜 프록시 객체를 동적으로 생성하는 원리로 동작합니다. 하지만 자바 문법상 private 메서드나 final 메서드는 자식 클래스에서 오버라이딩(재정의)할 수 없죠.
따라서 스프링은 프록시 객체를 만들 때 private 메서드에 붙은 @Transactional은 아예 무시해 버립니다. AOP 기반의 트랜잭션은 오직 public 메서드에서만 동작한다는 점을 반드시 어필하세요! 이 대답 하나면 스프링 AOP의 동작 원리를 꿰뚫는 시니어로 인정받을 수 있습니다.
3. 해결 베스트 프랙티스: 프록시 우회 문제 뚫어내기
이 어이없는 현상을 해결하는 실무적인 방법은 크게 두 가지가 있습니다.
해결책 1: 자기 자신을 의존성 주입받기 (Self-Injection) - ⚠️ 주의 필요
가장 빠르게 코드를 고치는 꼼수는 원본 객체가 프록시 객체를 다시 호출하게 만드는 것입니다. 자기 자신(OrderService)을 필드로 주입받아 this 대신 사용하는 방식이죠.
Java
@Service
public class OrderService {
// 💡 원본 객체가 아니라 프록시 객체를 주입받기 위해 @Lazy를 사용합니다.
private final OrderService self;
private final OrderRepository orderRepository;
public OrderService(@Lazy OrderService self, OrderRepository orderRepository) {
this.self = self;
this.orderRepository = orderRepository;
}
public void createOrder(OrderRequest request) {
// this.saveOrderData() 대신 프록시 객체를 통해 호출!
self.saveOrderData(request);
}
// ... 생략
}
- 실무 주의점: 스프링 부트 2.6 버전부터는 순환 참조(Circular Reference)를 기본적으로 엄격하게 금지하고 있습니다. 자기 자신을 주입받는 것도 순환 참조에 해당하므로 서버가 켜지다 에러를 뱉습니다. 이를 피하려면 위 코드처럼 반드시 @Lazy를 붙여서 지연 초기화를 유도해야 합니다.
해결책 2: 클래스 분리하기 (가장 깔끔한 실무 표준)
Self-Injection은 동작은 하지만, 아키텍처 관점에서 코드가 지저분해지고 객체지향의 원칙을 해칩니다. 가장 객관적이고 훌륭한 해결책은 트랜잭션이 필요한 로직을 아예 다른 클래스(다른 빈)로 분리해 버리는 것입니다.
Java
// 💡 트랜잭션 전용 컴포넌트로 분리!
@Component
public class OrderSaveProcessor {
private final OrderRepository orderRepository;
// ... 생성자 생략
@Transactional
public void saveOrderData(OrderRequest request) {
orderRepository.save(new Order(request));
throw new RuntimeException("DB 저장 중 강제 에러 발생!");
}
}
@Service
public class OrderService {
private final OrderSaveProcessor orderSaveProcessor;
// ... 생성자 생략
public void createOrder(OrderRequest request) {
// 💡 외부 클래스의 메서드를 호출하므로 무조건 프록시를 거치게 됩니다! 완벽!
orderSaveProcessor.saveOrderData(request);
}
}
클래스를 분리하면 다른 빈(Bean)을 주입받아 호출하는 형태가 되므로, 무조건 프록시 객체를 통과하게 되어 트랜잭션이 100% 안전하게 보장됩니다. 책임(Responsibility)도 명확히 분리되니 유지보수성도 크게 상승합니다.
4. 전문가의 확고한 결론 (Verdict)
내부 호출(Self-Invocation)과 클래스 분리, 실무에 어떻게 적용해야 할까요?
- 👍 클래스 분리(리팩토링)의 필수 도입 타이밍: @Transactional뿐만 아니라 비동기 처리를 위한 @Async, 결과를 메모리에 저장하는 @Cacheable 등 스프링의 AOP(프록시 기반) 기술이 적용된 메서드를 내부에서 재호출해야 하는 상황을 마주했다면, 망설이지 말고 즉시 새로운 @Component나 @Service 클래스로 로직을 분리하세요. 개발 생산성을 핑계로 this 호출을 방치하면, 실서버에서 데이터 정합성이 깨지고 원인을 알 수 없는 버그로 밤을 새우게 됩니다.
- 🛑 피해야 할 안티 패턴 (Self-Injection 남발): 코드를 분리하기 귀찮다는 이유로 @Lazy를 남발하며 자기 자신을 주입(Self-Injection)하는 것은 명백한 안티 패턴입니다. 이는 스프링 컨테이너의 빈 초기화 순서를 꼬이게 만들며, 추후 테스트 코드(JUnit)를 작성할 때도 목(Mock) 객체를 생성하기 대단히 까다롭게 만듭니다. 스프링 부트가 2.6 버전부터 순환 참조를 강제로 막아버린 데에는 다 이유가 있습니다. 원칙대로 클래스의 책임을 분리하시기 바랍니다.
눈에 보이지 않는 스프링의 프록시 마법은 원리를 모르면 치명적인 함정이 되지만, 원리를 이해하면 여러분의 백엔드 코드를 가장 우아하게 만들어주는 든든한 무기가 됩니다.
항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 트러블슈팅 지식으로 찾아뵙겠습니다!
'💻 트러블슈팅 (에러 해결)' 카테고리의 다른 글
| 스케줄러가 멈췄어요! 스프링 @Scheduled 단일 스레드 병목 원인과 ThreadPool 완벽 해결법 (0) | 2026.08.16 |
|---|---|
| N+1 잡으려다 서버가 터졌다? JPA MultipleBagFetchException 원인과 실무 최적화 가이드 (0) | 2026.08.15 |
| 예외 처리(try-catch)를 했는데 왜 롤백될까? UnexpectedRollbackException 완벽 해결법 (0) | 2026.08.13 |
| 로그인 성공했는데 403 에러? 스프링 시큐리티 6 (Spring Boot 3) CSRF와 /error 3분 해결법 (0) | 2026.08.12 |
| 쿼리가 폭주해요! JPA N+1 문제 원인과 Fetch Join 해결법 (마트 심부름 비유) (0) | 2026.07.27 |