Spring boot - @Transactional 전파 주의 사항

2025. 1. 13. 20:52·Spring/SpringBoot

주제 ( 전체 트랜잭션이 롤백되는 상황에 대한 해결책 )

이번 포스팅에서는 @Transactional의 propagation 속성을 사용시 집중해볼만한 부분을 살펴본다.

주 내용은 마지막으로, @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용할 때, 전체 트랜잭션이 롤백되는 상황에 대한 해결책을 얘기한다.

테스트를 위한 코드 작성

테스트를 위해 다음과 같은 6 가지 클래스를 작성한다.

이 상황에서 수정하며 테스트할 클래스는 TransactionDisabledService, TransactionEnabledService 둘이다.

Parent ( Entity )

@Entity
@NoArgsConstructor
@Getter
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "parent")
    private List<Child> child;
}

Child ( Entity )

@Entity
@NoArgsConstructor
@Getter
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private Parent parent;

    public Child(Parent parent) {
        this.parent = parent;
    }
}

ChildRepository ( Repository )

public interface ChildRepository extends JpaRepository<Child, Long> {
}

ParentRepository ( Repository )

public interface ParentRepository extends JpaRepository<Parent, Long> {
}

ChildService ( Service )

@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new RuntimeException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

TransactionEnabledService ( Service )

@Service
@RequiredArgsConstructor
public class ParentService {
    private final ParentRepository parentRepository;
    private final ChildService childService;

    @Transactional
    public void addNewParent() {
        parentRepository.save(new Parent());
    }

    @Transactional
    public void addNewParentWithChild() {
        final Parent parent = parentRepository.save(new Parent());
        transactionDisabledService.addNewChild(parent);
    }

    @Transactional(readOnly = true)
    public List<Parent> loadAllParents() {
        return parentRepository.findAll();
    }
}

시나리오

포스팅에서는 Transaction 전파 과정에 생기는 UncheckedException의 영향을 다룬다.
그러므로 현재 시나리오의 모든 경우에서, ChildService의 메소드 호출은 ParentService를 통해서만 이루어진다.

1. 트랜잭션을 공유하는 경우

두 서비스 객체가 트랜잭션을 공유하는 경우

@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    // 트랜잭션 유지하도록 
    @Transactional(propagation = Propagation.MANDATORY)
    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new RuntimeException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

먼저, 첫번째 시나리오다. ParentService, ChildService가 하나의 트랜잭션을 공유한다.
각각 프록시 객체가 생성되며 하나의 Transaction을 공유하게 된다.

ParentService, ChildService 어디서든 UncheckedException이 발생할 경우, 트랜잭션이 롤백될 것임을 알 수 있다.

다음과 같은 테스트 코드에서 모든 insert 작업이 롤백 되었음을 알 수 있다.

@SpringBootTest
class TransactionApplicationTests {
    @Autowired
    private ParentService parentService;

    @Autowired
    private ParentRepository parentRepository;
    @Autowired
    private ChildRepository childRepository;

    @Test
    void 트랜잭션_롤백_테스트() {
        try {
            parentService.addNewParentWithChild();
        } catch (Exception e) {

        }

        org.junit.jupiter.api.Assertions.assertAll(
                () -> Assertions.assertThat(parentRepository.count()).isEqualTo(0),
                () -> Assertions.assertThat(childRepository.count()).isEqualTo(0)
        );
    }
}

2. 트랜잭션이 명시적이지 않은 경우

두 서비스 객체가 트랜잭션이 명시적이지 않은 경우

위 그림과 같이 하나의 트랜잭션 매니저를 공유하게 된다. RuntimeException이 발생했을 때, Call Stack에 따라 ParentService 까지 예외가 전해짐으로 모든 트랜잭션이 롤백될것임을 알 수 있다.

@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    // 트랜잭션 어노테이션 제거 
    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new RuntimeException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

3. 새로운 트랜잭션을 사용하는 경우

두 서비스 객체가 서로 다른 트랜잭션을 사용하는 경우

ParentService와 ChildService가 서로 다른 트랜잭션 상에서 동작할 필요가 있다고 하자.
ChildService 에서 Child 엔티티를 생성하는 중에 발생했을 때, Parent는 정상적으로 저장될거라 기대할 수 있다.

그럼 아래와 같이 간단히 새로운 트랜잭션을 생성하도록 할 수 있다.

@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    // 새로운 트랜잭션 상에서 동작하도록
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new RuntimeException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

트랜잭션은 새로 만들어질 것이며 아래와 같은 과정을 통해 이뤄질거라 예측된다.

아래는 이를 위한 테스트 코드이다.

    @Test
    void Parent만_저장되는_테스트() {
        try {
            parentService.addNewParentWithChild();
        } catch (Exception e) {
            log.info(e.getMessage());
        }

        org.junit.jupiter.api.Assertions.assertAll(
                () -> Assertions.assertThat(parentRepository.count()).isEqualTo(1),
                () -> Assertions.assertThat(childRepository.count()).isEqualTo(0)
        );
    }

테스트를 해보면 알겠지만, 놀랍게도 실패한다.
모든 트랜잭션이 롤백되어 데이터베이스엔 어떠한 데이터도 존재하지 않았다.

왜 이런 상황이 생기는걸까?

트랜잭션 roll-back only 마크

자바는 Exception이 발생하면 Call Stack을 따라 전파된다.

따라서, ChildService의 프록시에도 이 RuntimeException이 전해지며, ParentService에도 마찬가지로 RuntimeException이 전해진다.
이 과정에서 Transaction1, Transaction2 둘 모두 rollBack 플래그가 On 됨으로써 둘 모두 롤백되는 것이다.

해결책

1. Caller (ParentService)에서 try - catch

Caller (ParentService)에서 try - catch 를 활용해 ParentService에 RuntimeException이 전해지지 않도록 한다.

@Service
@RequiredArgsConstructor
@Slf4j
public class ParentService {
    private final ParentRepository parentRepository;
    private final ChildService childService;

    @Transactional
    public void addNewParent() {
        parentRepository.save(new Parent());
    }

    @Transactional
    public void addNewParentWithChild() {
        final Parent parent = parentRepository.save(new Parent());

        // RuntimeException이 CallStack에 더이상 전파되지 않도록 수정
        try {
            childService.addNewChild(parent);
        } catch (Exception e) {}
    }

    @Transactional(readOnly = true)
    public List<Parent> loadAllParents() {
        return parentRepository.findAll();
    }
}
@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new RuntimeException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

2. @Transactional(noRollbackFor = ChildCustomException.class)

Callee에서 발생할 특정 예외에 대해 Caller (ParentService)가 rollback 하지 않도록 구성한다.

이를 위해선 ChildService 에서 발생할 예외를 커스텀하고, 이를 ParenService에서 특정할 필요가 있다.

@Service
@RequiredArgsConstructor
@Slf4j
public class ParentService {
    private final ParentRepository parentRepository;
    private final ChildService childService;

    @Transactional
    public void addNewParent() {
        parentRepository.save(new Parent());
    }

    @Transactional(noRollbackFor = ChildCustomException.class)
    public void addNewParentWithChild() {
        final Parent parent = parentRepository.save(new Parent());
        childService.addNewChild(parent);
    }

    @Transactional(readOnly = true)
    public List<Parent> loadAllParents() {
        return parentRepository.findAll();
    }
}
@Service
@RequiredArgsConstructor
public class ChildService {
    private final ChildRepository childRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void addNewChild(final Parent parent) {
        childRepository.save(new Child(parent));
        throw new ChildCustomException("ChildService에서 addNewChild() 중에 에러 발생 !!!! 비상비상비상비상");
    }
}

1. 방법: Caller (ParentService)에서 try - catch

  • 장점

    • 직관적으로 이해하기 쉬움.
  • 단점

    • BusinessException이 Controller까지 전파되지 않음.
      → 서비스 내부에서 성공 여부나 실패 여부에 대한 정보를 알 수 없음.
    • 비즈니스 로직이 더럽혀짐.
      → try-catch 블록이 삽입되어 코드 가독성이 저하됨.

2. 방법: @Transactional(noRollbackFor = ChildCustomException.class)

  • 장점

    • RuntimeException이 그대로 Controller까지 전파됨.
    • 비즈니스 로직이 더럽혀지지 않음.
      → try-catch가 없어 깔끔한 코드 유지.
  • 단점

    • 커스텀 예외를 만들어야 하는 불편함.
      → 예외 클래스 설계에 추가 작업 필요.
    • 호출자가 하위 비즈니스 로직에서 발생 가능한 예외에 대한 정보를 알아야 함.

'Spring > SpringBoot' 카테고리의 다른 글

[Spring boot] Repository의 메소드 노출  (0) 2025.04.04
Spring boot - Cache (2) 페이지 조회 캐싱  (0) 2025.01.08
Spring boot - Cache  (0) 2025.01.07
[SpringBoot] 스프링의 에러처리 탐구  (0) 2024.01.17
[Spring] Spring Security 인증 구성  (0) 2024.01.02
'Spring/SpringBoot' 카테고리의 다른 글
  • [Spring boot] Repository의 메소드 노출
  • Spring boot - Cache (2) 페이지 조회 캐싱
  • Spring boot - Cache
  • [SpringBoot] 스프링의 에러처리 탐구
윤희종
윤희종
호기심을 잃지 말자 지적, 질문은 언제나 환영합니다 ;)
  • 윤희종
    서버견문록
    윤희종
  • 전체
    오늘
    어제
    • 분류 전체보기 (36)
      • 데일리 플랜 (1)
      • 이것저것 (4)
      • Java (6)
      • Spring (12)
        • SpringBoot (10)
        • Spring MVC (0)
      • Computer Science (4)
        • Network (1)
        • Operating System (0)
        • Data Structure (0)
        • Algorithm (2)
        • Database (0)
      • IOS (0)
      • 프로그래머스 문제풀이 (2)
      • 프로젝트 일기 (7)
        • 한편의 수학 학원 (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Spring
    mysql 쿼리 최적화
    인증 우회 테스트
    알고리즘
    springboot
    SecurityFilterChain
    read through
    servlet
    성능 개선
    스프링 시큐리티 사용법
    비동기 처리 유의점
    스프링 부트
    제네릭
    SecurityFilterChain 구성
    cache write back
    인증 테스트
    스프링 시큐리티 구성
    스프링
    캐시
    스프링 부트 인증 우회
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
윤희종
Spring boot - @Transactional 전파 주의 사항
상단으로

티스토리툴바