본문 바로가기
Backend/Spring

[Spring] Transaction - 트랜잭션 전파(Transaction Propagation)

by 제이동 개발자 2025. 8. 17.
728x90

[Spring] 트랜잭션 전파(Transaction Propagation)

트랜잭션 전파(Transaction Propagation)이미 진행 중인 트랜잭션이 있을 때, 새로 호출되는 메서드의 트랜잭션을 어떻게 처리할지(합류/분리/금지)를 정하는 정책입니다. 스프링은 기본적으로 프록시 기반 AOP로 @Transactional을 적용하므로, 다른 빈에서 ‘public 메서드’를 호출할 때 전파 규칙이 동작합니다. (같은 클래스 내부 self-invocation은 프록시를 안 거치기 때문에 전파가 적용되지 않으니 주의 바랍니다.)

 

1. @Transactional 메타데이터 해석 우선순위

 Transaction Advice는 호출 시점에 TransactionAttributeSource를 통해 메타데이터를 조회합니다. 조회 알고리즘은 다음과 같은 순서로 조회하며 첫 번째로 발견된 @Transactional를 선택하며 속성은 병합하지 않습니다.

  1. 타켓 클래스의 메서드 : TargetClass Method
  2. 타켓 클래스 : TargetClass
  3. 인터페이스의 메서드 : Interface Method
  4. 인터페이스 : Interface
// 4순위
public interface TxService {
  // 3순위
  void method1();
  // 3순위
  void method2();
}

// 2순위
public class TxServiceImpl implements TxService {
  // 1순위
  @Override
  public void method1() {}
  // 1순위
  @Override
  public void method2() {}
}

 

💡 일반적으로 인터페이스에 @Transactional을 사용하지 않고, 구현 클래스에서 사용합니다. 
인터페이스와 구현 클래스 모두 @Transactional을 사용하게 되면 관리하기 힘들어지고, 그로 인해 예기치 못한 상황이 발생할 수 있기 때문에 일반적으로 구현 클래스 레벨에서만 @Transactional을 사용합니다.

 

 

2. 트랜잭션 전파(Transaction Propagation) 속성

Propagation 기존 TX 있음 기존 TX 없음 설명
REQUIRED (기본) 참여 새로 시작 일반적인 서비스 로직 대부분. 요청 단위의 Unit of Work에 적합
REQUIRES_NEW 일시중단새로 시작 새로 시작 감사로그/이벤트 아웃박스/메일발송부수 작업을 메인 롤백과 분리할 때
NESTED 부모 TX 안에 세이브포인트 생성 새로 시작 부분 롤백이 필요할 때 (DB가 세이브포인트 지원 필요, JTA는 미지원)
SUPPORTS 참여 TX 없이 실행 읽기 전용 조회를 유연하게 처리. TX 있으면 참여, 없으면 그냥 읽기
MANDATORY 참여 예외 반드시 상위 TX 안에서만 호출돼야 하는 도메인 규칙적 메서드
NOT_SUPPORTED 일시중단, TX 없이 실행 TX 없이 실행 긴 외부 IO 호출·리포팅 등 TX가 불필요/유해할 때 (락 홀딩 방지)
NEVER 예외 TX 없이 실행 절대 TX에서 돌면 안 되는 API 를 보호적으로 강제할 때

 

 

3. 실무 팁

3-1. REQUIRES_NEW에서 부모 트랜잭션 데이터가 안 보이는 이유

 실무에서 REQUIRES_NEW 사용하다 보면 아래와 같은 실수를 하게 됩니다. 거의 대부분의 주니어 개발자들이 REQUIRES_NEW를 사용하다 마주치는 문제로 모두 주의하기 바랍니다.

public class Tx1Service {

  private final Tx2Service tx2Service;
  private final ArticleService articleService;

  @Transactional // Propagation.REQUIRED: 요청 단위 트랜잭션 시작
  public void requiredTest() {
    // Tx1 트랜잭션 시작
    Article article = new Article("제목", "내용");
    articleService.saveArticle(article);  // Tx1의 영속성 컨텍스트에 저장 (커밋은 아직)

    // REQUIRES_NEW : Tx2 트랜잭션 시작 
    // 아래 호출에서 Tx1은 '일시 중단'되고 Tx2가 '새 트랜잭션'으로 시작됨
    tx2Service.callMethod(article.getId());
    // REQUIRES_NEW : Tx2 트랜잭션 종료
    // Tx2 종료 후 Tx1 재개
    
    // Tx1 커밋
  }
}

public class Tx2Service {

  private final ArticleRepository articleRepository;

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void callMethod(Long articleId) {
    // ❗ 다른 트랜잭션(Tx2)에서 DB를 조회하므로, Tx1이 커밋되기 전 데이터는 보이지 않음
    // 조회되지 않는다.
    Optional<Article> articleOp = articleRepository.findById(articleId);
    ..
  }
}

 

  • Tx1 시작 → article이 Tx1의 1차 캐시에 들어감(INSERT는 보류되거나 IDENTITY면 즉시 나갈 수 있어도 커밋 전).
  • Tx2 시작(REQUIRES_NEW)별도 커넥션/별도 엔티티매니저.
  • Tx2의 findById → DB는 Tx1을 아직 커밋 안 된 상태로 인식 → 읽을 수 없음(더티 리드 금지).
  • 결론 → REQUIRES_NEW에서는 부모 미커밋 데이터보이지 않는 게 정상.

 

해결방법

  1. 하나의 트랜잭션에서 처리
    - 하위 호출의 전파를 기본(REQUIRED)으로 두거나, 부분 롤백이 필요하면 NESTED(세이브포인트) 사용.
  2. 사용하고자 하는 데이터가 커밋 이후에 실행되도록 처리
    - 메인 롤백과 분리하여 별도 커밋
  3. @TransactionalEventListener 리스너를 이용한 처리
// 1. 하나의 트랜잭션에서 처리
@Service
@RequiredArgsConstructor
public class Tx2Service {

  private final ArticleRepository articleRepository;

  @Transactional // REQUIRED: 부모 Tx1에 참여 → 같은 1차 캐시/커넥션 공유
  public void callMethod(Long articleId) {
    // ✅ 방금 저장한 엔티티 조회 가능 (같은 트랜잭션)
    Article article = articleRepository.findById(articleId)
        .orElseThrow(() -> new IllegalStateException("not found"));
    // ... 비즈니스 로직
  }
}
@Service
@RequiredArgsConstructor
public class Tx2ServiceNested {

  private final ArticleRepository articleRepository;

  @Transactional(propagation = Propagation.NESTED) // 세이브포인트로 '부분 롤백' 허용
  public void callMethod(Long articleId) {
    // ✅ 가시성은 부모와 동일, 실패 시 세이브포인트까지 롤백
    Article article = articleRepository.getReferenceById(articleId);
    // ... 위험한 일부 작업 수행
  }
}

// ===========================================================================

// 3. @TransactionalEventListener
// 발행 측 (메인 트랜잭션 안)
@Transactional
public void requiredTest() {
  Article saved = articleService.saveArticle(new Article("제목","내용"));
  publisher.publishEvent(new ArticleCreatedEvent(saved.getId())); // AFTER_COMMIT에 반응
}

@Component
@RequiredArgsConstructor
public class ArticleCreatedHandler {

  private final Tx2Service tx2Service;

  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void handle(ArticleCreatedEvent e) {
    // ✅ 이미 메인 트랜잭션 커밋됨 → REQUIRES_NEW에서 안전하게 조회 가능
    tx2Service.callMethod(e.articleId());
  }
}

 

728x90