@Transactional 동작 원리 - 프록시 생성부터 커밋까지 11단계

@Transactional 동작 원리 - 프록시 생성부터 커밋까지 11단계

@Transactional 하나만 붙이면 트랜잭션이 시작되고 커밋된다. 하지만 실제로는 그 사이에 여러 컴포넌트가 함께 동작한다. 애플리케이션이 시작되는 시점부터 SQL이 생성되어 DB에서 실행되기까지의 과정을 단계별로 따라가 본다.

트랜잭션과 비즈니스 로직은 왜 분리되었을까

트랜잭션 경계 설정 코드와 비즈니스 로직이 한 메서드에 섞이면, 그 메서드는 두 가지 책임을 갖는다. 비즈니스 로직이라는 책임과 트랜잭션 기술이라는 책임이다. 변경 이유가 둘이므로 단일 책임 원칙에 어긋난다.
그런데 이 둘은 분리하기 쉽다. 트랜잭션 코드는 비즈니스 로직의 앞뒤에서 시작과 종료만 담당하고, 비즈니스 로직과 직접 데이터를 주고받지 않기 때문이다. 따라서 스프링은 트랜잭션 처리를 프록시로 분리해, 비즈니스 메서드의 호출 전후에 적용한다.

프록시란

프록시(proxy)는 클라이언트가 사용하려는 실제 대상인 것처럼 위장해 요청을 받는 객체다. 진짜 객체 앞에서 요청을 먼저 받는다.
구조가 비슷한 두 패턴은 목적이 다르다.
  • 데코레이터 패턴: 타깃에 부가 기능을 런타임에 동적으로 부여하는 것이 목적이다. 로깅, 성능 측정, 캐싱, 그리고 트랜잭션이 여기 해당한다.
  • 프록시 패턴: 기능 확장이 아니라 타깃에 대한 접근 방식을 제어하는 것이 목적이다. 지연 생성, 접근 권한 제어가 예다.
스프링이 트랜잭션에 사용하는 것은 구조상 프록시지만, 목적으로 보면 데코레이터에 가깝다.

JDK 다이내믹 프록시와 CGLIB

프록시를 직접 클래스로 만들면 타깃의 모든 메서드에 위임 코드를 작성해야 하고, 부가 기능 코드가 메서드마다 중복된다. 그래서 런타임에 프록시를 자동 생성하는 방식을 사용한다. 두 가지가 있다.
JDK 다이내믹 프록시
CGLIB
생성 방식
인터페이스를 구현(implements)
대상 클래스를 상속(extends)
인터페이스
반드시 필요
없어도 됨
제약
인터페이스에 선언된 메서드만 프록시 가능
final 클래스, final 메서드는 프록시 불가
CGLIB은 상속과 오버라이드로 동작하므로 final을 프록시할 수 없다. 트랜잭션이 적용되는 클래스에 final을 붙이면 안 되는 이유가 여기 있다.

애플리케이션이 시작되면 어떤 일이 일어날까

애플리케이션이 시작된 뒤 트랜잭션이 커밋될 때까지의 흐름을 순서대로 살펴본다.

1. SpringApplication.run()

애플리케이션의 시작점이다. main()에서 SpringApplication.run()을 호출하면 스프링 컨테이너를 생성하고 애플리케이션을 시작한다.
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
@SpringBootApplication은 다음 세 어노테이션을 묶은 메타 어노테이션이다.
  • @SpringBootConfiguration : 설정 클래스임을 나타낸다. (@Configuration)
  • @EnableAutoConfiguration : 클래스패스를 기반으로 필요한 설정과 빈을 자동 등록한다.
  • @ComponentScan : @Component, @Service, @Repository, @Controller 등을 찾아 스프링 빈으로 등록한다.

2. Auto Configuration이 트랜잭션 관련 빈을 등록

Spring Boot가 트랜잭션에 필요한 빈들을 자동으로 등록한다.
  • DataSource
  • EntityManagerFactory
  • JpaTransactionManager
  • TransactionInterceptor
  • TransactionAdvisor
  • AutoProxyCreator

3. @Service 빈 생성

컴포넌트 스캔이 @Service 클래스를 찾아 빈으로 만든다.
@Service public class UserService { @Transactional public void join() { } }
먼저 UserService 원본 객체가 생성된다. 이 시점에는 프록시가 아니다.

4. AutoProxyCreator가 빈을 검사

AutoProxyCreator는 BeanPostProcessor를 구현한 빈 후처리기다.
빈이 생성된 뒤, 스프링 컨테이너에 등록되기 전에 개입한다.
빈 생성 ↓ BeanPostProcessor ↓ 컨테이너 등록
생성된 모든 빈을 검사하면서, 등록된 Advisor 중 해당 빈에 적용할 대상이 있는지 확인한다.

5. TransactionAdvisor가 @Transactional을 확인

TransactionAdvisor는 TransactionAttributeSource를 이용해 클래스나 메서드에 @Transactional이 붙어 있는지 검사한다. 붙어 있으면 트랜잭션을 적용할 대상이라고 판단한다.

6. AutoProxyCreator가 프록시를 생성

적용 대상이면 AutoProxyCreator가 원본 객체를 감싸는 프록시를 생성한다.
UserServiceProxy │ ▼ UserService
그리고 스프링 컨테이너에는 원본 대신 프록시를 빈으로 등록한다.
따라서 다음과 같이 주입받으면 실제로는 프록시 객체가 들어온다.
@Autowired private UserService userService;
여기서 사용된 AOP 용어를 정리하면 다음과 같다.
  • 포인트컷(Pointcut): 부가 기능을 적용할 메서드를 선정하는 알고리즘. 어디에 적용할지를 정한다.
  • 어드바이스(Advice): 부가 기능 자체를 담은 객체. 무엇을 할지를 정한다.
  • 어드바이저(Advisor): 포인트컷과 어드바이스를 하나로 묶은 객체.
포인트컷과 어드바이스를 따로 관리하면 어떤 기능을 어디에 적용해야 하는지 연결하기 어렵다. 그래서 스프링은 둘을 하나의 Advisor로 묶어 등록한다
어드바이스는 특정 타깃 객체를 참조하지 않는다. 실행 시점에 프록시가 타깃 객체와 메서드, 인자를 전달해 주기 때문이다. 따라서 상태를 가질 필요가 없고, 싱글톤 빈 하나를 여러 프록시가 함께 공유한다.
TransactionAdvice (1개) │ ├── UserService 프록시 ├── OrderService 프록시 └── MemberService 프록시

7. 컨트롤러가 UserService를 호출

여기부터는 실제 요청이 들어온 뒤의 흐름이다.
userService.join();
컨트롤러가 userService.join()을 호출하면 실제로는 프록시가 먼저 호출된다.

8. TransactionInterceptor가 동작

프록시는 TransactionInterceptor를 호출한다.
인터셉터는 다음 순서로 동작한다.
  • 트랜잭션 시작
  • 원본 메서드 실행
  • commit 또는 rollback
원본 메서드가 정상 종료되면 commit하고, 런타임 예외가 발생하면 rollback한다.

9. PlatformTransactionManager를 호출

TransactionInterceptor는 PlatformTransactionManager 인터페이스만 알고 있다. 실제로 주입된 객체는 JpaTransactionManager다.
PlatformTransactionManager (인터페이스) ▲ │ JpaTransactionManager (구현체)
이 분리가 PSA(Portable Service Abstraction)다.
JDBC, JTA, JPA는 트랜잭션을 다루는 로우레벨 API가 각각 다르다.스프링은 이를 PlatformTransactionManager로 추상화해 비즈니스 로직이 특정 트랜잭션 기술에 의존하지 않도록 만든다.
  • JDBC라면 DataSourceTransactionManager가 Connection#setAutoCommit(false) 등을 호출한다.
  • JPA라면 JpaTransactionManager가 EntityManager와 EntityTransaction을 이용한다.
구현체만 갈아 끼우면 코드 변경 없이 트랜잭션 기술을 바꿀 수 있다.

10. JpaTransactionManager가 트랜잭션을 시작

JpaTransactionManager는 실제 JPA API를 이용해 트랜잭션을 수행한다.
여기서 사용하는 EntityManager는 JPA가 정의한 인터페이스다. 영속성 컨텍스트를 관리하는 핵심 API이며, Hibernate가 이를 구현해 실제 SQL 생성과 실행을 담당한다.
  • EntityManager 생성 또는 조회
  • EntityTransaction.begin()
  • 현재 스레드에 EntityManager 저장
  • commit() 또는 rollback()
  • EntityManager 정리
여기서 중요한 부분은 현재 스레드에 EntityManager를 저장한다는 점이다. 이 내용은 뒤에서 트랜잭션 동기화에서 다시 설명한다.

11. Repository가 EntityManager를 사용

Repository는 현재 스레드에 저장된 EntityManager를 가져와 사용한다.
userRepository.save(user) ↓ EntityManager ↓ Hibernate ↓ JDBC ↓ DB
같은 트랜잭션 안의 모든 Repository는 동일한 EntityManager를 사용한다. EntityManager는 내부적으로 같은 JDBC Connection을 사용하므로, 하나의 트랜잭션 안에서 동일한 커넥션이 유지된다.

전체 흐름

Controller │ ▼ UserServiceProxy │ ▼ TransactionInterceptor (AOP) │ ▼ PlatformTransactionManager (PSA) │ ▼ JpaTransactionManager │ ▼ EntityManager │ ▼ Hibernate → JDBC → DB
객체
역할
AutoProxyCreator
Advisor 적용 대상이면 프록시 생성
TransactionAdvisor
@Transactional이 있는지 판단
TransactionInterceptor
begin → 원본 메서드 실행 → commit/rollback
PlatformTransactionManager
트랜잭션 추상화 인터페이스
JpaTransactionManager
JPA로 실제 트랜잭션 수행
EntityManager
영속성 컨텍스트 관리 및 Hibernate 호출

트랜잭션 동기화 - 여러 Repository가 같은 커넥션을 쓰는 방법

트랜잭션은 하나의 DB 커넥션 안에서 이뤄져야 한다. 트랜잭션 경계 안의 여러 Repository가 같은 커넥션을 사용해야 한다는 뜻이다. 커넥션 객체를 메서드 파라미터로 계속 넘기면 코드가 지저분해지고, 트랜잭션 코드가 비즈니스 로직에 침투한다.
트랜잭션 동기화는 트랜잭션을 시작할 때 만든 커넥션을 스레드에 바인딩된 저장소에 보관해 두고, 같은 트랜잭션 안의 Repository들이 파라미터 전달 없이 그것을 가져다 쓰게 하는 방식이다. 스프링에서는 TransactionSynchronizationManager가 ThreadLocal로 이를 관리한다.
HTTP 요청 │ ▼ 톰캣 스레드 하나 할당 │ ▼ @Transactional 시작 │ ▼ JpaTransactionManager │ ▼ EntityManager 생성 │ ▼ TransactionSynchronizationManager(ThreadLocal)에 저장 │ ▼ Repository들이 꺼내 사용
기술에 따라 바인딩되는 대상이 다르다.
  • JDBC는 ThreadLocal에 Connection을 바인딩한다.
  • JPA는 ThreadLocal에 EntityManager를 바인딩한다.
EntityManager는 하나의 트랜잭션 동안 동일한 영속성 컨텍스트를 유지하며, 내부적으로 동일한 JDBC Connection을 사용해 SQL을 실행한다.

프록시 방식의 한계 - Self Invocation

프록시 방식 AOP에는 명확한 한계가 있다. 부가 기능은 클라이언트가 프록시를 거쳐 호출할 때만 적용된다.
client → proxy → target AOP 적용됨 target → this.method() AOP 적용 안 됨
같은 클래스 안에서 자기 메서드를 직접 호출하면 프록시를 거치지 않는다. 이를 self-invocation이라고 한다.
@Service public class OrderService { public void order() { this.save(); // 프록시를 거치지 않으므로 트랜잭션이 적용되지 않는다 } @Transactional public void save() { } }
save()@Transactional이 붙어 있어도 트랜잭션이 시작되지 않는다. 외부에서 order()를 호출할 때는 프록시를 거쳤지만, 그 안에서 this.save()를 부르는 순간 원본 객체의 메서드를 직접 호출하는 것이기 때문이다.
해결하려면 호출을 다른 빈으로 분리해 프록시를 거치게 만들어야 한다.

여기까지가 @Transactional이 내부적으로 동작하는 과정이다.
그렇다면 개발자가 @Transactional에서 직접 제어할 수 있는 것은 무엇일까? 다음은 자주 사용하는 트랜잭션 속성이다.

@Transactional의 주요 속성

@Transactional은 트랜잭션의 동작 방식을 여러 속성으로 제어할 수 있다. @Transactional이 지정할 수 있는 속성은 네 가지다.
  • 전파(propagation): 이미 진행 중인 트랜잭션이 있을 때 어떻게 동작할지
전파 속성 중 자주 사용하는 둘은 다음과 같다.
REQUIRED (기본값) 트랜잭션이 있으면 참여하고, 없으면 새로 만든다. REQUIRES_NEW 무조건 새로운 트랜잭션을 만든다.
REQUIRES_NEW가 필요한 대표적인 경우가 로그다. 주문 처리가 롤백되어도 로그는 별도 트랜잭션으로 이미 커밋되었기 때문에 남는다.
  • 격리 수준(isolation): 동시에 진행되는 트랜잭션끼리 서로에게 얼마나 영향을 주는지
  • 제한 시간(timeout): 트랜잭션 수행 시간 제한
  • 읽기 전용(readOnly): 트랜잭션 내에서 쓰기를 막을지 여부