Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/reference/reference-nplus1-quantitative-diagnosis.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

7.4 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, topicName, project, status, studio, sourceRevision, source
id kind slug title topic topicName topicName project status studio sourceRevision source
b0b55ac9-c0a3-4c01-ba84-0aa478923ace REFERENCE nplus1-quantitative-diagnosis JPA N+1 정량 진단 기준 jpa-feed-query-performance JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit n+1liner-lab@2026-08
final/document.md#6-1
final/document.md#7-1

id: b0b55ac9-c0a3-4c01-ba84-0aa478923ace kind: REFERENCE slug: nplus1-quantitative-diagnosis title: JPA N+1 정량 진단 기준 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit" sourceRevision: n+1liner-lab@2026-08 source:

  • final/document.md#6-1
  • final/document.md#7-1

JPA N+1 정량 진단 기준

N+1은 쿼리 총계가 아니라 Hibernate Statistics의 지표를 나눠 읽어 확인한다. 세 지표는 세는 단위가 서로 달라서, 모두 SQL 실행 횟수로 바꿔 읽으면 Batch Fetch를 적용한 뒤 등식이 깨지고 결론이 어긋난다.

관계

  • Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 엔티티별 fetch 통계를 직접 읽어 여기서 역산한 ToOne 조회 수를 확인했다.
  • PostgreSQL Query Plan 측정 기준 같은 측정에서 뽑은 실행계획을 어떻게 읽을지는 그 기록이 정한다.

목적

쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계 하나에 목록 루트 쿼리, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 함께 들어 있기 때문이다. N=1,000에서 나온 총 PreparedStatement 2,022건도 이 네 가지가 섞인 값이다.

지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회됐는지 확정된다.

규칙

1. 지표 이름이 뜻하는 것을 그대로 읽는다

세 지표는 모두 Hibernate Statistics에서 읽는다. 스택에 이미 있는 도구라 의존성을 더하지 않지만, SQL 형태별 정확한 실행 수는 주지 않는다.

getCollectionFetchCount()는 지연 로딩이 초기화한 컬렉션 개수를 센다. 실행된 SELECT SQL 수가 아니다.

getPrepareStatementCount()는 JDBC에서 얻은 문장 객체(PreparedStatement) 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.

getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수이고, ToOne 연관 조회를 이 지표로 읽는다. 역시 실행된 SELECT SQL 수가 아니다.

2. 컬렉션 수와 SELECT 수가 같아지는 조건을 함께 적는다

Batch Fetch나 subselect가 없어야 컬렉션 하나를 초기화할 때 SQL이 하나 나가므로, 그때만 초기화된 컬렉션 수와 자식 SELECT 수가 같다.

Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨진다. 배치가 걸렸는지는 총 PreparedStatement와 초기화 컬렉션 수를 함께 보고 판단한다.

3. 총계를 SQL 형태별로 가르고 검산한다

총 PreparedStatement는 목록 루트 쿼리(content) 1건, 페이지 count 1건, 서로 다른 ToOne 대상 수, 아이템마다 달라 N번 나가는 ToOne, 컬렉션 초기화 N건으로 갈린다. N=1,000에서는 1 + 1 + 20 + 1,000 + 1,000 = 2,022였다.

이렇게 역산한 파생값은 직접 측정한 값과 맞는지 교차 검증한다. 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 빼면 entityFetch와 같아야 하고, N=1,000에서는 2,022 1,000 2 = 1,020으로 엔티티별 fetch 통계에서 직접 읽은 값과 일치했다.

처음 나눌 때는 count 1건을 빼지 않아 ToOne 조회 수를 1,021로 적었다. 페이지 count가 ToOne 쪽에 섞인 값이었고, 그래서 분해 항목에 count를 따로 둔다.

4. 회귀 가드는 시더 카디널리티와 무관한 값으로 고정한다

합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있어서 회귀 가드는 엔티티별 지표로 고정한다. 아이템마다 다른 연관이면 pageFetch == N이 성립하고, 이 값은 시더가 몇 개를 심었는지와 무관하다.

같은 측정에서 User fetch는 N=10일 때 3, N=100과 N=1,000일 때 모두 20이었다. 시더가 만드는 사용자 수가 20에서 멈추고 한 번 조회한 사용자는 1차 캐시에 남아 다시 조회되지 않기 때문에 N과 함께 늘지 않았다. 합계는 회귀 가드가 아니라 교차 검증에 쓴다.

5. count 쿼리가 실행되는 조건을 맞춘 뒤 비교한다

Page를 반환하면 Spring Data가 전체 페이지 수를 알려주려고 count를 한 번 더 실행한다. 다만 offset이 0이고 pageSize가 반환 건수보다 크면 이 count를 건너뛴다.

그래서 같은 코드라도 총계가 count 1건만큼 달라진다. pageSize를 반환 건수 N과 같게 맞춘 측정에서는 2,022건 안에 count 1건이 들어 있었고, 1건을 pageSize 10으로 조회한 라운드트립 스모크에서는 count가 생략되어 총 4건이 나왔다. 측정값을 비교할 때는 이 조건부터 맞춘다.

6. 반복마다 1차 캐시를 비우고 잰다

같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹어서 두 번째 반복부터 값이 섞인다. 지연을 반복 측정하는 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출하고, 그래서 매 호출이 실제로 DB를 때리면서 em.clear() 자체의 비용은 측정 구간 밖에 놓인다.

쿼리 수는 stats.clear() 직후 딱 1회 실행분으로만 읽어 회당 정확값을 얻는다.

7. N을 전체 테이블 크기가 아니라 한 요청의 부모 엔티티 수로 센다

N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 부모 하나마다 컬렉션을 한 번씩 초기화하기 때문에, 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않는다.

대신 전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.

8. 왕복과 행수를 다른 축으로 센다

하이라이트가 아무리 많아도 조회량이 그에 비례해 늘지 않아야 한다는 요구는 한 조회에서 두 가지 방식으로 동시에 깨질 수 있다. 하나는 부모 수만큼 DB를 왕복하는 N+1이고, 다른 하나는 한 번의 왕복에서 그 부모의 자식 행을 전부 읽어 오는 과조회다.

왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다.

적용 조건

  • ORM 조회에서 쿼리 발생량이 N에 비례해 늘어나는지 확인할 때
  • fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때
  • N+1 회귀를 테스트로 고정할 때

예외

  • SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다. 그 단계는 Batch Fetch가 컬렉션 수와 SQL 수의 등식을 깨뜨리는 때로 미리 정해 두었다.
  • 운영 종단 지연이나 처리량, 커넥션 풀 상태가 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링)으로 확인한다.

예시

  • 초기화 컬렉션 수 : 지연 로딩이 채운 컬렉션 개수. 실행된 SELECT 수가 아니다
  • 총 PreparedStatement : JDBC에서 얻은 문장 객체 수. SQL 실행 수와 다를 수 있다
  • 회계 항등식 : 총계 − 컬렉션 N content 1 count 1 = entityFetch
  • 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관)
  • 측정 규율 : 매 반복 전 em.clear(), stats.clear() 직후 1회만 읽기