Spring

[Spring] @Transactional 전파수준 정리 - Propagation 예제코드로 테스트해보기

HSRyuuu 2025. 6. 19. 22:52

@Transactional 전파 수준이란?


Spring에서는 @Transactional을 통해 트랜잭션을 선언적으로 쉽게 관리한다.

대부분의 경우 기본 전파 수준인 REQUIRED만으로도 충분하지만, 하나의 서비스 메서드에서 또 다른 트랜잭션 메서드를 호출할 때,

전파 전략을 명확히 이해하고 설정하지 않으면 원치 않는 rollback 또는 commit이 발생할 수 있다.

 

트랜잭션 전파 수준은 
이미 존재하는 트랜잭션이 있는 상황에서 이 메서드를 새 트랜잭션으로 실행할 것인가, 아니면 기존 트랜잭션에 참여할 것인가
를 결정하는 설정하는 것이다.

 

트랜잭션 전파가 중요한 상황들

  • 주문 entity 저장 진행 중, 로그테이블에 기록해야 됨. -> 로그 저장 중 에러 발생한다고 비즈니스 로직을 롤백하면 안 됨
  • 서비스 내의 비즈니스 로직 진행 중, 외부 API 호출 -> 외부 API 호출 중에 에러 발생해도, 비즈니스 로직은 롤백하면 안 됨
  • 작업 중간 단계에서 일부 로직을 분리해서, 해당 로직만 롤백하고 싶을 때
  • 트랜잭션과 무관하게 실행되어야 하는 비동기, 캐시, 통계 처리

 

전파 수준 개념

전파 수준은 트랜잭션을 "묶을 것인가, 나눌 것인가"의 문제이다.

현재 트랜잭션이 실행되고 있는 상태에서 또 다른 @Transactional 메서드를 호출했을 때,

  • 기존 트랜잭션에 합류할지
  • 새로운 트랜잭션을 만들지
  • 합류를 거부할지 

등을 설정하는 것이다.

 

아래는 전파 수준에 따른 동작 방식이다.

전파 수준 기존 트랜잭션 있을 때 기존 트랜잭션 없을 때 설명
REQUIRED 참여 새로 생성 기본값. 가능하면 합류
REQUIRES_NEW 기존 트랜잭션 중단 → 새로 시작 새로 시작 기존과 완전히 분리
NESTED Savepoint 생성 새로 생성 부분 롤백 가능한 하위 트랜잭션
SUPPORTS 참여 트랜잭션 없이 실행 읽기 전용에 유용
NOT_SUPPORTED 트랜잭션 중단 후 실행 그냥 실행 트랜잭션 없이 실행해야 할 때
NEVER 예외 발생 그냥 실행 트랜잭션이 존재하면 안 됨
MANDATORY 참여 예외 발생 반드시 트랜잭션 있어야 함

 

데이터 정합성과 롤백 범위에 대한 상세한 기준이 필요할 때 의도에 맞게 트랜잭션 흐름을 설계해야 한다.

 

각 전파 수준 상세 설명

1) REQUIRED (기본 값)

  • 설명: 현재 트랜잭션이 존재하면 참여하고, 없으면 새로 생성한다.
  • 실무 예: 대부분의 서비스 메서드에서 사용하는 기본 전략
  • 특징: 트랜잭션 공유됨 → 예외 발생 시 전체 롤백됨
@Transactional(propagation = Propagation.REQUIRED)
public void inner() {
    // outer가 트랜잭션을 갖고 있다면 outer의 트랜잭션에 참여
    // 예외 발생 시 outer도 함께 롤백
}

 

2) REQUIRES_NEW

  • 설명: 기존 트랜잭션이 존재하면 일시 정지하고 새로운 트랜잭션을 시작한다.
  • 실무 예: 로깅 등 실패 여부와 관계없이 동작해야 하는 로직
  • 특징: Outer와 완전히 분리된 트랜잭션 -> Inner에서 예외 발생해도 Outer는 영향받지 않음
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
    // 항상 새로운 트랜잭션 시작
    logService.saveLog();
}

 

3) NESTED

  • 설명: Savepoint를 생성하여 부분 롤백이 가능한 트랜잭션을 만든다.
  • 실무 예: 여러 건 처리 중 하나의 작업만 롤백하고 나머지는 유지하고 싶은 경우
  • 특징: 부모 트랜잭션이 존재할 때만 Savepoint 생성 (일부 DB나 트랜잭션 매니저에서만 지원됨)
@Transactional(propagation = Propagation.NESTED)
public void inner() {
    // 예외 발생 시 savepoint로 롤백되지만 outer는 유지 가능
    if (true) throw new RuntimeException("부분 롤백 테스트");
}

 

 

4) SUPPORTS

  • 설명: 트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행된다.
  • 실무 예: 단순 조회 전용 메서드
  • 특징: 트랜잭션이 없으면 트랜잭션이 없는 상태로도 실행된다.
@Transactional(propagation = Propagation.SUPPORTS)
public UserDto getUser() {
    return userRepository.findById(1L).orElseThrow();
}

 

5) NOT_SUPPORTED

  • 설명: 현재 트랜잭션이 존재하면 일시 중단시키고, 트랜잭션 없이 실행합니다.
  • 실무 예: 외부 API 호출, 캐시 저장, 통계 연산 등 트랜잭션 영향 없이 실행되어야 하는 작업
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void cacheWrite() {
    cacheService.put("key", "value"); // 트랜잭션 없음
}

 

6) NEVER

  • 설명: 현재 트랜잭션이 존재하면 예외를 발생시킵니다.
  • 실무 예: 트랜잭션 안에서 실행되면 안 되는 작업 보호할 때
@Transactional(propagation = Propagation.NEVER)
public void readWithoutTx() {
    // 트랜잭션이 있으면 IllegalTransactionStateException 발생
    System.out.println("Tx 없는 환경에서만 실행 가능");
}

 

7) MANDATORY

  • 설명: 현재 트랜잭션이 반드시 존재해야 하며, 없으면 예외 발생
  • 실무 예: 상위 서비스에서 트랜잭션이 열렸음을 보장하고 싶은 내부 컴포넌트
@Transactional(propagation = Propagation.MANDATORY)
public void audit() {
    // 트랜잭션이 없으면 예외 발생
    log.info("트랜잭션 내부에서만 실행되어야 함");
}

 

✅ 정리

  • 대부분은 REQUIRED 하나로 충분하지만, 필요에 따라 복잡한 비즈니스 로직에서는 상황에 맞는 전파 전략이 필요하다.
  • 전파 설정을 잘못하면, 롤백이 안 돼야할 때 롤백이 되거나, 데이터 정합성 문제가 발생할 수 있다.

실무 적용 전략

  • 서비스 로직은 대부분 REQUIRED
  • 로깅/감사는 REQUIRES_NEW
  • NESTED는 신중하게 사용
  • 외부 시스템 호출은 NOT_SUPPORTED 또는 별도 트랜잭션

 

예제 코드


테스트에서 사용할 코드

로그 AOP

AOP를 통해 @Transactional 전 후로 현재 트랜잭션 상태 로그를 찍는다.

아래와 같은 형식으로 찍힐 예정이다.

▶️ [TX-START] OuterService.outerMethod | active=true, readOnly=false
✅ [TX-END] OuterService.outerMethod | active=true, readOnly=false
❌ [TX-EXCEPTION] OuterService.outerMethod | message=some-message
더보기
@Aspect
@Component
public class TransactionLoggerAspect {

    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable {

        // 트랜잭션 시작 로그
        System.out.printf("▶️ [TX-START] %s | active=%s, readOnly=%s\n",
                TransactionSynchronizationManager.getCurrentTransactionName().replace("com.example.playground.spring.transactional.", ""),
                TransactionSynchronizationManager.isActualTransactionActive(),
                TransactionSynchronizationManager.isCurrentTransactionReadOnly()
        );

        try {
            Object result = joinPoint.proceed();

            // 트랜잭션 정상 종료 로그
            System.out.printf("✅ [TX-END] %s | active=%s, readOnly=%s\n",
                    TransactionSynchronizationManager.getCurrentTransactionName().replace("com.example.playground.spring.transactional.", ""),
                    TransactionSynchronizationManager.isActualTransactionActive(),
                    TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            );
            return result;
        } catch (Throwable e) {
            // 트랜잭션 예외 로그
            System.out.printf("❌ [TX-EXCEPTION] %s | message=%s\n",
                    TransactionSynchronizationManager.getCurrentTransactionName().replace("com.example.playground.spring.transactional.", ""),
                    e.getMessage());
            throw e;
        }
    }

 

Test 실행

단순히 outerMethod를 실행하고, innerMethod에서 생성한 testEntity의 존재 여부를 확인하여 롤백 여부를 확인한다.

testEntity.id는 항상 1L로 한다.

환경은 jpa.hibernate.ddl-auto: create로 하여 @SpringBootTest가 실행될 때마다 DB가 초기화되도록 한다.

더보기
    @Test
    void testTransactional() {
        try{
            outerService.outerMethod();
        }catch(Exception e){
            System.out.println("예외 발생: " + e.getMessage());
        }
        System.out.println("✅결과: entityExists=" + testEntityRepository.existsById(1L));
    }

 

1. REQUIRED -> REQUIRED (기본)

  • 기대 결과: outer에서 예외가 발생해도, inner는 롤백되지 않을 것이다.
  • 실무 예: 일반적인 상황
    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        innerService.innerMethod();
        throw new RuntimeException("outer exception");
    }

    // InnerService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void innerMethod(){
        testEntityRepository.save(new TestEntity(1L, "test1"));
    }
두번째 줄에서 inner가 outer의 트랜잭션에 합류한다.
outer에서 예외가 발생했을 때, 같은 트랜잭션에서 실행된 inner도 롤백된다.
결과를 보면 insert 문이 나가지 않은 것을 확인할 수 있다. (이 부분은 JPA가 트랜잭션 종료 후 flush() 하기 때문)
outer에서 예외가 발생했지만, inner까지 롤백 되었다는 뜻이다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
❌ [TX-EXCEPTION] transactionName=OuterService.outerMethod | message=outer exception
예외 발생: outer exception
Hibernate: select count(*) from test_table te1_0 where te1_0.id=?
✅ 결과: entityExists=false

 

2. REQUIRED -> REQUIRED_NEW (로그 성 작업)

  • 기대 결과: outer에서 예외가 발생해도, inner는 롤백되지 않을 것이다.
  • 실무 예: outer 작업이 실패해도 inner(로그/알림) 등은 커밋되어야 할 때
    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        innerService.innerMethod();
        throw new RuntimeException("outer exception");
    }
    
    // InnerService.class
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void innerMethod(){
        testEntityRepository.save(new TestEntity(1L, "test1"));
    }
두 번째 줄에서 이번엔 inner의 트랜잭션이 따로 생성되었다. 
inner 트랜잭션 종료 후 insert 문이 실행되었다.
outer에서 예외를 발생시키고, TestEntity를 조회했을 때, 존재한다는 것을 확인할 수 있다.
outer에서 예외가 발생해서 outer 트랜잭션이 롤백되었지만, inner를 롤백되지 않았다는 것을 의미한다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=InnerService.innerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=InnerService.innerMethod | active=true, readOnly=false
Hibernate: insert into test_table (name,id) values (?,?)
❌ [TX-EXCEPTION] transactionName=OuterService.outerMethod | message=outer exception
예외 발생: outer exception
Hibernate: select count(*) from test_table te1_0 where te1_0.id=?
✅ 결과: entityExists=true

 

3. REQUIRED -> NESTED (부분 롤백 제어)

  • 기대 결과: inner 중 일부는 롤백, 일부는 성공한다.
  • 실무 예: 대량 입력 중 일부 오류가 발생해도 전체가 롤백되지 않고, 오류 발생건만 롤백된다. (REQUIRED_NEW 사용)
    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        for(int i = 0; i < 5; i++){
            try{
                innerService.innerRandomErrorMethod(i);
                System.out.println("inner method 성공");
            }catch(Exception e){
                System.out.println("inner method 실패"+ e.getMessage());
            }
        }
    }
 
     @Transactional(propagation = Propagation.NESTED)
    public void innerRandomErrorMethod(int num){
        if(num % 2 == 0){
            throw new RuntimeException("innerError");
        }else{
            testEntityRepository.save(new TestEntity((long)num, "test" + num));
        }
    }
   
// Config
@Configuration
public class TxConfig {
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        DataSourceTransactionManager dataSourceTransactionManager = new DataSourceTransactionManager(dataSource);// Savepoint 지원
        dataSourceTransactionManager.setNestedTransactionAllowed(true);
        return dataSourceTransactionManager; // Savepoint 지원
    }
}

 

org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities
이런 에러가 뜬다. Spring 기본 설정인 JpaTransactionManager는 savepoint를 지원하지 않는다.

따라서 테스트를 위해 config를 해줬다. 
DataSourceTransactionManager가
savepoint를 지원한다
.
그러나 아래와 같이 최종 결과 findAll().size()=0이다. 왜일까?
Hibernate는 savepoint를 구현하지 않는다. 즉, 지원하지 않는다.
심지어 setNestedTransactionaAllowed(true) 도 해줬는데...
https://stackoverflow.com/questions/37927208/nested-transaction-in-spring-app-with-jpa-postgres

따라서 실무에선 NESTED 대신 REQUIRED_NEW를 사용한다고 한다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
❌ [TX-EXCEPTION] transactionName=OuterService.outerMethod | message=innerError
inner method 실패innerError
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
inner method 성공
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
❌ [TX-EXCEPTION] transactionName=OuterService.outerMethod | message=innerError
inner method 실패innerError
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
inner method 성공
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
❌ [TX-EXCEPTION] transactionName=OuterService.outerMethod | message=innerError
inner method 실패innerError
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0
✅ 결과: findAll.size()=0

 

4. REQUIRED -> SUPPORTS

  • 기대 결과: inner입장에서 트랜잭션이 있으면 참여하고, 없으면 그냥 실행한다.
  • 실무 예: 조회 전용 메서드 (있어도 되고, 없어도 될 때)
    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        innerService.innerMethod();
    }
    
    // InnerService.class
    @Transactional(propagation = Propagation.SUPPORTS)
    public void innerMethod(){
        // 조회 로직
        testEntityRepository.findById(1L);
    }
inner가 outer의 트랜잭션에 참여한 것을 볼 수 있다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false

4' : SUPPORTS -> outer 트랜잭션이 없을 때

    // OuterService.class
    public void outerMethod() {
        innerService.innerMethod();
    }
    
    // InnerService.class
    @Transactional(propagation = Propagation.SUPPORTS)
    public void innerMethod(){
        // 조회 로직
        testEntityRepository.findById(1L);
    }
inner의 트랜잭션이 보이지만, active=false 인 것을 확인할 수 있다.
▶️ [TX-START] transactionName=InnerService.innerMethod | active=false, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
✅ [TX-END] transactionName=InnerService.innerMethod | active=false, readOnly=false

 

5. REQUIRED -> NOT_SUPPORTED

  • 기대 결과: inner 메서드는 트랜잭션 없이 실행될 것이다.
  • 실무 예: 트랜잭션시 문제가 발생할 수 있는 외부 API 호출 등 (자주 사용하지는 않는다고 한다.)
    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        try{
            innerService.innerMethod();
        }catch(Exception e){
            System.out.println(e.getMessage()); // 예외를 잡지 않으면 예외가 전파되어 트랜잭션 롤백
        }
    }
    
    // InnerService.class
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void innerMethod(){
        // 외부 API 호출
        throw new RuntimeException("inner method exception");
    }
inner 트랜잭션이 active=false 라고 되어있고, inner 트랜잭션에서 예외가 발생했지만,
outer 트랜잭션은 문제없이 종료된 것을 확인할 수  있다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=InnerService.innerMethod | active=false, readOnly=false
❌ [TX-EXCEPTION] transactionName=InnerService.innerMethod | message=inner method exception
inner method exception
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false

 

5' : JPA에서 NOT_SUPPORTED

NOT_SUPPORTED 트랜잭션에서 Jpa save()와 flush()까지 실행해 보았다. 

insert가 될 것이라고 예측했으나, 실제로 insert 되지 않았다. 

    // OuterService.class
    @Transactional(propagation = Propagation.REQUIRED)
    public void outerMethod() {
        try{
            innerService.innerMethod();
        }catch(Exception e){
            System.out.println(e.getMessage());
        }
    }
    
    // InnerService.class
    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void innerMethod(){
        testEntityRepository.save(new TestEntity(1L, "test"));
        testEntityRepository.flush();
        throw new RuntimeException("inner method exception");
    }
entityExists=false로 출력되었고, no transaction is in progress 라는 메시지를 확인할 수 있다.
JPA는 트랜잭션 없이는 동작하지 않도록 설계되어 있다.
NOT_SUPPORTED는 단순히 트랜잭션을 지원하지 않는 게 아니고, "기존 트랜잭션을 일시정지" 한다. 
즉, inner에는 실행 중인 트랜잭션이 없다는 것이다.

따라서 NOT_SUPPORTED는 DB와 관계없는 외부 API 호출 등의 로직에서 사용하면 될 것 같다.
▶️ [TX-START] transactionName=OuterService.outerMethod | active=true, readOnly=false
▶️ [TX-START] transactionName=InnerService.innerMethod | active=false, readOnly=false
Hibernate: select te1_0.id,te1_0.name from test_table te1_0 where te1_0.id=?
❌ [TX-EXCEPTION] transactionName=InnerService.innerMethod | message=no transaction is in progress
no transaction is in progress
✅ [TX-END] transactionName=OuterService.outerMethod | active=true, readOnly=false
Hibernate: select count(*) from test_table te1_0 where te1_0.id=?
✅ 결과: entityExists=false

 

✅ 정리

트랜잭션 전파 단계는 의도된 트랜잭션의 흐름을 명시하기 위한 도구이다. 

단순 CRUD에는 기본값 REQUIRED 로도 충분하지만, 로직이 중첩되거나 예외에 대한 상세 처리가 필요할 경우,

의도적으로 트랜잭션을 조정해서 데이터 무결성이 깨지거나 불필요한 롤백 발생을 막아야 한다.

조합 특징 활용 상황
REQUIRED → REQUIRED 하나의 트랜잭션 기본값, 대부분의 상황
REQUIRED → REQUIRES_NEW 별도 트랜잭션 로그 저장, 알림 전송 등 반드시 커밋되어야 하는 로직
REQUIRED → NOT_SUPPORTED 트랜잭션 없이 실행 외부 API 호출, 캐시 업데이트 등 트랜잭션 불필요 로직
REQUIRED → SUPPORTS 있으면 참여, 없으면 비트랜잭션 단순 조회 등 트랜잭션이 필수 아닌 경우

⚠️ 실무에서 주의할 점

  • REQUIRED_NEW는 커넥션을 추가로 소모하므로 주의해야 한다.
  • NESTED는 JPA 환경에서는 거의 지원하지 않는다.
  • NOT_SUPPORTED는 JPA 쓰기 시에는 사용할 수 없다.

 

트랜잭션 관련 다른 글

트랜잭션 격리성 (isolation)

📁 [[ Computer Science ]/데이터베이스 이론] - [Database] 트랜잭션의 격리성 문제 - Dirty Read / Non-Repeatable Read / Phantom Read

 

[Database] 트랜잭션의 격리성 문제 - Dirty Read / Non-Repeatable Read / Phantom Read

트랜잭션? 트랜잭션은 하나의 데이터 교환 또는 변경을 안전하게 처리하도록 보장해 주는 것을 의미한다. 한 번의 애플리케이션 로직으로 인해 두 개 이상의 데이터가 영향을 받는 경우, 각각의

innovation123.tistory.com

트랜잭션의 ACID 속성

📁 [[ Computer Science ]/데이터베이스 이론] - [Database] 트랜잭션의 동작 원리와 ACID 속성

 

[Database] 트랜잭션의 동작 원리와 ACID 속성

트랜잭션(Transaction)이란? 트랜잭션은 하나의 데이터 교환 또는 변경을 안전하게 처리하도록 보장해 주는 것을 의미한다. 한 번의 애플리케이션 로직으로 인해 두 개 이상의 데이터가 영향을 받

innovation123.tistory.com