Spring Transaction 전파 전략

Spring Transaction Propagation이란?

Spring에서 @Transactionalpropagation 속성은 트랜잭션 경계가 중첩될 때 어떻게 동작할지 결정합니다. 서비스 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 함정 — 이 세 가지만 정확히 이해해도 실무에서 발생하는 트랜잭션 버그 대부분을 예방할 수 있습니다. 항상 “이 메서드가 다른 트랜잭션 컨텍스트에서 호출되면 어떻게 동작하는가?”를 먼저 생각하는 습관을 들이세요.

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