본문 바로가기
Tech/JPA

Spring Data JPA @Transactional 알아보기

반응형
기본적으로 Spring Data JPA에서 관리되는 EntityManager의 생명주기 개념이 전제로 깔려있습니다

참고 포스트: https://joojimin.tistory.com/67

 

EntityManager 라이프사이클

JPA 공부를 하다보니 EntityManager 라이프사이클에 대한 기초지식이 부족해 공부한 내용입니다 SpringBoot + Spring Data JPA 환경에서 Transaction, EntityManager 간의 관계를 주 관심사로 진행했습니다 1. 순..

joojimin.tistory.com

 

Spring Transaction 동작 참고 포스트: https://joojimin.tistory.com/25

 

Spring Transactional 어디까지 알고있니?

[Spring] @Transactional 트랜잭션이란? 2개 이상의 쿼리를 하나의 커넥션으로 묶어 DB에 전송하고, 이 과정에서 에러가 발생할 경우 자동으로 모든 과정을 원래대로 되돌려 놓는다. 하나 이상의 쿼리를

joojimin.tistory.com


1. Spring Data JPA, 선언적 트랜잭션(@Transactional)

  • Spring에서는 선언적 트랜잭션(@Transactional)을 클래스 혹은 메서드에 선언하면 빈 생성시 프록시(Transaction Aspect)로 생성하여 호출될 때 가로채 트랜잭션 처리를 진행하게 됩니다
  • 예제 코드를 진행하기전 알아둬야할 Spring 선언전 트랜잭션의 몇가지 특징
    • propagation
      • 전파 옵션
      • REQUIRED(default): 부모 트랜잭션이 있으면 참여, 없으면 새로 생성
      • REQUIRED_NEW: 부모 트랜잭션을 무시하고 항상 새로 생성
      • NESTED: 부모 트랜잭션의 영향을 받지만, 자식 트랜잭션이 부모 트랜잭션에 영향을 미치지 않음
      • 등등
    • isolation
      • 고립 옵션
      • 다수의 트랜잭션이 경쟁할 때 처리 방식
    • rollbackFor
      • 롤백 대상 지정
      • 기본적으로 Spring은 Unchecked Exception을 롤백 대상으로 지정합니다 ( Checked Exception은 미지정 )
      • 특정 Exception에 대해 롤백을 진행하고자할때 사용

 

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