Spring Transaction 전파 심화

Spring Transaction 전파란?

Spring의 @Transactional에서 가장 오해가 많은 속성이 propagation이다. 트랜잭션 전파(Propagation)는 이미 진행 중인 트랜잭션이 있을 때 새 트랜잭션을 어떻게 처리할지 결정하는 전략이다. 기본값 REQUIRED만 쓰다가 실무에서 부분 롤백, 독립 커밋 등의 요구사항을 만나면 전파 레벨을 정확히 이해해야 한다.

7가지 전파 레벨 정리

전파 레벨 기존 트랜잭션 있음 기존 트랜잭션 없음 핵심 용도
REQUIRED (기본) 참여 새로 생성 대부분의 서비스 메서드
REQUIRES_NEW 기존 보류 + 새로 생성 새로 생성 독립 커밋 (감사 로그)
NESTED 세이브포인트 생성 새로 생성 부분 롤백
SUPPORTS 참여 트랜잭션 없이 실행 읽기 전용 조회
NOT_SUPPORTED 기존 보류 + 비트랜잭션 트랜잭션 없이 실행 대용량 읽기 (락 방지)
MANDATORY 참여 예외 발생 반드시 트랜잭션 내 호출 강제
NEVER 예외 발생 트랜잭션 없이 실행 트랜잭션 진입 금지 강제

REQUIRED: 기본값의 함정

대부분의 서비스 메서드는 REQUIRED로 충분하지만, 참여(join) 특성 때문에 발생하는 함정이 있다:

@Service
public class OrderService {

    @Transactional // REQUIRED (기본)
    public void createOrder(OrderDto dto) {
        orderRepository.save(toEntity(dto));
        notificationService.sendNotification(dto.getUserId()); // 여기서 예외 발생하면?
    }
}

@Service
public class NotificationService {

    @Transactional // REQUIRED → 기존 트랜잭션에 참여
    public void sendNotification(Long userId) {
        // 알림 저장 중 예외 발생!
        throw new RuntimeException("알림 전송 실패");
    }
}

문제: sendNotification에서 예외가 발생하면 주문 저장까지 함께 롤백된다. 같은 물리 트랜잭션에 참여했기 때문이다. 더 위험한 건, 호출자가 예외를 catch해도 이미 트랜잭션이 rollback-only로 마킹되어 커밋 시점에 UnexpectedRollbackException이 발생한다:

@Transactional
public void createOrder(OrderDto dto) {
    orderRepository.save(toEntity(dto));
    try {
        notificationService.sendNotification(dto.getUserId());
    } catch (Exception e) {
        log.warn("알림 실패, 무시"); // 무시해도 소용없다!
    }
    // 커밋 시점에 UnexpectedRollbackException 발생
}

REQUIRES_NEW: 독립 트랜잭션

알림 실패가 주문에 영향을 주지 않아야 한다면 REQUIRES_NEW를 사용한다:

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Long userId) {
        notificationRepository.save(new Notification(userId, "주문 완료"));
        externalApiClient.push(userId); // 실패해도 주문은 안전
    }
}

REQUIRES_NEW는 기존 트랜잭션을 보류(suspend)하고 완전히 새로운 물리 트랜잭션을 시작한다. 새 트랜잭션의 커밋/롤백은 기존 트랜잭션과 완전히 독립적이다.

주의사항:

// ❌ 같은 클래스 내부 호출 → 프록시 우회, REQUIRES_NEW 무시됨
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        orderRepository.save(toEntity(dto));
        this.saveAuditLog(dto); // 내부 호출 → 프록시 안 탐!
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveAuditLog(OrderDto dto) { ... }
}

// ✅ 별도 빈으로 분리하거나 self-injection 사용
@Service
@RequiredArgsConstructor
public class OrderService {

    private final AuditLogService auditLogService; // 별도 빈

    @Transactional
    public void createOrder(OrderDto dto) {
        orderRepository.save(toEntity(dto));
        auditLogService.saveAuditLog(dto); // 프록시를 통해 호출
    }
}

Spring AOP 프록시 기반이므로 같은 클래스 내부 호출에서는 전파 설정이 무시된다. 이 문제는 Spring @Conditional 자동 설정에서 다루는 빈 분리 패턴과 동일한 원리다.

NESTED: 세이브포인트 부분 롤백

NESTED는 물리적으로 같은 트랜잭션을 사용하되, 세이브포인트(savepoint)를 생성해 부분 롤백을 가능하게 한다:

@Service
@RequiredArgsConstructor
public class OrderService {

    private final BonusService bonusService;

    @Transactional
    public void createOrder(OrderDto dto) {
        Order order = orderRepository.save(toEntity(dto));

        try {
            bonusService.grantBonus(order.getUserId(), 100);
        } catch (Exception e) {
            log.warn("보너스 지급 실패, 주문은 유지: {}", e.getMessage());
            // NESTED 롤백은 세이브포인트까지만 → 주문은 살아있음
        }
    }
}

@Service
public class BonusService {

    @Transactional(propagation = Propagation.NESTED)
    public void grantBonus(Long userId, int points) {
        bonusRepository.save(new Bonus(userId, points));
        // 예외 발생 시 → 세이브포인트까지만 롤백
    }
}

REQUIRES_NEW vs NESTED 차이:

비교 REQUIRES_NEW NESTED
물리 트랜잭션 별도 커넥션, 독립 트랜잭션 같은 커넥션, 세이브포인트
부모 롤백 시 자식은 이미 커밋됨 (영향 없음) 자식도 함께 롤백
자식 롤백 시 부모 영향 없음 세이브포인트까지만 롤백
커넥션 풀 추가 커넥션 필요 커넥션 추가 없음
JDBC 드라이버 모든 DB 지원 세이브포인트 지원 필요

MANDATORY와 NEVER: 계약 강제

아키텍처 레벨에서 트랜잭션 사용 규칙을 강제하는 방어적 전파 레벨:

// 반드시 트랜잭션 내에서만 호출되어야 하는 메서드
@Transactional(propagation = Propagation.MANDATORY)
public void deductBalance(Long accountId, BigDecimal amount) {
    // 트랜잭션 없이 호출하면 IllegalTransactionStateException
    Account account = accountRepository.findById(accountId).orElseThrow();
    account.deduct(amount);
}

// 절대 트랜잭션 안에서 호출하면 안 되는 메서드
@Transactional(propagation = Propagation.NEVER)
public ExternalApiResponse callExternalApi(String endpoint) {
    // 외부 API 호출은 트랜잭션 밖에서 해야 커넥션 점유를 방지
    return restTemplate.getForObject(endpoint, ExternalApiResponse.class);
}

MANDATORY는 “이 메서드를 트랜잭션 없이 호출하는 건 버그”라는 의도를 명시한다. NEVER는 외부 API 호출처럼 트랜잭션 내에서 실행하면 커넥션을 불필요하게 점유하는 작업에 사용한다.

실전 패턴: 감사 로그 + 독립 커밋

실무에서 가장 많이 쓰는 REQUIRES_NEW 패턴. 비즈니스 로직이 실패해도 감사 로그는 반드시 남겨야 하는 경우:

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final AuditService auditService;

    @Transactional
    public PaymentResult processPayment(PaymentRequest request) {
        // 1. 감사 로그 먼저 기록 (독립 트랜잭션)
        auditService.log("PAYMENT_ATTEMPT", request);

        try {
            // 2. 결제 처리
            Payment payment = executePayment(request);
            auditService.log("PAYMENT_SUCCESS", payment);
            return PaymentResult.success(payment);
        } catch (Exception e) {
            // 3. 실패해도 감사 로그는 이미 커밋됨
            auditService.log("PAYMENT_FAILED", e.getMessage());
            throw e; // 메인 트랜잭션 롤백
        }
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String event, Object detail) {
        auditRepository.save(AuditLog.builder()
            .event(event)
            .detail(objectMapper.writeValueAsString(detail))
            .createdAt(LocalDateTime.now())
            .build());
        // 즉시 커밋 → 메인 트랜잭션 롤백과 무관
    }
}

readOnly와 전파의 조합

readOnly = true와 전파 레벨을 함께 사용할 때 주의할 점:

@Transactional(readOnly = true)
public OrderDto getOrderDetail(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    // ❌ 여기서 쓰기 메서드 호출 시
    auditService.logRead(orderId); // REQUIRES_NEW → 별도 트랜잭션이라 쓰기 가능
    return toDto(order);
}

readOnly 트랜잭션 내에서 REQUIRES_NEW로 호출하면 새 트랜잭션은 readOnly 제약에서 벗어난다. 반면 REQUIRED로 참여하면 기존의 readOnly 설정을 따른다. Spring Read/Write 분리 설계와 함께 사용할 때 이 동작을 정확히 이해해야 라우팅 오류를 방지할 수 있다.

트랜잭션 전파 디버깅

전파 동작을 확인하려면 로그 레벨을 조정한다:

# application.yml
logging:
  level:
    org.springframework.transaction: DEBUG
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG

# 출력 예시:
# Creating new transaction with name [OrderService.createOrder]: PROPAGATION_REQUIRED
# Suspending current transaction, creating new transaction with name [AuditService.log]
# Committing JPA transaction on EntityManager [session#2]
# Resuming suspended transaction after completion of inner transaction

Suspending/Resuming 로그가 보이면 REQUIRES_NEW가 정상 동작하는 것이다.

정리: 전파 레벨 선택 기준

상황 추천 전파 레벨
일반적인 비즈니스 로직 REQUIRED (기본)
실패해도 반드시 커밋 (감사 로그) REQUIRES_NEW
부분 롤백 허용 (보너스 지급) NESTED
트랜잭션 없이 호출 금지 강제 MANDATORY
외부 API 호출 (커넥션 점유 방지) NEVER 또는 NOT_SUPPORTED
읽기 전용, 트랜잭션 선택적 SUPPORTS

트랜잭션 전파는 “기본값으로 대부분 해결된다”는 인식이 위험하다. REQUIRED의 rollback-only 전파, 프록시 내부 호출 문제, REQUIRES_NEW의 커넥션 풀 압박을 이해하고, 상황에 맞는 전파 레벨을 선택해야 안정적인 트랜잭션 설계가 가능하다.

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