Spring Transaction Propagation이란?
Spring에서 @Transactional의 propagation 속성은 트랜잭션 경계가 중첩될 때 어떻게 동작할지 결정합니다. 서비스 A가 서비스 B를 호출하고, 둘 다 @Transactional이 붙어 있다면 — 하나의 트랜잭션으로 합칠 것인가, 별도로 분리할 것인가? 이 결정이 바로 Transaction Propagation입니다.
실무에서 propagation을 잘못 설정하면 부분 롤백 실패, 의도치 않은 커밋, UnexpectedRollbackException 같은 치명적 버그가 발생합니다. 이 글에서는 7가지 전파 레벨을 코드와 함께 깊이 분석합니다.
7가지 Propagation 레벨 비교
Spring은 Propagation enum으로 7가지 전파 전략을 제공합니다:
- REQUIRED (기본값) — 기존 트랜잭션이 있으면 참여, 없으면 새로 생성
- REQUIRES_NEW — 항상 새 트랜잭션 생성. 기존 트랜잭션은 일시 중단(suspend)
- SUPPORTS — 기존 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
- NOT_SUPPORTED — 항상 트랜잭션 없이 실행. 기존 트랜잭션은 일시 중단
- MANDATORY — 기존 트랜잭션 필수. 없으면 예외 발생
- NEVER — 트랜잭션이 있으면 예외 발생. 반드시 비트랜잭션 실행
- NESTED — 기존 트랜잭션 내에서 Savepoint 기반 중첩 트랜잭션 생성
REQUIRED: 기본값의 함정
REQUIRED는 가장 많이 쓰이는 기본값이지만, 중첩 호출 시 롤백 전파라는 함정이 있습니다:
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Transactional // REQUIRED (기본값)
public void createOrder(OrderRequest req) {
orderRepository.save(new Order(req));
try {
paymentService.processPayment(req.getPaymentInfo());
} catch (PaymentException e) {
// 결제 실패해도 주문은 저장하고 싶다면?
log.warn("결제 실패, 주문만 저장", e);
}
}
}
@Service
public class PaymentService {
@Transactional // REQUIRED → 같은 트랜잭션에 참여
public void processPayment(PaymentInfo info) {
// 결제 처리 중 예외 발생!
throw new PaymentException("잔액 부족");
}
}
위 코드에서 PaymentService에서 예외가 발생하면, Spring은 해당 트랜잭션을 rollback-only로 마킹합니다. OrderService에서 예외를 catch해도 소용없습니다. 커밋 시점에 UnexpectedRollbackException이 발생하고 주문도 함께 롤백됩니다.
이것이 바로 REQUIRED의 핵심 특성입니다: 같은 물리 트랜잭션을 공유하므로, 어느 한쪽의 롤백이 전체를 롤백시킵니다.
REQUIRES_NEW: 독립 트랜잭션 분리
위 문제를 해결하려면 REQUIRES_NEW를 사용합니다:
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(PaymentInfo info) {
// 완전히 새로운 트랜잭션에서 실행
// 이 트랜잭션이 롤백되어도 호출자 트랜잭션에 영향 없음
paymentRepository.save(new Payment(info));
}
}
REQUIRES_NEW는 기존 트랜잭션을 일시 중단(suspend)하고 완전히 새로운 물리 트랜잭션을 시작합니다. 내부 트랜잭션의 커밋/롤백이 외부에 영향을 주지 않습니다.
주의사항: REQUIRES_NEW는 별도 DB 커넥션을 사용합니다. 커넥션 풀이 작은 환경에서 남용하면 커넥션 고갈(deadlock)이 발생할 수 있습니다. 외부 트랜잭션이 커넥션을 잡고 있는 상태에서 내부 트랜잭션이 새 커넥션을 요청하기 때문입니다.
NESTED: Savepoint 기반 부분 롤백
NESTED는 JDBC Savepoint를 활용하여 같은 물리 트랜잭션 내에서 부분 롤백을 가능하게 합니다:
@Service
public class OrderService {
@Transactional
public void createOrderWithBonus(OrderRequest req) {
orderRepository.save(new Order(req));
try {
bonusService.grantBonus(req.getUserId());
} catch (BonusException e) {
// NESTED 덕분에 보너스만 롤백, 주문은 유지
log.info("보너스 지급 실패, 주문은 정상 처리");
}
}
}
@Service
public class BonusService {
@Transactional(propagation = Propagation.NESTED)
public void grantBonus(Long userId) {
// Savepoint 생성 → 여기서 롤백되면 Savepoint까지만 되돌림
bonusRepository.save(new Bonus(userId, 1000));
if (isDuplicate(userId)) {
throw new BonusException("중복 지급");
}
}
}
NESTED vs REQUIRES_NEW 차이점:
- NESTED: 같은 커넥션, 같은 물리 트랜잭션. 외부가 롤백되면 내부도 함께 롤백됨
- REQUIRES_NEW: 별도 커넥션, 별도 물리 트랜잭션. 외부 롤백과 완전히 독립
- NESTED는 JPA/Hibernate에서 공식 지원하지 않습니다. JDBC 직접 사용 시에만 안정적으로 동작합니다
Self-Invocation 문제와 해결
Spring AOP 프록시 기반 트랜잭션의 가장 흔한 실수는 self-invocation(자기 호출)입니다:
@Service
public class UserService {
@Transactional
public void registerUser(UserDto dto) {
userRepository.save(new User(dto));
// ⚠️ this.sendWelcomeEmail()은 프록시를 거치지 않음!
// REQUIRES_NEW가 무시됨
this.sendWelcomeEmail(dto.getEmail());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendWelcomeEmail(String email) {
emailLogRepository.save(new EmailLog(email));
emailSender.send(email);
}
}
같은 클래스 내에서 this로 호출하면 AOP 프록시를 우회하므로 @Transactional 설정이 무시됩니다. 해결 방법은 3가지입니다:
// 해결 1: 별도 서비스로 분리 (권장)
@Service
@RequiredArgsConstructor
public class UserService {
private final EmailService emailService; // 별도 빈
@Transactional
public void registerUser(UserDto dto) {
userRepository.save(new User(dto));
emailService.sendWelcomeEmail(dto.getEmail());
}
}
// 해결 2: ApplicationContext에서 자기 자신 주입
@Service
public class UserService {
@Autowired
private ApplicationContext context;
@Transactional
public void registerUser(UserDto dto) {
userRepository.save(new User(dto));
context.getBean(UserService.class)
.sendWelcomeEmail(dto.getEmail());
}
}
// 해결 3: @Lazy 자기 주입 (Spring 4.3+)
@Service
public class UserService {
@Lazy @Autowired
private UserService self;
@Transactional
public void registerUser(UserDto dto) {
userRepository.save(new User(dto));
self.sendWelcomeEmail(dto.getEmail()); // 프록시 경유
}
}
읽기 전용 트랜잭션 최적화
readOnly = true 설정은 단순한 힌트가 아니라 실질적인 성능 최적화를 제공합니다:
@Service
public class ReportService {
@Transactional(readOnly = true)
public ReportDto generateReport(Long reportId) {
// Hibernate: FlushMode.MANUAL → dirty checking 비활성화
// JDBC: Connection.setReadOnly(true) → DB 레벨 최적화
// MySQL: READ REPLICA로 라우팅 가능
List<Order> orders = orderRepository.findByReportId(reportId);
return ReportDto.from(orders);
}
}
readOnly가 제공하는 최적화:
- Hibernate dirty checking 비활성화 — 스냅샷 비교 생략으로 메모리/CPU 절약
- FlushMode.MANUAL — 불필요한 flush 방지
- DB 레벨 read-only — MySQL InnoDB에서 쓰기 잠금 생략
- 리플리카 라우팅 —
AbstractRoutingDataSource와 조합하여 읽기 전용 쿼리를 Replica로 분산
실전 패턴: 이벤트 로깅 분리
비즈니스 로직과 감사 로그를 트랜잭션 수준에서 분리하는 실전 패턴입니다:
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepo;
private final AuditService auditService;
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId).orElseThrow();
Account to = accountRepo.findById(toId).orElseThrow();
from.debit(amount);
to.credit(amount);
accountRepo.save(from);
accountRepo.save(to);
// 감사 로그는 독립 트랜잭션 — 이체 실패해도 로그는 남김
auditService.logTransfer(fromId, toId, amount);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransfer(Long from, Long to, BigDecimal amount) {
auditRepository.save(AuditLog.builder()
.action("TRANSFER")
.detail(String.format("%d → %d: %s", from, to, amount))
.timestamp(Instant.now())
.build());
}
}
이 패턴에서 REQUIRES_NEW를 사용하면, 이체가 롤백되더라도 감사 로그는 반드시 DB에 기록됩니다. 금융 시스템에서 자주 사용되는 패턴입니다.
TransactionTemplate: 프로그래밍 방식 제어
선언적 @Transactional보다 세밀한 제어가 필요할 때 TransactionTemplate을 사용합니다:
@Service
@RequiredArgsConstructor
public class BatchService {
private final TransactionTemplate txTemplate;
private final TransactionTemplate newTxTemplate;
@PostConstruct
void init() {
newTxTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW
);
}
public void processBatch(List<Item> items) {
for (Item item : items) {
// 각 아이템을 독립 트랜잭션으로 처리
// 하나가 실패해도 나머지는 계속 진행
try {
newTxTemplate.executeWithoutResult(status -> {
itemRepository.save(process(item));
});
} catch (Exception e) {
log.error("아이템 처리 실패: {}", item.getId(), e);
// 다음 아이템 계속 처리
}
}
}
}
Propagation 선택 가이드
실무에서 propagation을 선택하는 기준을 정리합니다:
- 기본 비즈니스 로직 →
REQUIRED(기본값 유지) - 실패해도 반드시 기록해야 하는 로그/감사 →
REQUIRES_NEW - 외부 API 호출 포함 시 롤백 범위 제한 →
REQUIRES_NEW - 트랜잭션 없이 읽기만 수행 →
SUPPORTS또는NOT_SUPPORTED - 반드시 트랜잭션 컨텍스트 내에서만 실행 →
MANDATORY - JDBC 기반 부분 롤백 →
NESTED(JPA에서는 사용 자제)
관련 글: Spring AOP 프록시 심화 가이드에서 프록시 동작 원리를, Spring Batch 대용량 처리 가이드에서 배치 트랜잭션 전략을 함께 확인하세요.
마무리
Transaction Propagation은 Spring 트랜잭션 관리의 핵심입니다. REQUIRED의 롤백 전파, REQUIRES_NEW의 커넥션 비용, self-invocation 함정 — 이 세 가지만 정확히 이해해도 실무에서 발생하는 트랜잭션 버그 대부분을 예방할 수 있습니다. 항상 “이 메서드가 다른 트랜잭션 컨텍스트에서 호출되면 어떻게 동작하는가?”를 먼저 생각하는 습관을 들이세요.