@Transactional은 항상 잘 동작할까 ?

HenryHong·2026년 1월 8일

Spring

목록 보기
2/4
post-thumbnail

Spring에서 @Transactional이 내부 메서드 호출에선 왜 안 먹을까? (Self-Invocation 문제 정리)


1. 문제 상황 한 번 짚고 가기

서비스 코드를 이렇게 짰다고 해보자.

@Service
public class OrderService {

    public void outer() {
        // 비즈니스 로직...
        inner(); // 내부 메서드 직접 호출
    }

    @Transactional
    public void inner() {
        // DB 저장/수정 로직...
    }
}

개발자 머릿속 기대:

  • outer()inner() 호출
  • inner()@Transactional 있으니까,
    • 트랜잭션 시작
    • 예외 나면 롤백

현실:

  • 예외가 터져도 롤백이 안 되거나,
  • 로그를 보면 트랜잭션이 시작도 안 된 것처럼 보이는 경우가 생긴다.

이게 바로 “self-invocation(자기 호출) 때문에 @Transactional이 적용 안 되는” 전형적인 상황이다.


2. 핵심 원인: 프록시(proxy)를 안 거치고 this로 바로 호출하기 때문

Spring의 선언적 트랜잭션(@Transactional)은 내부적으로 AOP + 프록시 기반으로 동작한다.

2-1. Spring이 실제로 하는 일

@Transactional이 붙은 빈은 대략 이런 식으로 감싸진다.

  • 진짜 구현체: OrderService (target)
  • Spring이 만든 프록시: OrderService$$SpringProxy (혹은 JDK dynamic proxy)

외부에서 주입받을 때 보통 이 프록시가 주입된다.

Client → (프록시) OrderService$$Proxy → (타깃) OrderService

프록시의 역할:

  1. 메서드 호출을 가로챈다.
  2. @Transactional 설정을 보고
    • 트랜잭션 시작
    • target 메서드 호출
    • 정상 종료 시 commit, 예외 시 rollback

2-2. 왜 내부 호출에선 이게 깨질까?

문제는 “같은 클래스 안에서 자기 메서드를 부를 때”다.

위 코드에서 outer() 안의 inner() 호출은 실제로 이렇게 동작한다.

public void outer() {
    // 이건 프록시를 통해 호출됨 (OK)
    inner(); // 여기서는 this.inner() 과 같다 → 프록시를 안 거침
}

흐름은 이렇게 된다.

  1. 클라이언트 코드가 orderService.outer() 호출
    → 이건 프록시를 거친다 (여기까진 OK).
  2. 프록시가 트랜잭션을 적용해야 할 메서드인지 확인
    outer()에는 @Transactional이 없으니 그냥 타깃으로 패스.
  3. 이제 타깃 인스턴스OrderServiceouter()가 실행된다.
  4. outer() 안에서 inner()를 호출할 때,
    • 실제로는 this.inner() 호출
    • this는 프록시가 아니라 순수한 OrderService 인스턴스다.
  5. 결과적으로 inner() 호출은 프록시를 전혀 거치지 않는다.
    • 트랜잭션 인터셉터가 개입할 기회가 없음.
    • 그래서 @Transactional무시된 것처럼 보이는 상황이 된다.

Spring 공식 문서도 이걸 아주 명확히 못 박고 있다.

프록시 모드(기본값)에서는 프록시를 통해 들어오는 외부 메서드 호출만 인터셉트된다.
동일한 객체 내부에서 자기 자신의 메서드를 호출하는 self-invocation은
런타임에 실제 트랜잭션을 만들지 않는다.


3. 문제 재현용 예제 코드

3-1. 트랜잭션이 안 먹는 코드

@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 때문에 프록시를 못 거친 결과다.


4. 어떻게 해결할까? (실무에서 자주 쓰는 패턴)

4-1. 가장 권장되는 방법: 트랜잭션 경계를 다른 빈으로 분리

@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으로 롤백이 정상 동작하는 걸 확인할 수 있다.

4-2. “꼼수” 방식: 자기 자신을 프록시로 주입해서 사용

코드를 분리하기 어렵거나, 연구용으로 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 방식)를 추천하는 편이다.

4-3. AspectJ 모드 사용 (비용이 큼)

프록시 기반 AOP 대신 AspectJ 모드로 바꾸면 self-invocation도 잡을 수 있다.

@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
@SpringBootApplication
public class Application {
}

다만:

  • 빌드/로딩 시점에 바이트코드 위빙 설정이 필요하고,
  • 러닝 환경이 좀 더 복잡해진다.

그래서 이건 프록시 AOP 한계를 근본적으로 넘고 싶을 때 선택하는 옵션이지,
일반적인 서비스 레벨에선 먼저 “설계 분리로 해결할 수 없는가?”를 보는 게 낫다.


5. 마무리 정리

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

profile
주니어 백엔드 개발자

0개의 댓글