반응형
기본적으로 Spring Data JPA에서 관리되는 EntityManager의 생명주기 개념이 전제로 깔려있습니다
참고 포스트: https://joojimin.tistory.com/67
Spring Transaction 동작 참고 포스트: https://joojimin.tistory.com/25
1. Spring Data JPA, 선언적 트랜잭션(@Transactional)
- Spring에서는 선언적 트랜잭션(@Transactional)을 클래스 혹은 메서드에 선언하면 빈 생성시 프록시(Transaction Aspect)로 생성하여 호출될 때 가로채 트랜잭션 처리를 진행하게 됩니다
- 예제 코드를 진행하기전 알아둬야할 Spring 선언전 트랜잭션의 몇가지 특징
- propagation
- 전파 옵션
- REQUIRED(default): 부모 트랜잭션이 있으면 참여, 없으면 새로 생성
- REQUIRED_NEW: 부모 트랜잭션을 무시하고 항상 새로 생성
- NESTED: 부모 트랜잭션의 영향을 받지만, 자식 트랜잭션이 부모 트랜잭션에 영향을 미치지 않음
- 등등
- isolation
- 고립 옵션
- 다수의 트랜잭션이 경쟁할 때 처리 방식
- rollbackFor
- 롤백 대상 지정
- 기본적으로 Spring은 Unchecked Exception을 롤백 대상으로 지정합니다 ( Checked Exception은 미지정 )
- 특정 Exception에 대해 롤백을 진행하고자할때 사용
- propagation
2. 기본 @Transactional 롤백 테스트
- 예제 코드
// MyService.class @Transactional public void checkRollback() { memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); childMyService.rollbackTest(); // RuntimeException memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); }
// ChildMyService.class @Transactional public void rollbackTest() { memberInfoRepository.save(new MemberInfo("룰루랄라1", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라2", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라3", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라4", "룰루비데")); throw new RuntimeException("자식 트랜잭션 Exception"); }
- 결과 정리
- MyService::checkRollback() 메서드가 호출되면 @Transactional 선언으로 인해 Proxy를 통한 트랜잭션 코드가 앞뒤로 걸리게 된다
- memberInfoRepository ( JpaRepository ) 또한 실제론 @Transactional(PROPAGATION_REQUIRED)이 걸려있는데 이미 생성된 부모 트랜잭션이 존재하므로 해당 트랜잭션에 참여해 save 메서드를 실행 ( "주지민2", "주지민3" )
- childMyService::rollbackTest() 를 호출하게 되고 해당 메서드 또한 @Transactional(PROPAGATION_REQUIRED)이 걸려있기 때문에 이미 존재하는 부모 트랜잭션에 참여하여 save 메서드 실행("룰루랄라1" ..... )
- 끝나기 직전 Spring Transaction의 기본 롤백 대상인 RuntimeException을 만나게 되고 참여한 트랜잭션에 Rollback 메서드를 실행, 즉 부모 트랜잭션 모두 롤백이 된다
- 당연하게도 childMyService::rollbackTest() 메서드의 RuntimeException을 제거하면 총 8개의 Entity가 저장되는 것을 확인할 수 있다
3. Propagation: REQUIRED_NEW
3-1. 정상적인 흐름 테스트
- 이번엔 childMySerivice의 Transactional 전파 옵션을 REQUIRED_NEW로 변경했을 때 어떻게 동작되는지 확인해보려고 한다
- REQUIRED_NEW: 부모 트랜잭션 무시하고 새로운 트랜잭션을 생성
- 상황1. 정상적인 흐름 테스트
// MyService.class @Transactional public void checkRequiredNew() { log.info("============================= MyService Start ============================="); memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); childMyService.requiredNewTest(); memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); log.info("============================= MyService End ============================="); }
// ChildMyService.class @Transactional(propagation = Propagation.REQUIRES_NEW) public void requiredNewTest() { System.out.println("============================= ChildMySerive Start ============================="); memberInfoRepository.save(new MemberInfo("룰루랄라1", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라2", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라3", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라4", "룰루비데")); System.out.println("============================= ChildMySerive End ============================="); }
- 결과 분석
- MyService::checkRequiredNew() 시작전 트랜잭션 시작
- JpaRepository에 걸려있는 트랜잭션이 기 생성된 트랜잭션으로 참여 (PROPAGATION_REQUIRED)
- Entity @Id 생성전략이 AUTO, 즉 sequence 테이블을 사용하게 설정되어있습니다
- 따라서 Id값을 얻기위한 sequence 테이블 select 및 update 쿼리만 발생하고 실제 save는 쓰기 지연이 동작합니다
- ChildMyService::requiredNewTest() 시작전 트랜잭션 결정
- 클래스, 메서드에 선언적 트랜잭션이 걸려있으면 Spring Transaction Aspect는 해당 트랜잭션의 전파옵션, 고립옵션을 보고 기존 트랜잭션(부모)에 참여할지, 새로 생성할지 결정합니다
- 여기선 PROPAGATION_REQUIRED_NEW 옵션, 즉 부모 트랜잭션을 무시하고 새로운 트랜잭션을 생성하는 전파 옵션을 사용했기 때문에 아래 로그와 같이 새로운 EntityManager, Transaction을 생성하는 것을 확인해볼 수 있습니다
- JpaRepository save()
- ChildMyService::requiredNewTest() 에서 실행되는 JpaRepository는 위에서 생성했던 새로운 EntityManager[SessionImpl(1060210990<open>)]에 참여하여 실행되는 것을 확인할 수 있습니다
- ChildMyService::requiredNewTest() 실행 종료
- ChildMyService::requiredNewTest() 메서드가 정상적으로 종료
- 걸려있던 Transaction Aspect가 정상적으로 끝났으니 Commit을 날리는 것을 확인할 수 있습니다
- 그 후 쓰기 지연 SQL에 저장되어 있던 4개의 insertions들을 차례대로 쿼리하는 것을 확인할 수 있습니다 ( Batch 미적용 )
- ChildMyService::requiredNewTest()의 트랜잭션이 종료된 후 중단된 MyService 트랜잭션을 가져옴
- MyService::checkRequiredNew() 나머지 save 로직 실행
- 참여하는 EntityManager가 기존의 생성되었던 부모 트랜잭션의 아이디와 같은 것을 확인할 수 있습니다
- MyService::checkRequiredNew() 실행 종료
- 실행이 종료된 후 부모 트랜잭션에 쌓여있던 4개의 쿼리가 발생하는 것을 확인할 수 있습니다
- 당연하게도 자식 트랜잭션에서 생성된 EntityManager와 다르기 때문에 두개는 독립적입니다 ( 해당 내용은 조금 이따 더 깊히 살펴보겠습니다 )
3-2. 롤백 테스트
- 이번엔 롤백이 되는지, 부모 트랜잭션(MyService.class), 자식 트랜잭션(ChildMyService.class) 두 곳에서 RuntimeException을 발생시켜 보겠습니다
- 자식 트랜잭션 Rollback 테스트
// MyService.class @Transactional public void checkRequiredNew() { System.out.println("============================= MyService Start ============================="); memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); try { childMyService.requiredNewTest(); } catch (RuntimeException ex) { // 부모는 롤백 안함 } memberInfoRepository.save(new MemberInfo("주지민2", "123123")); memberInfoRepository.save(new MemberInfo("주지민3", "123123")); System.out.println("============================= MyService End ============================="); }
// ChildMyService.class @Transactional(propagation = Propagation.REQUIRES_NEW) public void requiredNewTest() { System.out.println("============================= ChildMySerive Start ============================="); memberInfoRepository.save(new MemberInfo("룰루랄라1", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라2", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라3", "룰루비데")); memberInfoRepository.save(new MemberInfo("룰루랄라4", "룰루비데")); System.out.println("============================= ChildMySerive End ============================="); throw new RuntimeException("자식 트랜잭션 Exception"); }
- MyService 클래스와 ChildMyService 클래스를 위처럼 고쳤습니다
- 특히 MyService 클래스에서는 ChildMyService에서 던져지는 RuntimeException을 try...catch 했는데 그대로 놔두면 결국엔 MyService도 Exception이 던져지므로 두 개가 독립적이라는 개념을 검증하기 위해 try..catch로 감쌌습니다
- 결과 확인
- 예상한대로 자식 트랜잭션은 Rollback 됐지만, 기존에 생성된 부모트랜잭션은 다시 꺼내와 나머지 로직을 진행한 것을 확인할 수 있었습니다
- 부모 트랜잭션 Rollback 테스트
- 이번엔 MyService에서 RuntimeException이 발생하고, ChildMyService는 정상적인 로직 실행이 되게 구성했습니다
- 예상했던 대로 ChildMyService가 끝날때 트랜잭션은 정상적으로 Commit되고 MyService 부모 트랜잭션을 꺼내와 나머지 로직을 실행시키다 RuntimeException이 발생해 부모 트랜잭션만 롤백되는 것을 확인할 수 있습니다
3-3. 주의할 점
- 문득 자식 트랜잭션과 부모 트랜잭션이 다를 경우, 즉 두 곳의 EntityManager가 다를 때 쓰기 지연이 동작중인 영속성 컨텍스트를 주의해서 이용해야겠다는 생각이 들었습니다
- 실제 부모 트랜잭션에서 save한 Entity들은 Transaction이 끝날 때까지 실제 DB에 쿼리되지 않으며 영속성 컨텍스트에 1차캐시로 저장됩니다
- 하지만 자식 트랜잭션에서는 당연하게도 다른 영속성 컨텍스트를 가지므로 부모 트랜잭션에서 생성한 엔티티를 조회할 수 없습니다
- 또한 의식적으로 flush를 날린다 하더라도 Transaction 범위로 잡힌 곳의 고립옵션에 따라 Dirty Read를 방지하므로 조회할 수 없습니다 ( default: 커밋된 확정 데이터만 읽음 )
4. 결론
- 트랜잭션을 중첩해서 실행시켜야할때 전파 옵션은 조심히 사용해야한다
- 각기 다른 롤백 범위를 가져야 할때는 중첩해서 실행하지 말고 각 단위별로 쪼개서 트랜잭션을 걸고 트랜잭션이 걸리지 않은 상위 비즈니스 로직에서 관리하는게 좋지 않을까라는 생각을 해봤습니다
- Nested는 다음에.. 알아봐야지...
728x90
반응형
'Tech > JPA' 카테고리의 다른 글
Spring Data JPA @Modifying 알아보기 (0) | 2021.10.04 |
---|---|
EntityManager 라이프사이클 (0) | 2021.10.01 |
연관관계 조회 쿼리를 최적화해보자 (0) | 2021.09.23 |
Entity에 Serializable 구현해야할까? (0) | 2021.09.21 |
JPA Batch 연산 적용 (0) | 2021.09.19 |