[JPA #3] JPA vs TypeORM - Transaction

태그: ,

업데이트:

1. Transaction

트랜잭션은 데이터베이스의 일관성을 보장하기 위한 방법으로, ACID 원칙을 따른다.

  • 원자성(Atomicity): 전부 수행되거나 전부 취소된다.
  • 일관성(Consistency): 트랜잭션 전후로 데이터 무결성이 유지된다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션은 서로 간섭하지 않는다.
  • 지속성(Durability): 커밋된 데이터는 영구적으로 보존된다.

이 중에서도 ORM 에서는 특히 격리성이 중요하다. 여러 트랜잭션이 동시에 엔티티를 갱신할 때, 정합성을 깨지 않으려면 트랜잭션 경계가 올바르게 설정되어야 한다.

  • JPA 는 EntityManager와 영속성 컨텍스트가 트랜잭션 단위로 동작하며, @Transactional을 통해 격리 수준과 경계를 선언한다.
  • TypeORM은 상태 관리 개념이 없으므로, QueryRunnerTransactionManager로 격리 수준과 경계를 직접 지정해야 한다.

이번 포스팅에서는 Spring Data JPA 의 @Transactional의 특징에 대해 다룰것이며, 트랜잭션의 특징과 TypeORM 트랜잭션과의 차이점에 대해 서술할 것이다.

2. Spring Data JPA 트랜잭션 관리

2.1. @Transactional

@TransactionalSpring AOP Proxy를 이용해 트랜잭션을 선언적으로 관리할 수 있게 한다. 개발자가 직접 commit()이나 rollback()을 호출하지 않아도, 메서드 실행이 정상 종료되면 커밋되고 예외가 발생하면 롤백된다.

스프링의 기본 정책은 다음과 같다.

  • Unchecked Exception(RuntimeException, Error) 발생 시 -> Rollback
  • Checked Exception 발생 시 -> commit

이 방식은 다음과 같은 장점을 가진다.

  • 트랜잭션 경계를 명확하게 선언할 수 있다.
  • 중첩된 서비스 호출도 동일한 트랜잭션 안에서 관리할 수 있다.
  • 트랜잭션 관리 로직을 코드에 직접 작성하지 않아도 된다.

예시

@Service
public class OrderService {

    private final OrderRepository orderRepository

    public OderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public void placeOrder(Long productId, Long userId) {
        Order order = new Order(productId, userId);
        orderRepository.save(order)
        // commit/rollback 은 Spring AOP 에서 자동 처리
    }
}

2.2. 트랜잭션과 EntityManager

Spring Data JPA에서 하나의 트랜잭션은 하나의 EntityManager와 연결된다. 트랜잭션이 시작되면 스프링은 데이터베이스 커넥션을 획득하고, 이를 새로운 EntityManager에 바인딩한다. 이후 트랜잭션 경계가 끝날때까지 동일한 EntityManager를 사용한다.

이를 통해 다음이 보장된다.

  • 동일 엔티티 조회 시 1차 캐시를 통한 동일성 보장
  • 엔티티 변경 시 별도 update() 호출 없이 Dirty Checking으로 자동 반영
  • 커밋 시점(메서드 종료 시점)에만 flush() -> SQL 실행 -> commit 순서로 데이터베이스에 반영
  • 예외 발생 시 전체 트랜잭션이 rollback 처리

예시

@Service
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;

    public OrderService(OrderRepository orderRepository, PaymentRepository paymentRepository) {
        this.orderRepository = orderRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void placeOrder(Long productId, Long userId) {
        // 같은 트랜잭션/영속성 컨텍스트 안에서 실행됨
        Order order = new Order(productId, userId);
        orderRepository.save(order);    // INSERT SQL 지연(flush 전까지 DB 미반영)

        Payment payment = new Payment(order.getId(), 10000L);
        paymentRepository.save(payment);    // orderRepository 와 같은 EntityManager 사용

        // 메서드 정상 종료 -> flush -> commit -> DB 반영 
    }
}

트랜잭션이 종료되면 EntityManager와 영속성 컨텍스트도 함께 닫힌다. 이때문에 트랜잭션 밖에서 지연로딩을 시도하면 LazyInitializationException이 발생한다.

@Transactional 이 없는 경우

서비스 메서드에 트랜잭션 경계가 없으면 Repository 호출마다 새로운 EntityManager가 생성된다. findById() 같은 Repository 메서드는 내부적으로 자체 트랜잭션(read-only)을 사용하기 때문에, 호출이 끝나면 EntityManager가 바로 닫힌다. 그 결과 반환된 엔티티는 이미 Detached 상태이며, 변경 추적(Dirty Checking)이 일어나지 않는다.

// @Transactional 이 없는 경우
User u1 = userRepository.findById(1L).get();    // 첫 번째 EntityManager
u1.setName("new Name");                         // 영속성 상태는 이미 Detached 상태, 변경 추적 안됨

User u2 = userRepository.findById(1L).get();    // 두 번째 EntityManager
// u1 != u2 (다른 객체 인스턴스)
// 1차 캐시가 공유 불가 -> Dirty Checking 효과 없음

2.3. 트랜잭션 전파옵션 (Propagation)

트랜잭션 전파(Propagation)는 이미 진행 중인 트랜잭션이 있을 때, 새로운 트랜잭션 경계를 어떻게 적용할지를 결정한다. 스프링은 @Transactionalpropagation 속성을 통해 전파 방식을 지정할 수 있다.

주요 옵션

  • REQUIRED (기본값)
    • 실행중인 기존 트랜잭션에 참여한다. 기존에 실행중인 트랜잭션이 없으면 새로운 트랜잭션을 만든다.
    • 가장 일반적으로 사용되는 방식.
  • REQUIRES_NEW
    • 항상 새로운 트랜잭션을 시작한다.
    • 기존 트랜잭션은 잠시 보류된다.
    • 독립적인 작업(로그 기록 등)에 사용.
  • NESTED
    • 부모 트랜잭션 안에서 중첩 트랜잭션을 시작한다.
    • 부모 롤백 시 같이 자식 트랜잭션도 롤백.
    • 자식 롤백 시 부모 트랜잭션에 영향을 주지 않는다. (부모 트랜잭션은 그대로 실행)
    • save point를 지원하는 DB 에서만 동작한다.
  • MANDATORY
    • 반드시 기존 트랜잭션이 존재해야 한다.
    • 기존 트랜잭션이 없으면 예외 발생.
  • NEVER
    • 항상 트랜잭션 없이 실행한다.
    • 기존 트랜잭션이 있으면 예외 발생.
  • SUPPORTS
    • 기존 트랜잭션이 있으면 참여한다.
    • 기존 트랜잭션이 없으면 트랜잭션 없이 실행.
  • NOT_SUPPORTED
    • 기존 트랜잭션이 있으면 잠시 중단시키고, 트랜잭션 없이 실행.

예시 A) REQUIRED: 한번에 묶기 (UseCase 레벨)

  • 주문 생성과 결제 승인까지 하나의 트랜잭션으로 처리
  • UseCase(서비스)에서 @Transactional REQUIRED(기본값) 선언
    // Application Layer
    @Service
    public class PlaceOrderUseCase {
    
      private final OrderPort orderPort;
      private final PaymentPort paymentPort;
    
      public PlaceOrderUseCase(OrderPort orderPort, PaymentPort paymentPort) {
          this.orderPort = orderPort;
          this.paymentPort = paymentPort;
      }
    
      // REQUIRED(기본값): 호출 체인을 하나의 트랜잭션으로 처리
      // 각 port 메서드는 기본값(REQUIRED) 전파옵션 사용
      @Transactional
      public void execute(Long userId, Long productId) {
          Long orderId = orderPort.createOrder(userId, productId);    // INSERT (지연 flush)
          paymentPort.approvePayment(orderId);                        // UPDATE/INSERT 등
          // 메서드 정상 종료 -> flush -> commit
      }
    }
    
  • createOrderapprovePayment동일 EntityManager/영속성컨텍스트로 수행되어 동일성 보장, Dirty Checking 반영, 원자성 확보 등의 효과를 갖는다.

예시 B) REQUIRES_NEW: 기존 트랜잭션과 운영 분리

  • 기존 트랜잭션은 진행시키면서, 부가 로그/아웃박스는 별도 트랜잭션으로 커밋
  • 실패해도 기존 트랜잭션에 영향 없음.
  • Adapter 에서 REQUIRES_NEW를 선언하여 분리한다.
    @Repository
    public class AuditLogJpaAdapter implements AuditLogPort {
        
      private final AuditLogRepository auditLogRepository;
    
      public AuditLogJpaAdapter(AuditLogRepository) {
          this.auditLogRepository = auditLogRepository;
      }
    
      // 항상 새로운 트랜잭션으로 감사 로그 기록
      @Transactional(propagation = Propagation.REQUIRES_NEW)
      @Override
      public void writeAudit(String action, Long userId) {
          auditLogRepository.save(new AuditLog(action, userId));
          // 여기서 예외 발생 시, 이 트랜잭션만 롤백, 기존 트랜잭션은 유지
      }
    }
    
    // Application Layer - UserCase에서 호출
    @Service
    public class PlaceOrderUseCase {
    
      private final OrderPort orderPort;
      private final PaymentPort paymentPort;
      private final AuditLogPort auditLogPort;
    
      public PlaceOrderUseCase(OrderPort orderPort, PaymentPort paymentPort, AuditLog) {
          this.orderPort = orderPort;
          this.paymentPort = paymentPort;
      }
    
      @Transactional
      public void execute(Long userId, Long productId) {
          Long orderId = orderPort.createOrder(userId, productId);
          paymentPort.approvePayment(orderId);
          // 여기까지 본 트랜잭션 REQUIRED
    
          audit
      }
    }
    

2.4. 트랜잭션 격리수준 (Isolation level)

3. TypeORM의 트랜잭션 관리

4. 결론

댓글남기기