@Transactional 전파와 격리 수준 설정과 예외처리

양성준·2025년 4월 11일

스프링

목록 보기
29/49

트랜잭션 전파(Propagation)

Spring에서는 @Transactional 어노테이션을 통해 트랜잭션을 선언적으로 처리할 수 있으며, 트랜잭션의 전파(Propagation) 옵션을 설정해 트랜잭션 간의 흐름을 제어할 수 있다.

1. Propagation.REQUIRED (기본값)

  • 특징
    • 진행 중인 트랜잭션이 없으면 새로 시작
    • 진행 중인 트랜잭션이 있으면 해당 트랜잭션에 합류
    • 대부분의 서비스 메서드는 이 기본값으로도 충분히 커버된다.
  • 전체 트랜잭션이 하나로 묶임
    • 예외가 발생하면 합류된 모든 작업이 함께 롤백됨!
inventoryService 
@Transactional // propagation = REQUIRED (기본값)
public void decraseStock(...) {
}

@Transactional // propagation = REQUIRED (기본값)
public Order createOrder(...) {
    inventoryService.decreaseStock(...); // 같은 트랜잭션에 참여하므로 롤백
    orderRepository.save(...); // save 메서드같은 경우도 REQUIRED
    // 예외 발생 시 둘 다 롤백됨
}

2. Propagation.REQUIRES_NEW

  • 특징
    • 기존 트랜잭션이 있든 없든, 항상 새로운 트랜잭션을 시작합니다.
    • 기존 트랜잭션이 있다면 일시 중단(suspend) 되고, 자식 트랜잭션을 시작
    • 독립적인 작업 처리가 필요할 때 사용합니다.
  • 예외 발생 시 영향 범위
    • 부모 트랜잭션이 롤백돼도 REQUIRES_NEW는 롤백되지 않음
    • 자식 트랜잭션에서 예외가 발생해도 부모에는 영향 없음
  • 예외가 발생해도 DB 반영이 필요한 작업에 사용
    • 예: 결제 실패 시 로그 저장, 에러 발생 시 복구 기록 등
@Transactional
public void processPayment() {
	 Payment payment = new Payment();
     payment.setOrderId(orderId);
     payment.setAmount(amount);
     payment.setStatus(PaymentStatus.PROCESSING);

     Payment savedPayment = paymentRepository.save(payment); // 예외 발생 시 롤백됨

    try {
     	// 외부 결제 게이트웨이 호출 (이니시스, 토스같은거)
        // 예외 발생 가능성 있는 로직
        boolean success = callExternalPaymentGateway(orderId, amount);
    } catch (Exception e) {
        // 예외가 발생하면 rollback 발생
        throw e; // throw가 없고 return으로 정상종료된다면 롤백 X 
    } finally {
        auditLogService.logActivity(...); 
        // REQUIRES_NEW로 별도 트랜잭션이므로 processPayment에서 예외가 터져도롤백되지 않음.
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW) // 예외 발생해도 상위 트랜잭션과 분리되어 반영됨
public void logActivity(...) {
    auditLogRepository.save(...); 
}

그 외에도 SUPPORT, MANDATORY, NEVER, NESTED, NOT_SUPPORTED등의 설정이 있지만, 잘 사용하지 않는다.

예외 처리와 트랜잭션 롤백

catch (Exception e) {
    payment.setStatus(ERROR);
    paymentRepository.save(...); // 이 시점에서 트랜잭션은 rollbackOnly 상태
    return; //예외를 던지지 않으면 트랜잭션은 커밋됨
}

catch (Exception e) {
    payment.setStatus(ERROR);
    paymentRepository.save(...);
    throw e; // 트랜잭션은 롤백됨
}
  • 이 외에, 예외가 IOException,SQLException, ClassNotFoundException 같은 체크 예외일 경우 트랜잭션 롤백 발생 X
  • 예외가 언체크 예외 (RuntimeException)인 경우, 예외 발생시 트랜잭션 롤백 발생 O

save(), findAll()은 트랜잭션 없이 호출해도 동작할까?

  • JpaRepository의 메서드는 내부적으로 @Transactional이 선언되어 있음
    • save() → @Transactional
    • findAll() → @Transactional(readOnly = true)
    • 기본값이므로 REQUIRED 설정!
  • 트랜잭션이 없는 상태에서 호출하면 → 자체적으로 새 트랜잭션을 생성해서 처리됨
  • 트랜잭션이 이미 있으면 그 트랜잭션에 참여함 (REQUIRED이기 때문)
    • 예외가 터져도 해당 트랜잭션에서 Repository.save를 반영하고 싶다면, 별도의 Service를 만들어서 그 Repository를 주입받고,
      Service 메서드에 REQUIRES_NEW를 달아 별도의 트랜잭션으로 관리해줘야함
public void noTransactionalMethod() {
    MyEntity saved = repository.save(new MyEntity("test")); // 여기서 트랜잭션 시작 → 커밋
    Optional<MyEntity> result = repository.findById(saved.getId()); // DB에서 조회 가능
    System.out.println(result.isPresent()); // true
}
  • save()를 호출한 시점에 트랜잭션이 없기 때문에, 트랜잭션이 시작되어 커밋됨
    • 커밋되어 findById로 바로 조회가 가능하다!, 외부 트랜잭션에서도 조회 가능
@Transactional
public void noTransactionalMethod() {
    MyEntity saved = repository.save(new MyEntity("test")); // 여기서 트랜잭션 시작 → 커밋
    Optional<MyEntity> result = repository.findById(saved.getId()); // DB에서 조회 가능
    System.out.println(result.isPresent()); // true
}
  • 트랜잭션에 합류하여 save()가 실행 -> 영속성 컨텍스트의 1차캐시에는 저장하지만, DB에는 반영 X
  • 동일 트랜잭션에서는 1차 캐시에 findById로 조회가 가능! 외부 Transaction에서는 조회가 불가능.

트랜잭션 격리(Isolation)

트랜잭션 격리 수준은 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 어느 정도까지 읽을 수 있는지를 제어하는 설정이다.
격리 수준이 높을수록 데이터 정합성이 올라가지만, 성능과 병렬성은 떨어지는 trade-off가 존재함!

격리 수준이 낮을 때 발생하는 문제

Dirty Read

  • A 트랜잭션이 커밋하지 않은 데이터를 B 트랜잭션이 읽을 때 발생
  • 예시
    • A 트랜잭션이 계좌 잔액을 500원에서 1000원으로 업데이트 (커밋 X)
    • B 트랜잭션이 수정된 계좌 잔액을 읽음 (Dirty Read)
    • A 트랜잭션이 롤백되어 잔액이 500원으로 돌아옴.

Non-Repetable Read

  • 한 트랜잭션 내에서 같은 쿼리를 반복해서 실행했을 때 결과가 달라짐
  • 예시
    • A 트랜잭션에서 id=1의 name인 "Alice"를 조회
    • B 트랜잭션에서 id=1의 name을 "Bob"으로 바꿈
    • A 트랜잭션에서 id=1을 다시 조회했을 때 "Bob"으로 바뀜 (Non-Repetable Read)

Phantom Read

  • 한 트랜잭션 내에서 같은 조건으로 여러 번 검색했을 때, 결과 row 수가 달라짐
  • 예시
    • A 트랜잭션에서 where age > 30 조회했을 때의 결과가 5명
    • B 트랜잭션에서 age 35인 row insert 후 커밋
    • A 트랜잭션에서 where age > 30 조회했을 때의 결과가 6명으로 바뀜 (Phantom Read)

격리 수준

READ UNCOMMITTED (가장 낮은 수준)

  • 특징

    • 커밋되지 않은 데이터도 읽을 수 있음 (Dirty Read 허용)
    • 성능은 좋지만, 정합성은 매우 낮음
    • 모든 이상 현상 발생 가능
  • 사용 예

    • 실시간 데이터 흐름 모니터링 (주식 가격 등)
    • 통계성 데이터
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public List<StockPrice> getCurrentPrices() {
    return stockRepository.findLatest();
}

READ COMMITTED (기본값)

  • 특징
    • 커밋된 데이터만 읽을 수 있음 → Dirty Read 방지
    • Non-repeatable Read, Phantom Read는 여전히 발생 가능
      • 다른 트랜잭션에서 커밋됐으면 값이 바뀌거나 추가될 수 있기 때문
    • 대부분의 RDB의 기본값 (Oracle, PostgreSQL 등)
  • 사용 예
    • 일반적인 웹 서비스의 조회 API
    • 중간 정도의 정합성과 성능
@Transactional(isolation = Isolation.READ_COMMITTED)
public Order getOrder(Long id) {
    return orderRepository.findById(id).orElseThrow();
}
  • 주의: 같은 쿼리를 반복하면 값이 달라질 수 있음
@Transactional(isolation = Isolation.READ_COMMITTED)
public void processOrder(Long id) {
    Order order1 = orderRepository.findById(id).orElseThrow();
    // 외부에서 update & commit 가능
    Order order2 = orderRepository.findById(id).orElseThrow();
    // order1 ≠ order2 가능성 있음 (Non-repeatable Read)
}

REPEATABLE READ

  • 특징
    • Dirty Read, Non-repeatable Read 방지
    • 트랜잭션 시작 시점의 데이터 스냅샷을 기준으로 데이터를 읽음
    • 기존 row의 값 변화는 막을 수 있지만, 새로운 row의 삽입은 감지하지 못함
      (스냅샷은 기존 row의 값만 고정)
    • Phantom Read는 여전히 발생할 수 있음
  • 사용 예
    • 보고서 생성
    • 금융 거래 등 정합성이 중요한 상황
@Transactional(isolation = Isolation.REPEATABLE_READ)
public FinancialReport generateReport(...) {
    // 동일한 조건으로 여러 쿼리 실행해도 결과 일관됨
}

주의: 범위 조회에서는 Phantom Read 발생 가능

@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processNewCustomers() {
    long count = customerRepository.countNewCustomers();
    List<Customer> list = customerRepository.findAllNewCustomers();
    // 두 결과가 다를 수 있음
}

SERIALIZABLE (가장 높은 수준)

  • 특징

    • 모든 이상 현상 방지 (Dirty, Non-repeatable, Phantom Read)
    • 트랜잭션 간 완벽한 고립을 제공
      • 읽기의 경우 공유락을 사용해 해당 데이터에 접근하려는 다른 트랜잭션의 쓰기에 대한 락 획득을 대기하게 만듦,
      • 쓰기의 경우 베타락을 사용해 해당 데이터에 접근하려는 다른 트랜잭션의 읽기/쓰기에 대한 Lock 획득을 모두 대기하게 만듦

    => A트랜잭션이 커밋/롤백될 때 까지, B트랜잭션은 Lock을 획득하지 못하고 A트랜잭션의 Lock이 끝날때까지 대기

    • 성능 낮음, 락 과도, 교착 상태 위험 높음
  • 사용 예

    • 은행 이체, 티켓 예매 등 정합성이 최우선인 경우
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transferFunds(...) {
    // from → to 계좌로 안전하게 자금 이체
}

교착 상태 발생 가능 예시
트랜잭션 A: 계좌 X → Y 이체
트랜잭션 B: 계좌 Y → X 이체
서로 잠금 획득을 기다리다가 Deadlock 발생
(트랜잭션 A에서는 X를 먼저 Lock을 걸고 Y의 Lock을 획득해야하는데, 트랜잭션 B에서는 Y를 먼저 Lock을 걸었으므로 획득이 불가능. 트랜잭션 B의 경우에도 X의 Lock을 획득 불가능 -> 무한 대기)

해결 방법

  • timeout 설정
  • 항상 동일한 순서로 자원 접근 (예: 항상 낮은 ID의 계좌부터 잠금)
@Transactional(isolation = Isolation.SERIALIZABLE, timeout = 10)
public void criticalOperation() {
    // 제한 시간 내 실행, 교착 상태 방지
}

격리 수준 비교표

정리

  • READ_COMMITTED: Spring + JPA의 기본값. 대부분의 서비스에 적절.
  • REPEATABLE_READ: 보고서, 금융 데이터 등 일관성이 중요한 곳.
  • SERIALIZABLE: 티켓팅, 계좌 이체 등 무결성이 최우선일 때만 사용.
  • READ_UNCOMMITTED: 성능 최우선. 실시간 분석 또는 모니터링 용도에 한정.

=> 격리 수준 선택은 성능 ↔ 정합성 트레이드오프를 고려하여,
비즈니스 요구사항과 시스템 특성에 따라 신중하게 결정해야 합니다.

profile
백엔드 개발자

0개의 댓글