Spring Transaction 전파 심화

Spring Transaction Propagation이란?

Spring의 @Transactional은 단순히 트랜잭션을 시작하고 커밋하는 것 이상의 역할을 한다. 핵심은 전파(Propagation) 설정으로, 이미 진행 중인 트랜잭션이 있을 때 새 트랜잭션을 어떻게 처리할지 결정한다. 실무에서 트랜잭션 관련 버그 대부분은 전파 옵션을 잘못 이해한 데서 비롯된다.

@Transactional(propagation = Propagation.REQUIRED)
public void outerMethod() {
    innerService.innerMethod(); // 이 호출의 트랜잭션은?
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void innerMethod() {
    // 새 트랜잭션? 기존 트랜잭션 참여?
}

이 질문에 정확히 답하려면 7가지 전파 옵션의 동작을 이해해야 한다.

7가지 Propagation 옵션 완전 정리

옵션 기존 TX 있음 기존 TX 없음 사용 빈도
REQUIRED 참여 새로 생성 ★★★★★ (기본값)
REQUIRES_NEW 기존 일시정지 + 새로 생성 새로 생성 ★★★★
NESTED Savepoint로 중첩 새로 생성 ★★★
SUPPORTS 참여 TX 없이 실행 ★★
NOT_SUPPORTED 기존 일시정지, TX 없이 실행 TX 없이 실행 ★★
MANDATORY 참여 예외 발생 ★★
NEVER 예외 발생 TX 없이 실행

REQUIRED: 기본값의 함정

REQUIRED는 가장 많이 쓰이는 기본값이지만, 참여(join)의 의미를 정확히 알아야 한다. 내부 메서드가 기존 트랜잭션에 참여하면, 내부에서 발생한 예외가 전체 트랜잭션을 롤백시킨다.

@Service
public class OrderService {

    @Transactional // REQUIRED (기본값)
    public void createOrder(OrderDto dto) {
        orderRepository.save(toEntity(dto));

        try {
            notificationService.sendEmail(dto); // 여기서 예외 발생!
        } catch (Exception e) {
            log.warn("이메일 발송 실패, 무시", e);
            // ⚠️ catch해도 소용없다! 이미 rollback-only 마킹됨
        }
    }
}

@Service
public class NotificationService {

    @Transactional // REQUIRED → 같은 TX에 참여
    public void sendEmail(OrderDto dto) {
        throw new RuntimeException("SMTP 오류");
        // 이 예외가 TX를 rollback-only로 마킹
    }
}

위 코드에서 sendEmail의 예외를 catch해도 주문 저장이 롤백된다. Spring은 내부적으로 rollback-only 플래그를 설정하고, 외부 트랜잭션 커밋 시점에 UnexpectedRollbackException을 던진다.

REQUIRES_NEW: 독립 트랜잭션

위 문제의 해결책이 REQUIRES_NEW다. 기존 트랜잭션을 일시정지(suspend)하고 완전히 새로운 트랜잭션을 시작한다.

@Service
public class NotificationService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendEmail(OrderDto dto) {
        // 별도 트랜잭션 → 실패해도 주문 TX에 영향 없음
        emailLogRepository.save(new EmailLog(dto));
        emailClient.send(dto.getEmail(), buildContent(dto));
    }
}

주의할 점은 DB 커넥션을 2개 사용한다는 것이다. 외부 트랜잭션의 커넥션은 일시정지된 상태로 유지되고, 내부 트랜잭션이 새 커넥션을 획득한다. 커넥션 풀 크기가 작으면 데드락이 발생할 수 있다.

# HikariCP 풀 크기 공식 (REQUIRES_NEW 사용 시)
# minimumIdle >= 동시 요청 수 × (1 + 최대 REQUIRES_NEW 중첩 깊이)
spring.datasource.hikari.maximum-pool-size=20

NESTED: Savepoint 활용

NESTEDREQUIRES_NEW와 혼동되기 쉽지만 근본적으로 다르다. 같은 물리적 트랜잭션 안에서 Savepoint를 설정하여 부분 롤백이 가능하다.

@Transactional
public void processOrder(OrderDto dto) {
    orderRepository.save(toEntity(dto));

    try {
        bonusService.grantBonus(dto); // NESTED TX
    } catch (Exception e) {
        // NESTED 실패 → Savepoint까지만 롤백
        // 주문 저장은 유지됨!
        log.warn("보너스 지급 실패, 주문은 계속 진행", e);
    }
}

@Service
public class BonusService {

    @Transactional(propagation = Propagation.NESTED)
    public void grantBonus(OrderDto dto) {
        // Savepoint 설정됨
        bonusRepository.save(new Bonus(dto.getUserId(), 100));
        // 실패하면 이 Savepoint까지만 롤백
    }
}
비교 REQUIRES_NEW NESTED
물리적 TX 별도 동일
DB 커넥션 2개 1개
내부 롤백 시 내부만 롤백 Savepoint까지 롤백
외부 롤백 시 내부는 이미 커밋됨 내부도 함께 롤백
JPA 지원 완전 JDBC만 (JPA 미지원)

주의: NESTED는 JPA/Hibernate에서 지원하지 않는다. JdbcTemplate이나 DataSourceTransactionManager를 사용할 때만 동작한다.

프록시 기반 동작의 함정: Self-Invocation

Spring @TransactionalAOP 프록시를 통해 동작한다. 같은 클래스 내부에서 메서드를 호출하면 프록시를 거치지 않아 전파 설정이 무시된다.

@Service
public class PaymentService {

    @Transactional
    public void processPayment(PaymentDto dto) {
        // ⚠️ this.saveLog()는 프록시를 거치지 않음!
        // REQUIRES_NEW가 무시되고 기존 TX에 참여
        this.saveLog(dto);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(PaymentDto dto) {
        logRepository.save(new PaymentLog(dto));
    }
}

해결 방법은 3가지다:

// 방법 1: 별도 서비스로 분리 (권장)
@Service
public class PaymentLogService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(PaymentDto dto) { ... }
}

// 방법 2: Self-injection
@Service
public class PaymentService {
    @Lazy @Autowired private PaymentService self;

    @Transactional
    public void processPayment(PaymentDto dto) {
        self.saveLog(dto); // 프록시를 통해 호출
    }
}

// 방법 3: TransactionTemplate 직접 사용
@Service
public class PaymentService {
    private final TransactionTemplate txTemplate;

    public PaymentService(PlatformTransactionManager txManager) {
        this.txTemplate = new TransactionTemplate(txManager);
        this.txTemplate.setPropagationBehavior(
            TransactionDefinition.PROPAGATION_REQUIRES_NEW
        );
    }

    @Transactional
    public void processPayment(PaymentDto dto) {
        txTemplate.execute(status -> {
            logRepository.save(new PaymentLog(dto));
            return null;
        });
    }
}

실전 시나리오별 전파 전략

시나리오 권장 전파 이유
주문 + 결제 REQUIRED 원자적 처리 필수
주문 + 이메일 알림 REQUIRES_NEW 알림 실패가 주문에 영향 X
감사 로그 기록 REQUIRES_NEW 비즈니스 실패해도 로그 보존
조회 전용 서비스 SUPPORTS TX 있으면 참여, 없어도 OK
외부 API 호출 NOT_SUPPORTED 긴 외부 호출 중 커넥션 점유 방지
Repository 메서드 MANDATORY 반드시 TX 내에서 호출 강제

디버깅: 트랜잭션 로그 활성화

전파 관련 문제를 디버깅할 때는 Spring 내부 동작의 로그 레벨을 올리면 된다.

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

# 출력 예시:
# Creating new transaction with name [OrderService.createOrder]
# Participating in existing transaction
# Suspending current transaction, creating new transaction with name [NotificationService.sendEmail]

로그에서 Creating, Participating, Suspending 키워드를 확인하면 전파가 의도대로 동작하는지 바로 알 수 있다.

정리

Spring Transaction Propagation은 @Transactional의 핵심 설정이다. REQUIRED의 rollback-only 함정, REQUIRES_NEW의 커넥션 풀 영향, NESTED의 JPA 미지원, Self-Invocation 프록시 우회 문제까지 이해하면 트랜잭션 관련 버그를 사전에 방지할 수 있다. 실무에서는 시나리오별로 적절한 전파 전략을 선택하고, 이벤트 기반 비동기 처리와 결합하면 더욱 견고한 설계가 가능하다.

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