광고
~ / posts / developer
💻 개발자

JPA 더티 체킹 비용과 플러시 모드 고르는 법, 영속성 컨텍스트 점검표

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 이 나가고, 돌려받은 엔티티는 항상 준영속이에요.

한 트랜잭션에서 고치는 엔티티가 수백 개 이하인지, 조건 하나로 여러 행을 같은 식으로 바꾸는지, 수만 건을 한 건씩 고치는지, 지연 로딩이 필요 없는 적재 작업인지를 차례로 물어 더티 체킹, 벌크 UPDATE, flush·clear 주기, StatelessSession 중 하나로 가는 순서도
위에서부터 물어 처음 '예'가 나온 칸으로 가요

영속성 컨텍스트 크기별 flush 비용, 직접 잰 값

더티 체킹은 flush 때 관리 엔티티를 모두 돌며 스냅샷과 비교해요. 아무것도 안 바꿔도 비교는 해요. 엔티티를 N개 읽어 둔 뒤 변경 없이 flush() 한 시간이에요.

관리 엔티티 수별 변경 없는 flush 1회 시간
일반 조회읽기 전용 힌트
0204060801001201천 개1만 개5만 개10만 개1천 개 · 일반 조회 1.2ms1만 개 · 일반 조회 15.9ms5만 개 · 일반 조회 53ms10만 개 · 일반 조회 102ms1021천 개 · 읽기 전용 힌트 0.9ms1만 개 · 읽기 전용 힌트 8.3ms5만 개 · 읽기 전용 힌트 43.9ms10만 개 · 읽기 전용 힌트 87.6ms87.6

엔티티 하나당 약 1마이크로초. 읽기 전용이어도 flush 순회 자체는 돌아서 시간이 크게 줄지 않아요

단위: ms · 자료: Hibernate 7.4.13·H2 인메모리·JDK 24 에서 직접 측정, 5회 중앙값을 두 번 돌려 평균 (필드 10개 엔티티)
표로 보기
구분일반 조회읽기 전용 힌트
1천 개1.20.9
1만 개15.98.3
5만 개5343.9
10만 개10287.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회당 시간을 냈어요.

다른 테이블 JPQL 1회 시간 (관리 엔티티 수·플러시 모드별)
AUTO 엔티티 1천 개AUTO 엔티티 1천 개 · 0.78ms0.78msAUTO 엔티티 1만 개AUTO 엔티티 1만 개 · 7.3ms7.3msAUTO 엔티티 5만 개AUTO 엔티티 5만 개 · 44ms44msCOMMIT 엔티티 5만 개COMMIT 엔티티 5만 개 · 0.09ms0.09ms

COMMIT 은 쿼리 직전 플러시를 건너뛰어 엔티티 수와 상관없이 0.1ms 아래예요

단위: ms · 자료: Hibernate 7.4.13·H2 인메모리에서 JPQL 100회 총시간을 직접 측정해 100으로 나눔 (두 번 돌려 평균)
표로 보기
구분쿼리 1회
AUTO 엔티티 1천 개0.78
AUTO 엔티티 1만 개7.3
AUTO 엔티티 5만 개44
COMMIT 엔티티 5만 개0.09

엔티티 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초가 돼요.

10만 건을 1,000건씩 읽어 한 필드씩 고칠 때 총 시간
컨텍스트 하나로 끝까지1,000건마다 flush·clear
02.557.51012.515페이지마다 다른 테이블 조회 1회 · 컨텍스트 하나로 끝까지 15초페이지마다 다른 테이블 조회 1회 · 1,000건마다 flush·clear 4.4초페이지마다 다른 테이블 조회 1회추가 조회 없음 · 컨텍스트 하나로 끝까지 9.8초추가 조회 없음 · 1,000건마다 flush·clear 3.4초추가 조회 없음

차이 10.6초·6.4초가 위 공식으로 계산한 약 10초·5초와 거의 맞아요

단위: 초 · 자료: Hibernate 7.4.13·H2 인메모리·jdbc.batch_size 50 에서 직접 측정 (두 번 돌려 평균)
표로 보기
구분컨텍스트 하나로 끝까지1,000건마다 flush·clear
페이지마다 다른 테이블 조회 1회154.4
추가 조회 없음9.83.4

느린 원인이 SQL 이 아니라 반복된 전체 비교라는 뜻이에요. 주기는 이렇게 정해요.

스프링 배치의 JPA 라이터라면 청크 경계가 이 주기예요. 스프링 배치 청크 크기 정하는 법을 참고해요.

벌크 UPDATE 뒤 옛 값 문제, clearAutomatically 판단

Jakarta Persistence 3.2 명세 4.11절은 영속성 컨텍스트가 벌크 update·delete 결과와 동기화되지 않는다고 적어요. 벌크 연산 앞에 읽어 둔 엔티티는 DB 와 다른 값을 들고 있어요. 그 엔티티의 다른 필드를 바꾸면 기본 UPDATE 가 모든 컬럼을 쓰므로, 벌크로 바뀐 컬럼까지 옛 값으로 되돌려 놓아요.

스프링 데이터의 @Modifying에는 두 속성이 있고 기본값은 모두 false 예요.

벌크 쿼리 앞뒤 상황flushAutomaticallyclearAutomatically
벌크 뒤 같은 엔티티를 다시 쓰지 않음falsefalse
벌크 뒤 같은 엔티티를 다시 조회falsetrue
벌크 앞에 바꾼 엔티티가 있고 뒤에 다시 조회truetrue

스프링 데이터 JPA 문서의 수정 쿼리 절은 자동 clear 가 flush 안 된 변경을 모두 버린다고 경고해요. 앞에서 바꾼 값이 있으면 셋째 줄처럼 둘을 같이 켜요.

광고
광고

UPDATE 가 안 나갈 때 확인 순서, 증상별 체크리스트

UPDATE 가 안 나가거나 옛 값이 보일 때 트랜잭션이 열렸는지, 객체가 관리 상태인지, 읽기 전용으로 읽혔는지, 값이 스냅샷과 다른지, 컨텍스트 밖에서 DB 가 바뀌었는지를 차례로 확인하는 다섯 단계
위에서 아래로, 앞 단계에서 원인이 나오면 멈춰요
증상흔한 원인확인·해결
필드를 바꿨는데 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 문장 캐시와 배치 묶기를, 단점으로 인덱스가 많을 때의 불필요한 갱신을 들어요.

바이트코드 향상 더티 추적은 엔티티가 바뀐 속성을 스스로 기록해 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())가 필요해요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 JPA 영속성 컨텍스트·1차 캐시와 더티 체킹 순서에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#JPA 더티 체킹 성능#플러시 모드 COMMIT AUTO#영속성 컨텍스트 크기#flush clear 주기#readOnly 트랜잭션#DynamicUpdate#벌크 업데이트 clearAutomatically#UPDATE 안 나감
← 이전 글0-1 BFS 언제 쓰나, 다익스트라·Dial 판단표와 오답·시간 초과 점검다음 글 →리랭커 고르는 법, 크로스 인코더 API·직접 서빙·LLM 비용과 지연 비교
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →