Spring 트랜잭션 전파란?
@Transactional의 propagation 속성은 “이미 트랜잭션이 존재할 때 어떻게 할 것인가”를 결정합니다. 기본값 REQUIRED만 사용하다가 실무에서 트랜잭션 경계 문제를 겪는 경우가 많습니다. 7가지 전파 옵션의 동작 원리와 실전 사용 시나리오를 정확히 이해해야 합니다.
7가지 전파 옵션
| 전파 옵션 | 기존 TX 있을 때 | 기존 TX 없을 때 |
|---|---|---|
REQUIRED |
참여 | 새로 생성 |
REQUIRES_NEW |
기존 일시 중단, 새로 생성 | 새로 생성 |
NESTED |
Savepoint로 중첩 | 새로 생성 |
SUPPORTS |
참여 | TX 없이 실행 |
NOT_SUPPORTED |
기존 일시 중단 | TX 없이 실행 |
MANDATORY |
참여 | 예외 발생 |
NEVER |
예외 발생 | TX 없이 실행 |
REQUIRED vs REQUIRES_NEW
가장 중요한 차이입니다. 주문 생성 + 감사 로그 시나리오로 비교합니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepo;
private final AuditService auditService;
@Transactional // REQUIRED (기본값)
public Order createOrder(CreateOrderDto dto) {
Order order = orderRepo.save(new Order(dto));
// 감사 로그도 같은 트랜잭션에 참여
auditService.log("ORDER_CREATED", order.getId());
return order;
}
}
@Service
public class AuditService {
@Transactional // REQUIRED → 기존 TX에 참여
public void log(String action, Long entityId) {
auditRepo.save(new AuditLog(action, entityId));
// 여기서 예외 발생 시 → 주문도 롤백!
}
}
REQUIRED는 같은 트랜잭션을 공유하므로, 감사 로그 저장 실패 시 주문까지 롤백됩니다. 감사 로그 실패가 주문을 취소해야 하나요?
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action, Long entityId) {
auditRepo.save(new AuditLog(action, entityId));
// 여기서 예외 발생 → 감사 로그만 롤백, 주문은 무관
}
}
REQUIRES_NEW는 독립된 트랜잭션을 생성합니다. 감사 로그가 실패해도 주문 트랜잭션에 영향을 주지 않습니다. 단, 새 DB 커넥션을 사용하므로 커넥션 풀 고갈에 주의해야 합니다.
NESTED: Savepoint 활용
NESTED는 부모 트랜잭션 안에서 Savepoint를 만듭니다. 부분 롤백이 가능합니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final NotificationService notificationService;
@Transactional
public Order createOrder(CreateOrderDto dto) {
Order order = orderRepo.save(new Order(dto));
try {
// NESTED → Savepoint 생성
notificationService.sendOrderConfirmation(order);
} catch (Exception e) {
// 알림 실패 → Savepoint로 롤백, 주문은 유지
log.warn("알림 전송 실패, 주문은 유지: {}", e.getMessage());
}
return order;
}
}
@Service
public class NotificationService {
@Transactional(propagation = Propagation.NESTED)
public void sendOrderConfirmation(Order order) {
notificationRepo.save(new Notification(order));
// 실패 시 이 Savepoint만 롤백
}
}
REQUIRES_NEW와의 차이: NESTED는 부모 트랜잭션이 롤백되면 함께 롤백됩니다. REQUIRES_NEW는 이미 커밋되었으므로 영향 없습니다. 주의: JPA/Hibernate에서는 NESTED를 지원하지 않는 경우가 많아 JDBC 기반에서 주로 사용합니다.
트랜잭션 격리 수준
격리 수준은 동시 트랜잭션 간의 데이터 가시성을 결정합니다:
| 격리 수준 | Dirty Read | Non-Repeatable | Phantom |
|---|---|---|---|
READ_UNCOMMITTED |
⚠️ 발생 | ⚠️ 발생 | ⚠️ 발생 |
READ_COMMITTED |
✅ 방지 | ⚠️ 발생 | ⚠️ 발생 |
REPEATABLE_READ |
✅ 방지 | ✅ 방지 | ⚠️ 발생 |
SERIALIZABLE |
✅ 방지 | ✅ 방지 | ✅ 방지 |
// 재고 차감: 동시성 문제 방지
@Transactional(isolation = Isolation.SERIALIZABLE)
public void deductStock(Long productId, int quantity) {
Product product = productRepo.findById(productId)
.orElseThrow();
if (product.getStock() < quantity) {
throw new InsufficientStockException();
}
product.setStock(product.getStock() - quantity);
}
// 더 나은 방법: 비관적 락
@Transactional
public void deductStock(Long productId, int quantity) {
Product product = productRepo.findByIdWithLock(productId);
// @Lock(LockModeType.PESSIMISTIC_WRITE)
// SELECT ... FOR UPDATE
product.deductStock(quantity);
}
SERIALIZABLE은 성능 비용이 크므로, 실무에서는 비관적 락(SELECT FOR UPDATE)이나 낙관적 락(@Version)을 선호합니다.
readOnly 최적화
readOnly = true는 단순 힌트가 아닙니다. 실질적인 성능 이점이 있습니다:
@Transactional(readOnly = true)
public List<Order> findAllOrders() {
return orderRepo.findAll();
}
// readOnly가 하는 일:
// 1. Hibernate flush 모드 → MANUAL (dirty checking 스킵)
// 2. DB에 SET TRANSACTION READ ONLY 전송 (DB 최적화)
// 3. CQRS 구조에서 읽기 전용 DataSource로 라우팅 가능
// CQRS: readOnly에 따라 DataSource 분기
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager
.isCurrentTransactionReadOnly()
? "replica" // 읽기 → 복제본
: "primary"; // 쓰기 → 마스터
}
}
롤백 규칙 커스터마이징
기본적으로 RuntimeException만 롤백됩니다. Checked Exception은 커밋됩니다:
// ❌ checked exception → 롤백 안 됨!
@Transactional
public void process() throws IOException {
orderRepo.save(order);
throw new IOException("파일 오류");
// 트랜잭션 커밋됨 (의도하지 않은 동작)
}
// ✅ rollbackFor로 명시
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException {
orderRepo.save(order);
throw new IOException("파일 오류");
// 롤백됨
}
// 특정 예외만 롤백 제외
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = MailSendException.class
)
public void createOrderWithEmail() {
orderRepo.save(order);
mailService.send(email); // 메일 실패해도 주문은 커밋
}
흔한 함정: 프록시와 Self-Invocation
@Transactional도 AOP 프록시로 동작하므로, Spring AOP 프록시 심화에서 다룬 Self-Invocation 문제가 동일하게 적용됩니다:
@Service
public class OrderService {
// ❌ 같은 클래스 내 호출 → @Transactional 무시
public void processOrders(List<OrderDto> dtos) {
for (OrderDto dto : dtos) {
this.createOrder(dto); // 프록시 안 거침!
}
}
@Transactional
public void createOrder(OrderDto dto) { ... }
}
// ✅ 해결: 별도 서비스로 분리
@Service
@RequiredArgsConstructor
public class OrderBatchService {
private final OrderService orderService;
public void processOrders(List<OrderDto> dtos) {
for (OrderDto dto : dtos) {
orderService.createOrder(dto); // 프록시 경유
}
}
}
TransactionTemplate: 프로그래밍 방식
메서드 전체가 아닌 특정 구간만 트랜잭션으로 감쌀 때 사용합니다:
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate txTemplate;
public OrderResult processOrder(OrderDto dto) {
// 1단계: 외부 API 호출 (TX 밖)
PaymentResult payment = paymentApi.charge(dto);
// 2단계: DB 저장만 TX로 감싸기
Order order = txTemplate.execute(status -> {
Order o = orderRepo.save(new Order(dto));
o.setPaymentId(payment.getId());
return orderRepo.save(o);
});
// 3단계: 알림 발송 (TX 밖)
notificationService.send(order);
return new OrderResult(order);
}
}
Spring Event 비동기 처리의 @TransactionalEventListener와 조합하면, 트랜잭션 커밋 후에만 이벤트를 처리하는 패턴을 구현할 수 있습니다.
마무리
Spring 트랜잭션은 @Transactional을 붙이는 것으로 끝나지 않습니다. 전파 옵션으로 트랜잭션 경계를 설계하고, 격리 수준과 락 전략으로 동시성을 제어하며, readOnly로 읽기 성능을 최적화하고, rollbackFor로 롤백 규칙을 명시해야 합니다. Self-Invocation 함정을 피하고, 필요 시 TransactionTemplate으로 세밀한 트랜잭션 경계를 설정하는 것이 프로덕션 수준의 트랜잭션 설계입니다.