Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md
T

11 KiB
Raw Blame History

id, kind, slug, title, topic, project, status, studio, assets, evidence
id kind slug title topic project status studio assets evidence
32d0be7d-d88e-4760-8d91-35d3a233a99a CASE eager-toone-nplus1-without-access Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/edit
key file
eager-lazy-query-sequence ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg
../../../final/evidence/raw/explain/highlights-child-plan-A.txt
../../../final/evidence/raw/explain/toone-pages-plan.txt
../../../final/evidence/raw/explain/toone-users-plan.txt

Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1

@ManyToOne의 기본값인 EAGER는 로딩 시점 계약이지 루트 SQL의 JOIN 보장이 아니다. 파생 쿼리에서는 행마다 2차 SELECT가 나갔다. getUser()와 getPage()를 한 번도 호출하지 않은 조회에서도 Page 조회가 N번 실행됐다. 같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. N+1을 가르는 것은 fetch 타입이 아니라 조회 방식이다.

관계

  • Fetch Type과 Fetch Strategy 구분 이 현상을 기준으로 정리한 기록이다.
  • Projection 이후에도 1,509행을 읽은 Row Over-fetch 이 조회 방식을 프로젝션으로 바꾼 뒤 남은 행 수를 다룬 기록이다.
  • JPA N+1 정량 진단 기준 엔티티별 fetch 통계로 확인한 방법이다.

문제

이 조회의 총 PreparedStatement에는 컬렉션 조회를 빼고도 남는 몫이 있었다. 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.

엔티티에는 fetch를 따로 명시하지 않았다. @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 사용했다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.

결론

Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N을 따라 10, 100, 1,000으로 늘고 User fetch는 3, 20, 20에서 멈췄다.

같은 @ManyToOne(EAGER)인데 증가 곡선이 정반대였다. Page는 아이템마다 달라 N번 조회되고, User는 소수 풀을 재사용해 한 번 로드한 대상이 1차 캐시에 남는다. N+1이 생길 가능성은 EAGER라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.

getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 조회 코드를 작성하지 않았는데 EAGER 기본값 때문에 생긴 N+1이다. 같은 조건에서 LAZY 컬렉션은 0이었다.

pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였다.

같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. 지연이냐 즉시냐가 아니라, 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.

검증 환경

Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers)

시더 카디널리티 feed_item : N page : N (아이템당 1개, 전부 다름) user : max(3, min(20, N/5+1)) (소수 풀 재사용)

측정 지표 getEntityFetchCount() : 2차 SELECT로 로드된 엔티티 인스턴스 수 getEntityStatistics(PageJpaEntity).getFetchCount() : 엔티티별 fetch 수

재현 조건

  1. seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만들고 loadFeed(0, N)을 실행한다.

  2. getEntityStatistics(PageJpaEntity)와 getEntityStatistics(UserJpaEntity)의 getFetchCount()를 각각 읽는다.

  3. entityFetch = pageFetch + userFetch가 성립하는지 확인한다.

  4. 회계 항등식으로 교차 검증한다. 총 PreparedStatement 컬렉션 N content 1 count 1 = entityFetch.

  5. 접근 0회 확인: seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 상태에서 fetch 수를 읽는다.

  6. 반복되는 ToOne 부모 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.

본문

측정한 loadFeed 구현

@Override
public List<FeedSummary> loadFeed(int page, int size) {
  return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream()
      .map(fi -> new FeedSummary(
          fi.getId().toString(),
          fi.getUser().getName(), fi.getUser().getUsername(),   // ToOne (즉시 로딩)
          fi.getPage().getUrl(),  fi.getPage().getTitle(),      // ToOne (즉시 로딩)
          fi.getFirstHighlightedAt(),
          fi.getHighlights().stream()                           // 컬렉션 (지연 로딩)
              .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt()))
              .toList()))
      .toList();
}

엔티티를 조회한 뒤 메모리에서 DTO로 옮긴다. user·page는 즉시 로딩이고 highlights는 지연 로딩이다.

같은 EAGER가 정반대 곡선을 그린다

:::evidence key="eager-lazy-query-sequence" alt="loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스." caption=" " zoom="true" :::

N Page fetch User fetch ToOne 합(entityFetch) 초기화 컬렉션 총 PreparedStatement
10 10 3 13 10 25
100 100 20 120 100 222
1,000 1,000 20 1,020 1,000 2,022

검산: 10+3=13 · 100+20=120 · 1000+20=1020. 회계 항등식으로도 25102=13 · 2221002=120 · 202210002=1020.

연관 데이터 분포 1차 캐시로 걸러지나 N=10 / 100 / 1,000
User (EAGER ToOne) 소수 풀 재사용(≤20명) 그렇다 3 / 20 / 20
Page (EAGER ToOne) 아이템당 1개(전부 다름) 아니다 10 / 100 / 1,000
highlights (지연 로딩 컬렉션) 아이템당 컬렉션 해당 없음 10 / 100 / 1,000

EAGER의 2차 SELECT 구조가 추가 조회의 가능성을 만든다. 실제 실행 횟수는 Persistence Context 안에서 서로 다른 연관 대상이 몇 개인지가 정한다.

필드에 접근하지 않아도 조회가 나간다

seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.

접근 연관 fetch 계약 접근 0에서 fetch 수
0회 Page @ManyToOne (EAGER) 100 (= N)
0회 User @ManyToOne (EAGER) 20 (풀 dedup)
0회 highlights @OneToMany (LAZY) 0

EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다.

같은 실행계획, 정반대 비용

-- pages
Index Scan using pk_pages on pages
  (cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1)
  Buffers: shared hit=2      Execution Time: 0.021 ms
-- users
Index Scan using pk_users on users
  (cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1)
  Buffers: shared hit=2      Execution Time: 0.022 ms

두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 단건 계획이 이미 Index Scan이므로 인덱스를 더해도 해결되지 않는다.

핵심은 지연이냐 즉시냐가 아니다

fetch 계약과 실제 사용을 교차하면 네 칸이 나온다. 네 칸 모두 이 랩에서 잰 값이다. LAZY 자리는 같은 조회의 highlights 컬렉션이 증인이다.

fetch 계약 접근하지 않을 때 접근할 때(loadFeed)
EAGER — User·Page (@ManyToOne) 나간다 · Page 100 · User 20 나간다 · Page N번
LAZY — highlights (@OneToMany) 안 나간다 · 0 나간다 · N번

네 칸 중 세 칸에서 추가 조회가 난다. 나지 않는 칸은 「LAZY이면서 접근하지 않음」 하나뿐인데, 그것은 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 이 칸에 들어가지 않는다.

EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 실제로 같은 조회에서 LAZY인 highlights가 EAGER인 Page와 똑같이 10 / 100 / 1,000번 조회됐다. 같은 증상이 서로 다른 fetch 타입에서 나왔으므로 타입이 왕복 수를 가르는 기준이 아니다.

왕복 수를 정하는 것은 조회 방식이다. 루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 타입과 무관하게 N번이 된다. 줄이려면 fetch join, 배치, 프로젝션처럼 조회 방식 자체를 바꿔야 한다.

한 번의 하이라이트 조회가 읽는 행 수

왕복 수와 별개로 그 한 번이 읽어 오는 행 수도 확인했다.

Index Scan using ix_highlights_feed_items_created on highlights
  (cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1)
  Index Cond: (feed_item_id = '2b5b931f-...'::uuid)
  Buffers: shared hit=14
Planning Time: 0.086 ms
Execution Time: 0.173 ms

이 조회도 pk 조회들처럼 인덱스를 타고 0.173 ms에 끝났다. 쿼리는 SELECT * FROM highlights WHERE feed_item_id = ?라 ORDER BY와 LIMIT이 없고, 그 아이템의 하이라이트를 전부 읽는다. 하이라이트가 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다.

추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.

지표 이름을 정확히 읽는다

getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초기화된 엔티티 수다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 있어, 회귀 가드는 시더 카디널리티와 무관하게 성립하는 엔티티별 pageFetch == N으로 고정하고 합계는 회계 항등식으로 교차 검증했다.

이 절의 지연은 앞 기록과 같은 loadFeed 호출을 잰 것이라 별도 지연 축이 아니다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐이다.

로컬 미리보기

본문 「같은 EAGER가 정반대 곡선을 그린다」 아래 :::evidence key="eager-lazy-query-sequence" 자리에 들어갈 그림이다.

loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스.