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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b25357c48a
commit
62520a4dce
+34
-28
@@ -4,70 +4,75 @@ kind: REFERENCE
|
||||
slug: feed-visibility-query-pattern
|
||||
title: Feed Visibility Query Pattern
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/635fcedd-d402-4297-bcf3-9fcdf4200d28/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#15-1
|
||||
- final/document.md#15-4
|
||||
---
|
||||
|
||||
# Feed Visibility Query Pattern
|
||||
|
||||
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기를 만든다. 하나의 OR로 묶는 방식, 분기를 UNION으로 나누는 방식, 사용자별로 미리 계산하는 방식이 같은 결과를 다른 비용으로 만든다.
|
||||
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기로 조건이 나뉜다. 세 분기를 하나의 OR로 묶는 방식, UNION으로 나누는 방식, 사용자별로 미리 펼쳐 두는 방식은 같은 항목 집합을 돌려주지만 실행계획과 읽는 블록 수가 다르다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
|
||||
단일 OR이 정렬 인덱스를 못 쓰는 것을 확인한 기록이다.
|
||||
keyset 쿼리에 이 세 분기를 얹자 정렬키 인덱스가 계획에서 빠지는 것을 확인한 기록이다.
|
||||
- **feed_visible을 Production CQRS로 승격할 것인가**
|
||||
사전계산 방식이 남긴 판단이다.
|
||||
사전계산 테이블을 실험 밖에서 상시 유지할지는 이 질문이 다룬다.
|
||||
- **Keyset Pagination 설계 기준**
|
||||
이 조건과 함께 서야 하는 페이징 기준이다.
|
||||
가시성 조건을 얹기 전에 커서와 정렬키 인덱스를 어떻게 맞출지 정한 기준이다.
|
||||
|
||||
## 목적
|
||||
|
||||
세 분기는 조건의 성격이 다르다. 값 비교, 다른 테이블과의 관계 확인, 소유자 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 한 접근 경로로 만족시킬 수 없다.
|
||||
세 분기가 보는 것이 서로 다르다. 공개 분기는 visibility 값만 비교하고, 멘션 분기는 feed_item_mentions를 뒤져 조회자가 멘션됐는지 확인하며, 비공개 분기는 항목의 user_id가 조회자인지 본다. 하나의 인덱스는 선두 컬럼 순서 하나만 주기 때문에 셋을 한 접근 경로로 만족시킬 수 없다.
|
||||
|
||||
정렬과 페이징을 함께 요구하면 이 차이가 실행계획에서 드러난다.
|
||||
여기에 정렬과 페이징까지 요구하면 세 분기를 어떻게 합치느냐에 따라 Sort 노드가 붙기도 하고 사라지기도 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 단일 OR은 정렬 순서를 잃는다
|
||||
### 1. 단일 OR로 묶으면 Sort 노드가 다시 붙는다
|
||||
|
||||
세 분기를 하나의 조건으로 묶으면 플래너가 분기별로 스캔한 뒤 bitmap으로 합친다. 이 과정에서 인덱스가 주던 순서가 사라져 상위 몇 행을 만들기 위한 정렬이 다시 필요해진다.
|
||||
세 분기를 하나의 조건으로 묶으면 플래너가 분기마다 인덱스를 스캔해 BitmapOr로 합치고 Bitmap Heap Scan으로 테이블을 훑는다. 이 과정에서 인덱스가 주던 순서가 사라지기 때문에 상위 20행을 만드는 top-N Sort가 다시 붙는다.
|
||||
|
||||
관계 확인 조건은 hashed SubPlan으로 처리될 수 있다.
|
||||
멘션 여부를 EXISTS로 확인하는 분기는 이 계획에서 hashed SubPlan으로 처리됐다.
|
||||
|
||||
### 2. UNION 분해는 분기마다 자기 인덱스를 태운다
|
||||
### 2. UNION으로 나누면 전체 재정렬이 사라진다
|
||||
|
||||
세 분기를 각각 정렬이 보장되는 쿼리로 만들고 병합하면 전체 재정렬이 사라진다. 관계 확인 조건도 조인으로 바뀐다.
|
||||
세 분기를 각각 정렬이 보장되는 쿼리로 만들어 Merge Append로 병합하면 전체를 다시 정렬하지 않는다. 분기마다 자기 조건에 맞는 인덱스를 쓰고 멘션 조건도 SubPlan 대신 Hash Join으로 바뀐다.
|
||||
|
||||
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기 수만큼 접근이 늘어 buffers가 단일 OR보다 클 수 있다.
|
||||
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기가 셋이면 스캔도 세 번 나가므로 읽는 블록이 단일 OR보다 많아질 수 있다.
|
||||
|
||||
### 3. 사전계산은 조회를 단일 인덱스 스캔으로 바꾼다
|
||||
### 3. 미리 펼쳐 두면 조회가 커버링 인덱스 하나로 끝난다
|
||||
|
||||
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 읽는다. OR도 조인도 정렬도 없다.
|
||||
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 Index Only Scan으로 읽는다. OR도 조인도 정렬도 없다.
|
||||
|
||||
대신 원본이 바뀔 때 이 투영을 갱신해야 하고 사용자 수만큼 저장 공간이 늘어난다.
|
||||
대신 원본이 바뀔 때마다 이 투영을 갱신해야 하고 저장 공간은 조회 사용자 수만큼 늘어난다.
|
||||
|
||||
### 4. 세 방식이 같은 결과를 내는지 먼저 확인한다
|
||||
|
||||
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다.
|
||||
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다. 피드에서는 FeedVisibilityIT의 l16ThreeApproachesReturnSameVisibleSet이 seed 2,000에서 조회 사용자 user008에게 세 방식이 같은 20개 항목을 돌려주는지 먼저 맞춰 봤다.
|
||||
|
||||
### 5. 분기별 선택도에 맞는 인덱스를 따로 둔다
|
||||
### 5. 분기마다 선택도가 다르니 인덱스도 분기별로 본다
|
||||
|
||||
선택도가 낮은 분기는 전용 인덱스나 부분 인덱스가 유리하다. 관계 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다.
|
||||
비공개 분기는 조회자 자신의 항목만 남기므로 부분 인덱스가 맞고, 공개 분기는 선택도가 높아 UNION 안에서도 비트맵으로 훑은 뒤 top-N Sort로 상위를 냈다.
|
||||
|
||||
부모를 찾는 인덱스와 조회자를 찾는 인덱스는 컬럼 순서가 다르다.
|
||||
멘션 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다. V7이 만든 feed_item_mentions 인덱스는 선두 컬럼이 feed_item_id라 항목에 달린 멘션을 찾는 데는 맞지만 조회자를 멘션한 항목을 찾는 쿼리에는 맞지 않아, (mentioned_user_id, feed_item_id) 순서인 ix_mentions_user를 따로 만들어 비교했다.
|
||||
|
||||
### 6. buffers만으로 우열을 정하지 않는다
|
||||
### 6. buffers 하나만 보고 방식을 고르지 않는다
|
||||
|
||||
UNION은 분기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬을 없앤다. 무엇을 줄이려는지에 따라 선택이 달라진다.
|
||||
단일 OR의 buffers는 122였는데 UNION 분해는 200으로 오히려 컸다. 분기를 나눈 만큼 스캔이 늘었기 때문이다. 훑는 후보로 보면 단일 OR의 1,500이 UNION 분해에서 60 이하로 줄었고, 사전계산은 20행만 읽으면서 buffers도 1이었다.
|
||||
|
||||
훑는 후보 수, 정렬 유무, buffers를 함께 본다.
|
||||
buffers 하나로 줄 세우면 UNION 분해가 뒤로 밀리지만 훑은 행수와 재정렬 유무로는 앞선다. 무엇을 줄이려는지 정한 뒤 훑는 후보 수, 정렬 유무, buffers를 함께 본다. 피드에서는 셋을 그렇게 견주고 보통은 UNION 분해를, 고트래픽 읽기가 극단이면 사전계산을 고르는 것으로 정리했다.
|
||||
|
||||
### 7. 사전계산을 상시 유지하면 읽기 모델이 된다
|
||||
### 7. 사전계산을 상시 유지하면 읽기 모델 설계로 넘어간다
|
||||
|
||||
미리 계산한 테이블을 계속 유지하려면 원본 변경을 투영에 반영해야 한다. 이 시점에 조회 최적화가 아니라 읽기 모델 설계 문제가 된다.
|
||||
미리 계산한 테이블을 실험 밖에서 계속 유지하려면 피드·멘션·가시성이 바뀔 때마다 투영에 반영해야 한다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
@@ -83,8 +88,9 @@ UNION은 분기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬
|
||||
|
||||
## 예시
|
||||
|
||||
- 단일 OR : 분기별 스캔을 bitmap으로 합침, 정렬 재수행, 관계 조건은 hashed SubPlan
|
||||
- UNION 분해 : 분기별 정렬 스트림을 병합, 전체 재정렬 없음, 관계 조건은 조인
|
||||
- 사전계산 : 커버링 인덱스 하나, OR·조인·정렬 없음
|
||||
- 단일 OR : 분기별 스캔을 BitmapOr로 합침, top-N Sort 재등장, 멘션 조건은 hashed SubPlan, buffers 122
|
||||
- UNION 분해 : 분기별 정렬 스트림을 Merge Append로 병합, 전체 재정렬 없음, 멘션 조건은 Hash Join, buffers 200
|
||||
- 사전계산 : 커버링 인덱스 하나를 Index Only Scan, OR·조인·정렬 없음, buffers 1
|
||||
- 훑는 후보 : 단일 OR 1,500, UNION 분해 60 이하, 사전계산 20
|
||||
- 갱신 비용 : 사전계산만 있음
|
||||
- 저장 공간 : 사전계산은 조회 사용자 수에 비례
|
||||
- 저장 공간 : 사전계산은 조회 사용자 수만큼 늘어남
|
||||
|
||||
+35
-31
@@ -4,93 +4,97 @@ kind: REFERENCE
|
||||
slug: fetch-strategy-selection
|
||||
title: Fetch Join · Batch · Projection 선택 기준
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#9-4
|
||||
- final/document.md#11-5
|
||||
- final/document.md#12-5
|
||||
---
|
||||
|
||||
# Fetch Join · Batch · Projection 선택 기준
|
||||
|
||||
세 전략은 서로 다른 비용을 줄인다. fetch join은 왕복을 접지만 행을 곱하고, batch는 왕복을 묶지만 엔티티를 그대로 만들고, 프로젝션은 적재를 없애지만 행수를 줄이지 않는다.
|
||||
세 전략은 줄이는 것이 서로 다르다. fetch join은 쿼리 수를 1+N에서 1로 줄이지만 전송 행을 자식 수만큼 부풀리고, 배치는 왕복을 배치 크기 단위로 묶지만 엔티티는 통째로 하이드레이트하고, 프로젝션은 엔티티를 하나도 만들지 않지만 부모당 자식 행은 전부 가져온다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
|
||||
fetch join의 한계를 확인한 기록이다.
|
||||
컬렉션 둘을 동시에 fetch join하면 거부되고, 하나만 걸면 행이 늘어난다.
|
||||
- **Collection Fetch Join Pagination의 In-memory Paging**
|
||||
fetch join과 페이징이 함께 서지 못하는 것을 확인한 기록이다.
|
||||
컬렉션 fetch join에 페이징을 걸자 DB LIMIT이 빠지고 부모가 전부 메모리에 올라왔다.
|
||||
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
|
||||
프로젝션이 남기는 비용을 확인한 기록이다.
|
||||
프로젝션으로 엔티티 로드는 0이 되었지만 자식 행 1,509개는 줄지 않았다.
|
||||
|
||||
## 목적
|
||||
|
||||
쿼리 수만 보고 전략을 고르면 비용이 다른 축으로 옮겨 간 것을 놓친다. 컬렉션 fetch join은 쿼리 수를 크게 줄이면서 전송 행수와 메모리를 키운다.
|
||||
컬렉션 fetch join은 쿼리 수를 1+N에서 1로 줄이지만, 부모 100행짜리 조회가 조인 뒤 1,961행을 실어 날랐다. 배치는 왕복을 2,022개에서 23개로 줄였지만 엔티티 1,569개를 하이드레이트했다.
|
||||
|
||||
무엇을 줄이려는지 먼저 정하고 그 축을 재는 지표로 전후를 비교한다.
|
||||
무엇을 줄이려는지 먼저 정하고 그것을 세는 지표로 전후를 비교한다. 왕복은 PreparedStatement 수로, 메모리에 올린 부모 수는 feedItemLoaded로, 영속 엔티티 수는 Statistics.getEntityLoadCount()로 잰다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 컬렉션 fetch join은 두 개 이상 쓰지 않는다
|
||||
|
||||
순서 컬럼이 없는 List 두 개를 동시에 fetch join하면 곱집합을 원래 컬렉션으로 되돌릴 수 없어 쿼리 생성 시점에 거부된다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
|
||||
순서 컬럼(@OrderColumn)이 없는 List를 bag이라고 한다. bag 두 개를 동시에 fetch join하면 부모 한 행이 두 자식 개수의 곱만큼 늘어나는데, Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 createQuery 시점에 MultipleBagFetchException을 던진다. 데이터가 0건이어도 매핑 단계에서 거부한다.
|
||||
|
||||
### 2. 컬렉션 fetch join은 행을 곱한다
|
||||
|
||||
컬렉션 하나만 fetch join해도 부모 한 행이 자식 수만큼 반복된다. 전송 행수는 자식 총합이 된다.
|
||||
컬렉션 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복된다. seed(100)에서 부모 feed_items는 100행인데 조인이 실어 나른 행은 자식 총합인 1,961행이었다.
|
||||
|
||||
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기로는 이 증가가 보이지 않는다. 조인 카디널리티나 실행계획의 actual rows로 확인한다.
|
||||
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 100이라 이 증가가 보이지 않는다. 실행계획의 actual rows로 확인한다.
|
||||
|
||||
### 3. 컬렉션 fetch join과 페이징을 같이 쓰지 않는다
|
||||
|
||||
부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다. Hibernate는 이를 피하려고 SQL에서 LIMIT을 빼고 전체를 읽은 뒤 메모리에서 자른다.
|
||||
조인 행에 부모 기준 LIMIT을 그대로 걸면 일부 부모의 자식이 잘려 나가므로, Hibernate는 SQL에서 LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 내고 경고를 남긴다.
|
||||
|
||||
응답은 한 페이지지만 로드한 부모는 전체다. 발행 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
|
||||
setMaxResults(20)을 걸어도 반환 목록만 20건이었고 메모리에 올린 부모는 N건 전부였다. 발행 SQL에는 Limit 노드가 없었다.
|
||||
|
||||
### 4. fetch join은 ToOne에 쓴다
|
||||
|
||||
ToOne은 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join이 적합하다.
|
||||
ToOne 연관은 부모 한 행에 자식이 하나라 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join은 여기에 쓴다.
|
||||
|
||||
### 5. 컬렉션에는 batch fetch를 쓴다
|
||||
### 5. 컬렉션에는 배치 페치를 쓴다
|
||||
|
||||
엔티티만 페이징해 DB LIMIT이 정상 작동하게 한 뒤, 지연 연관은 부모 키를 모아 IN으로 채운다. 배치 크기가 B면 왕복은 부모 수를 B로 나눈 올림값이 된다.
|
||||
fetch join을 빼고 엔티티만 페이징하면 DB LIMIT이 다시 적용되고, 지연 연관은 부모 키를 모아 IN으로 채운다. hibernate.default_batch_fetch_size를 B로 두면 초기화되지 않은 프록시를 최대 B개씩 모아 ceil(N/B)번에 로드한다. N=1,000에서 PreparedStatement는 2,022개에서 23개로 줄었고 메모리에 올린 부모는 페이지 크기인 20에 머물렀다.
|
||||
|
||||
배치는 부모와 자식을 곱하지 않는다. 실행계획에서 semi-join으로 나타난다.
|
||||
배치는 부모와 자식을 곱하지 않는다. 실행계획에는 Hash Semi Join으로 나타났고 자식 행 1,509개만 돌아왔다.
|
||||
|
||||
### 6. 화면 조회에는 프로젝션을 쓴다
|
||||
|
||||
필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않는다. 1차 캐시, 더티체킹, 지연 프록시도 생기지 않는다.
|
||||
SELECT new Carrier(...)로 필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않으므로 1차 캐시도, 더티체킹도, 지연 프록시도 생기지 않는다. 배치까지 적용하고도 1,569개였던 엔티티 로드가 프로젝션에서는 0개가 되었다.
|
||||
|
||||
join이 있어도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 이 동작은 배치 설정 여부와 관계없이 성립한다.
|
||||
조인이 있어도 컬럼을 읽는 경로일 뿐 엔티티를 만들지 않는다. 배치 설정 여부와 관계없이 성립하는 동작이다.
|
||||
|
||||
### 7. 프로젝션의 효과는 실행계획이 아니라 ORM 층에서 확인한다
|
||||
|
||||
필요한 컬럼만 골라도 EXPLAIN의 width가 줄지 않을 수 있다. 조인 대상의 행폭이 반영되고 width가 실제 전송 바이트가 아니라 타입의 평균폭 추정치이기 때문이다.
|
||||
필요한 컬럼만 골라도 EXPLAIN의 width는 줄지 않을 수 있다. 이 랩에서는 부모 프로젝션의 width가 2088로 엔티티 조회의 1194보다 오히려 컸는데, users와 pages 조인의 행폭이 반영되고 PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
|
||||
|
||||
프로젝션의 이득은 엔티티 로드 수로 확인한다.
|
||||
프로젝션의 이득은 Statistics.getEntityLoadCount()로 확인한다.
|
||||
|
||||
### 8. 세 전략이 남기는 비용을 적는다
|
||||
### 8. 전략을 바꾼 뒤 무엇이 줄지 않았는지 적는다
|
||||
|
||||
fetch join은 행 폭증과 페이징 불가를 남긴다. batch는 엔티티 과적재를 남긴다. 프로젝션은 부모당 자식 전량 조회를 남긴다.
|
||||
|
||||
남은 비용을 적어야 다음 단계가 무엇을 풀어야 하는지 이어진다.
|
||||
fetch join은 쿼리 수를 1로 줄이는 대신 행을 곱했고 페이징을 막았다. 배치는 왕복과 페이징을 풀었지만 FeedItem·User·Page·Highlight를 합해 1,569개를 하이드레이트했다. 프로젝션은 그 1,569개를 0개로 만들었지만 페이지 부모 20개의 자식 1,509행을 전부 가져왔다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 연관을 포함한 목록 조회를 설계할 때
|
||||
- N+1을 확인하고 fetch 전략을 고를 때
|
||||
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 남았는지 정리할 때
|
||||
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 줄지 않았는지 정리할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 컬렉션이 하나이고 페이징이 없으며 자식 수가 작다면 컬렉션 fetch join이 단순하다. 자식 수가 커질 수 있는 구조에는 쓰지 않는다.
|
||||
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 측정을 유지하려면 별도 설정 범위로 격리한다.
|
||||
- hibernate.default_batch_fetch_size는 세션 전체에 영향을 준다. 앞서 잰 값을 다시 재려면 이 랩처럼 별도 테스트 클래스(FeedBatchFetchIT)에만 설정한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 컬렉션 두 개 fetch join : 쿼리 생성 시점 거부
|
||||
- 컬렉션 한 개 fetch join : 전송 행수 = 자식 총합
|
||||
- 컬렉션 fetch join + 페이징 : DB LIMIT 없음, 부모 전체 로드
|
||||
- ToOne fetch join : 행 곱하지 않음, 적합
|
||||
- batch fetch : 왕복 = 부모 수 / 배치 크기 올림
|
||||
- 프로젝션 : 엔티티 로드 0, 쿼리 상수, 자식 행수는 그대로
|
||||
- 컬렉션 두 개 fetch join : createQuery 시점에 MultipleBagFetchException
|
||||
- 컬렉션 한 개 fetch join : 부모 100행, 조인 행 1,961행
|
||||
- 컬렉션 fetch join + 페이징 : Limit 노드 없음, 반환 20건에 부모 로드 N건
|
||||
- ToOne fetch join : 행 곱하지 않음
|
||||
- 배치 페치 : 왕복 ceil(N/B), N=1,000에서 2,022개 → 23개
|
||||
- 프로젝션 : 엔티티 로드 1,569개 → 0개, 쿼리 2개, 자식 행 1,509개
|
||||
|
||||
+32
-26
@@ -4,20 +4,25 @@ kind: REFERENCE
|
||||
slug: fetch-type-vs-fetch-strategy
|
||||
title: Fetch Type과 Fetch Strategy 구분
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#5-4
|
||||
- final/document.md#7-5
|
||||
---
|
||||
|
||||
# Fetch Type과 Fetch Strategy 구분
|
||||
|
||||
EAGER와 LAZY는 연관이 언제 로딩돼야 하는지를 정하는 계약이다. 어떤 SQL로 가져올지는 정하지 않는다. N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 전략으로 푼다.
|
||||
즉시 로딩(EAGER)과 지연 로딩(LAZY)은 연관을 언제 로딩할지 정하는 계약이지, 어떤 SQL로 가져올지는 정하지 않는다. 그래서 N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 조회 전략으로 푼다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **필드 접근 없이 발생한 EAGER ToOne N+1**
|
||||
이 구분을 실제 측정으로 확인한 기록이다.
|
||||
연관 필드를 한 번도 읽지 않았는데 2차 SELECT가 아이템 수만큼 나간 것을 잰 기록이다.
|
||||
- **Fetch Join · Batch · Projection 선택 기준**
|
||||
전략을 고르는 기준이다.
|
||||
- **JPA N+1 정량 진단 기준**
|
||||
@@ -25,60 +30,60 @@ EAGER와 LAZY는 연관이 언제 로딩돼야 하는지를 정하는 계약이
|
||||
|
||||
## 목적
|
||||
|
||||
즉시 로딩이면 한 번에 가져올 것이라고 읽기 쉽다. 실제로는 파생 쿼리에서 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우는 경우가 있다.
|
||||
즉시 로딩이면 루트와 연관을 한 번에 가져온다고 읽기 쉽다. 실제로는 파생 쿼리가 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우기도 한다.
|
||||
|
||||
이 구분을 세워야 애너테이션을 바꾸는 것과 조회 방식을 바꾸는 것이 서로 다른 작업이라는 점이 드러난다.
|
||||
애너테이션의 fetch 값을 바꾸면 조회가 언제 나갈지가 달라지고, 조회 방식을 바꾸면 몇 번 나갈지가 달라진다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. EAGER는 로딩 시점 계약이지 JOIN 보장이 아니다
|
||||
|
||||
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이다. 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
|
||||
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이지, 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
|
||||
|
||||
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다.
|
||||
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다. 이 프로젝트의 findAllBy가 그랬다. 루트인 feed_items를 먼저 가져오고 user와 page는 행마다 한 번씩 조회했다.
|
||||
|
||||
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 provider, 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
|
||||
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 JPA 구현체(provider)와 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
|
||||
|
||||
### 2. 기본값을 명시적으로 확인한다
|
||||
### 2. fetch를 적지 않은 연관의 기본값을 확인한다
|
||||
|
||||
@ManyToOne과 @OneToOne의 기본값은 EAGER다. @OneToMany와 @ManyToMany의 기본값은 LAZY다.
|
||||
@ManyToOne과 @OneToOne의 기본값은 EAGER이고, @OneToMany와 @ManyToMany의 기본값은 LAZY다.
|
||||
|
||||
fetch를 적지 않은 코드에도 기본값이 적용된다. 코드에 조회가 보이지 않는다는 것이 조회가 나가지 않는다는 뜻은 아니다.
|
||||
fetch를 적지 않은 연관에도 이 기본값이 걸리므로, 코드에 조회가 보이지 않는다고 해서 조회가 나가지 않는 것은 아니다.
|
||||
|
||||
### 3. 접근 여부와 fetch 계약을 교차해서 본다
|
||||
|
||||
EAGER는 접근하지 않아도 나간다. 사용하지 않는 연관까지 조회하는 낭비가 된다.
|
||||
EAGER 연관은 코드에서 한 번도 읽지 않아도 조회가 나간다. 아무 필드도 매핑하지 않고 feed_items만 조회한 측정에서 아이템 100개에 Page 2차 SELECT가 100번 나갔고, 같은 조건에서 LAZY인 highlights는 한 번도 나가지 않았다. 쓰지도 않는 연관을 조회하는 낭비다.
|
||||
|
||||
LAZY는 접근할 때 나간다. 접근하면 같은 N+1이 시점만 달라져 다시 생긴다.
|
||||
LAZY는 접근할 때 나가므로 매핑에서 그 연관을 실제로 읽으면 같은 N+1이 시점만 밀려 다시 생긴다. 매핑 루프에서 user와 page를 쓰는 조회라면 EAGER를 LAZY로 바꿔도 쿼리 수는 줄지 않는다.
|
||||
|
||||
매핑 루프에서 연관을 실제로 사용한다면 EAGER를 LAZY로 바꿔도 N+1은 남는다.
|
||||
### 4. 조회 횟수는 서로 다른 연관 대상 수로 센다
|
||||
|
||||
### 4. 실제 증가 폭은 서로 다른 연관 대상 수가 정한다
|
||||
부모마다 다른 연관은 부모 수만큼 조회되고, 소수를 돌려 쓰는 연관은 한 번 로드한 대상이 1차 캐시에 남아 서로 다른 대상 수만큼만 조회된다. 같은 @ManyToOne(EAGER)인 Page와 User를 두고 한 번에 가져오는 아이템 수 N을 10, 100, 1,000으로 바꿔 가며 쟀다. Page fetch는 N과 같은 수인 10, 100, 1,000이었고 User fetch는 3, 20, 20으로 20에서 멈췄다.
|
||||
|
||||
같은 EAGER ToOne이라도 증가 곡선이 갈린다. 소수를 재사용하는 연관은 1차 캐시가 재조회를 걸러 서로 다른 대상 수만큼만 조회된다. 부모마다 다른 연관은 부모 수만큼 조회된다.
|
||||
N+1이 생길 가능성은 fetch 계약이 만들지만, 실제로 몇 번 조회되는지는 Persistence Context 안에 서로 다른 대상이 몇 개 있는지가 정한다.
|
||||
|
||||
N+1이 생길 가능성은 fetch 계약이 만들고, 실제 실행 횟수는 Persistence Context 안에서 서로 다른 대상이 몇 개인지가 정한다.
|
||||
### 5. 반복문이 없어도 컬렉션 조회는 아이템마다 나간다
|
||||
|
||||
### 5. 컬렉션 접근은 반복문 없이도 반복된다
|
||||
지연 로딩 컬렉션은 접근하는 순간 조회하므로, 아이템이 N개면 접근도 조회도 N번이다.
|
||||
|
||||
지연 로딩 컬렉션은 접근하는 순간 조회한다. 부모가 N개면 접근과 조회도 N번이다.
|
||||
컬렉션 접근이 스트림이나 매핑 함수 뒤에 있으면 코드에 for가 보이지 않는다.
|
||||
|
||||
스트림이나 매핑 함수 뒤에 있으면 명시적인 반복문이 보이지 않는다. 반복이 사라진 것이 아니라 표현이 바뀐 것이다.
|
||||
이 프로젝트의 매핑에도 하이라이트를 위한 명시적인 for가 없고 getHighlights().stream()만 있었다. 처음에는 그 코드만 보고 조회가 N번 나간다고 알아차리기 어려웠다.
|
||||
|
||||
### 6. 타입이 아니라 전략을 바꾼다
|
||||
### 6. 왕복 수를 줄이려면 조회 방식을 바꾼다
|
||||
|
||||
fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이려면 fetch join, batch fetch, 프로젝션처럼 조회 방식 자체를 바꾼다.
|
||||
fetch 타입을 바꾸면 조회가 나가는 시점만 달라진다. 왕복 수를 줄이려면 fetch join, 배치 페치, 프로젝션처럼 조회 방식 자체를 바꾼다. 배치 페치는 N=1,000에서 총 PreparedStatement를 2,022개에서 23개로 줄였다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 연관 매핑을 정하거나 바꿀 때
|
||||
- N+1의 원인을 애너테이션에서 찾으려 할 때
|
||||
- EAGER를 LAZY로 바꾸는 것으로 문제가 풀린다고 판단하기 전에
|
||||
- EAGER를 LAZY로 바꾸면 N+1이 풀린다고 판단하기 전에
|
||||
|
||||
## 예외
|
||||
|
||||
- 단건 조회에서 provider가 JOIN을 선택하는 구현이 있다. 그 동작에 의존하려면 사용하는 provider와 버전에서 확인한 뒤 적는다.
|
||||
- 연관을 전혀 사용하지 않는다면 LAZY로 바꾸는 것만으로 낭비가 사라진다. 이때는 전략 변경이 아니라 타입 변경이 맞는 해법이다.
|
||||
- 단건 조회에서는 구현체가 JOIN을 고르기도 한다. 그 동작에 기대려면 쓰는 구현체와 버전에서 직접 확인한 뒤 적는다.
|
||||
- 연관을 어디에서도 읽지 않는다면 LAZY로 바꾸는 것만으로 그 조회가 사라진다. 이때는 조회 방식을 손대지 않고 fetch 값만 바꾸면 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
@@ -86,6 +91,7 @@ fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이
|
||||
- LAZY : 접근 시점에 로딩
|
||||
- @ManyToOne 기본값 : EAGER
|
||||
- @OneToMany 기본값 : LAZY
|
||||
- 접근 0회 EAGER : 조회 나감 (낭비)
|
||||
- 접근 0회 LAZY : 조회 안 나감
|
||||
- 접근 0회 EAGER : 아이템 100개에서 Page fetch 100, User fetch 20
|
||||
- 접근 0회 LAZY : highlights fetch 0
|
||||
- 접근함 EAGER / LAZY : 둘 다 N+1, 시점만 다름
|
||||
- 같은 EAGER의 조회 수 : N=10, 100, 1,000에서 Page fetch 10, 100, 1,000 / User fetch 3, 20, 20
|
||||
|
||||
+34
-25
@@ -4,70 +4,79 @@ kind: REFERENCE
|
||||
slug: keyset-pagination-design
|
||||
title: Keyset Pagination 설계 기준
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#14-1
|
||||
- final/document.md#14-4
|
||||
---
|
||||
|
||||
# Keyset Pagination 설계 기준
|
||||
|
||||
OFFSET은 건너뛸 행까지 만든 뒤 버린다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽는다. 다만 커서와 같은 순서의 정렬키 인덱스가 있어야 이 이점이 생긴다.
|
||||
OFFSET은 정렬 순서에서 앞의 offset행을 만든 뒤 버리기 때문에 페이지가 깊어질수록 훑는 행이 늘어난다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽으므로 깊이와 상관없이 페이지 크기만큼만 훑는다. 다만 커서와 같은 순서의 정렬키 인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
|
||||
이 기준의 전제가 깨지는 조건을 확인한 기록이다.
|
||||
가시성 조건을 같은 쿼리에 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌다.
|
||||
- **Feed Pagination은 Keyset을 사용한다**
|
||||
이 기준에서 나온 결정이다.
|
||||
이 기준을 따라 부모 피드 페이징을 OFFSET에서 커서로 바꿨다.
|
||||
- **PostgreSQL Query Plan 측정 기준**
|
||||
깊이별 비용을 실행계획으로 확인하는 기준이다.
|
||||
깊이별로 훑은 행과 읽은 블록 수를 EXPLAIN으로 잴 때 그 기준을 따랐다.
|
||||
|
||||
## 목적
|
||||
|
||||
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊으면 전량 스캔과 정렬로 떨어진다.
|
||||
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊어지면 아예 Seq Scan과 Sort로 전량을 훑는다.
|
||||
|
||||
페이지 깊이와 무관하게 읽는 행수를 일정하게 유지하려면 커서 방식이 필요하다.
|
||||
페이지 깊이와 무관하게 읽는 행수를 페이지 크기에 고정하려면 이전 페이지의 마지막 행을 커서로 넘겨야 한다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 커서에 정렬키를 모두 담는다
|
||||
|
||||
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 빠지거나 중복된다.
|
||||
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 행이 빠지거나 중복될 수 있다.
|
||||
|
||||
### 2. tie-break 컬럼을 정렬과 커서에 넣는다
|
||||
|
||||
정렬키에 중복이 있을 수 있으면 유일한 컬럼을 마지막 정렬키로 더한다. 커서에도 같이 담는다.
|
||||
정렬키에 중복이 있을 수 있으면 값이 유일한 컬럼을 마지막 정렬키로 더하고 커서에도 같이 담는다. 피드의 커서는 first_highlighted_at과 id 두 컬럼이었는데, 시각이 같은 행도 안정적으로 넘기려면 tie-break인 id까지 커서에 포함해야 한다.
|
||||
|
||||
### 3. 정렬키, 커서, 인덱스의 컬럼과 방향을 일치시킨다
|
||||
|
||||
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort가 다시 생긴다. 방향까지 같아야 한다.
|
||||
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort 노드가 다시 나타난다. 컬럼 목록뿐 아니라 내림차순·오름차순 방향까지 같아야 한다.
|
||||
|
||||
### 4. 정렬키 전용 인덱스를 확인한다
|
||||
|
||||
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 필터가 없는 정렬 쿼리라면 정렬키만으로 된 인덱스가 필요하다.
|
||||
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 피드에서도 V6가 만든 ix_feed_items_visibility_sort는 선두 컬럼이 visibility라 가시성 필터가 없는 keyset 쿼리에 맞지 않았고, first_highlighted_at DESC와 id DESC만으로 된 ix_feed_items_keyset을 따로 만들어 썼다.
|
||||
|
||||
인덱스가 없으면 keyset도 전량을 스캔한다. keyset 문법이 아니라 인덱스가 비용을 줄인다.
|
||||
스키마에 인덱스가 있어도 선두 컬럼이 맞물리지 않으면 정렬에는 쓰이지 못한다.
|
||||
|
||||
인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다. offset 1980의 깊은 페이지에서 정렬키 인덱스가 있을 때는 Index Only Scan으로 20행만 읽었고 읽은 블록은 1개였지만, 인덱스를 지우자 같은 쿼리가 Seq Scan으로 떨어지면서 141개가 됐다.
|
||||
|
||||
### 5. 깊이별로 훑은 행을 측정한다
|
||||
|
||||
OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
|
||||
OFFSET은 offset에 페이지 크기를 더한 만큼 훑고 keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
|
||||
|
||||
한 페이지만 재면 차이가 보이지 않는다. 깊이를 바꿔 가며 곡선으로 확인한다.
|
||||
한 페이지만 재면 차이가 드러나지 않으니 깊이를 바꿔 가며 곡선으로 확인한다. seed 2,000에 페이지 크기 20으로 재면 1페이지에서는 둘 다 20행이다. OFFSET이 훑은 행은 50페이지(offset 980)에서 1,000행, 100페이지(offset 1980)에서 2,000행까지 늘었고 keyset은 세 페이지 모두 20행이었다.
|
||||
|
||||
### 6. 결과가 OFFSET과 같은지 검증한다
|
||||
|
||||
커서로 넘긴 페이지가 같은 순서의 같은 행을 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다.
|
||||
커서로 넘긴 페이지가 OFFSET으로 넘긴 페이지와 같은 행을 같은 순서로 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다. 피드에서는 FeedKeysetIT의 l15KeysetWalkMatchesOffsetPages가 두 방식의 두 번째 페이지를 맞춰 보고 같은 20개 id, 같은 순서인지 확인했다.
|
||||
|
||||
### 7. 필터를 얹으면 전제가 깨질 수 있다
|
||||
### 7. 필터를 얹으면 정렬키 인덱스를 못 쓸 수 있다
|
||||
|
||||
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 합치면서 정렬 순서를 잃는다. 이때 Sort가 다시 나타난다.
|
||||
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 BitmapOr로 합치면서 인덱스의 정렬 순서를 잃고, 사라졌던 Sort 노드가 다시 나타난다. 피드에서도 가시성 3분기를 keyset과 같은 쿼리에 얹자 플래너가 ix_feed_items_keyset을 더 이상 쓰지 못했다.
|
||||
|
||||
필터가 있는 keyset은 필터를 포함한 인덱스 설계나 쿼리 분해가 함께 필요하다.
|
||||
필터가 있는 keyset은 필터까지 포함한 인덱스를 설계하거나, 분기를 UNION ALL로 나누거나, 뷰어별 결과를 미리 계산하는 방법을 함께 본다.
|
||||
|
||||
### 8. 정렬키에 null이 있을 수 있는지 먼저 정한다
|
||||
|
||||
정렬키가 nullable이면 null의 정렬 위치와 커서 표현을 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
|
||||
정렬키에 null이 들어갈 수 있으면 null을 어디에 정렬할지와 커서에 어떻게 담을지를 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
|
||||
|
||||
피드의 first_highlighted_at은 아직 NOT NULL이 아니다. NOT NULL로 좁힐지 NULLS LAST 같은 null 정렬 위치를 정의할지는 keyset 페이징 단계 이전에 결론 내기로 하고 미결정으로 뒀다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
@@ -78,14 +87,14 @@ OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페
|
||||
## 예외
|
||||
|
||||
- 임의 페이지 점프가 필요하면 커서만으로는 부족하다. OFFSET을 함께 두거나 다른 탐색을 설계한다.
|
||||
- 전체 건수를 화면에 표시해야 하면 count를 별도로 다룬다. 커서 결과에는 전체 건수가 없다.
|
||||
- 전체 건수를 화면에 표시해야 하면 count 쿼리를 따로 실행한다. 커서 결과에는 전체 건수가 없다.
|
||||
- 정렬 기준이 자주 바뀌면 기준마다 인덱스가 필요하다. 인덱스 수와 쓰기 비용을 함께 본다.
|
||||
|
||||
## 예시
|
||||
|
||||
- OFFSET 훑은 행 : offset + 페이지 크기
|
||||
- keyset 훑은 행 : 페이지 크기 (깊이 무관)
|
||||
- 커서 : (정렬키, tie-break) 조합
|
||||
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향
|
||||
- 인덱스 없는 keyset : 전량 스캔, 이점 없음
|
||||
- 필터 추가 : 분기가 갈리면 Sort 재등장
|
||||
- OFFSET 훑은 행 : offset + 페이지 크기 (offset 1980에서 2,000행)
|
||||
- keyset 훑은 행 : 페이지 크기 (offset 1980에서도 20행)
|
||||
- 커서 : 정렬키와 tie-break 조합 (first_highlighted_at, id)
|
||||
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향 (ix_feed_items_keyset)
|
||||
- 인덱스 없는 keyset : Seq Scan으로 전량 훑음, 읽은 블록 141개 (인덱스가 있으면 1개)
|
||||
- 필터 추가 : 분기가 갈리면 BitmapOr로 떨어지고 Sort 재등장
|
||||
|
||||
+41
-38
@@ -4,102 +4,105 @@ 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 실행 횟수로 바꿔 읽으면 배치를 적용한 뒤 결론이 어긋난다.
|
||||
N+1은 쿼리 총계가 아니라 Hibernate Statistics의 지표를 나눠 읽어 확인한다. 세 지표는 세는 단위가 서로 달라서, 모두 SQL 실행 횟수로 바꿔 읽으면 Batch Fetch를 적용한 뒤 등식이 깨지고 결론이 어긋난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
|
||||
엔티티별 fetch 통계로 ToOne 쪽을 확인한 기록이다.
|
||||
엔티티별 fetch 통계를 직접 읽어 여기서 역산한 ToOne 조회 수를 확인했다.
|
||||
- **PostgreSQL Query Plan 측정 기준**
|
||||
같은 측정에서 실행계획을 다루는 기준이다.
|
||||
같은 측정에서 뽑은 실행계획을 어떻게 읽을지는 그 기록이 정한다.
|
||||
|
||||
## 목적
|
||||
|
||||
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계에는 목록 루트, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 섞여 있다.
|
||||
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계 하나에 목록 루트 쿼리, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 함께 들어 있기 때문이다. N=1,000에서 나온 총 PreparedStatement 2,022건도 이 네 가지가 섞인 값이다.
|
||||
|
||||
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회되는지 확정할 수 있다. 그래야 fetch 전략을 바꿨을 때 무엇이 줄었는지 말할 수 있다.
|
||||
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회됐는지 확정된다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 지표 이름이 뜻하는 것을 그대로 읽는다
|
||||
|
||||
getCollectionFetchCount()는 초기화된 컬렉션 수다. 실행된 SELECT SQL 수가 아니다.
|
||||
세 지표는 모두 Hibernate Statistics에서 읽는다. 스택에 이미 있는 도구라 의존성을 더하지 않지만, SQL 형태별 정확한 실행 수는 주지 않는다.
|
||||
|
||||
getPrepareStatementCount()는 획득한 PreparedStatement 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
|
||||
getCollectionFetchCount()는 지연 로딩이 초기화한 컬렉션 개수를 센다. 실행된 SELECT SQL 수가 아니다.
|
||||
|
||||
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수다. 실행된 SELECT SQL 수가 아니다.
|
||||
getPrepareStatementCount()는 JDBC에서 얻은 문장 객체(PreparedStatement) 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
|
||||
|
||||
### 2. 등식이 성립하는 조건을 함께 적는다
|
||||
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수이고, ToOne 연관 조회를 이 지표로 읽는다. 역시 실행된 SELECT SQL 수가 아니다.
|
||||
|
||||
batch나 subselect가 없을 때만 초기화 컬렉션 수와 자식 SELECT 수가 같다. 이 조건에서만 컬렉션 수를 SQL 수로 바꿔 읽을 수 있다.
|
||||
### 2. 컬렉션 수와 SELECT 수가 같아지는 조건을 함께 적는다
|
||||
|
||||
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 등식이 깨진다. 배치 적용 여부는 prepared와 collectionFetch를 함께 보고 판단한다.
|
||||
Batch Fetch나 subselect가 없어야 컬렉션 하나를 초기화할 때 SQL이 하나 나가므로, 그때만 초기화된 컬렉션 수와 자식 SELECT 수가 같다.
|
||||
|
||||
### 3. 총계를 형태별로 가르고 검산한다
|
||||
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨진다. 배치가 걸렸는지는 총 PreparedStatement와 초기화 컬렉션 수를 함께 보고 판단한다.
|
||||
|
||||
총 PreparedStatement를 다음처럼 나눈다.
|
||||
### 3. 총계를 SQL 형태별로 가르고 검산한다
|
||||
|
||||
content 1
|
||||
count 1
|
||||
distinct ToOne 대상 수
|
||||
N ToOne (아이템마다 다른 연관)
|
||||
N 컬렉션 초기화
|
||||
총 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와 같은지 보는 것이다.
|
||||
이렇게 역산한 파생값은 직접 측정한 값과 맞는지 교차 검증한다. 총 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이 성립한다.
|
||||
합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있어서 회귀 가드는 엔티티별 지표로 고정한다. 아이템마다 다른 연관이면 pageFetch == N이 성립하고, 이 값은 시더가 몇 개를 심었는지와 무관하다.
|
||||
|
||||
합계는 회귀 가드가 아니라 교차 검증에 쓴다.
|
||||
같은 측정에서 User fetch는 N=10일 때 3, N=100과 N=1,000일 때 모두 20이었다. 시더가 만드는 사용자 수가 20에서 멈추고 한 번 조회한 사용자는 1차 캐시에 남아 다시 조회되지 않기 때문에 N과 함께 늘지 않았다. 합계는 회귀 가드가 아니라 교차 검증에 쓴다.
|
||||
|
||||
### 5. count 쿼리가 언제 나오는지 안다
|
||||
### 5. count 쿼리가 실행되는 조건을 맞춘 뒤 비교한다
|
||||
|
||||
Page를 반환하면 Spring Data가 전체 건수 count를 한 번 더 실행한다. offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다.
|
||||
Page를 반환하면 Spring Data가 전체 페이지 수를 알려주려고 count를 한 번 더 실행한다. 다만 offset이 0이고 pageSize가 반환 건수보다 크면 이 count를 건너뛴다.
|
||||
|
||||
같은 코드라도 pageSize와 반환 건수의 관계에 따라 총계가 달라진다. 측정값을 비교할 때 이 조건을 맞춘다.
|
||||
그래서 같은 코드라도 총계가 count 1건만큼 달라진다. pageSize를 반환 건수 N과 같게 맞춘 측정에서는 2,022건 안에 count 1건이 들어 있었고, 1건을 pageSize 10으로 조회한 라운드트립 스모크에서는 count가 생략되어 총 4건이 나왔다. 측정값을 비교할 때는 이 조건부터 맞춘다.
|
||||
|
||||
### 6. 캐시가 결과를 먹지 않게 한다
|
||||
### 6. 반복마다 1차 캐시를 비우고 잰다
|
||||
|
||||
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 흡수한다. 지연 반복 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출한다. clear 비용은 측정 구간 밖에 둔다.
|
||||
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹어서 두 번째 반복부터 값이 섞인다. 지연을 반복 측정하는 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출하고, 그래서 매 호출이 실제로 DB를 때리면서 em.clear() 자체의 비용은 측정 구간 밖에 놓인다.
|
||||
|
||||
쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다.
|
||||
쿼리 수는 stats.clear() 직후 딱 1회 실행분으로만 읽어 회당 정확값을 얻는다.
|
||||
|
||||
### 7. 증가 기준이 무엇인지 명시한다
|
||||
### 7. N을 전체 테이블 크기가 아니라 한 요청의 부모 엔티티 수로 센다
|
||||
|
||||
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 테이블이 100만 행이어도 이 왕복 수는 늘지 않는다.
|
||||
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 부모 하나마다 컬렉션을 한 번씩 초기화하기 때문에, 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않는다.
|
||||
|
||||
전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
|
||||
대신 전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
|
||||
|
||||
### 8. 왕복과 행수를 다른 축으로 센다
|
||||
|
||||
한 조회에 두 위반이 함께 있을 수 있다. 부모 수에 비례하는 왕복과, 한 번의 왕복에서 자식을 전부 읽는 과조회다.
|
||||
하이라이트가 아무리 많아도 조회량이 그에 비례해 늘지 않아야 한다는 요구는 한 조회에서 두 가지 방식으로 동시에 깨질 수 있다. 하나는 부모 수만큼 DB를 왕복하는 N+1이고, 다른 하나는 한 번의 왕복에서 그 부모의 자식 행을 전부 읽어 오는 과조회다.
|
||||
|
||||
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다. 한쪽을 고쳐 놓고 다른 쪽이 해결됐다고 적지 않는다.
|
||||
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- ORM 조회에서 쿼리 발생량이 데이터 규모를 따라 늘어나는지 확인할 때
|
||||
- ORM 조회에서 쿼리 발생량이 N에 비례해 늘어나는지 확인할 때
|
||||
- fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때
|
||||
- N+1 회귀를 테스트로 고정할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다.
|
||||
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
|
||||
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다. 그 단계는 Batch Fetch가 컬렉션 수와 SQL 수의 등식을 깨뜨리는 때로 미리 정해 두었다.
|
||||
- 운영 종단 지연이나 처리량, 커넥션 풀 상태가 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링)으로 확인한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 초기화 컬렉션 수 : 실행된 SELECT 수가 아니라 초기화된 컬렉션 수
|
||||
- 총 PreparedStatement : 획득한 statement 수, SQL 실행 수와 다를 수 있음
|
||||
- 초기화 컬렉션 수 : 지연 로딩이 채운 컬렉션 개수. 실행된 SELECT 수가 아니다
|
||||
- 총 PreparedStatement : JDBC에서 얻은 문장 객체 수. SQL 실행 수와 다를 수 있다
|
||||
- 회계 항등식 : 총계 − 컬렉션 N − content 1 − count 1 = entityFetch
|
||||
- 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관)
|
||||
- 측정 규율 : 매 반복 전 em.clear, stats.clear 직후 1회만 읽기
|
||||
- 측정 규율 : 매 반복 전 em.clear(), stats.clear() 직후 1회만 읽기
|
||||
|
||||
+39
-28
@@ -4,15 +4,22 @@ kind: REFERENCE
|
||||
slug: postgresql-query-plan-measurement
|
||||
title: PostgreSQL Query Plan 측정 기준
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#4-1
|
||||
- final/document.md#4-6
|
||||
- final/document.md#4-7
|
||||
- final/document.md#6-4
|
||||
---
|
||||
|
||||
# PostgreSQL Query Plan 측정 기준
|
||||
|
||||
실행계획과 인덱스 동작을 측정하려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 인덱스 기능이 엔진마다 달라서 다른 엔진의 계획을 그대로 옮겨 읽으면 체계적으로 틀린 결론에 이른다.
|
||||
실행계획과 인덱스 동작을 재려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 쓸 수 있는 인덱스 기능이 엔진마다 달라서, 인메모리 DB에서 잰 계획은 운영 엔진이 고를 계획이 아니다.
|
||||
|
||||
## 관계
|
||||
|
||||
@@ -21,65 +28,69 @@ studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd4
|
||||
- **JPA N+1 정량 진단 기준**
|
||||
같은 측정에서 쿼리 수를 다루는 기준이다.
|
||||
- **ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가**
|
||||
통계 축에서 남은 질문이다.
|
||||
통계를 갱신하면 추정 행수가 어떻게 달라지는지는 아직 재지 않았다.
|
||||
|
||||
## 목적
|
||||
|
||||
쿼리 수만으로는 보이지 않는 것이 있다. 한 쿼리가 실어 나르는 행수, 정렬 방식, 인덱스 사용 여부, 읽은 블록 수다.
|
||||
쿼리가 몇 개 나갔는지만 세면 그 한 쿼리가 무엇을 얼마나 읽는지는 보이지 않는다. 반복되는 하이라이트 조회를 EXPLAIN으로 열어 보니 feed_item_id를 Index Scan으로 찾아 들어가는데도 한 번에 rows=500을 읽고 있었고, 응답에 필요한 것은 그중 최신 3개뿐이었다.
|
||||
|
||||
이 값을 확인하려면 엔진이 실제로 고른 계획을 봐야 한다.
|
||||
한 쿼리가 실어 나르는 행수, 스캔과 정렬 방식, 인덱스를 탔는지, 읽은 블록 수는 엔진이 실제로 고른 계획에만 적혀 있다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 운영과 같은 엔진에서 측정한다
|
||||
|
||||
비용 기반 옵티마이저는 가능한 계획의 비용을 추정해 고른다. 추정값도, 고를 수 있는 선택지도 엔진마다 다르다.
|
||||
비용 기반 옵티마이저는 고를 수 있는 계획마다 비용을 추정해 가장 싼 것을 고른다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다르다.
|
||||
|
||||
네 축이 갈린다. 비용 상수로 표현되는 비용 모델, 수집하는 통계의 종류, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류와 기능이다.
|
||||
네 가지가 갈린다. 비용 모델은 PostgreSQL에서 random_page_cost=4와 seq_page_cost=1 같은 상수로 랜덤 접근을 순차 접근보다 비싸게 잡는데, 이 상수와 추정 규칙이 엔진마다 다르다. 나머지 셋은 ANALYZE가 수집하는 통계의 종류, heap과 가시성 맵을 거치는 저장 구조, 부분 인덱스와 표현식 인덱스처럼 쓸 수 있는 인덱스 기능이다.
|
||||
|
||||
인메모리 대체 DB에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작이 재현되지 않는다.
|
||||
H2 같은 인메모리 DB에서 재면 Seq Scan과 Index Scan 사이의 선택이 뒤집히고, 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라진다. 가시성 맵과 Index Only Scan의 heap 재방문 같은 PostgreSQL 특유의 동작도 재현되지 않는다.
|
||||
|
||||
### 2. 스키마를 운영 마이그레이션과 같게 맞춘다
|
||||
|
||||
같은 마이그레이션을 적용하고 엔티티와 스키마의 불일치를 조기에 잡는다.
|
||||
운영에 쓰는 Flyway 마이그레이션을 그대로 적용하고, ddl-auto=validate로 엔티티가 기대하는 테이블·컬럼·타입이 스키마와 어긋난 것을 조기에 잡는다.
|
||||
|
||||
다만 스키마 검증만으로 모든 드리프트를 막을 수 없다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이므로 따로 확인한다.
|
||||
다만 validate가 모든 드리프트를 막지는 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이라 마이그레이션 검증과 카탈로그 조회로 따로 확인한다.
|
||||
|
||||
### 3. EXPLAIN은 ANALYZE와 BUFFERS를 함께 쓴다
|
||||
|
||||
추정만으로는 실제 행수를 알 수 없다. 실제 실행 결과와 읽은 블록 수를 함께 본다.
|
||||
옵션 없이 EXPLAIN만 하면 계획과 추정값만 나온다. ANALYZE를 붙이면 쿼리를 실제로 실행해 노드마다 actual time과 실제 rows, loops가 함께 찍힌다. BUFFERS를 붙이면 Buffers: shared hit처럼 그 노드가 읽은 블록 수가 따라 붙는다. 추정과 실제를 대조하고 무엇을 얼마나 읽었는지 보려면 두 옵션이 다 필요하다.
|
||||
|
||||
### 4. 추정 행수와 실제 행수의 차이를 기록한다
|
||||
|
||||
둘이 크게 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있다. 대량 데이터를 넣은 직후에 특히 그렇다.
|
||||
둘이 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있고, 대량으로 데이터를 넣은 직후에 특히 그렇다. 위 계획도 추정은 rows=1인데 실제는 rows=500이라 500배가 벌어졌다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 feed_item_id별 편중을 반영하지 못했다는 가설을 세웠다.
|
||||
|
||||
이 차이를 발견하면 통계를 갱신한 뒤 다시 측정하고 전후를 비교한다.
|
||||
차이를 발견하면 통계를 갱신한 뒤 다시 재고 전후를 비교한다. 이 가설을 검증할 ANALYZE highlights 뒤의 재측정은 아직 실행하지 않았다.
|
||||
|
||||
### 5. warm cache 결과를 cold 실행시간으로 읽지 않는다
|
||||
### 5. 캐시가 채워진 상태에서 잰 값을 디스크를 읽은 값으로 읽지 않는다
|
||||
|
||||
읽은 블록이 모두 캐시에서 왔다면 디스크 접근이 없는 값이다. 캐시 상태를 함께 기록한다.
|
||||
Buffers에 shared hit만 찍히고 read=0이면 읽은 블록이 모두 버퍼 캐시에 있었다는 뜻이어서 디스크에서 읽어 오는 시간이 그 실행시간에 빠져 있다. 위 조회를 잴 때도 Buffers는 shared hit=14, read=0이었다. 캐시가 채워진 상태(warm)에서 쟀는지 아닌지를 계획과 함께 적는다.
|
||||
|
||||
### 6. Execution Time을 애플리케이션 지연과 합산하지 않는다
|
||||
|
||||
Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결과 전달, DTO 매핑, 직렬화, HTTP를 포함하지 않는다. 같은 지표가 아니다.
|
||||
Execution Time은 PostgreSQL executor 안에서 쓴 시간에 가깝고 ORM 엔티티 생성, JDBC 결과 전달, DTO 매핑, 직렬화, HTTP는 여기에 들어 있지 않다. 같은 조회에서 계획의 Execution Time은 0.173 ms였고 N=1,000에서 피드 한 번 로딩은 194 ms였는데, 두 값은 재는 구간이 서로 다르므로 더하거나 나란히 비교하지 않는다.
|
||||
|
||||
### 7. 여러 방식을 비교할 때는 같은 실행에서 잰다
|
||||
|
||||
캐시 상태를 맞추려면 같은 테스트 실행 안에서 연속으로 측정한다. 실행을 나누면 캐시 차이가 비교에 섞인다.
|
||||
비교할 SQL을 같은 테스트 실행 안에서 연속으로 EXPLAIN (ANALYZE, BUFFERS)로 잰다. 실행을 나누면 어느 쪽이 먼저 캐시를 채웠는지가 달라지기 때문에 그 차이가 buffers와 실행시간 비교에 섞인다. 그래서 buffers와 실행시간은 같은 실행 안에서 상대 비교로만 읽는다.
|
||||
|
||||
### 8. 인덱스 의존을 확인하려면 토글한다
|
||||
### 8. 인덱스에 기댄 결과인지 보려면 인덱스를 지우고 다시 잰다
|
||||
|
||||
어떤 방식이 빠른 이유가 문법인지 인덱스인지 가르려면 인덱스를 제거한 뒤 같은 쿼리를 다시 잰다. 측정이 끝나면 복구한다.
|
||||
어떤 방식이 빠른 이유가 SQL 문법 때문인지 인덱스 때문인지 가르려면 그 인덱스를 지운 뒤 같은 쿼리를 다시 잰다. 실제로 인덱스를 지웠을 때 같은 쿼리가 Seq Scan으로 떨어졌고 읽는 블록 수도 늘었다. 측정이 끝나면 인덱스를 되돌린다.
|
||||
|
||||
다만 Seq Scan이 찍혔다는 것만으로 그 계획을 곧바로 문제로 판정하지는 않는다. 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다.
|
||||
|
||||
### 9. 측정 도구의 정밀도를 주장 강도에 맞춘다
|
||||
|
||||
방향성만 확인하는 값에 더 엄밀한 도구를 붙인다고 근거가 강해지지 않는다. 오히려 측정보다 정밀한 결론처럼 보인다.
|
||||
지연을 더 엄밀하게 재는 JMH(Java Microbenchmark Harness), 종단 지연을 보는 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링) 같은 전용 도구가 있다. 다만 방향성만 확인하는 값에 그 엄밀도를 붙인다고 근거가 더 강해지지는 않고, 오히려 측정한 데이터보다 정밀한 결론처럼 보일 수 있다.
|
||||
|
||||
표본이 적으면 백분위수로 부르지 않고 중앙값과 최댓값으로 적는다.
|
||||
같은 이유로 표본이 적으면 p50이나 p99 같은 백분위수로 부르지 않는다. 이 측정도 5회를 재고 중앙값과 최댓값으로 적었다.
|
||||
|
||||
### 10. 재현 조건을 함께 남긴다
|
||||
|
||||
이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 측정 시작 시 엔진 버전과 주요 플래너 설정을 함께 기록한다.
|
||||
같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문에, 컨테이너 이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다.
|
||||
|
||||
측정을 시작할 때 엔진 버전과 주요 플래너 설정도 함께 기록한다. 이 측정은 Testcontainers로 띄운 PostgreSQL 16에서 했다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
@@ -91,14 +102,14 @@ Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결
|
||||
|
||||
- 쿼리 발생 횟수만 확인하면 되는 단계에서는 실행계획까지 필요하지 않다.
|
||||
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
|
||||
- 안정적인 꼬리 지연이 필요하면 반복 횟수를 크게 늘린 독립 세트가 필요하다.
|
||||
- p99 같은 안정적인 꼬리 지연이 필요하면 워밍업 뒤 100회 넘게 반복하는 독립 세트나 JMH로 따로 잰다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 엔진 : 운영과 같은 것. 인메모리 대체 금지
|
||||
- 엔진 : 운영과 같은 PostgreSQL, H2 같은 인메모리 DB로 대체하지 않는다
|
||||
- 명령 : EXPLAIN (ANALYZE, BUFFERS)
|
||||
- 캐시 : warm인지 cold인지 기록
|
||||
- 추정 vs 실제 : 차이가 크면 통계 갱신 후 재측정
|
||||
- 비교 : 같은 실행 안에서 연속 측정
|
||||
- 인덱스 의존 : DROP 후 재측정, 끝나면 복구
|
||||
- Execution Time : 애플리케이션 지연과 다른 지표
|
||||
- 캐시 : 버퍼 캐시가 채워진 상태(warm)였는지 함께 기록
|
||||
- 추정과 실제 : rows=1과 rows=500처럼 벌어지면 통계를 갱신하고 다시 잰다
|
||||
- 비교 : 같은 테스트 실행 안에서 연속으로 측정
|
||||
- 인덱스 의존 : 인덱스를 지우고 다시 잰 뒤 되돌린다
|
||||
- Execution Time : 애플리케이션 지연과 재는 구간이 다르다
|
||||
|
||||
+47
-36
@@ -4,91 +4,102 @@ kind: REFERENCE
|
||||
slug: top-n-per-group-selection
|
||||
title: Top-N-per-group 선택 기준
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
studio: "https://hyeonworks.com/studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit"
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#13-1
|
||||
- final/document.md#13-6
|
||||
---
|
||||
|
||||
# Top-N-per-group 선택 기준
|
||||
|
||||
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑 세 가지가 같은 결과를 만들지만 읽는 행수가 다르다.
|
||||
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑이 부모 20개에서 똑같은 60행을 돌려줬지만, LATERAL은 인덱스에서 부모당 3개만 읽어 buffers가 204였고 나머지 둘은 1,509행을 모두 읽어 430이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
|
||||
이 기준이 풀려던 문제다.
|
||||
프로젝션으로 엔티티 로드는 0이 되었지만 페이지 부모 20개의 자식 1,509행은 줄지 않았다. 그 1,509행을 60행으로 깎으려고 이 기준을 만들었다.
|
||||
- **PostgreSQL Query Plan 측정 기준**
|
||||
세 방식을 실행계획으로 비교한 기준이다.
|
||||
세 방식의 EXPLAIN (ANALYZE, BUFFERS)를 어떤 조건에서 재고 어느 값을 읽을지는 그 기록이 정한다.
|
||||
- **Fetch Join · Batch · Projection 선택 기준**
|
||||
앞 단계에서 왕복과 적재를 푼 기준이다.
|
||||
왕복은 배치가 2,022개에서 23개로, 엔티티 적재는 프로젝션이 1,569개에서 0개로 앞 단계에서 풀었다. 여기서는 남은 자식 1,509행을 60행까지 깎는다.
|
||||
|
||||
## 목적
|
||||
|
||||
자식 조회에 LIMIT을 붙이면 최종 결과 집합 전체에 적용되어 부모 하나만 채워진다. 그룹당 상한은 다른 표현이 필요하다.
|
||||
자식 조회 끝에 LIMIT 3을 붙이면 부모별 그룹이 아니라 최종 결과 집합 전체에 적용된다. 부모 20개를 조회했는데 반환은 3행이었고 하이라이트가 들어간 부모는 1개였다. 그룹당 상한은 다른 표현으로 써야 한다.
|
||||
|
||||
세 방식은 결과가 같으므로 정확성만으로 고를 수 없다. 읽는 행수와 buffers로 갈린다.
|
||||
세 표현은 모두 부모 20개에서 60행을 돌려주므로 정확성만으로는 고를 수 없다. 갈리는 곳은 그 60행을 만들기까지 읽은 행수와 buffers인데, 윈도우와 2단계는 1,509행을 읽어 430이었고 LATERAL은 204였다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 단순 LIMIT은 그룹당 상한이 아니다
|
||||
### 1. 자식 쿼리 끝에 붙인 LIMIT은 부모별로 적용되지 않는다
|
||||
|
||||
LIMIT은 최종 결과 집합에 적용된다. 부모 20개를 조회하면서 LIMIT 3을 붙이면 3행만 남아 부모 하나만 채워진다.
|
||||
LIMIT은 부모별 그룹이 아니라 최종 결과 집합에 적용되므로, 부모 20개를 조회하면서 LIMIT 3을 붙이면 가장 최신 하이라이트를 가진 부모 하나만 3개를 받고 나머지 부모에는 하이라이트가 들어가지 않는다.
|
||||
|
||||
이 오작동은 결과 행수가 적어 정상처럼 보일 수 있다. 커버한 부모 수를 함께 확인한다.
|
||||
반환이 3행뿐이라 결과가 작아 보일 뿐 오작동인지는 드러나지 않는다. 커버한 부모 수를 같이 세면 20이 아니라 1로 나온다.
|
||||
|
||||
### 2. 세 가지 표현을 구분한다
|
||||
### 2. 윈도우 · LATERAL · 앱 그룹핑이 각각 어디까지 읽는지 적는다
|
||||
|
||||
윈도우 함수는 부모별로 순번을 매기고 상위 몇 개를 남긴다. 순번을 만들려고 파티션 전체를 읽는다.
|
||||
윈도우 함수는 PARTITION BY feed_item_id로 부모마다 순번을 매기고 순번 3 이하만 남긴다. 자르는 곳은 DB지만 순번을 매기려고 파티션 전체인 1,509행을 먼저 읽는다.
|
||||
|
||||
LATERAL은 부모마다 상관 서브쿼리를 실행하고 인덱스에서 필요한 개수만 읽고 멈춘다.
|
||||
LATERAL은 부모마다 상관 서브쿼리를 실행해 부모별 정렬 인덱스에서 K개를 읽고 멈춘다. 실행계획에는 Nested Loop 아래 Index Scan과 Limit 3이 부모 수만큼 반복되어 loops=20으로 찍힌다.
|
||||
|
||||
애플리케이션 그룹핑은 자식을 한 번에 가져온 뒤 코드에서 자른다. 자르기 전에 전량이 전송된다.
|
||||
애플리케이션 그룹핑은 자식을 한 번에 모아 오는 쿼리로 가져온 뒤 코드에서 부모별로 자른다. 자르기 전에 1,509행이 모두 전송되고, 실행계획은 윈도우와 같은 Hash Semi Join이다.
|
||||
|
||||
### 3. 작은 K에는 LATERAL이 유리하다
|
||||
### 3. 그룹이 크고 K가 작으면 LATERAL을 쓴다
|
||||
|
||||
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개만 읽고 멈춘다. 그룹이 크고 K가 작을수록 읽지 않는 행이 많아진다.
|
||||
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개를 읽고 멈추므로, 그룹이 클수록 읽지 않고 넘어가는 행이 많아진다. 이 랩의 피드는 부모 하나에 하이라이트가 500개인데 화면에 필요한 K는 3이었고, K=3에서 buffers는 LATERAL 114, 윈도우 162였다.
|
||||
|
||||
### 4. K가 그룹 크기에 가까우면 윈도우로 수렴한다
|
||||
### 4. K가 그룹 크기에 가까우면 더 단순한 윈도우 함수를 고른다
|
||||
|
||||
K가 그룹 크기에 가까워지면 LATERAL도 대부분을 읽는다. 이때는 더 단순한 윈도우 함수를 고를 수 있다.
|
||||
K를 3 · 50 · 500으로 바꾸자 반환 행수는 60 · 695 · 1,509였다. K가 그룹 크기인 500에 가까워지면 LATERAL도 부모의 하이라이트를 대부분 읽으므로, 이 구간에서는 더 단순한 윈도우 함수를 골라도 된다.
|
||||
|
||||
K를 바꿔 가며 buffers를 재면 어느 지점에서 뒤집히는지 볼 수 있다.
|
||||
다만 이 랩에서는 세 K 모두 LATERAL의 buffers가 윈도우보다 작았다. 114 대 162, 155 대 216, 171 대 269였고, 어느 K에서 윈도우가 앞서는지는 재지 않았다.
|
||||
|
||||
### 5. LATERAL의 이점은 인덱스에서 나온다
|
||||
### 5. LATERAL을 쓰기 전에 부모별 정렬 인덱스가 있는지 확인한다
|
||||
|
||||
LATERAL 문법 자체가 빠른 것이 아니다. 부모별 정렬 인덱스가 있어야 상위 K개를 바로 찾는다.
|
||||
LATERAL 문법 자체가 빠른 것이 아니라, 부모 키와 정렬 컬럼을 함께 담은 복합 인덱스 ix_highlights_feed_items_created로 부모별 상위 3개를 바로 찾을 수 있어서 빨랐다.
|
||||
|
||||
인덱스가 없으면 부모마다 자식 테이블을 스캔하고 대부분을 필터로 버린다. 인덱스 유무를 토글해 확인한다.
|
||||
같은 LATERAL 쿼리를 두고 인덱스를 지웠다가 다시 만들며 재자 자식 접근이 Index Scan에서 Seq Scan으로 바뀌었고, Rows Removed by Filter가 부모 한 번마다 2842였다. buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 늘었다.
|
||||
|
||||
### 6. 애플리케이션 그룹핑은 전송량을 줄이지 않는다
|
||||
이 랩에서 그 인덱스는 마이그레이션 V6__feed.sql부터 있었다. LATERAL을 고르면서 새로 만든 것이 아니라 이미 있는 인덱스에 맞는 표현을 고른 것이다.
|
||||
|
||||
코드에서 자르면 결과는 맞지만 DB가 전달한 행은 전량이다. 전송량이 문제인 상황에서는 해법이 아니다.
|
||||
### 6. 애플리케이션 그룹핑은 전송 행수를 줄이지 않는다
|
||||
|
||||
### 7. 표준 JPQL로 표현되지 않는다
|
||||
코드에서 자르면 부모당 3개라는 결과는 맞지만, DB가 넘긴 행은 60이 아니라 1,509였고 buffers도 윈도우와 같은 430이었다. 줄이려는 것이 전송 행수라면 이 방식으로는 줄지 않는다.
|
||||
|
||||
윈도우 함수와 LATERAL은 표준 JPQL에 없다. native SQL로 내려가야 한다. 이 결정을 기록에 남긴다.
|
||||
### 7. 윈도우 함수와 LATERAL은 native SQL로 내려가야 쓸 수 있다
|
||||
|
||||
### 8. 반환 행수와 커버한 부모를 함께 검증한다
|
||||
표준 JPQL(Java Persistence Query Language)에는 윈도우 함수도 LATERAL도 없고, Hibernate 6 이상의 HQL(Hibernate Query Language)도 LATERAL은 지원하지 않는다. 두 표현을 쓰려면 native SQL로 내려간다.
|
||||
|
||||
세 방식이 같은 결과를 만드는지 먼저 확인한 뒤 실행계획을 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 본다.
|
||||
이 랩은 native SQL을 JdbcTemplate으로 실행하는 별도 테스트 클래스 FeedTopNIT에만 두어 프로덕션 코드 변경이 0이었다.
|
||||
|
||||
### 8. 같은 결과가 나오는지 먼저 맞춘 뒤 실행계획을 비교한다
|
||||
|
||||
세 표현이 같은 결과를 만드는지부터 확인하고 실행계획은 그다음에 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 봐야 순진한 LIMIT 3처럼 행수만 작고 부모를 못 채우는 경우가 걸러진다.
|
||||
|
||||
이 랩에서는 윈도우와 LATERAL이 60행에 부모 20, 부모당 3으로 같았고, 2단계는 코드에서 자르기 전 1,509행에 부모당 전량이었으며, 순진한 LIMIT 3은 3행에 부모 1이었다. 셋의 결과를 맞춘 뒤 캐시 상태를 맞추려고 같은 테스트 실행 안에서 EXPLAIN (ANALYZE, BUFFERS)를 돌렸다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 목록 응답에 부모별 자식 상위 몇 개를 포함해야 할 때
|
||||
- 자식 전량 조회가 전송량 문제를 만들 때
|
||||
- 그룹 크기가 크고 필요한 개수가 작을 때
|
||||
- 부모의 자식을 전부 가져오는 조회가 전송 행수 때문에 문제가 될 때
|
||||
- 그룹 크기가 크고 화면에 필요한 개수가 작을 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 그룹 크기가 작아 전량을 읽어도 부담이 없으면 애플리케이션 그룹핑이 단순하다.
|
||||
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL의 이점이 사라진다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
|
||||
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL은 부모마다 Seq Scan을 돌고 buffers가 4,446까지 올라간다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 순진 LIMIT 3 : 전체에 적용, 부모 1개만 채워짐
|
||||
- 윈도우 : 부모별 순번 뒤 상위 K, 파티션 전량 읽음
|
||||
- LATERAL : 부모마다 인덱스에서 K개 읽고 멈춤
|
||||
- 2단계 : 자식 전량 전송 뒤 코드에서 그룹핑
|
||||
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 급증
|
||||
- 선택 : 작은 K는 LATERAL, K가 그룹 크기에 근접하면 윈도우
|
||||
- 순진 LIMIT 3 : 부모 20개 조회에 반환 3행, 채워진 부모 1개
|
||||
- 윈도우 : 파티션 1,509행을 읽어 순번을 매긴 뒤 60행, buffers 430
|
||||
- LATERAL : 부모마다 인덱스에서 3개 읽고 멈춰 60행, buffers 204
|
||||
- 2단계 : 자식 1,509행을 모두 전송한 뒤 코드에서 그룹핑, buffers 430
|
||||
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 168에서 4,446으로
|
||||
- K 곡선 : K가 3 · 50 · 500일 때 반환 60 · 695 · 1,509, LATERAL buffers가 세 K 모두 윈도우보다 작음
|
||||
|
||||
Reference in New Issue
Block a user