본 글에 앞서

JPA에서 Entity의 변경을 감지하고 쓰기 쿼리를 날리는 것을 더티 체킹이라고 한다. 영속성 컨택스트 내부에서 어떻게 동작하길래 이런 기능을 제공하는지 궁금해서 이번 글을 작성하게 되었다. 이번 글에서는 EntityManager에서 어떤 식으로 더티 체킹을 하는지 디버거를 통해 알아보고, 더티 체킹의 단점에 대해서 정리하고자 한다.




JPA 주요 객체 정리

우선 더티 체킹의 동작에 앞서 JPA 주요 개념을 정리하고, 더티 체킹의 동작을 알아보자.


EntityManager

EntityManagerEntity의 영속성을 관리하는 인터페이스이다. EntityManager는 내부에서 PersistenceContext를 가지고 있으며, 이 객체를 통해 Entity의 영속성을 관리한다.

Screenshot 2024-02-20 at 20 28 31@2x

SessionImplEntityManager의 구현체이다. 위 코드를 보면 내부에 private transient StatefulPersistenceContext persistenceContext;가 존재하는 것을 확인할 수 있다. PersistenceContext는 다음절에서 하술하겠다.


Screenshot 2024-02-19 at 19 09 07

SpringBoot에서 jpa를 사용할 경우 PlatformTransactionManager 구현체 JpaTransactionManager를 사용한다. 그리고 JpaTransactionManager에서는 JpaTransactionObject라는 TransactionObject를 가지고 있는 것을 위 코드에서 확인할 수 있다.


Screenshot 2024-02-19 at 19 11 00

JpaTransactionObject 내부에는 EntityManagerHolder가 존재하고, EntityManagerHolderEntityManager를 가지고 있다. 같은 트랜잭션으로 동작하는 서비스 객체는 같은 JpaTransactionObject 즉, 동기화된 트랜잭션을 가지기 때문에 같은 EntityManager를 사용한다.


그렇다면 다른 트랜잭션(eg. @Transactional(propagation = Propagation.REQUIRES_NEW))에서 동작할 경우 다른 EntityManager를 사용할까? ServiceAServiceB를 호출할 때, ServiceB@Transactional(propagation = Propagation.REQUIRES_NEW)로 동작한다고 가정하자.


ServiceA 트랜잭션 시작 시 Screenshot 2024-02-19 at 19 32 26


ServiceB 트랜잭션 시작 시 Screenshot 2024-02-19 at 19 33 11


다른 트랜잭션에서 동작하기 때문에 서로 다른 txObject를 가지고, 내부에서 서로 다른 EntityManager를 사용한다. 당연한 소리겠지만 다른 트랜잭션에서는 서로 다른 PersistenceContext를 가지고 있다.




PersistenceContext

Screenshot 2024-02-20 at 20 23 21@2x

PersistenceContext 구현체 StatefulPersistenceContext 내부 코드를 보면 HashMap을 사용하여 Entity를 관리한다.


Screenshot 2024-02-19 at 19 50 35 EntryKey를 통해 엔티티를 보관하는 것을 볼 수 있다.


Entity

EntityPersistenceContext에서 관리된다. Entity의 상태는 아래와 같다.

Screenshot 2024-02-19 at 20 07 48

  • New: EntityManager.persist()로 영속성 컨텍스트에 저장되지 않은 상태
  • Managed: EntityManager.find()로 영속성 컨텍스트에 저장된 상태
  • Detached: EntityManager.detach()로 영속성 컨텍스트에서 분리된 상태
  • Removed: EntityManager.remove()로 영속성 컨텍스트에서 삭제된 상태




Dirty Checking 동작

영속성 컨텍스트에서 관리되는 Entity의 필드에 변화가 있을 경우, 프로그래머는 repository.save()를 호출하지 않더라도 Entity의 변화를 데이터베이스에 반영할 수 있다. 이는 트랜잭션이 종료될 때 정확하게는 flush()가 호출될 때 Entity의 변화를 감지하여 데이터베이스에 반영하기 때문이다.

하이버네이트는 managed 상태의 엔티티 객체를 모두 체크한다. 엔티티가 영속성 컨텍스트에 적재될 때마다 엔티티 속성 값을 스냅샷으로 저장해두고, flush()가 호출될 때 이 스냅샷과 비교하여 변경된 속성을 찾아낸다.

Screenshot 2024-02-20 at 01 52 24

위 동작을 좀 더 구체적으로 보면

  1. SessionImpl(= EntityManager 구현체)에서 flush()가 호출된다. flush()에서 이벤트 리스너 그룹에 있는 DefaultFlushEventListener를 호출한다.
  2. DefaultFlushEventListener에서는 this.flushEntities(event, persistenceContext)를 호출하여, DefaultFlushEntityEventListener를 호출한다.
  3. DefaultFlushEntityEventListener에서는 persister.dirtyCheck()를 호출하여 Entity의 변화를 감지한다.
  4. AbstractEntityPersister에서는 DirtyHelper.findDirty()를 호출하여 변경된 속성을 찾아낸다.

Dirty Checking은 이벤트 기반으로 동작하고, ActionQueue에 정의된 쓰기 지연 SQL을 통해 Entity의 변화를 데이터베이스에 반영한다.


DirtyHelper

아래 DirtyHelper 코드를 보면 findDirty()에서 currentStatepreviousState를 하나하나 비교하여 변경된 속성을 찾아냄을 볼 수 있다.

Screenshot 2024-02-20 at 20 25 42@2x


DefaultFlushEntityEventListener

DirtyCheck를 하기 위해서는 snapshot을 가지고 있어야 한다. DefaultFlushEntityEventListener에서는 persister를 통해 Entity의 snapshot을 가져옴을 아래 코드에서 확인할 수 있다.

Screenshot 2024-02-20 at 02 24 11


DefaultFlushEventListener

Session에 정의된 즉, 쓰기 지연 SQL이 정의된 ActionQueue를 통해 Entity의 변화를 데이터베이스에 반영한다.

Screenshot 2024-02-20 at 20 27 34@2x

Screenshot 2024-02-20 at 02 39 09

테스트 예제에서는 엔티티의 특정 필드 1개를 변경하는 예인데, update 쿼리가 1개만 발생하는 것을 확인할 수 있다. 그리고 dirtyFields 는 배열의 인덱스로 관리하는 것을 볼 수 있다.


요약

스냅샷을 기반으로 엔티티의 필드 하나하나를 비교하여 변경된 필드를 찾아내고, 쓰기 지연 SQL을 통해 데이터베이스에 반영한다.




Dirty Checking 단점

영속성 컨텍스트에서 관리되는 엔티티의 변경을 감지해서 알아서 SQL을 생성해주는 더티 체킹은 개발자에게 많은 편의를 제공한다. 그럼에도 불구하고 더티 체킹은 몇 가지 단점을 가지고 있다.

  • 성능 이슈
    • 영속성 컨텍스트에서 관리되는 엔티티의 수가 많아질수록 그리고 엔티티의 필드 수가 많아질수록 Dirty Checking에 리소스를 많이 사용된다.
    • 또한, 비교를 위해 스냅샷을 메모리에서 유지해야하므로 메모리 사용량이 증가한다.
  • 예측하지 못한 데이터베이스 쓰기 작업
    • Dirty Checking은 트랜잭션이 종료될 때 flush()가 호출될 때 동작한다.
    • 이는 트랜잭션 범위가 넓어질수록 그리고 로직이 복잡해수록 개발자는 언제 flush()가 호출될지 예측하기 어려워질 수 있다.
  • 코드 명확성 저하
    • Dirty Checking은 개발자가 repository.save()를 호출하지 않더라도 Entity의 변화를 데이터베이스에 반영한다.
    • repository.save()를 직접 명시하지 않고 엔티티의 속성 값을 변경시키기 때문에 엔티티의 변화 추적이 어렵다.
  • 테스트 어려움
    • 객체가 아니라, 영속성 컨텍스트에 적재된 엔티티 객체가 변화되었음을 테스트해야 하므로, 테스트가 어려워진다.




필자의 생각

필자는 Dirty Checking보다는 save(), saveAll(), saveAndFlush() 등을 명시적으로 호출하는 것을 선호한다. 그 이유는 로직이 복잡해질수록 더티 체킹은 언제 쓰기 쿼리가 발생하는지 예측하기 어렵고, 테스트하기 어려워지기 때문이다.

save() 메서드 내부를 보면 영속성 컨텍스트에서 관리하는 엔티티인지 확인하는 과정이 추가적으로 발생하고, 최적화에서 손해를 볼 수 있다고 생각할 수 있다. 하지만 이보다 더 중요한 것은 명시적으로 코드를 작성함으로써 코드의 명확성을 높이고, 테스트하기 쉽게 만드는 것이여야 한다고 생각한다.

jpa 내부를 잘 알고 쓰면 뭐든 잘할 수 있겠지만.. 예측하기 어려운 것은 피해는 게 좋은 것 같다.




Ref

  • https://medium.com/jpa-java-persistence-api-guide/dirty-checking-magic-in-hibernate-how-it-works-and-why-its-important-3cdb422dc4d4
  • https://jojoldu.tistory.com/415
  • https://vladmihalcea.com/the-anatomy-of-hibernate-dirty-checking/
  • https://brunch.co.kr/@purpledev/32
  • https://docs.jboss.org/hibernate/orm/6.4/introduction/html_single/Hibernate_Introduction.html
  • https://thorben-janssen.com/6-performance-pitfalls-when-using-spring-data-jpa/#Pitfall_2_Calling_the_saveAndFlush_method_to_persist_updates
  • https://velog.io/@wisepine/JPA-%EC%82%AC%EC%9A%A9-%EC%8B%9C-19%EA%B0%80%EC%A7%80-Tip