728x90
[Spring] 트랜잭션 전파(Transaction Propagation)
트랜잭션 전파(Transaction Propagation)란 이미 진행 중인 트랜잭션이 있을 때, 새로 호출되는 메서드의 트랜잭션을 어떻게 처리할지(합류/분리/금지)를 정하는 정책입니다. 스프링은 기본적으로 프록시 기반 AOP로 @Transactional을 적용하므로, 다른 빈에서 ‘public 메서드’를 호출할 때 전파 규칙이 동작합니다. (같은 클래스 내부 self-invocation은 프록시를 안 거치기 때문에 전파가 적용되지 않으니 주의 바랍니다.)
1. @Transactional 메타데이터 해석 우선순위
Transaction Advice는 호출 시점에 TransactionAttributeSource를 통해 메타데이터를 조회합니다. 조회 알고리즘은 다음과 같은 순서로 조회하며 첫 번째로 발견된 @Transactional를 선택하며 속성은 병합하지 않습니다.
- 타켓 클래스의 메서드 : TargetClass Method
- 타켓 클래스 : TargetClass
- 인터페이스의 메서드 : Interface Method
- 인터페이스 : 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에서는 부모 미커밋 데이터가 보이지 않는 게 정상.
해결방법
- 하나의 트랜잭션에서 처리
- 하위 호출의 전파를 기본(REQUIRED)으로 두거나, 부분 롤백이 필요하면 NESTED(세이브포인트) 사용. - 사용하고자 하는 데이터가 커밋 이후에 실행되도록 처리
- 메인 롤백과 분리하여 별도 커밋 - @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
'Backend > Spring' 카테고리의 다른 글
[Spring] Transaction - 격리 수준(Isolation) (3) | 2025.08.17 |
---|---|
[Spring] Transaction - @TransactionalEventListener를 이용한 이벤트 처리 (3) | 2025.08.17 |
[TDD] Test Double (0) | 2025.08.03 |
[Spring] @Value와 @ConfigurationProperties (0) | 2023.07.09 |
[Spring] Bean Validation과 Custom Validation (0) | 2023.07.08 |