[JPA #3] JPA vs TypeORM - Transaction

8 분 소요

태그: ,

카테고리:

업데이트:

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 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, AuditLogPort auditLogPort) {
          this.orderPort = orderPort;
          this.paymentPort = paymentPort;
          this.auditLogPort = auditLogPort;
      }
    
      @Transactional
      public void execute(Long userId, Long productId) {
          // 본 트랜잭션
          Long orderId = orderPort.createOrder(userId, productId);    // REQUIRED
          paymentPort.approvePayment(orderId);    //REQUIRED
    
          // 별도 트랜잭션 REQUIRES_NEW
          // 이 트랜잭션의 실패는 기존 트랜잭션(REQUIRED)에 영향을 주지 않음
          auditLogPort.writeAudit("ORDER_PLACED", userId);   
      }
    }
    
  • 본 트랜잭션 성공 + Audit Log 실패 -> 주문은 커밋, Audit Log 만 롤백
  • 본 트랜잭션 실패 -> Audit Log 는 이미 커밋되어서 유지된다.
  • REQUIRES_NEW 는 추가 커넥션을 점유한다. 고빈도 사용 시 커넥션 풀 고갈에 주의해야 한다.
  • 로깅/아웃박스 처럼 트랜잭션 실패가 비즈니스 로직에 영향을 끼치면 안되는 부수 작업에 한정한다.

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

트랜잭재션 격리수준은 동시 실행되는 트랜잭션 간의 읽기/쓰기 간섭을 얼마나 허용할지를 정의한다. ANSI SQL 은 다음 네 가지 수준을 정의한다.

주요 격리수준

  • READ UNCOMMITTED
    • 가장 낮은 격리수준, 거의 사용하지 않는다.
    • Dirty Read 허용: 다른 트랜잭션에서 커밋되지 않은 데이터를 읽을 수 있다. (Dirty Read 허용)
  • READ COMMITTED (대부분 RDMS 의 기본값)
    • 커밋된 데이터만 읽는다. Dirty Read 방지.
    • Non-Repeatable Read 발생 가능: 동일 트랜잭션에서 재조회 시 값이 달라질 수 있다.
  • REPEATABLE READ (MySQL 기본값)
    • 트랜잭션 동안 동일한 행을 반복 조회하면 항상 같은 값을 보장한다.
    • Non-Repeatable Read 방지.
    • Phantom Read 발생 가능: 새로운 행이 삽입되면 결과 집합이 달라질 수 있다.
  • SERIALIZABLE
    • 가장 엄격한 격리수준. 모든 트랜잭션을 순차적으로 실행하는 것과 동일한 효과를 나타낸다.
    • 동시성 처리량이 크게 떨어진다.

Spring @Transactional 과 격리수준

스프링은 @Transactionalisolation 속성으로 격리수준을 지정할 수 있다. 지정하지 않으면 DBMS의 기본 격리수준을 따른다.

@Service
public class BankService {
    
    private final AccountRepository accountRepository;

    public BankService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // READ_COMMITTED 수준에서 실행
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transfer(Long fromId, Long amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        from.withdraw(amount);

        Account to = accountRepository.findById(toId).orElseThrow();
        to.deposit(amount);
        // 커밋 시점에만 DB 반영
    }
}

주의

  • DB 기본 격리수준 확인이 우선
    • 스프링 설정을 따르지 않아도 DB가 무시하는 경우도 있다.
    • MySQL 의 기본 격리수준은 REPEATABLE_READ 로, Spring에서 isolation = Isolation.READ_COMMITTED로 설정해도 실제 실행은 MySQL 의 기본 설정인 REPEATABLE_READ로 동작한다.
    • 개발환경(H2, PostgreSQL 등)과 운영 환경(MySQL) 간 차이로 인해 동시성 이슈가 발생할 수 있다.
  • 격리수준이 높을수록 동시성 처리량 저하
    • 성능 문제로 인해 SERIALIZABLE은 실무에서 거의사용하지 않는다.
  • ORM 환경에서 READ_COMMITTED 일반적
    • Dirty Checking, Lazy Loading 등 JPA 동작 특성과 잘 맞는다.
  • MySQL REPEATABLE READ 특이성
    • 표준 정의와 달리, InnoDB는 MVCC를 사용해 Phantom Read까지 방지한다.
    • 하지만 SELECT ... FOR UPDATE 같은 락 기반 쿼리는 별도 동작을 고려해야 한다.
  • 격리수준만으로 트랜잭션 정합성을 보장하지 못할경우 Lock 을 사용한다
    • 동시성 갱신이 빈번한 도메인에서는 @Version을 활용한 낙관적 락이나, 비관적 락(@Lock(PESSIMISTIC_WRITE))을 병행하는 것이 일반적.

MySQL REPEATABLE READ 특이성

  • 표준 REPEATABLE READ
    • 동일 트랜잭션 내에서 같은 행(Row)을 재조회 하면 값이 변하지 않음을 보장.
    • 하지만 새로 삽입된 행은 보이지 않을 수 있다. -> Phantom Read 발생 가능.
  • MySQL(InnoDB)의 REPEATABLE READ
    • MVCC(Multi-Version Concurrency Control)를 이용해, 트랜잭션 시작 시점의 스냅샷을 기준으로 데이터를 읽는다.
    • 따라서 기본 SELECT 는 Phantom Read까지 방지된다.
    • 이 때문에 MySQL의 REPEATABLE READ는 사실상 REPEATABLE READ + Phantom Read 방지 수준이다.
  • 예외: 락 기반 쿼리
    • SELECT ... FOR UPDATE 또는 SELECT ... LOCK IN SHARE MODE는 실시간 최신 데이터를 읽고 락을 건다.
    • 이 경우는 스냅샷이 아니라 현재 상태를 보는 것으로, 삽입된 새로운 행이 결과 집합에 나타날 수 있고 Phantom Read가 다시 발생할 수 있다.
    • 즉, InnoDB의 REPEATABLE READ는 순수 스냅샷 기반 SELECT에만 Phantom Read를 막는다.

시나리오 (MySQL)

sequenceDiagram
    participant S1 as Session 1 (Tx1)
    participant S2 as Session 2 (Tx2)

    S1->>S1: START TRANSACTION (Isolation: RR)
    S1->>S1: SELECT amount>120\n→ 결과 2건

    S2->>S2: START TRANSACTION
    S2->>S2: INSERT (id=3, amount=200)
    S2->>S2: COMMIT

    S1->>S1: SELECT amount>120\n→ 여전히 2건 (스냅샷 기준)

    S1->>S1: SELECT amount>120 FOR UPDATE\n→ 3건 (Tx2 데이터 포함)
    Note right of S1: 여기서 Phantom Read 발생 가능

3. TypeORM의 트랜잭션 관리

3.1. 기본방식 - DataSource.transaction

  • JPA의 @Transactional처럼 블록 단위로 트랜잭션을 관리한다.
  • 블록 내부에서만 동일한 EntityManager가 보장된다.
  • 블록 외부에서 조회한 엔티티는 트랜잭션 매니저와 무관하기 때문에, 내부에서 다시 조회해야 한다.
  • 블록이 끝나면 자동으로 commit/rollback 처리된다.
await dataSource.transaction(async (manager) => {
    const order = manager.getRepository(Order).create({ userId, productId });
    await manager.getRepository(Order).save(order); // INSERT

    const payment = manager.getRepository(Payment).create({ orderId: order.id, amount: 10000 });
    await manager.getRepository(Payment).save(payment); // INSERT
    // 블록 정상 종료 -> commit
});

3.2. QueryRunner 사용

  • QueryRunner를 직접 생성해 트랜잭션을 관리한다.
  • startTransaction(), commitTransaction(), rollbackTransaction()을 직접 호출해야 한다.
  • 트랜잭션 경계가 명확하지 않으면 커밋/롤백 타이밍을 놓칠 수 있다.
const queryRunner = dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
    const order = queryRunner.manager.getRepository(Order).create({ userId, productId });
    await queryRunner.manager.getRepository(Order).save(order); // INSERT

    await queryRunner.commitTransaction(); // 명시적 커밋
} catch (err) {
    await queryRunner.rollbackTransaction(); // 명시적 롤백
} finally {
    await queryRunner.release(); // 커넥션 해제
}

3.3. JPA와 차이점

  • JPA
    • @Transactional로 트랜잭션을 선언적으로 관리.
    • 전파옵션(REQUIRES_NEW, NESTED 등)과 격리수준을 선언적으로 지정 가능.
    • 동일 트랜잭션 내에서 자동으로 동일 EntityManager/영속성 컨텍스트 사용.
  • TypeORM
    • DataSource.transaction 블록 내부에서만 동일 EntityManager 보장.
    • 전파옵션 개념이 없어, 별도 트랜잭션이 필요하면 직접 새로운 transaction() 호출.
    • QueryRunner.startTransaction('READ COMMITTED')처럼 코드에서 격리수준 지정.

4. 결론

트랜잭션은 단순히 commit/rollback의 개념을 넘어, 영속성 컨텍스트, 전파옵션, 격리수준과 같은 다양한 요소가 복합적으로 작용해야 올바르게 동작한다. JPA 는 이를 프레임워크 차원에서 추상화 하여 개발자가 선언적으로 다룰 수 있도록 하지만, TypeORM은 이러한 요소들을 명시적으로 관리해야 한다. 결국 트랜잭션 경계와 DB의 특성을 명확히 이해하고, 도메인 요구사항에 맞게 적절히 설계하는 것이 중요하다.

댓글남기기