티스토리 뷰

여러분은 Spring 프레임워크를 사용하면서 @Transactional 어노테이션을 자주 사용해봤을 것입니다. 하지만 정확히 어떻게 동작하는지 그 원리를 제대로 이해하고 있나요?

최근 기술 면접에서 다음과 같은 질문을 받았습니다.

하나의 클래스에 두 개의 메소드가 있다고 가정합니다. 둘 중 하나의 메소드에만 @Transactional 어노테이션이 선언되어 있습니다. 그런데 선언되지 않은 메소드에서 선언된 메소드를 호출하면 트랜잭션이 동작하지 않습니다. 그 이유는 무엇인가요?

코드로 보면 다음과 같습니다.

@Service
public class OrderService {

    // @Transactional이 없는 메서드
    public void processAndCreateOrder(Order order) {
        validateOrder(order);
        createOrder(order); // 같은 클래스 안에서 호출
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }
}

processAndCreateOrdercreateOrder를 호출하니 트랜잭션이 적용될 것 같지만, 실제로는 적용되지 않습니다. 그 이유가 이 글의 핵심입니다.

처음에는 트랜잭션 전파(propagation)와 관련된 질문이라고 생각했지만, 면접관의 의도는 그게 아니었습니다. 결국 제대로 답하지 못했고, 트랜잭션 어노테이션이 어떻게 동작하는지 원리부터 설명해 보라는 추가 질문을 받았습니다.

솔직히 프록시 객체를 통해 실행된다는 점 정도만 알고 있을 뿐, 자세한 원리는 설명하지 못했습니다. 개발자로서 TDD, DDD, 클린 코드, 아키텍처, JPA 등 다양한 개념을 공부해 왔지만, 정작 가장 자주 사용하는 어노테이션의 원리를 제대로 몰랐다는 사실이 조금 부끄러웠습니다.

그래서 이번 기회를 통해 Spring @Transactional 어노테이션의 원리와, 자주 놓치기 쉬운 함정들을 정리해 보고자 합니다.

1. Spring AOP와 동적 프록시의 동작 원리

Spring의 AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직과 부가 기능(트랜잭션, 로깅 등)을 명확히 분리하여 관리할 수 있도록 해주는 기술입니다. Spring은 프록시 객체를 이용해 부가 기능을 메소드 호출 전후에 주입합니다.

즉, 메소드 호출이 발생하면 프록시 객체가 호출을 중간에서 가로채어 부가 기능을 추가하는 형태입니다.

그런데 이 프록시는 두 가지 방식으로 만들어집니다. 어떤 방식이 쓰이는지가 뒤에 나올 함정과도 연결되니 짚고 넘어가겠습니다.

  • JDK Dynamic Proxy: 대상 클래스가 인터페이스를 구현하고 있을 때 사용됩니다. 인터페이스를 기반으로 프록시를 만듭니다.
  • CGLIB Proxy: 인터페이스가 없을 때 사용됩니다. 대상 클래스를 상속받아 프록시를 만듭니다.

원래 Spring은 인터페이스가 있으면 JDK, 없으면 CGLIB을 쓰는 방식이었지만, Spring Boot 2.0부터는 기본적으로 CGLIB을 사용합니다. 상속 기반이다 보니, 뒤에서 이야기할 final 관련 제약도 여기서 생깁니다.

2. @Transactional 어노테이션 적용 원리

Spring은 @Transactional을 발견하면 다음 구성 요소를 빈으로 등록합니다.

  • BeanFactoryTransactionAttributeSourceAdvisor: 어떤 메서드에 트랜잭션을 적용할지 판단하는 Advisor입니다.
  • TransactionInterceptor (Advice): 실제 트랜잭션 로직(시작, 커밋, 롤백)을 수행하는 핵심입니다.

프록시 객체가 메서드를 호출하면, 어드바이저 체인을 거쳐 TransactionInterceptor가 동작하며 트랜잭션이 관리됩니다.

TransactionInterceptor가 하는 일을 조금 더 풀어보면 이렇습니다.

flowchart TD
    S["트랜잭션 시작 (begin)"] --> C["target 메서드 실행"]
    C -->|정상 반환| CM["커밋 (commit)"]
    C -->|"RuntimeException / Error"| RB["롤백 (rollback)"]
    C -->|"체크 예외 (IOException 등)"| CM2["커밋 — 함정 주의"]

여기서 눈여겨볼 점이 하나 있습니다. 트랜잭션이 롤백되는 것은 기본적으로 RuntimeException이나 Error가 발생했을 때뿐입니다. 이 부분은 함정과 직결되니 아래에서 다시 다루겠습니다.

3. 클래스 내부 호출 시 트랜잭션이 동작하지 않는 이유

이제 면접에서 받았던 질문의 답입니다.

하나의 클래스 내부에서 메서드를 직접 호출하면, 프록시 객체를 거치지 않고 원본 객체(this)를 직접 호출합니다. 프록시를 거치지 않으니 AOP의 어드바이저 체인도 타지 않고, 결국 트랜잭션도 적용되지 않습니다.

  • 외부 호출: 프록시 → TransactionInterceptor → 원본 객체 (트랜잭션 적용)
  • 내부 호출: 원본 객체(this) 직접 호출 (트랜잭션 미적용)
flowchart TD
    subgraph ext["외부 호출 — 트랜잭션 O"]
        A[호출자] --> P[프록시 객체]
        P --> I["TransactionInterceptor (begin tx)"]
        I --> M1["원본 객체.createOrder()"]
    end
    subgraph self["내부 호출 — 트랜잭션 X"]
        M2["원본 객체.processAndCreateOrder()"] -->|"this.createOrder()"| M3["원본 객체.createOrder() (프록시 미경유)"]
    end

즉, @Transactional이 붙어 있어도 같은 클래스 안에서 this로 부르면 아무 소용이 없습니다.

덧붙이면, 내부 호출은 트랜잭션 적용뿐 아니라 propagation에도 영향을 줍니다. REQUIRES_NEW를 붙인 메서드를 내부 호출로 부르면, 프록시를 거치지 않으니 새 트랜잭션은 만들어지지 않습니다.

4. 그 외 자주 놓치는 함정

내부 호출 말고도, 프록시 기반이라는 특성 때문에 조용히 트랜잭션이 안 걸리는 경우가 몇 가지 더 있습니다.

1) private 메서드에는 적용되지 않습니다

프록시가 가로챌 수 있는 메서드에만 트랜잭션이 적용되기 때문에, private 메서드에 @Transactional을 붙여도 조용히 무시됩니다. 에러도 나지 않아서 오히려 더 위험합니다.

예전에는 public 메서드에만 적용됐지만, Spring 6.0(Spring Boot 3.0)부터는 클래스 기반 프록시(CGLIB)에서 protected, package-private 메서드도 지원됩니다. 다만 private은 여전히 제외입니다.

2) 체크 예외는 기본적으로 롤백되지 않습니다

많이들 놓치는 부분입니다. @Transactional은 기본적으로 RuntimeExceptionError에만 롤백합니다. IOException 같은 체크 예외가 터지면 롤백되지 않고 그대로 커밋됩니다. 체크 예외에도 롤백이 필요하면 직접 명시해야 합니다.

@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
    orderRepository.save(order);
}

3) final 클래스/메서드는 프록시를 만들 수 없습니다

CGLIB은 대상 클래스를 상속해서 프록시를 만드는데, final 클래스나 final 메서드는 상속과 오버라이드가 불가능합니다. 그래서 프록시가 만들어지지 않고 트랜잭션도 적용되지 않습니다.

5. 해결 방법

내부 호출 문제를 푸는 방법은 여러 가지가 있는데, 저는 아래 순서로 고려하는 것을 권합니다.

1) 트랜잭션 메서드를 다른 빈으로 분리 (권장)

가장 깔끔하고 근본적인 방법입니다. 트랜잭션이 필요한 메서드를 별도 클래스로 옮기면, 호출이 자연스럽게 프록시를 거치게 됩니다.

@Service
public class OrderService {

    private final OrderProcessor orderProcessor;

    public void processAndCreateOrder(Order order) {
        validateOrder(order);
        orderProcessor.createOrder(order); // 다른 빈 → 프록시 경유
    }
}

@Service
public class OrderProcessor {

    private final OrderRepository orderRepository;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }
}

2) 자기 자신을 주입받아 사용

구조를 바꾸기 어렵다면, 스프링 컨텍스트를 통해 자기 자신(프록시)을 주입받아 호출할 수 있습니다. @Lazy를 붙이는 이유는 자기 자신을 주입할 때 생기는 순환 참조 문제를 피하기 위해서입니다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    @Autowired
    @Lazy
    private OrderService self;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
    }

    public void processAndCreateOrder(Order order) {
        validateOrder(order);
        self.createOrder(order); // 프록시를 통해 호출
    }
}

3) 그 외

  • TransactionTemplate으로 트랜잭션을 코드에서 직접 여는 방법
  • AopContext.currentProxy()로 현재 프록시를 얻는 방법 (@EnableAspectJAutoProxy(exposeProxy = true) 필요)

상황에 따라 쓸 수 있지만, 개인적으로는 가능하면 1번처럼 설계로 푸는 쪽을 선호합니다.

마무리

지금까지 Spring @Transactional의 동작 원리와, 내부 호출을 비롯해 자주 놓치는 함정들을 살펴봤습니다.

돌이켜보면 그때 저는 문제 자체를 제대로 이해하지 못했습니다. 매일 쓰던 어노테이션인데, 정작 어떻게 동작하는지는 모르고 있었던 것입니다. 이번에 정리하면서 그 부분을 처음으로 제대로 들여다봤습니다.

반응형

'Java > Spring' 카테고리의 다른 글

spring boot h2 memtestdb not found 문제  (0) 2020.07.13
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함