Spring Transaction 전파·격리

Spring 트랜잭션 전파란?

@Transactionalpropagation 속성은 “이미 트랜잭션이 존재할 때 어떻게 할 것인가”를 결정합니다. 기본값 REQUIRED만 사용하다가 실무에서 트랜잭션 경계 문제를 겪는 경우가 많습니다. 7가지 전파 옵션의 동작 원리와 실전 사용 시나리오를 정확히 이해해야 합니다.

7가지 전파 옵션

전파 옵션 기존 TX 있을 때 기존 TX 없을 때
REQUIRED 참여 새로 생성
REQUIRES_NEW 기존 일시 중단, 새로 생성 새로 생성
NESTED Savepoint로 중첩 새로 생성
SUPPORTS 참여 TX 없이 실행
NOT_SUPPORTED 기존 일시 중단 TX 없이 실행
MANDATORY 참여 예외 발생
NEVER 예외 발생 TX 없이 실행

REQUIRED vs REQUIRES_NEW

가장 중요한 차이입니다. 주문 생성 + 감사 로그 시나리오로 비교합니다:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepo;
    private final AuditService auditService;

    @Transactional  // REQUIRED (기본값)
    public Order createOrder(CreateOrderDto dto) {
        Order order = orderRepo.save(new Order(dto));

        // 감사 로그도 같은 트랜잭션에 참여
        auditService.log("ORDER_CREATED", order.getId());

        return order;
    }
}

@Service
public class AuditService {

    @Transactional  // REQUIRED → 기존 TX에 참여
    public void log(String action, Long entityId) {
        auditRepo.save(new AuditLog(action, entityId));
        // 여기서 예외 발생 시 → 주문도 롤백!
    }
}

REQUIRED는 같은 트랜잭션을 공유하므로, 감사 로그 저장 실패 시 주문까지 롤백됩니다. 감사 로그 실패가 주문을 취소해야 하나요?

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, Long entityId) {
        auditRepo.save(new AuditLog(action, entityId));
        // 여기서 예외 발생 → 감사 로그만 롤백, 주문은 무관
    }
}

REQUIRES_NEW독립된 트랜잭션을 생성합니다. 감사 로그가 실패해도 주문 트랜잭션에 영향을 주지 않습니다. 단, 새 DB 커넥션을 사용하므로 커넥션 풀 고갈에 주의해야 합니다.

NESTED: Savepoint 활용

NESTED는 부모 트랜잭션 안에서 Savepoint를 만듭니다. 부분 롤백이 가능합니다:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final NotificationService notificationService;

    @Transactional
    public Order createOrder(CreateOrderDto dto) {
        Order order = orderRepo.save(new Order(dto));

        try {
            // NESTED → Savepoint 생성
            notificationService.sendOrderConfirmation(order);
        } catch (Exception e) {
            // 알림 실패 → Savepoint로 롤백, 주문은 유지
            log.warn("알림 전송 실패, 주문은 유지: {}", e.getMessage());
        }

        return order;
    }
}

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.NESTED)
    public void sendOrderConfirmation(Order order) {
        notificationRepo.save(new Notification(order));
        // 실패 시 이 Savepoint만 롤백
    }
}

REQUIRES_NEW와의 차이: NESTED는 부모 트랜잭션이 롤백되면 함께 롤백됩니다. REQUIRES_NEW는 이미 커밋되었으므로 영향 없습니다. 주의: JPA/Hibernate에서는 NESTED를 지원하지 않는 경우가 많아 JDBC 기반에서 주로 사용합니다.

트랜잭션 격리 수준

격리 수준은 동시 트랜잭션 간의 데이터 가시성을 결정합니다:

격리 수준 Dirty Read Non-Repeatable Phantom
READ_UNCOMMITTED ⚠️ 발생 ⚠️ 발생 ⚠️ 발생
READ_COMMITTED ✅ 방지 ⚠️ 발생 ⚠️ 발생
REPEATABLE_READ ✅ 방지 ✅ 방지 ⚠️ 발생
SERIALIZABLE ✅ 방지 ✅ 방지 ✅ 방지
// 재고 차감: 동시성 문제 방지
@Transactional(isolation = Isolation.SERIALIZABLE)
public void deductStock(Long productId, int quantity) {
    Product product = productRepo.findById(productId)
        .orElseThrow();

    if (product.getStock() < quantity) {
        throw new InsufficientStockException();
    }

    product.setStock(product.getStock() - quantity);
}

// 더 나은 방법: 비관적 락
@Transactional
public void deductStock(Long productId, int quantity) {
    Product product = productRepo.findByIdWithLock(productId);
    // @Lock(LockModeType.PESSIMISTIC_WRITE)
    // SELECT ... FOR UPDATE

    product.deductStock(quantity);
}

SERIALIZABLE은 성능 비용이 크므로, 실무에서는 비관적 락(SELECT FOR UPDATE)이나 낙관적 락(@Version)을 선호합니다.

readOnly 최적화

readOnly = true는 단순 힌트가 아닙니다. 실질적인 성능 이점이 있습니다:

@Transactional(readOnly = true)
public List<Order> findAllOrders() {
    return orderRepo.findAll();
}

// readOnly가 하는 일:
// 1. Hibernate flush 모드 → MANUAL (dirty checking 스킵)
// 2. DB에 SET TRANSACTION READ ONLY 전송 (DB 최적화)
// 3. CQRS 구조에서 읽기 전용 DataSource로 라우팅 가능
// CQRS: readOnly에 따라 DataSource 분기
public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager
            .isCurrentTransactionReadOnly()
            ? "replica"   // 읽기 → 복제본
            : "primary";  // 쓰기 → 마스터
    }
}

롤백 규칙 커스터마이징

기본적으로 RuntimeException만 롤백됩니다. Checked Exception은 커밋됩니다:

// ❌ checked exception → 롤백 안 됨!
@Transactional
public void process() throws IOException {
    orderRepo.save(order);
    throw new IOException("파일 오류");
    // 트랜잭션 커밋됨 (의도하지 않은 동작)
}

// ✅ rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
    orderRepo.save(order);
    throw new IOException("파일 오류");
    // 롤백됨
}

// 특정 예외만 롤백 제외
@Transactional(
    rollbackFor = Exception.class,
    noRollbackFor = MailSendException.class
)
public void createOrderWithEmail() {
    orderRepo.save(order);
    mailService.send(email);  // 메일 실패해도 주문은 커밋
}

흔한 함정: 프록시와 Self-Invocation

@Transactional도 AOP 프록시로 동작하므로, Spring AOP 프록시 심화에서 다룬 Self-Invocation 문제가 동일하게 적용됩니다:

@Service
public class OrderService {

    // ❌ 같은 클래스 내 호출 → @Transactional 무시
    public void processOrders(List<OrderDto> dtos) {
        for (OrderDto dto : dtos) {
            this.createOrder(dto);  // 프록시 안 거침!
        }
    }

    @Transactional
    public void createOrder(OrderDto dto) { ... }
}

// ✅ 해결: 별도 서비스로 분리
@Service
@RequiredArgsConstructor
public class OrderBatchService {
    private final OrderService orderService;

    public void processOrders(List<OrderDto> dtos) {
        for (OrderDto dto : dtos) {
            orderService.createOrder(dto);  // 프록시 경유
        }
    }
}

TransactionTemplate: 프로그래밍 방식

메서드 전체가 아닌 특정 구간만 트랜잭션으로 감쌀 때 사용합니다:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final TransactionTemplate txTemplate;

    public OrderResult processOrder(OrderDto dto) {
        // 1단계: 외부 API 호출 (TX 밖)
        PaymentResult payment = paymentApi.charge(dto);

        // 2단계: DB 저장만 TX로 감싸기
        Order order = txTemplate.execute(status -> {
            Order o = orderRepo.save(new Order(dto));
            o.setPaymentId(payment.getId());
            return orderRepo.save(o);
        });

        // 3단계: 알림 발송 (TX 밖)
        notificationService.send(order);

        return new OrderResult(order);
    }
}

Spring Event 비동기 처리@TransactionalEventListener와 조합하면, 트랜잭션 커밋 후에만 이벤트를 처리하는 패턴을 구현할 수 있습니다.

마무리

Spring 트랜잭션은 @Transactional을 붙이는 것으로 끝나지 않습니다. 전파 옵션으로 트랜잭션 경계를 설계하고, 격리 수준락 전략으로 동시성을 제어하며, readOnly로 읽기 성능을 최적화하고, rollbackFor로 롤백 규칙을 명시해야 합니다. Self-Invocation 함정을 피하고, 필요 시 TransactionTemplate으로 세밀한 트랜잭션 경계를 설정하는 것이 프로덕션 수준의 트랜잭션 설계입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux