Spring에서 @Transactional이 내부 메서드 호출에선 왜 안 먹을까? (Self-Invocation 문제 정리)
서비스 코드를 이렇게 짰다고 해보자.
@Service
public class OrderService {
public void outer() {
// 비즈니스 로직...
inner(); // 내부 메서드 직접 호출
}
@Transactional
public void inner() {
// DB 저장/수정 로직...
}
}
개발자 머릿속 기대:
outer() → inner() 호출inner()에 @Transactional 있으니까,현실:
이게 바로 “self-invocation(자기 호출) 때문에 @Transactional이 적용 안 되는” 전형적인 상황이다.
this로 바로 호출하기 때문Spring의 선언적 트랜잭션(@Transactional)은 내부적으로 AOP + 프록시 기반으로 동작한다.
@Transactional이 붙은 빈은 대략 이런 식으로 감싸진다.
OrderService (target)OrderService$$SpringProxy (혹은 JDK dynamic proxy)외부에서 주입받을 때 보통 이 프록시가 주입된다.
Client → (프록시) OrderService$$Proxy → (타깃) OrderService
프록시의 역할:
@Transactional 설정을 보고문제는 “같은 클래스 안에서 자기 메서드를 부를 때”다.
위 코드에서 outer() 안의 inner() 호출은 실제로 이렇게 동작한다.
public void outer() {
// 이건 프록시를 통해 호출됨 (OK)
inner(); // 여기서는 this.inner() 과 같다 → 프록시를 안 거침
}
흐름은 이렇게 된다.
orderService.outer() 호출outer()에는 @Transactional이 없으니 그냥 타깃으로 패스.OrderService의 outer()가 실행된다.outer() 안에서 inner()를 호출할 때,this.inner() 호출this는 프록시가 아니라 순수한 OrderService 인스턴스다.inner() 호출은 프록시를 전혀 거치지 않는다.@Transactional이 무시된 것처럼 보이는 상황이 된다.Spring 공식 문서도 이걸 아주 명확히 못 박고 있다.
프록시 모드(기본값)에서는 프록시를 통해 들어오는 외부 메서드 호출만 인터셉트된다.
동일한 객체 내부에서 자기 자신의 메서드를 호출하는 self-invocation은
런타임에 실제 트랜잭션을 만들지 않는다.
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
public void joinOuter() {
// 여기서 예외 날 예정인 내부 메서드를 호출
joinInner("outer-call");
}
@Transactional
public void joinInner(String name) {
memberRepository.save(new Member(name));
if (true) {
throw new RuntimeException("강제 예외");
}
}
}
테스트를 돌려보면:
@SpringBootTest
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
void selfInvocationTransactional() {
try {
memberService.joinOuter();
} catch (Exception e) {
// ignore
}
// 기대: 예외로 롤백되어야 해서 데이터가 없어야 함
// 현실: 데이터가 저장되어 있음
long count = memberRepository.count();
System.out.println("member count = " + count);
}
}
여기서 count가 1이 찍힌다면, joinInner()에 붙인 @Transactional이 실제로는 적용되지 않은 것이다.
바로 위에서 설명한 self-invocation 때문에 프록시를 못 거친 결과다.
@Transactional이 필요한 메서드를 다른 서비스로 분리해서,
항상 “외부에서 호출되도록” 구조를 바꾸는 게 가장 깔끔한 해결책이다.
@Service
public class MemberService {
private final MemberTxService memberTxService;
public MemberService(MemberTxService memberTxService) {
this.memberTxService = memberTxService;
}
public void joinOuter() {
// 이 호출은 항상 프록시를 거쳐감
memberTxService.joinInner("outer-call");
}
}
@Service
public class MemberTxService {
@Autowired
private MemberRepository memberRepository;
@Transactional
public void joinInner(String name) {
memberRepository.save(new Member(name));
if (true) {
throw new RuntimeException("강제 예외");
}
}
}
이제 흐름은 이렇게 바뀐다.
MemberService.joinOuter() 호출MemberService 안에서 프록시가 주입된 memberTxService.joinInner() 호출@Transactional이 제대로 적용된다.테스트를 다시 돌려보면, 예외 발생 후 count == 0으로 롤백이 정상 동작하는 걸 확인할 수 있다.
코드를 분리하기 어렵거나, 연구용으로 AOP 메커니즘을 직접 보고 싶다면
자기 자신을 주입해서 항상 프록시를 통해 호출하는 방법도 있다.
@Service
public class MemberService {
@Autowired
private MemberService selfProxy; // 여기에 프록시가 주입됨
@Autowired
private MemberRepository memberRepository;
public void joinOuter() {
// this.joinInner(...) 말고, 항상 프록시를 거치도록
selfProxy.joinInner("outer-call");
}
@Transactional
public void joinInner(String name) {
memberRepository.save(new Member(name));
if (true) {
throw new RuntimeException("강제 예외");
}
}
}
이 방식은 “빈이 자기 자신에 의존”한다는 면에서 설계적으로 깔끔하진 않아서,
실무에선 가능하면 분리(4-1 방식)를 추천하는 편이다.
프록시 기반 AOP 대신 AspectJ 모드로 바꾸면 self-invocation도 잡을 수 있다.
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@SpringBootApplication
public class Application {
}
다만:
그래서 이건 프록시 AOP 한계를 근본적으로 넘고 싶을 때 선택하는 옵션이지,
일반적인 서비스 레벨에선 먼저 “설계 분리로 해결할 수 없는가?”를 보는 게 낫다.
Spring의
@Transactional은 기본적으로 프록시를 통해서만 적용된다.
같은 클래스 내부에서this.someMethod()식으로 직접 호출하면 프록시를 우회해서,
@Transactional이 전혀 동작하지 않는다.
이걸 self-invocation 문제라고 부르고,
가장 좋은 해결책은 트랜잭션 경계를 다른 빈으로 분리해서 항상 프록시를 거치게 만드는 것이다.
[참고]
https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/annotations.html
https://stackoverflow.com/questions/1099025/spring-transactional-what-happens-in-background
https://gkdbssla97.tistory.com/13
https://stackoverflow.com/questions/23931698/spring-transactional-annotation-self-invocation