JPA 더티 체킹은 편하지만 공짜는 아니에요. 결론부터 말하면 비용은 "flush 횟수 × 그때 영속성 컨텍스트에 든 엔티티 수"에 비례해요. 엔티티가 수백 개면 신경 쓸 일이 없고, 수천 개를 넘긴 채 JPQL 을 반복하면 쿼리마다 전체 비교가 붙어 느려져요. 그때부터 flush·clear 주기, 플러시 모드, 읽기 전용 설정을 골라요.
이 글은 내부 동작 순서 대신 운영에서 고를 설정값과 문제가 났을 때 볼 순서를 다뤄요. 기준은 Hibernate ORM 7.4.13 소스와 Hibernate 7.4 사용자 가이드의 Flushing 장이에요. 시간·메모리 숫자는 Hibernate 7.4.13, H2 2.4.240 인메모리, JDK 24, 힙 2GB 에서 필드 10개짜리 엔티티로 직접 잰 값이에요(예열 뒤 5회 중앙값, 두 번 돌려 평균, 2026-10-31). 네트워크 왕복이 없는 JVM 안의 비용이라 절댓값보다 엔티티 수에 따라 늘어나는 비율로 읽으세요.
변경 반영 방법 판단표, 더티 체킹이 맞는 경우
| 방법 | 맞는 상황 | 영속성 컨텍스트 | 놓치기 쉬운 점 |
|---|---|---|---|
| 더티 체킹(필드만 변경) | 요청 하나에서 엔티티 수십~수백 개, 건마다 규칙이 다름 | 거침 | 준영속 객체는 바꿔도 반영 안 됨 |
merge()·스프링 데이터 save() | 요청 DTO 로 만든 준영속 객체를 붙일 때 | 거침 | 관리 중인 엔티티에는 의미 없는 호출 |
벌크 JPQL UPDATE·DELETE | 조건 하나로 여러 행을 같은 식으로 바꿈 | 우회 | 이미 읽은 엔티티는 옛 값 그대로 |
| flush·clear 주기 + JDBC 배치 | 수만 건을 엔티티 로직으로 한 건씩 | 거침(주기적으로 비움) | IDENTITY 키면 INSERT 배치가 꺼짐 |
StatelessSession·JDBC | 적재·이관처럼 지연 로딩·캐스케이드가 필요 없음 | 없음 | 더티 체킹·1차 캐시·쓰기 지연 없음 |
스프링 데이터 JPA 의 SimpleJpaRepository.save()는 새 엔티티면 persist(), 아니면 merge() 를 불러요. 관리 엔티티에 덧붙이면 해는 없지만 하는 일도 없어요. "관리 엔티티엔 save() 를 안 쓴다"를 팀 규칙으로 두면 save() 가 보이는 곳이 곧 준영속 객체를 다루는 곳이 돼요.
StatelessSession 은 사용자 가이드의 배치 장대로 1차 캐시·쓰기 지연·자동 더티 체킹이 없어요. 호출 즉시 SQL 이 나가고, 돌려받은 엔티티는 항상 준영속이에요.
영속성 컨텍스트 크기별 flush 비용, 직접 잰 값
더티 체킹은 flush 때 관리 엔티티를 모두 돌며 스냅샷과 비교해요. 아무것도 안 바꿔도 비교는 해요. 엔티티를 N개 읽어 둔 뒤 변경 없이 flush() 한 시간이에요.
엔티티 하나당 약 1마이크로초. 읽기 전용이어도 flush 순회 자체는 돌아서 시간이 크게 줄지 않아요
표로 보기
| 구분 | 일반 조회 | 읽기 전용 힌트 |
|---|---|---|
| 1천 개 | 1.2 | 0.9 |
| 1만 개 | 15.9 | 8.3 |
| 5만 개 | 53 | 43.9 |
| 10만 개 | 102 | 87.6 |
엔티티 10만 개면 flush 한 번이 약 0.1초이고, flush 가 여러 번이면 그만큼 곱해져요. 읽기 전용 힌트(org.hibernate.readOnly)로 읽은 엔티티는 비교에서 빠지지만 flush 순회는 그대로 돌아서, 10만 개 기준 14% 정도만 줄었어요. flush 를 아예 없애려면 아래의 MANUAL 플러시가 맞아요.
메모리는 관리 상태로 하나 더 읽었을 때 늘어난 힙을 엔티티 수로 나눴어요. 엔티티 객체를 포함해 1개당 약 443바이트, 읽기 전용이면 약 347바이트이고, 차이 96바이트가 스냅샷 몫이에요. 10만 개면 약 44MB 대 35MB 라 메모리보다 flush 시간이 먼저 문제가 돼요.
플러시 모드와 readOnly 고르는 법, AUTO 플러시 비용
다른 테이블을 조회해도 더티 체킹이 돈다
7.4.13 의 DefaultAutoFlushEventListener는 AUTO 모드에서 JPQL 직전에 전체 더티 체킹부터 하고, 그다음 테이블이 겹치는지 봐요. 안 겹치면 SQL 은 안 보내지만 비교 비용은 이미 쓴 뒤예요.
엔티티 N개(Member)를 든 채 상관없는 테이블(Item)을 JPQL 로 100번 읽어 쿼리 1회당 시간을 냈어요.
엔티티 5만 개를 든 AUTO 는 같은 쿼리가 약 500배 느려요. 루프 안에서 코드 테이블을 JPQL 로 읽는 코드가 흔한 예예요.
플러시 모드 판단표
| 상황 | 고를 설정 | 이유 |
|---|---|---|
| 일반 쓰기 트랜잭션, 엔티티 수백 개 이하 | AUTO(기본) | 쿼리가 방금 바꾼 값을 보는 게 안전 |
| 엔티티 수천 개 + 상관없는 테이블 조회 반복 | 그 쿼리만 setFlushMode(COMMIT) | 쿼리 직전 전체 비교를 건너뜀 |
| 방금 바꾼 엔티티를 JPQL 로 다시 조회 | AUTO 유지 | COMMIT 이면 결과가 바뀌기 전 값일 수 있음 |
| 조회만 하는 트랜잭션 | @Transactional(readOnly = true) | 스프링이 MANUAL 로 바꿔 flush 자체를 안 함 |
COMMIT 은 트랜잭션 전체보다 쿼리 하나에만 거는 쪽이 안전해요. 전체에 걸면 나중에 "방금 바꾼 값을 조회하는" 코드가 들어왔을 때 조용히 틀린 결과가 나와요.
readOnly = true 는 스프링 HibernateJpaDialect가 플러시 모드를 MANUAL 로 바꾸고, 로컬 트랜잭션이면 session.setDefaultReadOnly(true) 도 불러요. 커밋 때 flush 와 스냅샷 메모리(위 측정에서 약 22%)를 아끼는 대신, 그 안에서 바꾼 값은 에러 없이 무시돼요.
대량 수정 flush·clear 주기, 순회 횟수 계산
수만 건을 고칠 때 컨텍스트를 비우지 않으면 비용이 제곱으로 커져요. 다음 페이지를 읽는 JPQL 직전마다 AUTO 플러시가 쌓인 엔티티를 모두 비교하기 때문이에요.
페이지 크기 p, 페이지 수 P 일 때 k번째 페이지 쿼리 직전 비교 수는 p × k 라서, 전체 비교 수는 p × P × (P + 1) ÷ 2 예요. 10만 건을 1,000건씩 읽으면 1,000 × 100 × 101 ÷ 2 = 약 505만 번이고, 엔티티당 1마이크로초면 약 5초예요. 페이지마다 다른 테이블 조회가 한 번 더 있으면 순회가 두 배라 약 10초가 돼요.
느린 원인이 SQL 이 아니라 반복된 전체 비교라는 뜻이에요. 주기는 이렇게 정해요.
- 주기는 JDBC 배치 크기의 배수로 잡아요. 가이드는
hibernate.jdbc.batch_size를 10~50 사이로 권하고, 기본값은 배치를 안 해요. 50이면 500~1,000건마다가 무난해요. - clear 전에 flush 해요. 거꾸로면 안 나간 변경이 사라져요.
- clear 뒤에는 앞에서 읽은 객체를 쓰지 않아요. 준영속이라 바꿔도 반영되지 않아요.
- 키 생성이
IDENTITY면 가이드 설명대로 INSERT 배치가 꺼지니, 대량 INSERT 가 주 작업이면 시퀀스 방식을 따져요.
스프링 배치의 JPA 라이터라면 청크 경계가 이 주기예요. 스프링 배치 청크 크기 정하는 법을 참고해요.
벌크 UPDATE 뒤 옛 값 문제, clearAutomatically 판단
Jakarta Persistence 3.2 명세 4.11절은 영속성 컨텍스트가 벌크 update·delete 결과와 동기화되지 않는다고 적어요. 벌크 연산 앞에 읽어 둔 엔티티는 DB 와 다른 값을 들고 있어요. 그 엔티티의 다른 필드를 바꾸면 기본 UPDATE 가 모든 컬럼을 쓰므로, 벌크로 바뀐 컬럼까지 옛 값으로 되돌려 놓아요.
스프링 데이터의 @Modifying에는 두 속성이 있고 기본값은 모두 false 예요.
| 벌크 쿼리 앞뒤 상황 | flushAutomatically | clearAutomatically |
|---|---|---|
| 벌크 뒤 같은 엔티티를 다시 쓰지 않음 | false | false |
| 벌크 뒤 같은 엔티티를 다시 조회 | false | true |
| 벌크 앞에 바꾼 엔티티가 있고 뒤에 다시 조회 | true | true |
스프링 데이터 JPA 문서의 수정 쿼리 절은 자동 clear 가 flush 안 된 변경을 모두 버린다고 경고해요. 앞에서 바꾼 값이 있으면 셋째 줄처럼 둘을 같이 켜요.
UPDATE 가 안 나갈 때 확인 순서, 증상별 체크리스트
| 증상 | 흔한 원인 | 확인·해결 |
|---|---|---|
| 필드를 바꿨는데 UPDATE 가 없음 | 같은 클래스 안에서 @Transactional 메서드 호출 | 프록시 우회, 메서드를 다른 빈으로 분리 |
| 조회 메서드 안의 변경이 사라짐 | readOnly = true 트랜잭션 | 쓰기 메서드를 읽기 전용 밖으로 |
| DTO 로 받은 객체를 고쳤는데 무반응 | 준영속 객체 | em.contains() 확인, find() 후 변경 |
| 같은 행을 지우고 다시 넣었더니 유니크 위반 | flush 는 INSERT 를 DELETE 보다 먼저 실행 | remove() 뒤 flush(), 또는 기존 행 수정 |
| 벌크 UPDATE 뒤 조회가 옛 값 | 1차 캐시가 이김 | clearAutomatically·refresh() |
| 요청이 길수록 느려짐 | 화면 렌더링까지 컨텍스트 유지 | open-in-view 설정 확인 |
첫 줄은 스프링 트랜잭션 문서대로, 기본 프록시 모드에서는 같은 객체 안의 호출(self-invocation)이 트랜잭션을 만들지 않고 조용히 무시돼요.
마지막 줄은 스프링 부트 문서대로 웹 앱에 OpenEntityManagerInViewInterceptor 가 기본 등록돼, 지연 로딩된 엔티티까지 뷰가 끝날 때까지 쌓이기 때문이에요. 끌지 말지는 JPA N+1 해결 방법 고르기의 open-in-view 절에 판단표가 있어요.
@DynamicUpdate·바이트코드 향상, 도입 전에 따질 것
@DynamicUpdate 는 바뀐 컬럼만 SET 절에 넣어요. 사용자 가이드는 기본 전체 컬럼 UPDATE 의 이점으로 JDBC 문장 캐시와 배치 묶기를, 단점으로 인덱스가 많을 때의 불필요한 갱신을 들어요.
- 컬럼이 많거나 큰 텍스트·JSON 컬럼이 있고 한두 필드만 바꾸면 켜요.
- 대량 수정을 배치로 묶는 게 주 작업이면 기본값을 유지해요.
OptimisticLockType.DIRTY·ALL버전 없는 낙관적 잠금을 쓰면 가이드대로 함께 켜야 해요.
바이트코드 향상 더티 추적은 엔티티가 바뀐 속성을 스스로 기록해 flush 때 비교를 줄여요. 가이드의 인라인 더티 추적 절은 내부 상태가 바뀌는 타입(java.util.Date 등)까지 잡는 건 스냅샷 비교라며, 그런 타입이 없을 때의 대안으로 소개해요. 7.4 설정 목록에서 hibernate.enhancer.enableDirtyTracking 은 대체 없이 제거 예정으로 표시돼 있어, 먼저 컨텍스트 크기를 줄이는 쪽이 대부분 싸요.
한 요청에서 수천 개를 고친다면 트랜잭션 경계가 너무 크지 않은지도 따져요. 기준은 DDD 애그리거트 경계 정하는 법에 있어요.
자주 묻는 질문
더티 체킹은 몇 개부터 성능 문제가 되나요?
위 측정에서 1,000개면 flush 1회 약 1ms라 거의 안 보이고, 1만 개부터 쿼리마다 7ms 이상이 붙어요. hibernate.generate_statistics 로 flush 횟수를 보고 엔티티 수와 곱해 보세요.
조회만 하는 메서드에 readOnly = true 를 꼭 붙여야 하나요?
엔티티를 많이 읽는다면 붙이는 게 좋아요. 커밋 때 비교를 안 하고 스냅샷 메모리도 아껴요. 다만 그 안에서 바꾼 값은 에러 없이 버려져요.
플러시 모드를 COMMIT 으로 바꾸면 위험한가요?
트랜잭션 전체에 걸면 방금 바꾼 값을 조회하는 코드가 바뀌기 전 결과를 받을 수 있어요. 상관없는 테이블을 반복 조회하는 쿼리 하나에만 거는 게 안전해요.
flush() 를 직접 부르면 커밋되나요?
아니요. flush 는 SQL 을 보낼 뿐이고, 이후 롤백되면 그 변경도 함께 취소돼요.
save() 를 빼면 정말 UPDATE 가 나가나요?
같은 트랜잭션에서 읽은 관리 엔티티라면 커밋 직전 flush 가 UPDATE 를 만들어요. 준영속 객체라면 save()(merge())가 필요해요.
정리
- 수백 개 이하는 더티 체킹, 같은 식의 일괄 변경은 벌크 UPDATE, 수만 건은 flush·clear 주기, 적재는 StatelessSession 이에요.
- 비용은
flush 횟수 × 관리 엔티티 수 × 약 1마이크로초(측정 환경 기준)로 어림하고, AUTO 에선 상관없는 테이블 JPQL 도 횟수에 들어가요. - 조회 전용은
readOnly = true, 반복 조회 쿼리 하나는COMMIT, 대량 수정은 배치 크기의 배수마다 flush 후 clear 예요. - UPDATE 가 안 나가면 트랜잭션 → 관리 상태 → 읽기 전용 → 스냅샷 차이 → 컨텍스트 밖 변경 순으로 확인해요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 JPA 영속성 컨텍스트·1차 캐시와 더티 체킹 순서에서 도식과 코드로 단계별로 풀었어요.