코드 리뷰 과제를 진행하다가 @Transactional 이 붙어 있는데 두 저장이 각각 커밋되는 코드를 만났다. 예약을 저장하고 예약 슬롯의 상태를 갱신하는 두 저장 사이에서 예외가 나면 절반만 반영된다. 어노테이션은 분명히 있다. 에러도 경고도 없다. 원인은 두 가지였다. 메소드가 private 이었고, 같은 클래스 안에서 호출되고 있었다.

Spring 의 @Transactional 은 붙였다고 동작하는 게 아니다. Spring 은 bean 을 프록시로 감싸고, 프록시를 거쳐 들어오는 호출만 가로채 트랜잭션을 연다. 이 경로를 벗어난 호출에서 어노테이션은 조용히 무시된다. 왜 그런지 공식 문서를 따라가며 확인했다.

프록시 기반 AOP

Spring 의 선언적 트랜잭션은 관점 지향 프로그래밍(AOP)으로 구현된다. @Transactional 이 붙은 bean 을 컨테이너에 등록할 때 Spring 은 원본 객체를 그대로 노출하지 않고 프록시로 감싼다. 트랜잭션을 시작하고 커밋하고 롤백하는 코드는 어노테이션이 아니라 이 프록시에 있다. 호출자가 프록시를 거쳐 메소드를 호출하면 프록시가 트랜잭션을 열고, 원본 메소드를 실행한 뒤, 정상 반환이면 커밋하고 예외면 롤백한다.

sequenceDiagram
    participant Caller as 호출자
    participant Proxy as 프록시
    participant Target as 원본 객체

    Caller->>Proxy: reserve()
    activate Proxy
    Note over Proxy: 트랜잭션 시작
    Proxy->>Target: reserve()
    activate Target
    Target->>Target: this.saveAll()
프록시를 거치지 않음 Target-->>Proxy: 반환 deactivate Target Note over Proxy: 커밋 또는 롤백 Proxy-->>Caller: 반환 deactivate Proxy

프록시를 만드는 방식은 두 가지가 주로 쓰인다. 대상이 인터페이스를 구현하면 JDK dynamic proxy 로 인터페이스 기반 프록시를 만들고, 그렇지 않으면 CGLIB 로 원본 클래스를 상속한 하위 클래스를 런타임에 생성한다. Spring Boot 는 기본 설정에서 class-based(CGLIB) 프록시를 사용한다.

트랜잭션 위에서 동작하는 것들이 많다. 락도, JPA 의 변경 감지도, 두 저장을 하나로 묶는 원자성도 “트랜잭션이 실제로 열려 있는가” 에 의존한다. 그래서 호출이 프록시를 거치는지부터 확인해야 했다.

private 메소드

CGLIB 프록시는 상속 기반이다. 프록시가 트랜잭션 코드를 끼워 넣으려면 대상 메소드를 재정의해야 하는데, private 메소드는 언어 차원에서 재정의가 불가능하다. Spring 이 우회할 수 있는 제약이 아니다.

@Service
public class ReservationService {

    public void reserve(String slotId, String userId) {
        // ... 정원 검증
        saveAll(reservation, slot);
    }

    @Transactional // private 은 재정의가 불가능해 무시된다
    private void saveAll(Reservation reservation, Slot slot) {
        reservationRepository.save(reservation);
        slotRepository.save(slot);
    }
}

Spring Framework 6.0 부터 protected 와 package-visible 메소드는 class-based 프록시에서 트랜잭션이 지원된다. private 은 여전히 불가능하다. 인터페이스 기반 프록시라면 조건이 더 엄격해서, 메소드가 public 이고 프록시 대상 인터페이스에 선언되어 있어야 한다.

Self-Invocation

위 코드에서 saveAll 을 public 으로 바꾸면 해결될 것 같지만, 그래도 트랜잭션은 열리지 않는다. reserve() 가 같은 클래스의 saveAll()this 로 호출하기 때문이다. 프록시는 원본 객체를 감싸는 별도 객체다. 호출이 원본 객체에 도달한 뒤에 일어나는 내부의 this.saveAll() 은 프록시가 아니라 원본을 직접 가리킨다. 공식 문서는 이를 자기 호출(self-invocation)이라 부르고, proxy mode 에서는 가로채지 못한다고 명시한다.

가시성 문제가 아니라 호출 경로 문제라서, 해결도 호출 경로를 바꾸는 쪽이다.

  • 트랜잭션 경계를 호출 진입점으로 옮긴다. reserve()@Transactional 을 붙이면 프록시를 거친 호출부터 트랜잭션이 열린다. 리뷰한 코드에서는 이게 가장 단순한 수정이었다.
  • 트랜잭션이 필요한 메소드를 별도 bean 으로 분리해 주입받는다.
  • 공식 문서가 권하는 근본 해법도 self-invocation 자체를 피하는 리팩토링이다.

Spring 과 Jakarta 의 @Transactional

IDE 자동완성에는 @Transactional 이 두 개 뜬다. org.springframework.transaction.annotationjakarta.transaction 이다. Spring 은 JTA(Jakarta Transactions) 표준 어노테이션도 drop-in 으로 지원하므로 어느 쪽이든 프록시 처리는 동일하게 동작한다. 차이는 속성에 있다.

속성jakarta.transaction.TransactionalSpring @Transactional
propagationTxType 6종Propagation 7종 (NESTED 추가)
isolation지원 안 함지원
timeout지원 안 함지원
readOnly지원 안 함지원
rollback 규칙rollbackOn / dontRollbackOnrollbackFor / noRollbackFor
트랜잭션 매니저 지정지원 안 함transactionManager 지정 가능

isolation 이나 timeout 을 지정할 일이 생기면 jakarta 어노테이션으로는 방법이 없다. Spring 애플리케이션이라면 Spring 의 어노테이션을 쓰는 쪽이 자연스럽다.

@DataJpaTest 와 테스트 트랜잭션

결함이 있던 코드에도 테스트는 있었고, 통과하고 있었다. @DataJpaTest 는 각 테스트를 하나의 트랜잭션으로 감싸고 끝나면 롤백한다. 테스트 간 격리가 목적이지만 부수 효과가 있다. 서비스에 트랜잭션이 없어도 테스트가 연 트랜잭션이 모든 저장을 묶는다. 커밋 경계의 결함은 이 테스트에서 드러나지 않는다.

bean 구성도 확인할 지점이다. @DataJpaTest 는 slice 테스트라 repository 관련 bean 만 등록한다. 서비스를 new 로 직접 생성하면 프록시 없는 원본 객체를 테스트하게 된다. @Import 로 서비스를 bean 으로 등록하고 주입받아야 프로덕션과 같은 구성이 된다.

@DataJpaTest
@Import(ReservationService.class) // 프록시가 적용된 bean 으로 테스트
class ReservationServiceTest {

    @Autowired
    ReservationService reservationService;

    @Test
    @Transactional(propagation = Propagation.NOT_SUPPORTED) // 테스트 트랜잭션 해제
    void 저장_중_예외가_나면_전부_롤백된다() {
        // ...
    }
}

커밋/롤백 경계 자체를 검증하려면 테스트를 감싸는 트랜잭션부터 해제해야 한다. @Transactional(propagation = Propagation.NOT_SUPPORTED) 를 붙이면 해당 테스트는 트랜잭션 없이 실행된다. 대신 롤백 안전망도 사라지므로 테스트가 만든 데이터는 직접 정리해야 한다.

정리

@Transactional 은 선언이고, 실제 동작은 프록시가 한다. 트랜잭션이 열리는지는 어노테이션의 존재가 아니라 호출이 프록시를 거치는지가 결정한다. private 메소드와 self-invocation 은 그 경로를 벗어나고, @DataJpaTest 의 테스트 트랜잭션은 벗어났다는 사실을 가린다. 리뷰에서 만난 코드는 어노테이션을 진입점으로 옮기는 수정으로 끝났지만, 그 수정이 왜 필요한지는 프록시를 이해하고서야 보였다.

참고한 공식 문서: Using @Transactional, Understanding AOP Proxies