일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- springboot+redis
- insert
- 제이쿼리
- 만들면서 배우는 클린 아키텍처
- sftp
- 리눅스
- Java
- javascript
- feignClient
- jQuery
- 자바
- docker 컨테이너로 띄우기
- catalina.out
- redis + spring boot 함께
- 자바스크립트
- 초단위
- js
- 엔티티 코드 치환
- 톰캣
- 정규식
- for문
- 특수문자 치환
- Linux
- Entity Code 치환
- architecture
- mysql
- Tomcat
- Docker Compose
- aws elasticache 활용
- select
- Today
- Total
꾸준하게, 차근차근
[트러블슈팅 회고] JPA 엔티티 단순 조회만 했는데 update 쿼리가 남발된 이유 본문
(@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(커밋) 시 비교 순서
- 트랜잭션 내 비즈니스 로직(조회/수정 등)
- flush(트랜잭션 커밋 전)
- loadedState(스냅샷)와 엔티티 인스턴스의 현재 값이 equals()로 비교됨
- 값이 다르면 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.
'Spring' 카테고리의 다른 글
[Spring] FeignClient의 재시도(Retry), 너란 녀석! (2) | 2025.06.19 |
---|---|
[Spring] 스케줄러에서 ShedLock 실행 누락, ThreadPoolTaskScheduler, 그리고 millisecond의 함정 (0) | 2025.06.17 |
[Spring] Querydsl Gradle 설정 (Spring Boot 3.0 이상) (0) | 2024.04.18 |