[JPA #3] JPA vs TypeORM - Transaction
1. Transaction
트랜잭션은 데이터베이스의 일관성을 보장하기 위한 방법으로, ACID 원칙을 따른다.
- 원자성(Atomicity): 전부 수행되거나 전부 취소된다.
- 일관성(Consistency): 트랜잭션 전후로 데이터 무결성이 유지된다.
- 격리성(Isolation): 동시에 실행되는 트랜잭션은 서로 간섭하지 않는다.
- 지속성(Durability): 커밋된 데이터는 영구적으로 보존된다.
이 중에서도 ORM 에서는 특히 격리성이 중요하다. 여러 트랜잭션이 동시에 엔티티를 갱신할 때, 정합성을 깨지 않으려면 트랜잭션 경계가 올바르게 설정되어야 한다.
- JPA 는
EntityManager
와 영속성 컨텍스트가 트랜잭션 단위로 동작하며,@Transactional
을 통해 격리 수준과 경계를 선언한다. - TypeORM은 상태 관리 개념이 없으므로,
QueryRunner
나TransactionManager
로 격리 수준과 경계를 직접 지정해야 한다.
이번 포스팅에서는 Spring Data JPA 의 @Transactional
의 특징에 대해 다룰것이며, 트랜잭션의 특징과 TypeORM 트랜잭션과의 차이점에 대해 서술할 것이다.
2. Spring Data JPA 트랜잭션 관리
2.1. @Transactional
@Transactional
은 Spring 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)는 이미 진행 중인 트랜잭션이 있을 때, 새로운 트랜잭션 경계를 어떻게 적용할지를 결정한다.
스프링은 @Transactional
의 propagation
속성을 통해 전파 방식을 지정할 수 있다.
주요 옵션
- 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 } }
createOrder
와approvePayment
가 동일 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 과 격리수준
스프링은 @Transactional
의 isolation
속성으로 격리수준을 지정할 수 있다. 지정하지 않으면 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의 특성을 명확히 이해하고, 도메인 요구사항에 맞게 적절히 설계하는 것이 중요하다.
댓글남기기