Spring

[Spring] @Transactional이 동작하지 않는 이유: Proxy와 내부 호출(Self-Invocation)의 함정

수수한 인간 2025. 12. 9. 00:38

들어가면서

스프링 환경에서 트랜잭션을 관리할 때, 우리는 마법처럼 편리한 @Transactional 어노테이션에 익숙해져 있습니다.
하지만 이 '마법'이 때로는 우리를 배신하기도 합니다.
분명 어노테이션을 붙였는데도 롤백이 안 되거나, 아예 트랜잭션이 적용되지 않는 상황을 마주하게 됩니다.
데이터 정합성과 직결되는 트랜잭션 처리에서 이러한 문제는 치명적인 장애로 이어질 수 있습니다.
따라서 "왜 안 되는지"에 대한 명확한 이해는 선택이 아닌 필수입니다.
이 글에서는 스프링 트랜잭션의 '마법'이 벗겨지는 순간, 즉 AOP Proxy 패턴의 구조적 한계를 직접 코드로 파헤치고 그 해결책을 알아봅니다.


AOP와 Proxy

@Transactional이나 @Cacheable 같은 어노테이션이 마법처럼 동작할 수 있는 이유는 스프링이 AOP를 지원하기 때문입니다.
AOP는 핵심 비즈니스 로직과 부가 기능을 분리하여 모듈성을 높이는 기술입니다.
스프링은 이 AOP를 Proxy 패턴을 통해 구현합니다

우리가 @Service, @Component 어노테이션을 붙인 클래스를 만들고 다른 클래스에서 Spring DI로 주입받는 것을 가정해보겠습니다.
이때 스프링 컨테이너는 원본 객체를 그대로 주입해주는 것이 아니라 원본 객체를 상속받아 가짜객체(Proxy 객체)를 대신 주입합니다.
스프링 부트 2.0부터는 기본 Proxy 전략이 CGLIB라는 라이브러리를 사용합니다.(과거에는 인터페이스 유무에 따라 JDK Dynamic Proxy와 혼용)

//실제 주입된 객체의 클래스 이름 출력 결과
com.example.demo.service.UserService$$SpringCGLIB$$0

Proxy 및 어노테이션의 동작 흐름

이렇게 주입된 Proxy 객체는 실제 비즈니스 로직이 호출되기 전후에 부가 기능을 실행하는 '문지기' 역할을 합니다.
다음은 Proxy를 통해 어노테이션들이 어떻게 연쇄적으로 동작하는지 보여주는 전체 흐름입니다.
만약 UserService.createUser() 메서드에 다음과 같이 3개의 AOP가 걸려있다고 가정해 봅시다.

  • 커스텀 AOP: @LogExecutionTime (우리가 만든 실행 시간 측정 AOP)
  • 스프링 AOP: @Cacheable (캐시 조회)
  • 스프링 AOP: @Transactional (트랜잭션 관리)

이때 클라이언트가 userService.createUser()를 호출하면 다음과 같은 순서로 실행됩니다.
(순서는 @Order로 조정 가능하지만, 기본적으로는 스프링 내부 우선순위에 따릅니다.)

  1. 진입 - 껍질 뚫고 들어가기
    • Client: proxy.createUser() 호출.
    • CGLIB Proxy: 이 메서드엔 3개의 Advisor가 있네
    • Advisor 1 (Logging): 시간 측정 시작 -> proceed() 호출.
    • Advisor 2 (Cache): 캐시에 데이터 유무 확인 (없음) -> proceed() 호출.
    • Advisor 3 (Transaction): 트랜잭션 시작 -> proceed() 호출.
  2. 실행 - 원본의 수행
    • Target: 드디어 원본 객체(UserService)의 createUser()가 실행됩니다.
  3. 복귀 - 껍질 입고 나오기
    • Advisor 3 (Transaction): 작업 끝났네? 에러 없으니 Commit. -> 리턴.
    • Advisor 2 (Cache): DB에서 가져온 결과값을 캐시에 저장. -> 리턴.
    • Advisor 1 (Logging): 총 소요 시간 계산해서 로그 출력 -> 최종 결과 리턴.
  4. Client: 최종 결과값 수신.
    💡 Advisor Chain
    스프링 컨테이너가 빈을 생성할 때 해당 메서드에 적용되어야 할 모든 어드바이스를 찾아서 하나의 체인으로 엮어둡니다. 
    그래서 실행 시점에는 매번 어노테이션을 검사하는 것이 아니라, 미리 준비된 이 체인을 순차적으로 실행하기만 하면 됩니다.

이처럼 스프링 AOP는 "실제 객체를 감싸는 Proxy를 내세워, 요청을 가로채 부가적인 처리를 수행하는 방식"으로 동작합니다. 그리고 바로 이 'Proxy를 거쳐야만 한다'는 구조적 특징 때문에 트랜잭션이 적용되지 않는 한계점이 발생하게 됩니다.


문제 상황

앞서 Proxy가 "문지기" 역할을 한다는 것을 이해했다면, 이제 왜 @Transactional이 종종 무시되는지 그 원인을 명확히 알 수 있습니다.
바로 '문지기를 통하지 않고 뒷문으로 몰래 들어가는 경우', 즉 Self-Invocation(자기 호출)입니다.

문지기를 우회하는 내부 호출 (Self-Invocation)

외부에서 호출하는 createUser()는 트랜잭션이 없고, 내부의 saveUser()에만 @Transactional이 걸려있는 상황입니다.

@Service
@AllArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    // 1. 외부 호출 (Transaction 없음)
    public void createUser(UserDto userDto){
        System.out.println("createUser 호출");
        // 2. 내부 메서드 호출 (Transaction 적용 기대)
        this.save(userDto);
    }
    @Transactional
    public void save(UserDto userDto){
        System.out.println("save 호출");
        userRepository.save(new User(userDto));
        // 3. 예외 발생! 롤백 되어야 함
        throw new RuntimeException("롤백"); 
    }
}

왜 트랜잭션이 적용되지 않았을까?

  1. 진입: 클라이언트가 createUser()를 호출할 때는 Proxy 객체를 거칩니다.
    하지만 이 메서드에는 @Transactional이 없으므로 Proxy는 그냥 원본 객체를 호출합니다.
  2. createUser 호출 : 이제 제어권은 '원본 객체(UserService)' 안으로 들어왔습니다.
  3. save 호출: 원본 객체 내부에서 this.save()를 호출합니다.(내부 직접 호출)
  4. 결과: 껍질 밖으로 나갔다가 다시 들어오는 게 아니라, 객체 내부에서 다이렉트로 메서드를 호출해버렸기 때문에 트랜잭션 Advisor를 만날 기회가 전혀 없었던 것입니다.

이중으로 닫힌 문 (Private Method)

같은 원리로 private 메서드에 @Transactional을 붙여도 동작하지 않습니다.
정확하게는 Private 메서드는 구조적으로 트랜잭션 적용이 불가능합니다.

@Service
public class UserService {

    @Transactional
    private void internalProcess() {
        // 트랜잭션 적용 안 됨!
        userRepository.save(...);
    }
}

이중으로 닫힌 문

  1. Self-Invocation 필수: private 메서드는 외부 호출이 불가능하므로, 반드시 내부에서 this.internalProcess() 형태로 호출해야 합니다. 즉, 무조건 Self-Invocation 문제에 걸립니다.
  2. 상속 불가: 설령 자기 자신을 주입받아 Proxy를 통해 호출하려 해도 불가능합니다. 자바의 접근 제어자 규칙에 따라 외부 객체는 private 메서드에 접근할 수 없기 때문입니다.

게다가 근본적으로 CGLIB는 상속을 기반으로 동작하는데, private 메서드는 상속도 오버라이딩도 불가능하므로 애초에 Proxy 객체가 트랜잭션 로직을 심어넣을 수조차 없습니다.
결국 이 모든 문제의 근본 원인은 'Proxy를 거쳐야만 AOP가 동작한다'는 대원칙입니다. 그렇다면 이 문제를 어떻게 해결해야 할까요?


해결방안

Self-Invocation과 private 메서드 문제는 결국 "Proxy를 어떻게 다시 태울 것인가?"라는 질문으로 귀결됩니다.
몇 가지 해결책이 있지만, 각 방법의 한계와 비용을 명확히 이해하고 선택해야 합니다.

방법1: 자기 자신 주입 (Self-Injection) - 비추천

가장 쉽게 떠올릴 수 있는 방법은 자기 자신을 @Autowired로 주입받아 사용하는 것입니다.

@Service
public class UserService {
    @Autowired
    @Lazy
    private UserService self;

    @Autowired
    private UserRepository userRepository;

    // 1. 외부 호출 (Transaction 없음)
    public void createUser(UserDto userDto){
        System.out.println("createUser 호출");
        // 2. 내부 메서드 호출 (Transaction 적용 기대)
        self.save(userDto);
    }
    @Transactional
    public void save(UserDto userDto){
        System.out.println("save 호출");
        userRepository.save(new User(userDto));
        // 3. 예외 발생! 롤백 되어야 함
        throw new RuntimeException("롤백"); 
    }
}

원리: this는 원본 객체지만, 주입받은 self는 스프링 컨테이너가 관리하는 Proxy 객체입니다. 따라서 외부에서 호출하는 것과 동일한 효과를 냅니다.
한계:

  • 코드 복잡성: 순환 참조를 해결하기 위해 @Lazy를 써야 하며, 코드가 지저분해집니다.
  • Private 불가: 여전히 외부 객체가 접근하는 방식이므로 private 메서드에는 적용할 수 없습니다.
  • 순환 참조 위험: 스프링 부트 2.6+에서는 순환 참조가 기본 금지라, 구조를 바꾸지 않고 설정(allow-circular-references)이나 @Lazy로 설정하는 것은 좋지 않습니다.

방법 2: 클래스 분리 - 추천

가장 깔끔하고 권장되는 방법은 역할과 책임을 분리하는 것입니다. 트랜잭션이 필요한 로직을 별도의 클래스로 위임합니다.
가능하다면 한 메서드에서 트랜잭션 경계를 단순하게 관리하는 것이 가장 좋지만, 도메인 상 여러 단계로 나눌 수밖에 없는 경우에는 클래스를 분리해 외부 빈 호출 구조로 만드는 것이 가장 깔끔한 해결책입니다.

// 1. 외부 요청을 받는 Facade (진입점)
@Service
@RequiredArgsConstructor
public class UserFacade {
    private final UserService userService; // 핵심 로직(트랜잭션) 담당

    public void createUser(UserDto userDto) {
        // 다른 빈의 메서드를 호출하므로 무조건 Proxy를 거침!
        userService.save(userDto);
    }
}
// 2. 트랜잭션 핵심 로직 담당
@Service
public class UserService {    
    @Transactional
    public void save(UserDto userDto){
        ...
    }
}

이점:

  • 완벽한 해결: this 호출 문제와 private 메서드 문제를 근본적으로 해결합니다. (외부 호출로 변경됨)
  • 객체지향 설계: 비즈니스 흐름과 단위 로직의 책임이 명확히 분리됩니다.
  • 테스트 용이: 각 컴포넌트를 독립적으로 테스트하기 쉬워집니다.

그외 방법들

  1. AopContext.currentProxy()

    • ((UserService) AopContext.currentProxy()).save(userDto) 처럼 현재 Proxy를 강제로 꺼내올 수 있습니다.
    • 비추천 이유: 비즈니스 로직에 스프링 프레임워크 코드가 섞여 순수성이 깨지고, expose-proxy 같은 별도 설정을 켜야 동작하기 때문에 유지보수성이 떨어집니다.
  2. AspectJ Weaving

    • Proxy 방식 대신, 컴파일 시점에 바이트 코드를 직접 조작하여 트랜잭션 코드를 심는 방식입니다. private 메서드 호출이나 Self-Invocation 문제 자체가 발생하지 않습니다.
    • 비추천 이유: 설정이 매우 복잡하고(컴파일러 변경, Java Agent 등), 일반적인 웹 애플리케이션에서는 오버 엔지니어링이 되기 쉽습니다.

이런 다양한 방식들은 모두 사용 가능은 하지만, 구조를 더 복잡하게 만들고 프레임워크 결합도를 높인다는 공통된 단점이 있습니다.
그래서 실무에서는 결국 클래스 분리 방식이 가장 많이 쓰이고, 장기적으로도 안전한 선택입니다.


마무리하면서

지금까지 스프링이 부리는 '마법'의 실체인 Proxy와 AOP의 동작 원리를 깊이 있게 파헤쳐 보았습니다.
특히 @Transactional이 동작하지 않는 문제 상황을 통해 Proxy 패턴의 구조적 한계를 명확히 이해할 수 있었습니다.

여기서 중요한 점은, @Transactional은 단지 하나의 구체적인 예시일 뿐이라는 것입니다.
@Cacheable, @Async 등 스프링이 제공하는 다른 어노테이션이나 우리가 직접 만드는 커스텀 AOP 역시 모두 동일한 프록시 메커니즘 위에서 동작합니다. 따라서 오늘 다룬 문제와 해결책은 스프링의 모든 부가 기능에 그대로 적용할 수 있습니다.

여러 해결 방안 중 제가 가장 추천하는 방법은 '클래스 분리'입니다.
비즈니스 로직과 부가 기능의 경계를 명확히 나누는 과정에서, 우리는 AOP의 핵심 가치인 '관심사 분리'를 실천하게 됩니다.
또한 각 객체가 하나의 책임만 갖도록 하는 객체지향 설계 원칙(SRP)에도 부합합니다.

당장은 파일을 쪼개는 게 번거로워 보일 수 있습니다.
하지만 개발자가 번거로울수록 완성되는 코드는 더 건강하고 유지보수하기 좋게 만들어줄 것이라고 믿고있습니다.