JPA 실전 — N+1, Fetch Join/EntityGraph/BatchSize, flush 타이밍과 readOnly 최적화
들어가며
"회원 목록 조회가 500ms 걸린다"는 리포트로 시작해 쿼리 로그를 켜봤더니 한 요청에 SELECT가 101번 찍혀 있는 경험, JPA를 쓰다 보면 반드시 한 번은 만난다. 회원 100명을 가져오는 쿼리 한 번, 그리고 각 회원의 주문 컬렉션을 가져오는 쿼리 100번. 이게 N+1 문제다. 원리 자체는 12편 영속성 컨텍스트에서 본 지연 로딩의 당연한 결과이지만, 실전에서는 "고친 줄 알았는데 다른 곳에서 터지는" 일이 훨씬 많다.
이 글은 N+1을 감지하고, 해결하고, 해결책이 만드는 2차
함정까지 다룬다. Fetch Join을 배웠더니 페이징이 망가지고,
@EntityGraph를 배웠더니 여러 컬렉션을 동시에 못 붙이고,
@BatchSize를 배웠더니 이제는 flush 타이밍에서 다시 막힌다.
결국 현장에서 필요한 건 "언제 무엇을 쓰는가"의 의사결정이다. 특히 다음
세 가지는 실무에서 가장 자주 밟는다.
- Fetch Join으로 컬렉션에 페이징을 걸면 DB가 아닌 메모리에서
자르고 OOM을 낸다 — 로그에
HHH000104경고가 뜬다 @Transactional(readOnly=true)를 붙였는데 여전히 flush가 돈다 — readOnly는 트랜잭션 힌트일 뿐 FlushMode를 바꾸지 않는다saveAll(10000건)이 JDBC batch insert로 묶이지 않고 insert를 10000번 실행한다 —GenerationType.IDENTITY가 원인이다
이 글은 감지 → Fetch Join → EntityGraph → BatchSize → 2-Query 패턴 → DTO Projection → flush/readOnly 최적화 → 벌크/saveAll의 함정 순으로, Spring Boot 3.x / Hibernate 6.x / Jakarta Persistence 3.x 기준으로 정리한다.
목차
- 1) N+1은 왜 생기나: 지연 로딩의 당연한 결과
- 2) 감지: show_sql은 장난감, p6spy가 진짜
- 3) Fetch Join: 가장 직관적이지만 함정도 가장 많다
- 4) @EntityGraph: 선언형 Fetch Join의 대안
- 5) @BatchSize와 default_batch_fetch_size: IN 절로 N+1을 N/K+1로
- 6) 2-Query Pattern: XToOne은 Fetch Join, XToMany는 BatchSize
- 7) 의사결정 플로우: 어떤 도구를 언제 쓰나
- 8) DTO Projection vs Interface Projection: 읽기 전용이면 엔티티를 버려라
- 9) flush 타이밍: JPQL 직전에 AUTO flush가 도는 이유
- 10) 벌크 연산 후 clear는 필수
- 11) @Transactional(readOnly=true) + setDefaultReadOnly + FlushMode.MANUAL 3종
- 12) IDENTITY 전략은 JDBC batch insert를 비활성화한다
- 13) saveAll의 함정: 이름이 주는 착각
- 14) 실무에서 이렇게 읽고 쓴다
- 15) 한 줄 정리
1) N+1은 왜 생기나: 지연 로딩의 당연한 결과
N+1은 버그가 아니다. 지연 로딩(lazy loading)의 정의 그대로
동작한 결과다. JPA는 연관 엔티티 접근을 최소화하기 위해
@ManyToOne·@OneToMany·@ManyToMany를
기본적으로 지연 로딩으로 처리한다(@ManyToOne의 기본이
EAGER라는 건 예외이고, 실무에서는 항상 LAZY로 재정의한다). 그래서 부모
엔티티를 N건 조회한 뒤 각자의 컬렉션을 건드리면 프록시가 풀리며 자식
쿼리가 N번 더 나간다.
// Member 100건 조회 → SELECT 1번
List<Member> members = memberRepository.findAll();
// 각 member의 orders 컬렉션 접근 → SELECT 100번 추가
for (Member m : members) {
log.info("{}: {}건", m.getName(), m.getOrders().size());
}부모 N건에 대해 자식 쿼리가 N번, 합쳐서 1 + N번의 쿼리가 나가기 때문에 N+1이라고 부른다. 이걸 "버그"로 취급하면 해결책을 잘못 고른다. 지연 로딩을 쓴 이상 N+1은 기본 동작이고, 필요한 경우에만 한 번에 땡겨오는 쿼리 형태로 바꿔주는 것이 해결이다. 이 관점을 잡아야 Fetch Join과 BatchSize가 "왜 둘 다 필요한가"가 설명된다.
참고로 FetchType.EAGER를 붙이는 건 해결이 아니라
문제를 숨기는 것이다. EAGER는 모든 조회에 강제로 join을
붙이고, 쓸 일 없는 연관 데이터까지 끌어오며, JPQL을 쓰면 EAGER도
무시되고 다시 N+1이 터진다. 공식 권장은 "전부 LAZY로 두고,
필요한 쿼리에서만 명시적으로 load"다. 이 글의 나머지는 그
"명시적으로"의 도구들이다.
2) 감지: show_sql은 장난감, p6spy가 진짜
N+1은 고치기 전에 보여야 고친다. 가장 간단한 감지
도구는 spring.jpa.show-sql=true이지만, 실전에서는 여러
이유로 부족하다. 바인딩 파라미터가 ?로만 보이고, 쿼리 실행
시간이 안 찍히고, 포맷이 엉망이다. 실무에서 쓰는 도구는 다음 네
가지다.
| 도구 | 장점 | 한계 |
|---|---|---|
spring.jpa.show-sql |
설정 한 줄 | 바인딩 파라미터 ?, 실행 시간 없음 |
org.hibernate.SQL 로거 |
포맷팅 가능(format_sql), 바인딩
로거(BasicBinder) 별도 |
쿼리-파라미터 분리돼 읽기 불편, 실행 시간 없음 |
| p6spy | 완성된 SQL + 실행 시간 + 커스텀 포맷 | 프로덕션 상시 구동은 주의(오버헤드) |
| Hibernate Statistics | 총 쿼리 수, 캐시 히트율, 엔티티 로드 수 | SQL 텍스트는 안 찍힘, 집계 지표 |
감지의 첫걸음은 "이 엔드포인트 한 번 호출하면 SELECT가 몇 번 나가는가"를 숫자로 보는 것이다. 여기에는 Hibernate Statistics가 가장 깔끔하다.
# application.yml
spring:
jpa:
properties:
hibernate:
generate_statistics: true
logging:
level:
org.hibernate.stat: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE # Hibernate 6 이후 바인딩 파라미터 로거쿼리 텍스트까지 보고 싶으면 p6spy +
p6spy-spring-boot-starter를 테스트/로컬 프로파일에만
얹는다.
// build.gradle
testImplementation("com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1")p6spy는 JDBC 드라이버 앞에 프록시를 끼워 실행 시점의 완성된 SQL과 실행 시간(ms)을 로그로 뽑아준다. "N+1이 있다"는 심증을 **"한 요청에 SELECT 101건, 1.8s 소요"**처럼 증거로 바꾸는 단계가 여기다. 증거 없이 고치기 시작하면 고쳤는지도 모른다.
2-1) 테스트에서 쿼리 수를 단언하기
가장 확실한 회귀 방지는 테스트 코드에서 쿼리 카운트를
assert하는 것이다. AssertJ와 Hibernate
Statistics, 또는 datasource-proxy/p6spy를
이용해 통합 테스트에 쿼리 수 검증을 걸어두면 다음 사람이 실수로 N+1을
다시 도입해도 CI에서 빨갛게 잡힌다.
@Test
void 회원_목록_조회는_쿼리_2번_이하로_끝난다() {
// given: Member 100, 각 Member당 Order 3
seed();
Statistics stats = sessionFactory.getStatistics();
stats.clear();
List<MemberResponse> list = memberQueryService.list();
assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2);
assertThat(list).hasSize(100);
}3) Fetch Join: 가장 직관적이지만 함정도 가장 많다
Fetch Join은 JPQL 확장 문법 join fetch로 연관 엔티티를
한 SELECT에 조인해서 한꺼번에 가져오는 방법이다. 이름
그대로 "연관을 fetch하기 위한 join"이고, SQL 레벨로는
평범한 inner/left join이지만 JPA가 조인된 연관도 영속화한다는 차이가
있다.
@Query("""
select m from Member m
join fetch m.team
where m.active = true
""")
List<Member> findAllWithTeam();이 한 줄로 "회원 + 팀"은 완벽히 해결된다. 쿼리 1번이면 끝난다. 그런데 Fetch Join은 컬렉션 연관(XToMany) 앞에서 세 개의 고유한 함정을 만난다.
3-1) 함정 1:
XToMany에서의 페이징 — HHH000104
컬렉션을 fetch join한 상태에서
setFirstResult/setMaxResults를 쓰면
Hibernate는 다음 경고를 찍는다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory
직역하면 "컬렉션 fetch와 함께 페이징이 지정돼서 메모리에서
적용한다"는 뜻이다. 이게 왜 치명적인가. 회원 1명당 주문 10건이
있고 회원 100만 건이 있다고 하자.
select m from Member m join fetch m.orders 쿼리는 카티션
곱이 돼서 1000만 건이 만들어진다. 여기에 limit 20을 붙이면
DB는 1000만 건을 다 뽑아 네트워크로 올리고, Hibernate가 JVM
메모리에서 20건을 자른다. OOM은 시간문제다.
이 경고를 경고로만 보고 넘기면 안 된다. Hibernate
6부터는 이 상황에서 예외를 던지게 설정할 수도
있다(hibernate.query.fail_on_pagination_over_collection_fetch=true).
실무에서는 이 옵션을 켜두는 편을 권장한다. 에러로
바뀌면 최소한 조용한 OOM은 막는다.
3-2) 함정 2:
MultipleBagFetchException
List 타입 컬렉션을 두 개 이상 동시에
fetch join하면 Hibernate가 아예 쿼리 빌드를 거부한다.
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags: [Member.orders, Member.reviews]
List는 JPA 용어로 "bag"(순서 없는 중복 허용 컬렉션)이고,
두 개의 bag을 한꺼번에 조인하면 카티션 곱의 의미를 Hibernate가 감당 못
한다. 회피책은 세 가지다.
- 하나를
Set으로 바꾼다 — bag이 아니라 set이 되므로 조합 금지가 풀린다. 다만 중복 제거 비용과 정렬 의미 변경을 감수해야 한다 - 한쪽만 fetch join으로 붙이고 나머지는 BatchSize로 해결한다 — §6 2-Query Pattern의 출발점이다
- 두 쿼리로 쪼갠다 — 회원+주문, 회원+리뷰를 각각 조회한 뒤 애플리케이션에서 합친다
실무 정답은 대부분 2번이다.
3-3) 함정 3: 중복 제거와
distinct
join fetch가 컬렉션을 만나면 결과 행이 부모 ×
자식만큼 늘어난다. 회원 3명, 각자 주문 2건이면 6행이 돌아온다.
JPA는 이걸 자동으로 중복 제거하지 않으므로,
List<Member>를 받으면 같은 회원이 2번씩 들어 있을 수
있다. 해결책으로 select distinct m을 쓰는 게 전통적
관용구다.
@Query("select distinct m from Member m join fetch m.orders")
List<Member> findAllWithOrders();distinct 키워드는 여기서 JPA 레벨의 엔티티 중복
제거 힌트로 쓰인다. Hibernate 6부터는 JPQL
distinct가 SQL distinct로 전달되지
않지만(passDistinctThrough=false 기본), JPA 레벨
엔티티 중복 제거는 여전히 이 키워드로 트리거되므로 컬렉션 fetch
join에서는 계속 명시해야 한다.
4) @EntityGraph: 선언형 Fetch Join의 대안
@EntityGraph는 "fetch join을 쿼리 문자열 바깥에서
선언형으로 지정"하는 JPA 표준 기능이다. Spring Data
JPA의 Repository 메서드에 붙이면 해당 쿼리에 한해 지정한 경로를 fetch
join으로 변환한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
@EntityGraph(attributePaths = {"team", "orders"})
@Query("select m from Member m where m.active = true")
List<Member> findActiveWithTeamAndOrders();
}Fetch Join의 대안이지만 완전히 같지는 않다. 두 가지 차이를 구분해서 써야 한다.
| 항목 | Fetch Join | @EntityGraph |
|---|---|---|
| 지정 방식 | JPQL join fetch 명시 |
속성 경로 선언 |
| JPA 표준 | JPQL 확장 | JPA 2.1 표준 |
| join 유형 | 기본 inner (외부 조인은 left join fetch) |
기본 left outer join |
| type 속성 | 해당 없음 | FETCH / LOAD 두 모드 |
| 조합 가능성 | JPQL 한 줄로 자유 | 메서드별 1개 graph |
| 동적 쿼리 | QueryDSL과 결합 용이 | 정적 선언 위주 |
4-1) FETCH vs LOAD
@EntityGraph는 type 속성으로 동작 모드를
고를 수 있다(기본 FETCH).
EntityGraphType.FETCH(기본) — graph에 지정된 속성은 EAGER로, 지정되지 않은 속성은 전부 LAZY로 취급한다. 즉 그래프가 완전한 로딩 스펙이 된다EntityGraphType.LOAD— graph에 지정된 속성은 EAGER로, 지정되지 않은 속성은 엔티티에 선언된 원래 FetchType을 따른다
실무에서 LOAD는 "엔티티 기본 전략을 존중하면서
추가로 이것만 eager로 올리고 싶다"는 의미가 된다.
하지만 대부분 엔티티는 전부 LAZY가 원칙이므로 둘의 차이가 드러나는 일은
많지 않다. 명시적 의도가 있을 때만 LOAD를 쓰고, 기본은
FETCH로 간다.
4-2) EntityGraph의 한계
@EntityGraph도 Fetch Join과 같은 제약을
공유한다. 컬렉션 2개를 동시에 붙이면
MultipleBagFetchException이 뜨고, 컬렉션 fetch와 페이징을
함께 지정하면 HHH000104가 나온다. 즉 "문법만 다를 뿐 SQL
생성 결과는 동일 부류"라는 걸 잊으면 안 된다. 여기서 틀리기 쉬운 건
"@EntityGraph는 fetch join보다 안전하다"는 막연한 기대다.
안전하지 않다. 같은 한계를 선언형으로 옮겼을
뿐이다.
5) @BatchSize와 default_batch_fetch_size: IN 절로 N+1을 N/K+1로
Fetch Join이 "한 SELECT로 합치기"라면, @BatchSize는
"N번의 SELECT를 N/K번으로 뭉치기"다. 접근 방식이
다르다.
@Entity
public class Member {
@OneToMany(mappedBy = "member")
@BatchSize(size = 100)
private List<Order> orders = new ArrayList<>();
}이 설정이 붙은 상태에서 회원 1000명의 orders 컬렉션을
순회하면, Hibernate는 첫 번째 orders 접근 시 주변
회원 99명의 orders도 함께 모아서
where member_id in (?, ?, ?, ... 100개) 형태의 한 쿼리로
뽑는다. 1000명을 전부 순회하면 1000번 나갈 쿼리가 10번으로 줄어든다.
수학적으로는 N+1이 N/K+1로 바뀐다.
5-1) 엔티티 단위 vs 글로벌 설정
@BatchSize는 엔티티/컬렉션에 개별로 붙일 수도 있고,
전역으로 한 방에 설정할 수도 있다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000실무에서는 대부분 글로벌 설정 1000을 켜둔다. 이유는 단순하다. 엔티티마다 개별로 붙이는 건 관리 비용이 크고, DB의 IN 절 한도(Oracle 1000, PostgreSQL/MySQL 사실상 무제한이지만 실용상 수천)에 맞춰 1000이면 대부분의 상황에 충분하다. 개별 지정은 전역값보다 더 작거나 큰 값이 필요할 때만 예외적으로 붙인다.
5-2) Fetch Join과의 비교
| 항목 | Fetch Join | @BatchSize |
|---|---|---|
| 쿼리 수 | 1회 | N/K+1회 |
| 페이징 | 컬렉션 fetch + 페이징 불가 | 페이징과 자유 조합 가능 |
| XToOne | 매우 효과적 | 가능하지만 장점 작음 |
| XToMany (여러 개) | MultipleBagFetchException |
여러 컬렉션 동시 OK |
| 카티션 곱 | 발생 (distinct 필요) | 없음 (쿼리 분리) |
| 메모리 | 크다(카티션 곱) | 작다(쿼리 분리) |
핵심은 페이징이다. @BatchSize는 부모
쿼리와 자식 쿼리가 완전히 분리되므로 페이징이 깨지지 않는다. "페이징이
필요한데 컬렉션도 붙여야 한다"는 요구에는 Fetch Join이 답이 될 수 없고,
@BatchSize가 정답이다.
6) 2-Query Pattern: XToOne은 Fetch Join, XToMany는 BatchSize
여기가 이 글의 클라이맥스다. Fetch Join과 BatchSize의 장점만 취하는 실무 관용구가 2-Query Pattern이다. 김영한이 "JPA 실무편"에서 정리한 이름으로 유명하지만, 이름보다 규칙이 중요하다.
XToOne(ManyToOne, OneToOne)은 Fetch Join으로. XToMany(OneToMany, ManyToMany)는 BatchSize로.
이유는 간단하다. XToOne은 카티션 곱이 생기지 않으므로 Fetch Join이 완벽하게 작동한다. XToMany는 카티션 곱과 페이징 문제가 생기므로 BatchSize로 분리하는 게 깔끔하다. 둘을 한 쿼리에 섞지 말고 두 쿼리로 나눈다.
// XToOne 2개는 fetch join으로 한 쿼리에 묶는다
// XToMany 1개는 BatchSize로 별도 쿼리에 빠진다
@Query("""
select o from Order o
join fetch o.member m
join fetch o.delivery d
where o.status = :status
""")
List<Order> findOrdersWithMemberAndDelivery(@Param("status") OrderStatus status,
Pageable pageable);@Entity
public class Order {
@OneToMany(mappedBy = "order")
@BatchSize(size = 1000) // 또는 글로벌 default_batch_fetch_size
private List<OrderItem> orderItems = new ArrayList<>();
}실행 결과를 쿼리 수로 보면 다음과 같다.
Order+Member+Delivery조회 → 쿼리 1번 (fetch join)orderItems지연 로딩 접근 → 쿼리 1번 (where order_id in (...))- 총 쿼리 2번
페이징은 첫 쿼리에만 영향을 주고, 컬렉션은 IN 절 한 방에 전부 로드된다. 1000건을 페이징 단위 20건으로 잘라 읽어도 안전하다. 이 패턴이 안 풀리는 경우는 거의 없다.
6-1) 왜 컬렉션 Fetch Join을 안 쓰는가
누군가는 "그냥 컬렉션까지 fetch join 하고 distinct 걸면 되지 않냐"고 묻는다. 앞서 §3-1과 §3-2에서 본 함정 때문에 안 된다. 요약하면 페이징 불가, 여러 컬렉션 불가, 카티션 곱으로 메모리 부담 — 이 세 개가 전부 걸린다. 2-Query Pattern은 그 세 개를 전부 회피한다.
6-2) 컬렉션이 2개 이상이면?
Order에 orderItems도 있고
coupons도 있다면? 둘 다 LAZY + @BatchSize로
두면 된다. Fetch Join으로 여러 컬렉션을 동시에 붙일 때 터지는
MultipleBagFetchException이 여기서는 안 난다. 쿼리는
3번으로 늘지만(부모 + 컬렉션1 + 컬렉션2), 각 쿼리가 IN 절로 뭉쳐져 있어
여전히 N+1이 아니다.
7) 의사결정 플로우: 어떤 도구를 언제 쓰나
지금까지의 도구를 한 화면에 묶는다. 의사결정은 네 가지 질문으로 분기된다.
이 플로우가 말하는 건 단 하나다. "엔티티를 살려야 하는가"가 가장 먼저, "페이징이 필요한가"가 그다음, "컬렉션이 몇 개인가"가 세 번째. 이 순서로 묻는 것만으로 대부분의 케이스는 하나의 답에 수렴한다.
8) DTO Projection vs Interface Projection: 읽기 전용이면 엔티티를 버려라
플로우의 첫 분기가 **"엔티티가 필요한가"**였다. 목록 조회, 통계, 리포트처럼 엔티티의 도메인 메서드를 쓸 일이 없고 응답 JSON 모양만 필요한 경우, 엔티티 자체를 포기하는 게 가장 빠르다. 영속성 컨텍스트 관리 비용이 없고, 필요한 컬럼만 가져오므로 네트워크 페이로드도 작다.
Spring Data JPA가 제공하는 Projection은 세 가지다.
| 방식 | 문법 | 특징 | 성능 |
|---|---|---|---|
| DTO Projection | select new dto(...) + Constructor Expression |
JPQL에 생성자 명시 | 가장 빠름, 1급 추천 |
| Interface Projection | 인터페이스 + getter | Spring Data가 프록시 자동 생성 | DTO와 거의 동일, 중첩 제한 |
| Class Projection (record) | Java record + Constructor Expression | 불변 DTO를 record로 | DTO Projection과 동등 |
8-1) Constructor Expression
JPQL에는 생성자 호출 문법이 있다. 엔티티를 재구성하는 게 아니라 SELECT 결과를 바로 DTO 생성자에 넣는다.
public record OrderSummary(Long id, String memberName, int totalPrice) {}
@Query("""
select new com.example.order.OrderSummary(o.id, m.name, o.totalPrice)
from Order o
join o.member m
where o.status = :status
""")
List<OrderSummary> findSummariesByStatus(@Param("status") OrderStatus status);이 쿼리는 SELECT 컬럼이 3개뿐이고, 반환되는 객체는 영속성 컨텍스트에
들어가지 않는다. orderItems 같은 LAZY 연관도 존재하지
않으므로 N+1이 원천적으로 불가능하다. 읽기 전용 화면은 이 패턴이
최선이다.
8-2) Interface Projection
Projection 인터페이스를 선언하고 getter 시그니처만 맞추면 Spring Data가 런타임 프록시로 만들어준다.
public interface OrderSummaryView {
Long getId();
String getMemberName(); // nested: member.name을 매핑
int getTotalPrice();
}
List<OrderSummaryView> findByStatus(OrderStatus status);쓰기 편하지만 중첩 연관을 평탄화하는 규칙이 JPQL만큼 명확하지는 않다. 복잡한 쿼리에서는 예측이 어렵고, 메서드 이름 규칙으로 쿼리를 도출할 수 없는 경우도 많다. 단순한 리스트 화면에만 쓰고, 복잡해지면 Constructor Expression으로 넘어가는 편이 관리가 쉽다.
여기서 틀리기 쉬운 지점이 하나 더 있다. Interface Projection은
Closed Projection(선언된 getter가 모두 엔티티의 단순
속성만 가리키는 경우)일 때만 안전하다. Spring Data가 쿼리를 해석해
SELECT 컬럼을 필요한 것만 좁혀 발행하기 때문이다. 반면
Open
Projection(@Value("#{target.firstName + ' ' + target.lastName}")
같은 SpEL 표현)이나 Nested Projection(연관 엔티티를
다른 Projection 인터페이스로 노출)은 Spring Data가 SELECT를 좁힐 수 없어
엔티티를 먼저 전체 로드한 뒤 프록시로 감싼다.
결과적으로 LAZY 연관이 그대로 살아 있어 프록시의 getter를 건드리는 순간
N+1이 재발한다. Projection을 썼으니 안전하다는 감각은 Closed Projection
한정이다.
8-3) 언제 Projection을 안 쓰는가
엔티티 로직(예: order.cancel())을 호출해야 하거나,
저장/변경이 섞인 케이스에서는 Projection이 답이 아니다. 이런 경우는
엔티티로 로드하고 §6
2-Query Pattern을 쓴다. Projection은 조회 전용 경로의
최적화이지 범용 해법이 아니다.
9) flush 타이밍: JPQL 직전에 AUTO flush가 도는 이유
12편에서
설명한 것처럼 영속성 컨텍스트의 변경은 flush 시점에
SQL로 나간다. 기본 FlushModeType은 AUTO이고,
AUTO는 다음 세 시점에 자동으로 flush를 건다.
- 트랜잭션 커밋 직전 — 가장 흔한 시점
- JPQL / Criteria / QueryDSL 쿼리 실행 직전 — 여기가 핵심
- 명시적
em.flush()호출
2번이 중요하다. 변경된 엔티티가 있는 상태에서 JPQL을 날리면,
Hibernate는 "이 쿼리 결과가 지금 메모리에 있는 변경을 반영해야 한다"고
판단해 JPQL 실행 직전에 flush를 건다. 예를 들어 방금
member.updateName("kim")을 호출했고, 그 뒤에
select m from Member m where m.name = 'kim'을 실행하면,
flush가 먼저 돌아야 새 이름이 조회된다. 이 동작이 없으면 "내가 방금 바꾼
데이터가 쿼리 결과에 없는" 기괴한 상황이 벌어진다.
9-1) AUTO flush의 비용
편리하지만 비용이 있다. Hibernate는 쿼리를 날릴 때마다 "이 쿼리가 건드리는 테이블을 변경한 엔티티가 있는지"를 스캔해야 한다. 영속성 컨텍스트가 크고 JPQL이 많은 읽기 전용 경로에서 이 스캔이 누적되면 무시 못 할 오버헤드가 된다. 대표적 해결이 §11 readOnly + FlushMode.MANUAL이다.
9-2) "flush는 commit이다"라는 오해
flush가 돌았다고 해서 DB에 영구 반영된 건 아니다. flush는 "쓰기 SQL을 DB로 전송한다"이고, commit은 "트랜잭션을 확정한다"이다. flush 이후 commit 전에 예외가 나면 롤백된다. 이 차이를 혼동하면 "flush 호출했으니 끝"이라고 착각해 수동 flush를 남용한다.
10) 벌크 연산 후 clear는 필수
JPQL의 update / delete, 혹은 네이티브
쿼리로 DB를 한 번에 바꾸는 경우 영속성 컨텍스트는 그 변경을
모른다. Hibernate는 DB에 바로 SQL을 쏠 뿐, 이미 영속 상태인
엔티티 인스턴스를 메모리에서 갱신하지 않는다. 결과적으로 메모리의
엔티티와 DB의 실제 값이 다른 상태가 된다. 이후 같은
트랜잭션에서 그 엔티티를 읽으면 스냅샷 기준의 옛 값이 돌아온다.
// 피하기
@Modifying
@Query("update Member m set m.status = 'INACTIVE' where m.lastLoginAt < :dt")
int inactivateOldMembers(@Param("dt") LocalDateTime dt);
// 이후 같은 트랜잭션에서
Member m = memberRepository.findById(1L).orElseThrow();
System.out.println(m.getStatus()); // 영속 캐시 히트 → 여전히 ACTIVE해결은 벌크 직후 영속성 컨텍스트를 비우는 것이다.
Spring Data JPA에서는 @Modifying의 속성으로 자동화할 수
있다.
// 선호
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update Member m set m.status = 'INACTIVE' where m.lastLoginAt < :dt")
int inactivateOldMembers(@Param("dt") LocalDateTime dt);flushAutomatically = true— 벌크 쿼리 실행 직전에 flush. 영속성 컨텍스트의 대기 중인 변경을 먼저 DB에 반영해 벌크 쿼리가 최신 상태를 보고 실행되게 한다clearAutomatically = true— 벌크 쿼리 실행 직후에 clear. 이후 조회가 1차 캐시를 건너뛰고 DB를 다시 읽게 한다
여기서 틀리기 쉬운 건 "flush만 걸면 된다"는 착각이다. flush는 내 변경을 DB로 내보내는 것이고, 벌크 결과를 내 메모리에 반영하려면 clear가 필요하다. 두 속성이 쌍으로 있는 이유다.
11) @Transactional(readOnly=true) + setDefaultReadOnly + FlushMode.MANUAL 3종
"읽기 전용 트랜잭션"이라는 말은 실무에서 자주 쓰이지만, 무엇을 끄는 플래그인지 혼란이 많다. 세 가지가 겹쳐 있다.
| 플래그 | 위치 | 역할 | 영속성 컨텍스트 scan 비용 |
|---|---|---|---|
@Transactional(readOnly=true) |
Spring | JDBC 커넥션에 read-only 힌트, 일부 스냅샷 생성 생략. Hibernate
벤더에서는 내부적으로 session.setDefaultReadOnly(true)
효과를 주고, JDBC Connection.setReadOnly() 전달은
드라이버/설정에 따라 보장되지 않는다 |
기본 AUTO flush 유지 |
session.setDefaultReadOnly(true) |
Hibernate Session | 로드되는 엔티티를 읽기 전용으로 표시 → dirty checking 스냅샷 미보관 | AUTO flush 유지이나 flush할 변경이 없음 |
FlushMode.MANUAL /
COMMIT |
EntityManager | AUTO flush 비활성화 → JPQL 직전 flush 안 돌아감 | scan 비용 자체가 사라짐 |
11-1) 이름이 같은 readOnly의 서로 다른 층
Spring의 readOnly=true는 JPA의 FlushMode를
바꾸지 않는다. 이게 가장 혼동되는 지점이다. Hibernate는
Spring이 readOnly 힌트를 주면 엔티티 스냅샷 비교 비용을 줄이는
최적화는 하지만, AUTO flush 자체는 그대로 돈다. 로드한 엔티티가
많고 JPQL이 섞이는 읽기 경로에서는 scan 비용이 여전히 나간다. 성능
차이가 체감되는 경로라면 세 개를 같이 써야 효과가
최대화된다.
11-2) 3종 세트
@Service
@RequiredArgsConstructor
public class OrderQueryService {
private final EntityManager em;
private final OrderRepository orderRepository;
@Transactional(readOnly = true) // 1층: Spring 힌트
public List<OrderSummary> list() {
Session session = em.unwrap(Session.class);
session.setDefaultReadOnly(true); // 2층: 스냅샷 미보관
session.setHibernateFlushMode(FlushMode.MANUAL); // 3층: AUTO flush 비활성
return orderRepository.findAllSummaries();
}
}세 줄이지만 각 줄이 끄는 비용이 다르다. 1층은 트랜잭션 힌트, 2층은 dirty checking 스냅샷, 3층은 flush scan이다. 조회 전용 서비스(리포트, 통계, 대시보드)에서는 이 세 줄이 거의 공짜로 지연을 낮춘다.
11-3) 전역 기본값으로 만들기
서비스마다 세 줄을 붙이는 건 관리 비용이 크다. Spring Boot에서는
open-in-view와 별개로, OSIV 트랜잭션 열릴 때의 기본
FlushMode를 설정할 수도 있다.
spring:
jpa:
properties:
org.hibernate.flushMode: COMMITCOMMIT은 "커밋 시점에만 flush"이고,
MANUAL은 "명시 호출만"이다. 쓰기 경로가 섞인 기본
트랜잭션에서 MANUAL은 너무 공격적이라 쓰기가 누락될 위험이
있다. 전역 기본은 AUTO 유지 + 특정 조회 서비스만
MANUAL 재정의가 안전하다.
12) IDENTITY 전략은 JDBC batch insert를 비활성화한다
여러 엔티티를 한 번에 insert할 때 JDBC는 addBatch() /
executeBatch()로 여러 INSERT를 네트워크 1회 왕복에 묶는다.
Hibernate도 hibernate.jdbc.batch_size를 설정하면 이 기능을
자동으로 켠다. 그런데
@GeneratedValue(strategy = IDENTITY)를 쓰면 이
기능이 조용히 꺼진다.
// 피하기 (MySQL/MariaDB 기본 관행)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;이유는 구조적이다. IDENTITY는 INSERT를 실행해야만 DB가 PK를
돌려준다. Hibernate는 엔티티가 영속 상태가 되려면 PK를 알아야
하므로, persist가 호출되는 순간마다 INSERT를 즉시 실행해
PK를 받아야 한다. 배치로 묶을 수가 없다.
saveAll(10000건)을 부르면 INSERT가 10000번 찍힌다.
12-1) 해결 — SEQUENCE 전략
PostgreSQL/Oracle처럼 시퀀스를 지원하는 DB는 SEQUENCE를
쓰면 즉시 해결된다.
// 선호
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
@SequenceGenerator(name = "member_seq", sequenceName = "member_seq", allocationSize = 50)
private Long id;allocationSize = 50은 "시퀀스 한 번 호출에 50개 ID를
예약"한다는 뜻이다. Hibernate는 nextval 한 번으로 50개 PK를
메모리에 받아두고, INSERT들을 배치로 묶어 한 번에 실행한다. 배치 크기는
설정 옵션으로 맞춘다.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: truebatch_size— 한 번에 묶을 최대 statement 수order_inserts— 같은 테이블의 INSERT끼리 묶이도록 재정렬order_updates— UPDATE도 같은 방식으로
12-2) MySQL에서의 타협
MySQL은 전통적으로 SEQUENCE를 지원하지 않아 IDENTITY를 관용적으로 써왔다(MySQL 8.0은 별도 시퀀스 테이블로 시뮬레이션 가능하지만 운영 부담). 벌크 insert가 많은 경로라면 TABLE 전략 또는 JdbcTemplate의 네이티브 batch insert로 내려가는 편이 빠르다. "ORM으로 1만 건 꽂을 수 있다"는 기대는 MySQL + IDENTITY 조합에서 근본적으로 깨진다.
13) saveAll의 함정: 이름이 주는 착각
JpaRepository.saveAll(Iterable<S>) 시그니처는
"여러 개를 한 번에 저장"처럼 읽힌다. 실제 구현은 그런 최적화가
아니다.
// SimpleJpaRepository (Spring Data JPA)
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}그냥 for문으로 save를 반복한다.
save는 새 엔티티면 persist, 이미 영속이면
merge를 부른다. 배치 최적화는 이 메서드에 없다. 배치가
실제로 묶이는 조건은 딱 두 가지다.
- 배치 설정이 켜져 있을
것(
hibernate.jdbc.batch_size) - PK 전략이 IDENTITY가 아닐 것(§12에서 본 이유)
즉 saveAll이 배치를 보장하는 게 아니라,
Hibernate가 flush 시점에 쌓인 INSERT를 배치로 묶을 수 있는
조건을 갖춰야 한다. 이름이 주는 기대와 실제 동작의 간극이
크다.
13-1) 진짜 대량 insert가 필요하다면
수만 건 이상을 한 트랜잭션에 꽂아야 한다면 JPA를 쓰는 것 자체가 비효율이다. 실무에서는 세 단계로 선택한다.
| 규모 | 도구 | 주의 |
|---|---|---|
| ~100건 | saveAll + SEQUENCE + batch_size |
IDENTITY면 이 경로도 무의미 |
| ~수천 건 | saveAll + SEQUENCE + batch_size + 주기적
flush/clear |
영속성 컨텍스트 비대화 방지 |
| 수만 건 이상 | JdbcTemplate batchUpdate 또는 DB의
bulk loader |
JPA를 포기. 가장 빠름 |
주기적 flush/clear 패턴은 다음과 같다.
int BATCH = 500;
for (int i = 0; i < entities.size(); i++) {
em.persist(entities.get(i));
if (i % BATCH == 0 && i > 0) {
em.flush();
em.clear(); // 영속성 컨텍스트 비대화 방지
}
}
em.flush();
em.clear();이 루프는 메모리를 일정하게 유지하면서 INSERT를 배치로 묶어 내보낸다. flush + clear가 쌍이라는 사실은 §10 벌크 연산 후 clear와 같은 원리다.
14) 실무에서 이렇게 읽고 쓴다
- 조회 엔드포인트가 느리면 먼저 p6spy / Hibernate Statistics로 쿼리 수부터 세라. "왠지 느린 것 같다"는 언제나 틀리고, "SELECT 101건"은 언제나 맞다
- 엔티티 설계는 **전부 LAZY + 글로벌
default_batch_fetch_size: 1000**이 기본값. EAGER는 쓰지 말고, Fetch는 조회 시점에 명시하는 걸 규율로 삼는다 - 컬렉션 Fetch Join은 페이징과 조합 금지.
hibernate.query.fail_on_pagination_over_collection_fetch=true를 켜두면 조용한 OOM을 예방할 수 있다 - 조회 전용 서비스는
@Transactional(readOnly=true)+setDefaultReadOnly(true)+FlushMode.MANUAL3종을 관용구로 만들어둔다. 서비스 베이스 클래스나 AOP로 묶어 반복을 줄인다 - 벌크 insert가 많은 도메인은 PK 전략부터 점검. MySQL의
IDENTITY는 JDBC batch를 못 쓴다는 구조적 제약을 모르면 배치
설정을 아무리 올려도 소용없다. PostgreSQL로 넘어가거나
JdbcTemplate.batchUpdate로 내려가는 결단이 필요할 때가 있다 - Projection은 엔티티가 정말 필요 없는 경로에서만 쓴다. 엔티티 로직을 호출해야 하면 Projection이 아니라 2-Query Pattern. 도구를 섞지 말고 경로를 나눈다
15) 한 줄 정리
N+1은 지연 로딩의 정상 동작이고, 해법은 "감지 → 페이징 여부로
분기 → XToOne은 Fetch Join, XToMany는 BatchSize"로 귀결된다.
여기에 flush 타이밍과 readOnly 3종을 얹으면 조회 성능의 90%는 맞춰지고,
벌크 쓰기는 IDENTITY 전략과 saveAll의 이름이 주는
착각을 벗어나는 순간부터 제대로 묶인다. 도구가 많아 보여도
고르는 순서는 늘 같다 — 엔티티가 필요한가, 페이징이 필요한가,
컬렉션이 몇 개인가.
태그: JPA, Hibernate, N+1, Fetch Join, EntityGraph, BatchSize, DTO Projection, readOnly, FlushMode, Spring Data JPA