Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md
T
DongHyeonkaandClaude Fable 5.1 62520a4dce docs(n+1liner): adopt the decomposition contract and strip evaluative prose
- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 ·
  KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d
  (문서가 인용한 IT 7종이 그 커밋에 있다)
- 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를
  통과하지 못해 Case 로 빼지 않았다
- 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision
- 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은
  절반이 있는 문장은 평가만 뺐다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

13 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, topicName, project, status, studio, assets, evidence, sourceRevision, source
id kind slug title topic topicName topicName project status studio assets evidence sourceRevision source
32d0be7d-d88e-4760-8d91-35d3a233a99a CASE eager-toone-nplus1-without-access Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 jpa-feed-query-performance JPA 피드 조회 성능 — 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
n+1liner-lab@2026-08
final/document.md#7-2
final/document.md#7-3
final/document.md#7-4

id: 32d0be7d-d88e-4760-8d91-35d3a233a99a kind: CASE slug: eager-toone-nplus1-without-access title: Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/edit" assets:

  • key: eager-lazy-query-sequence file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg evidence:
  • ../../../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 sourceRevision: n+1liner-lab@2026-08 source:
  • final/document.md#7-2
  • final/document.md#7-3
  • final/document.md#7-4

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

@ManyToOne의 기본값인 EAGER는 연관을 반환 시점까지 로딩해 둔다는 계약이지 루트 SQL의 JOIN으로 가져온다는 보장이 아니다. 파생 쿼리에서는 루트를 먼저 조회한 뒤 행마다 2차 SELECT가 나갔고, getUser()와 getPage()를 한 번도 호출하지 않은 조회에서도 Page 조회가 N번 실행됐다. 지연 로딩인 highlights도 같은 조회에서 접근하는 순간 N번 나갔다. 왕복 수를 가른 것은 fetch 타입이 아니라 루트를 먼저 조회하고 연관을 행마다 채우는 조회 방식이었다.

관계

  • Fetch Type과 Fetch Strategy 구분 로딩 시점 계약과 실제 조회 방식을 나눠 정리한 기록이다.
  • Projection 이후에도 1,509행을 읽은 Row Over-fetch 이 조회 방식을 프로젝션으로 바꾼 뒤 남은 행 수를 다룬 기록이다.
  • JPA N+1 정량 진단 기준 엔티티별 fetch 통계로 추가 조회를 세는 방법을 적은 기록이다.

문제

이 조회의 총 PreparedStatement에서 컬렉션 조회를 빼도 User·Page 연관 조회가 남았고, 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.

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

결론

Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N이 커지는 만큼 같이 늘어 10, 100, 1,000이 됐고, User fetch는 3에서 20까지 늘었다가 N=1,000에서도 20에 머물렀다.

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

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

pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 왕복 수를 가른 것은 실행계획이 아니라 반복 횟수였고, Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됐다.

지연 로딩인 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. seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 채, 접근 0회에서 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는 즉시 로딩(EAGER)이고 highlights는 지연 로딩(LAZY)이다.

EAGER 연관 관계에서 나간 추가 조회

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

getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초기화된 엔티티 수를 센다. 아래 표의 ToOne 합이 이 값이고, Page fetch와 User fetch는 getEntityStatistics(...).getFetchCount()로 엔티티마다 따로 읽었다.

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.

Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 있어, 회귀 가드는 시더 카디널리티와 무관하게 성립하는 엔티티별 pageFetch == N으로 고정하고 합계는 위 검산으로 교차 확인했다.

연관 데이터 분포 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 안에 서로 다른 연관 대상이 몇 개 있는지가 정한다. User는 서로 다른 대상이 20개 이하라 약 20회에서 멈췄고, Page는 아이템마다 달라 N회로 갈렸다.

이 측정은 앞 기록과 같은 loadFeed 호출을 잰 것이다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 나눠 셌다.

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

지연 로딩 컬렉션은 매핑 루프가 접근하는 순간 조회를 냈다. ToOne도 그런지 아니면 접근과 무관하게 나가는지는 매핑이 남아 있으면 갈라 볼 수 없다. 그래서 loadFeed 대신 아무것도 매핑하지 않는 순수 JPQL로 feed_items만 조회하고, 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는 조회가 나갔고, 지연 로딩인 highlights는 나가지 않았다. EAGER는 사용 여부와 관계없이 미리 로딩하고, 지연 로딩은 접근할 때 로딩한다.

반복되는 ToOne 부모 쿼리의 실행계획

-- 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

WHERE id = ?는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 개별 쿼리는 빨랐지만 Page는 이 빠른 계획을 N번 반복했고, 단건 계획이 이미 Index Scan이라 인덱스를 더하는 것으로는 왕복이 줄지 않는다.

인덱스로 줄지 않는다는 말은 이 반복되는 ToOne 쿼리에 한정된다. 같은 랩의 기준 목록 쿼리는 Seq Scan과 Sort로 도는데 그쪽은 정렬 인덱스와 쿼리의 문제라 fetch 전략으로 풀리지 않는다. 그 목록 계획이 실제 병목인지는 아직 단정하지 않았다. N=1,000은 인덱스 효과를 판단하기에 작아서, 이후 keyset 페이징 랩에서 검증한다.

fetch 계약과 접근 여부로 갈라 본 네 경우

fetch 계약과 실제 접근 여부를 교차하면 네 경우가 나오고, 네 경우 모두 이 랩에서 잰 값이다. 지연 로딩 쪽 두 경우는 같은 조회의 highlights 컬렉션에서 읽었다.

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

네 경우 중 셋에서 추가 조회가 났다. 나지 않은 것은 지연 로딩이면서 접근하지 않는 경우 하나뿐인데, 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 여기에 들어가지 않는다.

EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 같은 조회에서 지연 로딩인 highlights도 N=100이면 EAGER인 Page와 똑같이 100번 나갔으니, fetch 타입이 왕복 수를 가르지는 않는다.

루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 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를 실행하지 않아 통계가 feed_item_id별 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.

로컬 미리보기

본문 「EAGER 연관 관계에서 나간 추가 조회」의 :::evidence key="eager-lazy-query-sequence" 구획에 들어갈 그림이다.

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