이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Spring은 JDBC, JPA, JTA의 트랜잭션 차이를 어떻게 하나의 모델로 감추는가?
  • PlatformTransactionManager와 선언적 트랜잭션은 어떤 관계인가?
  • 트랜잭션 경계를 Service 계층에 두는 이유는 무엇인가?

개요

Spring 트랜잭션의 핵심은 추상화다. 데이터 접근 기술이 JDBC인지, JPA인지, 분산 트랜잭션인지에 따라 실제 commit과 rollback 방법은 다르다. Spring은 이 차이를 PlatformTransactionManager 계층으로 감추고, 애플리케이션 코드는 선언적 트랜잭션 모델로 일관되게 작업할 수 있게 한다.

이 추상화 덕분에 개발자는 트랜잭션 경계를 비즈니스 유스케이스 관점에서 생각할 수 있다. 중요한 것은 어떤 API가 commit을 호출하는지가 아니라 어떤 작업들이 하나의 일관성 단위로 묶여야 하는지다.

원리

PlatformTransactionManager는 트랜잭션 시작, commit, rollback을 담당하는 중심 인터페이스다. JDBC 기반이면 DataSourceTransactionManager, JPA 기반이면 JpaTransactionManager 같은 구현이 실제 리소스와 통신한다. Spring은 현재 실행 흐름에 트랜잭션 리소스를 동기화해 같은 유스케이스 안의 여러 데이터 접근 코드가 같은 트랜잭션 컨텍스트를 공유하게 한다.

선언적 트랜잭션에서는 개발자가 이 인터페이스를 직접 호출하지 않는다. @Transactional 메타데이터를 읽은 Spring AOP 인프라가 메서드 호출 앞뒤에 트랜잭션 시작과 종료를 붙인다. 그래서 @Transactional은 단순 마커가 아니라 트랜잭션 매니저, AOP 프록시, 리소스 동기화가 함께 동작하는 진입점이다.

트랜잭션 리소스는 현재 실행 흐름에 바인딩된다. 같은 트랜잭션 안에서 Repository가 여러 번 호출되어도 같은 커넥션이나 영속성 컨텍스트가 공유될 수 있다. 이것이 Service 메서드 하나를 유스케이스 단위로 감싸야 하는 이유다. 단, 여러 데이터소스나 여러 TransactionManager가 있는 경우 어떤 매니저가 선택되는지 명시하지 않으면 의도와 다른 리소스에 트랜잭션이 걸릴 수 있다.

코드 예제

@Service
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;
    private final PaymentRepository paymentRepository;
 
    public OrderService(
            OrderRepository orderRepository,
            StockRepository stockRepository,
            PaymentRepository paymentRepository
    ) {
        this.orderRepository = orderRepository;
        this.stockRepository = stockRepository;
        this.paymentRepository = paymentRepository;
    }
 
    @Transactional
    public Long placeOrder(OrderCommand command) {
        Order order = orderRepository.save(Order.create(command));
        stockRepository.decrease(command.productId(), command.quantity());
        paymentRepository.save(Payment.pending(order.id(), command.amount()));
        return order.id();
    }
}

이 메서드의 트랜잭션 경계는 주문 생성 유스케이스 전체다. 주문 저장, 재고 차감, 결제 기록 생성 중 하나라도 실패하면 전체가 rollback되는 것이 의도다. Repository마다 각각 트랜잭션을 열면 이 일관성이 깨질 수 있다.

선언적과 프로그래밍 방식

대부분의 애플리케이션 서비스에는 선언적 트랜잭션이 적합하다. 유스케이스 메서드에 경계를 선언하면 코드가 간결하고 정책이 드러난다.

반면 아주 세밀하게 특정 블록만 트랜잭션으로 감싸거나, 반복 처리 중 일부만 독립적으로 commit해야 하는 경우에는 TransactionTemplate 같은 프로그래밍 방식이 더 명확할 수 있다. 중요한 것은 선언적 방식이 항상 우월한 것이 아니라 호출 경계와 코드 블록 경계 중 어느 쪽이 문제를 더 잘 표현하는지다.

실전 팁

  • 트랜잭션은 Repository가 아니라 유스케이스 경계에서 먼저 생각한다.
  • 하나의 Service 메서드가 너무 많은 외부 작업을 포함하면 트랜잭션과 외부 부작용을 분리할 신호로 본다.
  • 데이터 접근 기술을 바꿔도 Service의 트랜잭션 의도가 유지되도록 Spring 추상화를 활용한다.
  • 여러 TransactionManager가 있는 애플리케이션에서는 어떤 매니저를 쓰는지 명시한다.
  • 트랜잭션 매니저 선택, 영속성 컨텍스트 범위, 커넥션 동기화는 한 묶음으로 디버깅한다.
  • 프로그래밍 트랜잭션은 예외적 도구로 두고, 필요한 이유를 코드에서 드러낸다.

위험 신호!

  • Repository마다 @Transactional을 붙여 유스케이스 원자성이 흩어진다.
  • Controller가 요청 전체를 트랜잭션으로 감싼다.
  • Service 메서드 안에서 DB 작업과 긴 외부 네트워크 호출이 섞여 있다.
  • 여러 데이터소스를 쓰는데 트랜잭션 매니저 선택이 불명확하다.
  • JPA Repository를 호출하면서 JDBC용 트랜잭션 매니저가 적용된다고 오해한다.
  • 트랜잭션 실패를 문자열 로그만 남기고 호출자에게 의미 있는 실패로 전달하지 않는다.

확인 질문

확인 질문

  • PlatformTransactionManager의 역할은 무엇인가?
    • 트랜잭션 시작, commit, rollback을 데이터 접근 기술별 구현으로 수행하는 Spring의 트랜잭션 추상화다.
  • 선언적 트랜잭션에서 개발자가 트랜잭션 매니저를 직접 호출하지 않아도 되는 이유는 무엇인가?
    • Spring AOP 인프라가 @Transactional 메타데이터를 읽어 메서드 호출 전후에 트랜잭션 처리를 적용하기 때문이다.
  • 트랜잭션 경계를 Service에 두는 이유는 무엇인가?
    • Service가 여러 Repository 작업을 하나의 유스케이스 일관성 단위로 묶는 위치이기 때문이다.

참고 문서