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 활용
NESTED는 REQUIRES_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 @Transactional은 AOP 프록시를 통해 동작한다. 같은 클래스 내부에서 메서드를 호출하면 프록시를 거치지 않아 전파 설정이 무시된다.
@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 프록시 우회 문제까지 이해하면 트랜잭션 관련 버그를 사전에 방지할 수 있다. 실무에서는 시나리오별로 적절한 전파 전략을 선택하고, 이벤트 기반 비동기 처리와 결합하면 더욱 견고한 설계가 가능하다.