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의 커넥션 풀 압박을 이해하고, 상황에 맞는 전파 레벨을 선택해야 안정적인 트랜잭션 설계가 가능하다.