꾸준하게, 차근차근

[트러블슈팅 회고] JPA 엔티티 단순 조회만 했는데 update 쿼리가 남발된 이유 본문

Spring

[트러블슈팅 회고] JPA 엔티티 단순 조회만 했는데 update 쿼리가 남발된 이유

jn4624 2025. 7. 14. 23:17
반응형

(@DynamicUpdate, @Converter, dirty checking, equals/hashCode, Hibernate deep copy 내부 동작까지)

 

바쁜 주말을 보내고, 피로와 싸우며 출근을 짠! 했는데 출근과 동시에 이슈가 발생했다. 😱

덕분에 피로고 나발이고 활기찬 오전을 보냈다...

 

1. 문제 상황: "조회만 했는데 update 쿼리가 나온다?"

금요일 운영에 반영한 Search API가 update 쿼리를 대량으로 쏟아내는 현상이 발생했다... 🤯

단순히 JPA로 엔티티를 여러 번 조회했을 뿐인데, 데이터베이스에 update 쿼리가 조회 횟수만큼 쏟아졌다.

코드 상황은 이랬다:

  • 엔티티 클래스에 @DynamicUpdate 어노테이션이 선언되어 있었다.
  • 필드 중 하나는 @Converter를 적용한 커스텀 클래스로 구성되어 있었다.
  • 해당 커스텀 클래스에 equals/hashCode를 재정의하지 않았다.
  • Search API 로직상 데이터는 전혀 변경하지 않았고, 단순 조회 로직만 반복 실행되는 구조였다.

그런데도 로그에는 select뿐만 아니라 불필요한 update 쿼리가 계속 실행되어 데이터베이스에 부하를 주고 있었다. 😭

 

2. 원인 추적

처음에는 코드를 수정하면서 어딘가에 데이터를 변경하는 로직이 잘못 들어갔나 싶어 흐름을 몇 번이고 따라갔지만 데이터 변경 로직은 전혀 보이지 않았다. 😱🤯 완전 멘붕...

그룹장님의 감사하고 죄송하고 감사한 도움으로 원인을 빠르게 파악하여 문제를 해결하게 되었지만 왜 의도한 대로 동작하지 않은건지 궁금해서 JPA의 내부 동작, Hibernate의 dirty checking까지 들여다보기 시작했다. 🥲

 

3. Hibernate의 스냅샷 생성 및 dirty checking 구조

우선 hydratedState, loadedState, dirty checking에 대해서 알아보자.

3.1. hydratedState의 역할

  • Hibernate가 데이터베이스에서 엔티티를 로드할 때 각 필드의 값을 일단 hydratedState라는 Object[] 배열에 임시로 저장한다.
  • 이 hydratedState는 엔티티가 실제로 생성되기 전에 데이터베이스에서 가져온 "원시 값"의 임시 보관소다.
  • @Converter가 붙은 필드 역시, 변환된 커스텀 객체로 이 배열에 포함된다.
  • hydratedState 자체는 dirty checking에 직접 사용되지 않는다.

3.2. loadedState(스냅샷) 생성 이유과 과정

  • 엔티티 객체가 생성되면, Hibernate는 hydratedState를 deep copy(깊은 복사)해서 loadedState라는 배열로 저장한다.
  • 이 loadedState가 "처음 데이터베이스에서 엔티티를 로드했을 때의 값"이자 dirty checking의 기준(스냅샷)이 된다.
  • 왜 deep copy가 필요할까?
    • 뮤터블 객체(예: List, Map, 커스텀 객체)가 hydratedState와 엔티티 필드에서 동일한 객체(참조)를 공유하게 되면, 트랜잭션 중 엔티티를 변경하면 hydreatedState까지 같이 바뀌어 dirty checking이 무의미해지기 때문이다.
  • Hibernate는 이를 방지하기 위해 deep copy(=별도 객체 생성)로 loadedState를 만들어 변경감지 기준을 분리한다.

3.3. dirty checking의 기준

  • dirty checking은 flush(트랜잭션 커밋 전) 시점에 loadedState(스냅샷)와 엔티티 인스턴스의 현재 값을 equals()로 비교한다.
  • 만약 값이 달라졌다면 dirty(변경)로 간주하여 update 쿼리를 실행한다.
  • hydratedState는 오직 loadedState 생성을 위한 중간 단계일 뿐, dirty checking의 비교 대상은 "loadedState(스냅샷)"와 "현재 엔티티 인스턴스"다.

3.4. 관련 Hibernate 내부 구조

  • deep copy는 내부적으로 각 타입별 전략이 적용된다.
    • 이뮤터블(불변) 객체는 값만 복사(참조 유지)
    • 뮤터블(가변) 객체는 복제 객체 생성
  • 관련 클래스 체계
    • AbstractStandardBasicType -> JavaTypeDesciptor -> MutabilityPlan

 

4. 트러블의 근본 원인 - @Converter + equals/hashCode 미구현

이제 현상과 Hibernate 내부 구조를 연결해보면, 문제의 진짜 원인은 아래와 같았다.

  • @Converter로 데이터베이스 값을 읽을 때마다 매번 새로운 커스텀 객체가 생성된다.
  • 하지만 해당 커스텀 클래스에 equals/hashCode가 미구현이면 loadedState(스냅샷, 조회 시 객체)와 현재 엔티티(트랜잭션 중 새로 생성된 객체)의 참조값이 항상 다르게 되어버린다.
  • Hibernate의 dirty checking(=loadedState와 현재 객체의 equals() 비교)은 참조가 다르면 무조건 "dirty"로 판단한다.
  • 그 결과, 실제로 값은 안 바뀌었는데도 불구하고 update 쿼리가 남발된다.

 

5. @DynamicUpdate의 역할

여기서 헷갈릴 수 있는 점 한가지!

@DynamicUpdate는 dirty checking의 수행 여부와는 관계 없다.

  • @DynamicUpdate는 "실제로 변경된 필드만 update 쿼리에 포함"되도록 쿼리를 최적화하는 기능이다.
  • dirty checking(변경 감지)은 @DynamicUpdate와 무관하게 트랜잭션이 readOnly가 아니라면 항상 실행된다.

 

6. 동작 흐름 용어 요약 및 flush 시 비교 순서

6.1. 용어 정리

  • hydratedState
    • 데이터베이스에서 로딩한 엔티티의 "원시값"을 담은 임시 배열
    • dirty checking에 직접 사용되지 않고, loadedState 생성용 원본
  • loadedState
    • hydratedState를 deep copy해서 만든 "처음 로딩된 시점의 값"
    • dirty checking의 기준
  • dirty checking(변경 감지)
    • flush 시점에 loadedState(스냅샷)와 엔티티 인스턴스의 현재 값을 equals()로 비교해 변경 여부를 판단

6.2. flush(커밋) 시 비교 순서

  1. 트랜잭션 내 비즈니스 로직(조회/수정 등)
  2. flush(트랜잭션 커밋 전)
  3. loadedState(스냅샷)와 엔티티 인스턴스의 현재 값이 equals()로 비교됨
  4. 값이 다르면 dirty로 간주해 update 쿼리 발생

 

7. 해결 방법

커스텀 클래스에 equals/hashCode 반드시 구현

커스텀 객체의 실제 값이 같으면 동등하게 판단할 수 있도록 equals/hashCode를 구현해야 한다.

조회만 필요한 트랜잭션에는 readOnly 옵션 명시

@Transactional(readOnly = true)로 선언하면 Hibernate는 dirty checking 자체를 생략한다.

 

8. 여기서 문득 드는 궁금증?

hydratedState가 원시 데이터를 Object 배열에 임시 저장하고 이를 기반으로 loadedState를 생성한다고 했다 🤔

그럼 엔티티 인스턴스는 어떤 기준으로 생성되는거지??

 

엔티티 인스턴스는 어떻게 만들어지는가?

Hibernate가 데이터베이스에서 엔티티를 로드할 때,

  • 각 컬럼(필드)의 값을 읽어서 hydratedState 배열에 저장한다.
  • 이 hydratedState 배열에 들어 있는 값을 이용해서
    • 엔티티 객체의 각 필드에 값을 주입해 실제 엔티티 인스턴스를 생성한다.
    • 즉, hydratedState 배열에 있는 값이 엔티티의 생성자/필드/Setter 등에 그대로 할당된다.
  • 그리고 hydratedState의 값을 deep copy하여 loadedState 배열(스냅샷)로 만들어 dirty checking의 기준을 만든다.

정리하면?

  • 엔티티 인스턴스는 데이터베이스 -> hydratedState -> 엔티티 생성
  • loadedState(스냅샷)는 hydratedState -> deep copy -> loadedState(스냅샷)

으로 생성된다고 한다. 😆😆😆

 

이번 이슈로 세상 혼자 난리났었지만, 그래도 너무 좋은 경험이었던 것 같다. 👍 (하지만 두번의 실수는 안돼 🙅‍♂️🙅‍♂️🙅‍♂️)

앞으로 사소한 걸 사용하게 되더라도 실무에서의 경험이 부족하다면 사이드 이펙트를 먼저 확인하고, JPA가 관련되었다면 실행되는 쿼리도 꼭 확인하는 습관을 가져야겠다.

 

늘 나를 의심하고, 내 코드를 의심하고, 두 번, 세 번 확인하고, 늘 겸손하자 🙏

혹시 잘못된 내용이 있다면 댓글 감사드리겠습니다. 🙏

 

END.

반응형