주제 ( 전체 트랜잭션이 롤백되는 상황에 대한 해결책 )
이번 포스팅에서는 @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
가 없어 깔끔한 코드 유지.
단점
- 커스텀 예외를 만들어야 하는 불편함.
→ 예외 클래스 설계에 추가 작업 필요. - 호출자가 하위 비즈니스 로직에서 발생 가능한 예외에 대한 정보를 알아야 함.
- 커스텀 예외를 만들어야 하는 불편함.
'SpringBoot' 카테고리의 다른 글
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] 다수의 SecurityFilterChain 구성 방법 (0) | 2024.01.01 |