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
+30
-23
@@ -4,6 +4,7 @@ kind: CASE
|
||||
slug: collection-fetch-join-in-memory-paging
|
||||
title: Collection Fetch Join Pagination의 In-memory Paging
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
@@ -14,6 +15,10 @@ assets:
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/explain/l4-entity-paging-limit.txt
|
||||
- ../../../final/evidence/raw/explain/l5-entity-paging-limit.txt
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#10-2
|
||||
- final/document.md#10-4
|
||||
---
|
||||
|
||||
# Collection Fetch Join Pagination의 In-memory Paging
|
||||
@@ -27,23 +32,23 @@ evidence:
|
||||
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
|
||||
이 기록이 이어받은 앞 단계다.
|
||||
- **Fetch Join · Batch · Projection 선택 기준**
|
||||
이 실패가 배치 선택으로 이어진 기준이다.
|
||||
이 실패가 배치 선택으로 이어졌다.
|
||||
|
||||
## 문제
|
||||
|
||||
컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커졌다. 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.
|
||||
컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커져서, 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.
|
||||
|
||||
반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였다. 반환 크기만으로는 실제 적재량을 알 수 없어 메모리에 올린 부모 엔티티 수를 따로 측정했다.
|
||||
|
||||
## 결론
|
||||
|
||||
returned는 페이지 크기에 고정됐지만 feedItemLoaded는 N을 따라 늘었다. over-fetch 배수는 1.0배에서 50.0배로 커졌다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 같았고 문제가 보이지 않았다.
|
||||
returned는 페이지 크기에 고정됐지만 feedItemLoaded는 N과 같은 수로 늘었다. over-fetch 배수는 1.0배에서 50.0배로 커졌다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 같았고 문제가 보이지 않았다.
|
||||
|
||||
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘려 일부 부모의 하이라이트가 누락될 수 있다. Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 자른다.
|
||||
|
||||
발행된 SQL에 Limit 노드가 없다는 것 자체가 DB가 페이징을 하지 않았다는 증거다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
|
||||
발행된 SQL에는 Limit 노드가 없었다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
|
||||
|
||||
지연은 예상과 달리 기준선보다 낮았다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N을 따라 1.5 MB에서 10.0 MB로 늘었는데, 기준선의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
|
||||
지연은 예상과 달리 처음 구현한 loadFeed보다 낮았는데, 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N=10의 1.5 MB에서 N=1,000의 10.0 MB로 늘었지만, 그 loadFeed의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -84,7 +89,7 @@ feedItemLoaded : EntityStatistics.getLoadCount()
|
||||
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
|
||||
```
|
||||
|
||||
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
|
||||
앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다.
|
||||
|
||||
## 응답은 한 페이지인데 부모는 전부 로드한다
|
||||
|
||||
@@ -96,7 +101,7 @@ feedItemLoaded : EntityStatistics.getLoadCount()
|
||||
|
||||
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
|
||||
|
||||
getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
|
||||
fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
|
||||
|
||||
## 경고 코드가 알려진 것과 달랐다
|
||||
|
||||
@@ -104,7 +109,7 @@ getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로
|
||||
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
||||
```
|
||||
|
||||
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
|
||||
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
|
||||
|
||||
## 비용은 페이지가 아니라 데이터셋에 비례한다
|
||||
|
||||
@@ -114,11 +119,13 @@ HHH90003004: firstResult/maxResults specified with collection fetch; applying in
|
||||
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
|
||||
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
|
||||
|
||||
returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
|
||||
returned는 페이지 크기로 고정인데도 지연과 할당은 N이 커질수록 함께 올랐다.
|
||||
|
||||
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
|
||||
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
|
||||
|
||||
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
|
||||
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
|
||||
|
||||
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였다. 같은 실행의 used heap 델타 22,016 KB는 스레드 누적 할당 19,995 KB보다 컸다.
|
||||
|
||||
## 발행 SQL에 LIMIT이 없다
|
||||
|
||||
@@ -141,13 +148,11 @@ Limit (... rows=20 ...) (actual ... rows=20 loops=1)
|
||||
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
|
||||
```
|
||||
|
||||
(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
|
||||
(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취하므로, 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.
|
||||
|
||||
전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.
|
||||
## 엔티티만 페이징하고 배치로 하이라이트를 채우기
|
||||
|
||||
## 다음 선택
|
||||
|
||||
fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
|
||||
fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행되지만, highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
|
||||
|
||||
이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했다. 다음 단계의 전후 차이를 같은 기준으로 비교하기 위해서다.
|
||||
|
||||
@@ -157,7 +162,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
|
||||
|
||||
## 로컬 미리보기
|
||||
|
||||
본문 「무대 — 페이징 한 줄만 추가
|
||||
본문 「무대 — 페이징 한 줄만 추가」 절의 코드
|
||||
|
||||
```java label="통합 테스트 안에서 세운 무대 (프로덕션 아님)"
|
||||
"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 한 bag fetch join
|
||||
@@ -165,7 +170,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
|
||||
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
|
||||
```
|
||||
|
||||
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
|
||||
앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다.
|
||||
|
||||
## 응답은 한 페이지인데 부모는 전부 로드한다
|
||||
|
||||
@@ -177,7 +182,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
|
||||
|
||||
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
|
||||
|
||||
getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
|
||||
fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
|
||||
|
||||
## 경고 코드가 알려진 것과 달랐다
|
||||
|
||||
@@ -185,7 +190,7 @@ getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로
|
||||
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
||||
```
|
||||
|
||||
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
|
||||
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
|
||||
|
||||
## 비용은 페이지가 아니라 데이터셋에 비례한다
|
||||
|
||||
@@ -195,11 +200,13 @@ HHH90003004: firstResult/maxResults specified with collection fetch; applying in
|
||||
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
|
||||
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
|
||||
|
||||
returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
|
||||
returned는 페이지 크기로 고정인데도 지연과 할당은 N이 커질수록 함께 올랐다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
|
||||
|
||||
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
|
||||
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
|
||||
|
||||
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
|
||||
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
|
||||
|
||||
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였다. 같은 실행의 used heap 델타 22,016 KB는 스레드 누적 할당 19,995 KB보다 컸다.
|
||||
|
||||
## 발행 SQL에 LIMIT이 없다」 아래 `:::evidence key="in-memory-paging"` 자리에 들어갈 그림이다.
|
||||
|
||||
|
||||
+41
-33
@@ -4,6 +4,7 @@ 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: 게시 전
|
||||
@@ -15,38 +16,43 @@ 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번 실행됐다. 같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. N+1을 가르는 것은 fetch 타입이 아니라 조회 방식이다.
|
||||
@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 통계로 확인한 방법이다.
|
||||
엔티티별 fetch 통계로 추가 조회를 세는 방법을 적은 기록이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 조회의 총 PreparedStatement에는 컬렉션 조회를 빼고도 남는 몫이 있었다. 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.
|
||||
이 조회의 총 PreparedStatement에서 컬렉션 조회를 빼도 User·Page 연관 조회가 남았고, 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.
|
||||
|
||||
엔티티에는 fetch를 따로 명시하지 않았다. @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 사용했다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.
|
||||
엔티티에는 fetch를 따로 명시하지 않고 @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 그대로 썼다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.
|
||||
|
||||
## 결론
|
||||
|
||||
Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N을 따라 10, 100, 1,000으로 늘고 User fetch는 3, 20, 20에서 멈췄다.
|
||||
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라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.
|
||||
둘 다 @ManyToOne(EAGER)인데 Page는 아이템마다 달라 N번 조회됐고, User는 소수 풀을 재사용해 한 번 로드한 대상이 1차 캐시에 남아 다시 조회되지 않았다. N+1이 생길 가능성은 EAGER라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.
|
||||
|
||||
getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 조회 코드를 작성하지 않았는데 EAGER 기본값 때문에 생긴 N+1이다. 같은 조건에서 LAZY 컬렉션은 0이었다.
|
||||
getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 연관을 읽는 코드를 쓰지 않았는데 EAGER 기본값 때문에 생긴 N+1이고, 같은 조건에서 지연 로딩 컬렉션은 0이었다.
|
||||
|
||||
pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였다.
|
||||
pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 왕복 수를 가른 것은 실행계획이 아니라 반복 횟수였고, Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됐다.
|
||||
|
||||
같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. 지연이냐 즉시냐가 아니라, 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.
|
||||
지연 로딩인 highlights도 같은 조회에서 접근하는 순간 N번 나갔다. 지연이냐 즉시냐가 아니라 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -74,7 +80,7 @@ getEntityStatistics(PageJpaEntity).getFetchCount() : 엔티티별 fetch 수
|
||||
|
||||
4. 회계 항등식으로 교차 검증한다. 총 PreparedStatement − 컬렉션 N − content 1 − count 1 = entityFetch.
|
||||
|
||||
5. 접근 0회 확인: seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 상태에서 fetch 수를 읽는다.
|
||||
5. seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 채, 접근 0회에서 fetch 수를 읽는다.
|
||||
|
||||
6. 반복되는 ToOne 부모 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
|
||||
|
||||
@@ -100,13 +106,15 @@ public List<FeedSummary> loadFeed(int page, int size) {
|
||||
}
|
||||
```
|
||||
|
||||
엔티티를 조회한 뒤 메모리에서 DTO로 옮긴다. user·page는 즉시 로딩이고 highlights는 지연 로딩이다.
|
||||
피드 아이템을 엔티티로 조회한 뒤 메모리에서 DTO로 옮기는 코드다. user·page는 즉시 로딩(EAGER)이고 highlights는 지연 로딩(LAZY)이다.
|
||||
|
||||
## 같은 EAGER가 정반대 곡선을 그린다
|
||||
## 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 |
|
||||
@@ -115,17 +123,21 @@ public List<FeedSummary> loadFeed(int page, int size) {
|
||||
|
||||
검산: `10+3=13` · `100+20=120` · `1000+20=1020`. 회계 항등식으로도 `25−10−2=13` · `222−100−2=120` · `2022−1000−2=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 안에서 서로 다른 연관 대상이 몇 개인지가 정한다.
|
||||
EAGER의 2차 SELECT 구조가 추가 조회의 가능성을 만들고, 실제로 몇 번 실행되는지는 Persistence Context 안에 서로 다른 연관 대상이 몇 개 있는지가 정한다. User는 서로 다른 대상이 20개 이하라 약 20회에서 멈췄고, Page는 아이템마다 달라 N회로 갈렸다.
|
||||
|
||||
이 측정은 앞 기록과 같은 loadFeed 호출을 잰 것이다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 나눠 셌다.
|
||||
|
||||
## 필드에 접근하지 않아도 조회가 나간다
|
||||
|
||||
seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
|
||||
지연 로딩 컬렉션은 매핑 루프가 접근하는 순간 조회를 냈다. ToOne도 그런지 아니면 접근과 무관하게 나가는지는 매핑이 남아 있으면 갈라 볼 수 없다. 그래서 loadFeed 대신 아무것도 매핑하지 않는 순수 JPQL로 feed_items만 조회하고, seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
|
||||
|
||||
| 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 |
|
||||
|---|---|---|---:|
|
||||
@@ -133,9 +145,9 @@ seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지
|
||||
| 0회 | User | @ManyToOne (EAGER) | 20 (풀 dedup) |
|
||||
| 0회 | highlights | @OneToMany (LAZY) | 0 |
|
||||
|
||||
EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다.
|
||||
한 필드도 읽지 않았는데 EAGER인 Page와 User는 조회가 나갔고, 지연 로딩인 highlights는 나가지 않았다. EAGER는 사용 여부와 관계없이 미리 로딩하고, 지연 로딩은 접근할 때 로딩한다.
|
||||
|
||||
## 같은 실행계획, 정반대 비용
|
||||
## 반복되는 ToOne 부모 쿼리의 실행계획
|
||||
|
||||
```text label="반복되는 ToOne 부모 쿼리 — seed(100) 직후"
|
||||
-- pages
|
||||
@@ -148,26 +160,28 @@ Index Scan using pk_users on users
|
||||
Buffers: shared hit=2 Execution Time: 0.022 ms
|
||||
```
|
||||
|
||||
두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 단건 계획이 이미 Index Scan이므로 인덱스를 더해도 해결되지 않는다.
|
||||
`WHERE id = ?`는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 개별 쿼리는 빨랐지만 Page는 이 빠른 계획을 N번 반복했고, 단건 계획이 이미 Index Scan이라 인덱스를 더하는 것으로는 왕복이 줄지 않는다.
|
||||
|
||||
## 핵심은 지연이냐 즉시냐가 아니다
|
||||
인덱스로 줄지 않는다는 말은 이 반복되는 ToOne 쿼리에 한정된다. 같은 랩의 기준 목록 쿼리는 Seq Scan과 Sort로 도는데 그쪽은 정렬 인덱스와 쿼리의 문제라 fetch 전략으로 풀리지 않는다. 그 목록 계획이 실제 병목인지는 아직 단정하지 않았다. N=1,000은 인덱스 효과를 판단하기에 작아서, 이후 keyset 페이징 랩에서 검증한다.
|
||||
|
||||
fetch 계약과 실제 사용을 교차하면 네 칸이 나온다. 네 칸 모두 이 랩에서 잰 값이다. LAZY 자리는 같은 조회의 highlights 컬렉션이 증인이다.
|
||||
## fetch 계약과 접근 여부로 갈라 본 네 경우
|
||||
|
||||
fetch 계약과 실제 접근 여부를 교차하면 네 경우가 나오고, 네 경우 모두 이 랩에서 잰 값이다. 지연 로딩 쪽 두 경우는 같은 조회의 highlights 컬렉션에서 읽었다.
|
||||
|
||||
| fetch 계약 | 접근하지 않을 때 | 접근할 때(loadFeed) |
|
||||
|---|---|---|
|
||||
| EAGER — User·Page (`@ManyToOne`) | 나간다 · Page 100 · User 20 | 나간다 · Page N번 |
|
||||
| LAZY — highlights (`@OneToMany`) | 안 나간다 · 0 | 나간다 · N번 |
|
||||
|
||||
네 칸 중 세 칸에서 추가 조회가 난다. 나지 않는 칸은 「LAZY이면서 접근하지 않음」 하나뿐인데, 그것은 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 이 칸에 들어가지 않는다.
|
||||
네 경우 중 셋에서 추가 조회가 났다. 나지 않은 것은 지연 로딩이면서 접근하지 않는 경우 하나뿐인데, 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 여기에 들어가지 않는다.
|
||||
|
||||
EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 실제로 같은 조회에서 LAZY인 highlights가 EAGER인 Page와 똑같이 10 / 100 / 1,000번 조회됐다. 같은 증상이 서로 다른 fetch 타입에서 나왔으므로 타입이 왕복 수를 가르는 기준이 아니다.
|
||||
EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 같은 조회에서 지연 로딩인 highlights도 N=100이면 EAGER인 Page와 똑같이 100번 나갔으니, fetch 타입이 왕복 수를 가르지는 않는다.
|
||||
|
||||
왕복 수를 정하는 것은 조회 방식이다. 루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 타입과 무관하게 N번이 된다. 줄이려면 fetch join, 배치, 프로젝션처럼 조회 방식 자체를 바꿔야 한다.
|
||||
루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 fetch 타입과 무관하게 N번이 되므로, 줄이려면 fetch join·배치·프로젝션처럼 조회 방식 자체를 바꿔야 한다.
|
||||
|
||||
## 한 번의 하이라이트 조회가 읽는 행 수
|
||||
|
||||
왕복 수와 별개로 그 한 번이 읽어 오는 행 수도 확인했다.
|
||||
왕복 수와 별개로, 그 한 번이 읽어 오는 행 수도 실행계획으로 살펴보았다.
|
||||
|
||||
```text label="Plan A — 반복되는 하이라이트 자식 쿼리, 대량 시드 직후 ANALYZE 실행 전"
|
||||
Index Scan using ix_highlights_feed_items_created on highlights
|
||||
@@ -178,15 +192,9 @@ 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개다.
|
||||
이 조회도 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 종류별로 분해했을 뿐이다.
|
||||
추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 feed_item_id별 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
@@ -194,7 +202,7 @@ getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초
|
||||
|
||||
## 로컬 미리보기
|
||||
|
||||
본문 「같은 EAGER가 정반대 곡선을 그린다」 아래 `:::evidence key="eager-lazy-query-sequence"` 자리에 들어갈 그림이다.
|
||||
본문 「EAGER 연관 관계에서 나간 추가 조회」의 `:::evidence key="eager-lazy-query-sequence"` 구획에 들어갈 그림이다.
|
||||
|
||||

|
||||
|
||||
|
||||
+43
-36
@@ -4,6 +4,7 @@ kind: CASE
|
||||
slug: fetch-join-multibag-and-row-explosion
|
||||
title: Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
@@ -13,11 +14,16 @@ assets:
|
||||
file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#9-2
|
||||
- final/document.md#9-3
|
||||
- final/document.md#9-5
|
||||
---
|
||||
|
||||
# Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
|
||||
|
||||
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 비용이 전송 행수와 메모리로 옮겨 갔다.
|
||||
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 한 쿼리가 나르는 행수와 메모리 적재량은 커졌다.
|
||||
|
||||
## 관계
|
||||
|
||||
@@ -26,23 +32,23 @@ evidence:
|
||||
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
|
||||
이 시도가 풀려던 문제다.
|
||||
- **Collection Fetch Join Pagination의 In-memory Paging**
|
||||
한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||||
컬렉션 하나만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||||
|
||||
## 문제
|
||||
|
||||
컬렉션 N+1과 ToOne의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
|
||||
컬렉션 N+1과 User·Page 연관의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
|
||||
|
||||
MultipleBagFetchException을 재현하려면 fetch join할 두 번째 bag이 필요했다. 기준선 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
|
||||
MultipleBagFetchException을 재현하려면 fetch join할 컬렉션이 둘 이상 필요했다. 처음 만든 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 bag을 동시에 fetch join하면 쿼리 생성 시점에 거부된다. bag은 순서 컬럼이 없는 List라, feed_item 한 행이 highlights h개 × mentions m개로 늘어난 곱집합을 원래 컬렉션으로 되돌릴 수 없기 때문이다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
|
||||
bag은 순서 컬럼이 없는 List 매핑이다. highlights와 mentions가 둘 다 bag이면 feed_item 한 행이 highlights h개 × mentions m개로 늘어난다. Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 쿼리 생성 시점에 예외를 던진다. 데이터가 0건이어도 예외는 그대로 나온다. 실행 결과가 아니라 매핑 단계에서 막기 때문이다.
|
||||
|
||||
컬렉션을 하나만 fetch join하면 예외는 없지만 부모가 자식 수만큼 반복된 행이 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
|
||||
컬렉션을 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복돼 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
|
||||
|
||||
Hibernate 6 이상은 fetch join의 루트 엔티티를 자동으로 중복 제거한다. 그래서 결과 리스트 크기는 N이고, 카테시안은 SQL과 전송 단계에만 남는다. 리스트 크기로는 이 문제가 보이지 않는다.
|
||||
Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 N이다. 곱으로 늘어난 행은 SQL과 전송 단계를 그대로 지나가지만 리스트 크기에는 드러나지 않는다.
|
||||
|
||||
같은 N=100에서 기준선 222개가 121개로 줄었지만 그중 120개는 여전히 ToOne 2차 SELECT였고, 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보이는 구간이다.
|
||||
같은 N=100에서 PreparedStatement는 손대지 않은 loadFeed의 222개에서 121개로 줄었다. 그중 120개는 여전히 ToOne 2차 SELECT였고, 남은 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보여서 항목별로 다시 나눠 세어 보았다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -72,13 +78,17 @@ SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
|
||||
|
||||
4. 조인 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인해 조인 노드의 actual rows를 본다.
|
||||
|
||||
5. 기존 기준선 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 유지되는지 확인한다.
|
||||
5. 앞 단계에서 loadFeed를 재던 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 그대로 유지되는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 실패 하나 — 두 bag 동시 fetch join
|
||||
## 실패 하나 — 컬렉션 둘을 동시에 fetch join하면 거부된다
|
||||
|
||||
bag은 순서 컬럼(`@OrderColumn`)이 없는 `List` 매핑이다. `highlights`와 `mentions`가 둘 다 bag인 상태에서 연관을 전부 한 쿼리에 합쳐 보았다.
|
||||
|
||||
`mentions`는 이 예외를 재현하려고 이 단계에서 붙인 두 번째 bag이다. 목표 스키마의 `feed_item_mentions`는 `(feed_item_id, mentioned_user_id)`를 복합 기본키로 쓰지만, 여기서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 대리키 `id`를 두고 `UNIQUE(feed_item_id, mentioned_user_id)`를 걸었다. 복합 기본키가 주던 유일성은 이 제약이 대신 보장한다.
|
||||
|
||||
```java label="연관 전부 fetch join — 컬렉션 둘을 동시에"
|
||||
select distinct f from FeedItemJpaEntity f
|
||||
@@ -86,13 +96,17 @@ select distinct f from FeedItemJpaEntity f
|
||||
join fetch f.mentions
|
||||
```
|
||||
|
||||
이 쿼리는 실행까지 가지 못하고 `createQuery` 시점에 거부됐다. 콘솔에 찍힌 원인 체인은 이렇다.
|
||||
|
||||
```text label="실제로 나온 예외 원인 체인"
|
||||
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
|
||||
```
|
||||
|
||||
MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
|
||||
`MultipleBagFetchException`은 `IllegalArgumentException`으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약해서, 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
|
||||
|
||||
## 실패 둘 — 한 bag만 fetch join
|
||||
## 실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다
|
||||
|
||||
컬렉션을 `highlights` 하나만 fetch join하면 예외는 나지 않는다. 대신 `feed_items`와 `highlights`의 조인이 부모 한 행을 자식 수만큼 반복해 내보내므로, 쿼리 수가 아니라 DB가 애플리케이션에 전달한 조인 행수를 쟀다.
|
||||
|
||||
:::evidence key="cartesian-row-multiplication" alt="왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림." caption=" " zoom="true"
|
||||
:::
|
||||
@@ -103,10 +117,12 @@ MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다.
|
||||
| 100 | 1,961 | 100 | 100 | 1,961 | 19.6× | 121 |
|
||||
| 1,000 | 2,917 | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 |
|
||||
|
||||
전송 행수는 언제나 시드 하이라이트 총량과 같았다. 편중 분포에서 뒤쪽 아이템은 하이라이트가 하나뿐이라 폭발 배수는 128.5×에서 2.9×로 줄었지만 절대 전송 행수는 계속 하이라이트 총합이었다.
|
||||
편중 분포라 뒤쪽 아이템에는 하이라이트가 하나뿐이어서 폭발 배수는 128.5×에서 2.9×로 줄었다. 그래도 전송 행수는 세 N에서 모두 시드 하이라이트 총량과 정확히 같았다.
|
||||
|
||||
## 쿼리 수만 보면 개선처럼 보인다
|
||||
|
||||
같은 N=100 데이터에서 PreparedStatement를 항목별로 나눠 세어 보았다.
|
||||
|
||||
| 구분 | 기준선 loadFeed | highlights fetch join | 결과 |
|
||||
|---|---:|---:|---|
|
||||
| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 |
|
||||
@@ -115,9 +131,11 @@ MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다.
|
||||
| ToOne(User+Page) | 120 | 120 | 그대로 — highlights만 fetch join했으므로 |
|
||||
| 합 | 222 | 121 | |
|
||||
|
||||
222개가 121개로 줄어든 주된 이유는 컬렉션 N개가 루트 조인 하나로 합쳐졌기 때문이다. 121개 중 120개는 여전히 ToOne 2차 SELECT였다.
|
||||
222개가 121개로 줄어든 주된 이유는 `highlights` 컬렉션 SELECT 100개가 루트 조인 하나로 접혔기 때문이다. 나머지 1개 차이는 원시 JPQL에 Spring Data count가 없어서 생겼다. 121개 중 120개는 여전히 User·Page를 읽는 ToOne 2차 SELECT였다.
|
||||
|
||||
## 조인이 행을 곱하는 것을 실행계획에서
|
||||
## 조인이 곱한 행을 실행계획에서 확인하기
|
||||
|
||||
시드(100) 직후 같은 형태의 쿼리를 `EXPLAIN (ANALYZE, BUFFERS)`로 실행했다.
|
||||
|
||||
```text label="seed(100) 직후 fetch join 조인의 EXPLAIN"
|
||||
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
|
||||
@@ -128,19 +146,21 @@ Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 row
|
||||
Execution Time: 0.959 ms
|
||||
```
|
||||
|
||||
부모 feed_items는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 사실은 리스트 크기로는 보이지 않고 실행계획에서 드러난다.
|
||||
부모 `feed_items`는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나지만 그 하나가 1,961행을 실어 나르고, 리스트 크기 100에는 이 수가 잡히지 않는다.
|
||||
|
||||
추정 rows=4202와 실제 rows=1961의 오차는 대량 시드 직후 ANALYZE를 실행하지 않은 통계 문제다.
|
||||
추정 `rows=4202`와 실제 `rows=1961`의 오차는 대량 시드 직후 `ANALYZE`를 실행하지 않아 생긴 통계 문제다.
|
||||
|
||||
## 측정 정정
|
||||
## 측정 정정 — 리스트 크기는 N이었다
|
||||
|
||||
처음에는 distinct 없는 결과 리스트 크기가 전송 행수와 같을 것으로 예상했다. 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
|
||||
처음에는 `distinct` 없이 받은 결과 리스트 크기가 전송 행수와 같을 것으로 예상했지만, 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
|
||||
|
||||
카테시안은 SQL과 전송 단계에 그대로 남아 있다. 이 문제는 EXPLAIN의 actual rows나 조인 count로 확인해야 한다.
|
||||
리스트 크기가 N으로 접혀도 SQL이 만든 곱과 DB가 실어 보낸 행수는 줄지 않는다. 그래서 리스트 크기 대신 조인 카디널리티를 `count(*)`로 셌다. 이 문제는 `EXPLAIN`의 actual rows나 조인 count로 확인해야 한다.
|
||||
|
||||
쿼리 수는 줄었는데 전송량은 커졌으므로, 이 단계부터는 쿼리 수와 전송 행수를 같이 쟀다.
|
||||
|
||||
## 이 실패를 남긴 이유
|
||||
|
||||
distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지 않고 실패를 별도 테스트에 남겼다. fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인하기 위해서다.
|
||||
`.distinct()`를 붙이거나 `List`를 `Set`으로 바꾸거나 `@BatchSize`로 바로 우회하지 않고, 실패한 쿼리를 별도 통합 테스트에 남겼다. 그래야 fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문이다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
@@ -148,21 +168,8 @@ distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지
|
||||
|
||||
## 로컬 미리보기
|
||||
|
||||
본문 「실패 하나 — 두 bag 동시 fetch join
|
||||
|
||||
```java label="연관 전부 fetch join — 컬렉션 둘을 동시에"
|
||||
select distinct f from FeedItemJpaEntity f
|
||||
join fetch f.highlights
|
||||
join fetch f.mentions
|
||||
```
|
||||
|
||||
```text label="실제로 나온 예외 원인 체인"
|
||||
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
|
||||
```
|
||||
|
||||
MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
|
||||
|
||||
## 실패 둘 — 한 bag만 fetch join」 아래 `:::evidence key="cartesian-row-multiplication"` 자리에 들어갈 그림이다.
|
||||
본문 「실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다」 절의 첫 문단 다음
|
||||
`:::evidence key="cartesian-row-multiplication"`에 들어갈 그림이다.
|
||||
|
||||

|
||||
|
||||
|
||||
+24
-12
@@ -4,6 +4,7 @@ kind: CASE
|
||||
slug: projection-row-over-fetch
|
||||
title: Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
@@ -13,6 +14,11 @@ assets:
|
||||
file: ../../../final/assets/tech-log-studio/projection-row-over-fetch.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/explain/l6-parent-projection.txt
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#12-2
|
||||
- final/document.md#12-4
|
||||
- final/document.md#12-6
|
||||
---
|
||||
|
||||
# Projection 이후에도 1,509행을 읽은 Row Over-fetch
|
||||
@@ -30,9 +36,9 @@ DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로
|
||||
|
||||
## 문제
|
||||
|
||||
Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
|
||||
배치로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
|
||||
|
||||
화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
|
||||
화면에는 일부 컬럼만 필요했으므로, 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
|
||||
|
||||
## 결론
|
||||
|
||||
@@ -53,7 +59,7 @@ PostgreSQL : postgres:16-alpine (Testcontainers)
|
||||
|
||||
격리
|
||||
프로젝션 측정은 배치 설정이 없는 별도 IT 클래스
|
||||
loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드
|
||||
loadFeedProjection은 loadFeed를 두고 추가한 별도 메서드
|
||||
|
||||
측정 지표
|
||||
entitiesLoaded : Statistics.getEntityLoadCount()
|
||||
@@ -68,7 +74,7 @@ collectionFetch : Statistics.getCollectionFetchCount()
|
||||
|
||||
3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다.
|
||||
|
||||
4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다.
|
||||
4. 프로젝션 결과가 처음 구현한 loadFeed와 같은 형태인지 대조한다.
|
||||
|
||||
5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다.
|
||||
|
||||
@@ -90,6 +96,8 @@ select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
|
||||
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
|
||||
```
|
||||
|
||||
부모 쿼리에는 컬렉션 조인을 넣지 않았다. user와 page는 컬럼을 읽기 위한 조인이라 행이 곱해지지 않고, setMaxResults가 만드는 LIMIT도 DB에서 그대로 적용된다.
|
||||
|
||||
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
|
||||
|
||||
## 엔티티 로드가 0으로 줄어든다
|
||||
@@ -108,11 +116,11 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
|
||||
| 100 | 222 | 5 | 2 |
|
||||
| 1,000 | 2,022 | 23 | 2 |
|
||||
|
||||
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
|
||||
처음 구현한 loadFeed의 쿼리 수는 N에 비례해 늘었고 배치는 배치 크기 단위로 늘었다. 프로젝션은 2개로 유지되었다.
|
||||
|
||||
## 남은 비용 — 페이지당 전량
|
||||
|
||||
:::evidence key="projection-row-over-fetch" alt="왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수가 그대로 남는 것을 보여 주는 그림." caption=" " zoom="true"
|
||||
:::evidence key="projection-row-over-fetch" alt="왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수는 줄지 않는 것을 보여 주는 그림." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
| 항목 | 값 |
|
||||
@@ -123,7 +131,7 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
|
||||
|
||||
단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다.
|
||||
|
||||
## width는 좁아지지 않았다
|
||||
## width가 좁아질 것으로 예상했지만 아니었다
|
||||
|
||||
```text label="seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN"
|
||||
-- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다)
|
||||
@@ -136,13 +144,15 @@ Limit (... rows=20 width=2088) (actual ... rows=20 loops=1)
|
||||
Hash Semi Join (... rows=1509 loops=1)
|
||||
```
|
||||
|
||||
프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다.
|
||||
부모 페이징에 Limit이 붙었고, 자식 IN은 semi-join이라 부모와 자식을 곱하지 않았다.
|
||||
|
||||
어긋난 것은 width다. 필요한 컬럼만 선택하면 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다. 그래서 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인했다.
|
||||
|
||||
## 배치와 프로젝션은 다른 것을 줄인다
|
||||
|
||||
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
|
||||
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
|
||||
|
||||
기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다.
|
||||
기존 loadFeed를 바로 교체하면 앞 단계의 값을 다시 측정할 수 없어서, loadFeedProjection을 별도 메서드로 추가하고 같은 데이터로 비교했다. 처음 구현부터 배치까지의 테스트도 다시 실행해 기존 결과가 유지되는지 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
@@ -162,6 +172,8 @@ select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
|
||||
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
|
||||
```
|
||||
|
||||
부모 쿼리에는 컬렉션 조인을 넣지 않았다. user와 page는 컬럼을 읽기 위한 조인이라 행이 곱해지지 않고, setMaxResults가 만드는 LIMIT도 DB에서 그대로 적용된다.
|
||||
|
||||
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
|
||||
|
||||
## 엔티티 로드가 0으로 줄어든다
|
||||
@@ -180,10 +192,10 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
|
||||
| 100 | 222 | 5 | 2 |
|
||||
| 1,000 | 2,022 | 23 | 2 |
|
||||
|
||||
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
|
||||
처음 구현한 loadFeed의 쿼리 수는 N에 비례해 늘었고 배치는 배치 크기 단위로 늘었다. 프로젝션은 2개로 유지되었다.
|
||||
|
||||
## 남은 비용 — 페이지당 전량」 아래 `:::evidence key="projection-row-over-fetch"` 자리에 들어갈 그림이다.
|
||||
|
||||

|
||||

|
||||
|
||||
<!-- local-preview:end -->
|
||||
|
||||
+36
-22
@@ -4,6 +4,7 @@ kind: CASE
|
||||
slug: visibility-or-breaks-keyset-index
|
||||
title: Visibility OR이 Keyset Index를 깨뜨린 문제
|
||||
topic: jpa-feed-query-performance
|
||||
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
|
||||
topicName: JPA 피드 조회 성능
|
||||
project: Liner N + 1문제
|
||||
status: 게시 전
|
||||
@@ -14,34 +15,39 @@ assets:
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/explain/l15-keyset-no-index.txt
|
||||
- ../../../final/evidence/raw/explain/l15-offset-deep-page.txt
|
||||
sourceRevision: n+1liner-lab@2026-08
|
||||
source:
|
||||
- final/document.md#14-2
|
||||
- final/document.md#14-3
|
||||
- final/document.md#14-5
|
||||
---
|
||||
|
||||
# Visibility OR이 Keyset Index를 깨뜨린 문제
|
||||
|
||||
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 공개 범위 세 분기를 OR로 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌으며, 사라졌던 Sort 노드가 다시 나타났다.
|
||||
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 가시성 세 분기를 OR로 얹자 플래너가 그 인덱스를 쓰지 못하고 BitmapOr로 떨어졌고, 사라졌던 Sort 노드가 다시 나타났다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Feed Visibility Query Pattern**
|
||||
이 문제를 세 가지 방식으로 비교한 기준이다.
|
||||
가시성 세 분기를 단일 OR, UNION 분해, 사전계산으로 처리했을 때 실행계획이 어떻게 갈리는지 정리했다.
|
||||
- **Keyset Pagination 설계 기준**
|
||||
이 기록이 이어받은 앞 단계의 기준이다.
|
||||
커서와 정렬키 인덱스의 컬럼과 방향을 어떻게 맞추는지 앞 단계에서 정했다.
|
||||
- **feed_visible을 Production CQRS로 승격할 것인가**
|
||||
이 문제의 해법 중 하나가 남긴 판단이다.
|
||||
사전계산을 프로덕션에서 상시 유지할지는 아직 답하지 않았다.
|
||||
|
||||
## 문제
|
||||
|
||||
keyset 페이징으로 페이지 깊이 문제를 풀었다. 깊은 페이지에서 OFFSET은 2,000행을 훑고 20행만 남겼지만 keyset은 Index Only Scan으로 20행만 읽었고 buffers는 1이었다.
|
||||
|
||||
실서비스 피드는 조회 사용자에 따라 공개 범위를 판정해야 한다. public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기다. 이 필터를 keyset과 같은 쿼리에 얹었다.
|
||||
실서비스 피드는 조회하는 사용자마다 보이는 아이템이 다르다. 이 가시성은 public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기로 갈린다. 세 분기를 keyset과 같은 쿼리에 얹었다.
|
||||
|
||||
## 결론
|
||||
|
||||
가시성 조건을 추가하자 정렬키 인덱스를 더 이상 사용하지 못했다. 플래너는 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스의 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
|
||||
가시성 조건을 추가하자 플래너는 정렬키 인덱스 ix_feed_items_keyset을 더 이상 쓰지 못했다. 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스가 주던 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
|
||||
|
||||
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 각각 다른 조건이라 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
|
||||
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 visibility 값 비교, 멘션 테이블 조인, user_id 비교로 조건이 다르기 때문에 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
|
||||
|
||||
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 필요했는데, 가시성 OR이 그 전제를 깨뜨렸다.
|
||||
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 있어야 했는데, 가시성 OR이 그 전제를 깨뜨렸다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
@@ -74,11 +80,13 @@ ix_feed_items_visibility_sort : (visibility, first_highlighted_at DESC, id)
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 가시성을 얹기 전 — 인덱스로 커서 이후만
|
||||
## 가시성을 얹기 전 — 커서 이후 20행만 읽기
|
||||
|
||||
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림." caption=" " zoom="true"
|
||||
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉜다. 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉜다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어진다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
seed 2,000에서 100번째 페이지(offset 1980)를 요청하고 두 방식의 실행계획을 대조했다.
|
||||
|
||||
```text label="keyset + 정렬키 인덱스, 깊은 페이지"
|
||||
Limit (rows=20) Buffers: shared hit=1 read=2
|
||||
-> Index Only Scan using ix_feed_items_keyset on feed_items fi (actual rows=20)
|
||||
@@ -92,9 +100,17 @@ Limit (rows=20) Buffers: shared hit=1 read=2
|
||||
| keyset + 인덱스 | `Limit`←`Index Only Scan` | 20 | 1 | 0.076 ms |
|
||||
| keyset − 인덱스 | `Limit`←`Sort`←`Seq Scan`(filter) | 20 | 141 | 0.373 ms |
|
||||
|
||||
인덱스를 제거하면 keyset도 Seq Scan으로 전량을 훑는다. keyset 문법이 아니라 정렬키 인덱스가 비용을 줄인다.
|
||||
깊은 페이지에서 `OFFSET`은 `Seq Scan`과 `Sort`로 2,000행을 만든 뒤 20행만 남겼다. 같은 페이지를 커서로 요청한 keyset은 정렬키 인덱스에서 20행만 읽었고 `buffers`는 1이었다.
|
||||
|
||||
## 가시성 OR을 얹은 뒤
|
||||
커서로 넘긴 두 번째 페이지는 `OFFSET`의 두 번째 페이지와 같은 20개 식별자를 같은 순서로 반환했다.
|
||||
|
||||
인덱스를 제거하자 keyset도 `Seq Scan`으로 떨어졌다. 결과는 그대로 20행이었지만 테이블 2,000행을 읽고 1,980행을 필터로 걸러냈기 때문에 `buffers`는 `OFFSET`과 같은 141이 됐다. 비용을 줄인 것은 keyset 문법이 아니라 커서와 같은 순서의 정렬키 인덱스였다.
|
||||
|
||||
다만 이 정렬키 인덱스는 통합 테스트 안에서 만들고 지웠다. 운영 스키마에는 아직 들어가 있지 않다.
|
||||
|
||||
## 가시성 OR을 얹은 뒤의 실행계획
|
||||
|
||||
같은 커서에 가시성 세 분기를 얹고 실행계획을 다시 받았다. 플래너는 조건이 여러 분기로 갈릴 때 분기마다 인덱스에서 비트맵을 만들어 `BitmapOr`로 합치고 `Bitmap Heap Scan`으로 테이블을 한 번 훑는다. 비트맵에는 행의 순서가 남지 않는다.
|
||||
|
||||
```text label="keyset + 가시성 OR/EXISTS"
|
||||
Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장
|
||||
@@ -106,7 +122,7 @@ Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등
|
||||
SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS)
|
||||
```
|
||||
|
||||
정렬키 인덱스 `ix_feed_items_keyset`이 계획에서 사라지고 `ix_feed_items_visibility_sort`를 분기별로 스캔한 BitmapOr가 대신 들어왔다. bitmap으로 합치는 과정에서 인덱스가 주던 정렬 순서를 잃어 상위 20행을 만들기 위한 Sort가 다시 필요해졌다.
|
||||
정렬키 인덱스 `ix_feed_items_keyset`이 계획에서 사라지고 `ix_feed_items_visibility_sort`를 분기별로 스캔한 `BitmapOr`가 대신 들어왔다. 세 분기를 비트맵으로 합치면서 인덱스가 주던 정렬 순서를 잃었기 때문에, 상위 20행을 만드는 `Sort`가 다시 붙었다.
|
||||
|
||||
## 왜 하나의 쿼리로는 순서를 유지하지 못하나
|
||||
|
||||
@@ -119,17 +135,15 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20;
|
||||
```
|
||||
|
||||
세 분기는 조건이 서로 다르다. visibility 값 비교, 멘션 테이블 조인, user_id 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 동시에 만족하는 단일 접근 경로가 없다.
|
||||
세 분기는 `visibility` 값 비교, 멘션 테이블 조인, `user_id` 비교로 조건이 서로 다르다. 하나의 인덱스는 선두 컬럼 순서 하나만 주므로 셋을 동시에 만족하는 접근 경로가 없다.
|
||||
|
||||
## 커서에 tie-break가 필요한 이유
|
||||
keyset이 `Index Only Scan`으로 성립하려면 정렬키·커서·인덱스의 컬럼과 방향이 모두 일치해야 한다. `first_highlighted_at`이 같은 행까지 안정적으로 넘기려면 커서에 `id`를 함께 넣어야 하고, 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다. 가시성 OR은 이 일치를 깨뜨린다.
|
||||
|
||||
`first_highlighted_at`이 같은 행도 안정적으로 넘기려면 커서에 id까지 포함해야 한다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다.
|
||||
## 다음 단계 — UNION 분해와 사전계산
|
||||
|
||||
정렬키, 커서, 인덱스의 컬럼과 방향이 모두 일치해야 Index Only Scan이 성립한다. 가시성 OR은 이 일치를 깨뜨린다.
|
||||
세 분기를 `UNION ALL`로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 처음에는 단일 OR이 `Seq Scan`으로 떨어지고 `UNION` 분해가 읽는 블록을 줄일 것으로 예상했다.
|
||||
|
||||
## 다음 선택
|
||||
|
||||
세 분기를 UNION ALL로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 앞의 것은 요청할 때마다 세 분기를 스캔하고, 뒤의 것은 조회를 단일 Index Only Scan으로 바꾸는 대신 읽기 모델 갱신 비용을 만든다.
|
||||
실제 단일 OR은 `Seq Scan`이 아니라 `BitmapOr`와 top-N `Sort`, hashed SubPlan을 썼다. `UNION` 분해는 요청할 때마다 세 분기를 각각 스캔했고, 그래서 읽은 블록은 단일 OR보다 많았다. 읽은 블록을 가장 줄인 방식은 `UNION` 분해가 아니라 사전계산이었다. 사전계산은 조회를 단일 `Index Only Scan`으로 바꾸는 대신, 피드·멘션·가시성이 바뀔 때마다 읽기 모델을 갱신해야 하고 조회 사용자 수만큼 저장 공간도 늘어난다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
@@ -137,8 +151,8 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
|
||||
|
||||
## 로컬 미리보기
|
||||
|
||||
본문 「가시성을 얹기 전 — 인덱스로 커서 이후만」 아래 `:::evidence key="keyset-vs-offset"` 자리에 들어갈 그림이다.
|
||||
본문 「가시성을 얹기 전 — 커서 이후 20행만 읽기」 아래 `:::evidence key="keyset-vs-offset"` 자리에 들어갈 그림이다.
|
||||
|
||||

|
||||

|
||||
|
||||
<!-- local-preview:end -->
|
||||
|
||||
Reference in New Issue
Block a user