정리정리

@Transactional(readOnly = true) 성능 향상되는 이유 본문

JPA

@Transactional(readOnly = true) 성능 향상되는 이유

wlsh 2023. 5. 1. 04:08

@Transactional에는 읽기 전용인 readOnly 옵션이 있습니다. 처음에는 그저 Isolation level 설정 정도로 생각을 했었지만 그로 인해 JPA에서 성능의 차이가 발생하는 점을 알게 되어 포스팅을 하게 되었습니다.

@Transactional(readOnly = true) 성능 비교

우선 성능 차이가 왜 발생하는지 알아보기 전에 정말 성능 차이가 있는지 먼저 확인해 보겠습니다.

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void getMembersWithReadOnlyFalse() {
        memberRepository.findAll();
    }

    @Transactional(readOnly = true)
    public void getMembersWithReadOnlyTrue() {
        memberRepository.findAll();
    }
}
@SpringBootTest
@Rollback(false)
public class ReadOnlyTest {

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    MemberService memberService;

    static long readOnlyTrueTime;
    static long readOnlyFalseTime;

    @BeforeEach
    void init() {
        List<Member> members = new ArrayList<>();
        for (int i = 0; i < 1_000; i++) {
            members.add(new Member("member" + i));
        }
        memberRepository.saveAll(members);
    }

    @Test
    @DisplayName("readOnly = true")
    void readOnlyTest() throws Exception {
        long start = System.currentTimeMillis();

        memberService.getMembersWithReadOnlyTrue();

        long end = System.currentTimeMillis();
        readOnlyTrueTime = end - start;

    }

    @Test
    @DisplayName("readOnly = false")
    void noReadOnlyTest() throws Exception {
        long start = System.currentTimeMillis();

        memberService.getMembersWithReadOnlyFalse();

        long end = System.currentTimeMillis();
        readOnlyFalseTime = end - start;
    }

    @AfterAll
    static void printTime() {
        System.out.println("========================================");
        System.out.println("(readOnly = true): " + readOnlyTrueTime / 1000. + "초");
        System.out.println("(readOnly = false): " + readOnlyFalseTime / 1000. + "초");
        System.out.println("========================================");
    }
}

System.currentTimeMills를 이용해 데이터 1000개를 조회하는 테스트를 작성했습니다. 그리고 아래에서 설명하겠지만 성능에 차이를 주는 요소 중 하나인 flush를 하는 작업도 시간 측정에 포함이 되도록 @Rollback(false)를 추가해 줬습니다.

결과는 당연히 readOnly 옵션을 true로 줬을 때가 더 빨랐지만 생각보다 성능 차이가 꽤나 있었네요.

성능이 향상되는 이유

우선 결론부터 말씀드리면 크게 2가지 이유가 있습니다.

  • Dirty Check를 위한 스냅샷을 만들지 않아도 됨
  • flush가 발생하지 않음

1. Dirty Check를 위한 스냅샷을 만들지 않아도 됨

하이버네이트는 기본적으로 변화를 감지하는 더티 체킹을 위해 데이터를 조회할 때 스냅샷이라는 것을 만듭니다. 

해당 작업을 TwoPhaseLoad 라는 클래스의 initializeEntityFromEntityEntryLoadedState 메서드에서 확인할 수 있습니다.

우선 스냅샷을 만들기 전에 db에서 가져온 값을 저장하고 있는 loadedState를 이용해 hydratedState라는 변수를 만들고 조회할 엔티티에 값을 넣어주는 작업을 합니다.

그리고 맨 마지막에 hydratedState에 있는 값들을 deepCopy를 하여 loadedState가 스냅샷이 되도록 작업을 해줍니다.

이 작업때문에 추후에 dirtyCheck 메서드를 보면 entity와 loadedState를 이용해 변화의 감지를 할 수 있는 것을 볼 수 있습니다.

여기서 deepCopy를 하기 때문에 당연히 테스트할 때처럼 1000개를 조회하는 경우, 1000개만큼 새로 할당을 해주는 작업을 해야 하기 때문에 성능에 큰 저하가 온 것으로 보입니다.

반면에 readOnly = true인 경우 당연히 스냅샷을 만들지 않기 때문에 성능과 메모리의 차이가 생기게 됩니다. 그 과정도 한 번 살펴보겠습니다.

readOnly가 true라면 해당 status와 함께 setEntryStatus를 타고 들어가게 됩니다.

그리고 AbstractEntityEntry 클래스의 setStatus에서 스냅샷이 될 loadedState를 null로 초기화하는 모습을 볼 수 있습니다.

주의할 점

OSIV가 켜져있으면 readOnly = true여도 스냅샷이 생성이 됩니다. 정확히 왜 이런 일이 발생하는지는 잘 모르겠지만 디버깅을 하면서 간단하게 추측을 할 수는 있었습니다.

우선 위에서도 볼 수 있듯이 initializeEntityFromEntityEntryLoadedState메서드에 readOnly라는 플래그가 파라미터로 넘어옵니다.

그런데 readOnly = true일 때도 파라미터 값이 false로 넘어오길래 호출하는 메서드를 거꾸로 가다 보니 다음과 같은 코드를 볼 수 있었습니다.

OSIV가 켜져있을 때

해당 부분은 HibernateJpaDialect 클래스의 beginTransaction 메서드입니다. 브래이크를 걸어둔 if문을 통과해야 Hibernate가 관리하는 session의 readOnly의 값이 true가 되고, 이 값이 나중에 파라미터로 넘어가게 됩니다.

여기서 TransactionDefinition의 값을 보면 readOnly가 추가되어 있음을 알 수 있습니다. 하지만 if 문의 두 번째 조건인 isLocalResource에서 문제가 발생했습니다.

isLocalResource는 찾아보니 리소스를 local transaction에서만 다루는지 확인하는 메서드였고, OSIV가 켜져 있으면 해당 값이 사진처럼 false로 설정이 되어있는 것을 볼 수 있었습니다.

그래서 TransactionDefinition을 쫒아 디버깅을 또 하다가 localResource의 값이 newEntityManagerHolder인지에 따라 정해지는 것을 알게 되었고, entityManagerHolder에 대해 검색을 하다가 다음 글을 보게 되었습니다.

https://perfectacle.github.io/2021/05/24/entity-manager-lifecycle/

이 글에서는 OSIV가 켜져있을 때 다른 트랜잭션이더라도 1차 캐시가 공유된다는 내용이 적혀있었습니다.

그렇기 때문에 readOnly = true인 트랜잭션에서 데이터를 읽더라도 다른 트랜잭션에서 1차캐시를 공유해야하기 때문에 localResource가 아닌 globalResource로 설정을 하고 스냅샷을 저장하지 않나 라는 추측을 할 수 있었습니다.

2. flush가 발생하지 않음

readOnly=true일 경우에는 Hibernate에서 세션의 FlushMode를 MANUAL로 설정을 합니다.

FlushMode를 MANUAL로 설정을 docs에도 나와있듯이, 개발자가 직접 flush를 호출하지 않는 이상 flush가 발생하지 않습니다.

실제로 HibernateJpaDialect 클래스를 열어보면 readOnly가 true일 때 MANUAL로 FlushMode를 설정을 해줍니다.

그리고 Hibernate의 flushBeforeTransactionCompletion 메서드에서 FlushMode가 MANUAL이어야 flush가 doFlush가 true가 되도록 코드가 작성이 되어있습니다.

실제로 조회가 끝난 후에 entityManager를 통해 스냅샷이 생성되지 않은 것과 flushMode가 MANUAL인 것을 볼 수 있습니다.

참고

https://perfectacle.github.io/2021/08/08/readonly-transaction-doesnt-make-entity-snapshot/


자바 ORM 표준 JPA 프로그래밍

https://perfectacle.github.io/2021/05/24/entity-manager-lifecycle/

'JPA' 카테고리의 다른 글

JPA N + 1 문제  (0) 2023.04.11
[JPA] CascadeType.DELETE, orphanRemoval = true 차이  (0) 2023.03.27
Spring JPA 데이터베이스 초기화  (0) 2023.03.27
JPA, Hibernate, Spring Data JPA, JDBC  (0) 2023.03.27
Comments