feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,207 @@
---
id: c1158754-e3d2-47b8-bb41-81787c0ca84b
kind: CASE
slug: collection-fetch-join-in-memory-paging
title: Collection Fetch Join Pagination의 In-memory Paging
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit"
assets:
- key: in-memory-paging
file: ../../../final/assets/tech-log-studio/in-memory-paging.svg
evidence:
- ../../../final/evidence/terminal/explain/l4-entity-paging-limit.txt
- ../../../final/evidence/terminal/explain/l5-entity-paging-limit.txt
---
# Collection Fetch Join Pagination의 In-memory Paging
컬렉션 하나만 fetch join하고 setMaxResults(20)을 적용하면 전송량도 한 페이지로 줄어들 것이라고 보았다. Hibernate는 DB LIMIT을 사용하지 않고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘랐다. 반환 목록은 20이었지만 로드한 부모 엔티티는 N개 전부였다.
## 관계
- **Collection Fetch Join과 Pagination을 같이 사용하지 않는다**
이 관측에서 나온 결정이다.
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
이 기록이 이어받은 앞 단계다.
- **Fetch Join · Batch · Projection 선택 기준**
이 실패가 배치 선택으로 이어진 기준이다.
## 문제
컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커졌다. 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.
반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였다. 반환 크기만으로는 실제 적재량을 알 수 없어 메모리에 올린 부모 엔티티 수를 따로 측정했다.
## 결론
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로 상위 몇 행만 취한다.
지연은 예상과 달리 기준선보다 낮았다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N을 따라 1.5 MB에서 10.0 MB로 늘었는데, 기준선의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
## 검증 환경
Java 21
Spring Boot 4.0.0
Hibernate ORM 7.1.8.Final
PostgreSQL : postgres:16-alpine (Testcontainers)
쿼리 : 통합 테스트 안의 원시 JPQL (프로덕션 코드 변경 없음)
hibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)
측정 지표
returned : 결과 리스트 크기
feedItemLoaded : EntityStatistics.getLoadCount()
할당 : getThreadAllocatedBytes (HotSpot)
## 재현 조건
1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 함께 읽는다.
3. 경고 로그를 ListAppender로 캡처한다. 코드 번호만 비교하지 않고 문구도 함께 확인한다.
4. 지연은 반복 측정하고 스레드 누적 할당을 함께 잰다.
5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 대조한다.
## 본문
<!-- body:start -->
## 무대 — 페이징 한 줄만 추가
```java label="통합 테스트 안에서 세운 무대 (프로덕션 아님)"
"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 한 bag fetch join
+ "order by f.firstHighlightedAt desc, f.id asc"
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
```
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
## 응답은 한 페이지인데 부모는 전부 로드한다
| N | returned(페이지) | feedItemLoaded | over-fetch 배수 | 시드 하이라이트 |
|---:|---:|---:|---:|---:|
| 10 | 10 | 10 | 1.0× (안 보임) | 1,285 |
| 100 | 20 | 100 | 5.0× | 1,961 |
| 1,000 | 20 | 1,000 | 50.0× | 2,917 |
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
## 경고 코드가 알려진 것과 달랐다
```text label="Hibernate ORM 7.1.8이 기록한 경고"
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
```
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
## 비용은 페이지가 아니라 데이터셋에 비례한다
| N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 |
|---:|---:|---:|---:|
| 10 | 6.184 ms | 6.566 ms | 약 1.5 MB |
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
## 발행 SQL에 LIMIT이 없다
:::evidence key="in-memory-paging" alt="위쪽은 컬렉션 fetch join에 페이징을 건 경로로 조인 결과 전량이 애플리케이션으로 넘어와 메모리에서 잘리고, 아래쪽은 엔티티만 페이징한 경로로 정렬에 Limit이 붙어 DB가 페이지만 돌려주는 두 경로를 위아래로 대조한 그림." caption=" " zoom="true"
:::
```text label="seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징"
-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음
Sort (... rows=1782 ...) (actual ... rows=1961 loops=1)
Sort Method: quicksort Memory: 445kB
-> Hash Join (... actual ... rows=1961 loops=1)
-> Seq Scan on highlights h (actual ... rows=1961 loops=1)
-> Hash (actual ... rows=100 loops=1)
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
-- (b) 엔티티만 페이징 — Limit 노드 존재
Limit (... rows=20 ...) (actual ... rows=20 loops=1)
-> Sort (actual ... rows=20 loops=1)
Sort Method: top-N heapsort Memory: 28kB
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
```
(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.
## 다음 선택
fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했다. 다음 단계의 전후 차이를 같은 기준으로 비교하기 위해서다.
<!-- body:end -->
<!-- local-preview:start — Studio로 전송되지 않는다. 본문은 body:start~body:end 사이만이다. -->
## 로컬 미리보기
본문 「무대 — 페이징 한 줄만 추가
```java label="통합 테스트 안에서 세운 무대 (프로덕션 아님)"
"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 한 bag fetch join
+ "order by f.firstHighlightedAt desc, f.id asc"
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
```
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
## 응답은 한 페이지인데 부모는 전부 로드한다
| N | returned(페이지) | feedItemLoaded | over-fetch 배수 | 시드 하이라이트 |
|---:|---:|---:|---:|---:|
| 10 | 10 | 10 | 1.0× (안 보임) | 1,285 |
| 100 | 20 | 100 | 5.0× | 1,961 |
| 1,000 | 20 | 1,000 | 50.0× | 2,917 |
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
## 경고 코드가 알려진 것과 달랐다
```text label="Hibernate ORM 7.1.8이 기록한 경고"
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
```
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
## 비용은 페이지가 아니라 데이터셋에 비례한다
| N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 |
|---:|---:|---:|---:|
| 10 | 6.184 ms | 6.566 ms | 약 1.5 MB |
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
## 발행 SQL에 LIMIT이 없다」 아래 `:::evidence key="in-memory-paging"` 자리에 들어갈 그림이다.
![위쪽은 컬렉션 fetch join에 페이징을 건 경로로 조인 결과 전량이 애플리케이션으로 넘어와 메모리에서 잘리고, 아래쪽은 엔티티만 페이징한 경로로 정렬에 Limit이 붙어 DB가 페이지만 돌려주는 두 경로를 위아래로 대조한 그림.](../../../final/assets/tech-log-studio/in-memory-paging.svg)
<!-- local-preview:end -->
@@ -0,0 +1,200 @@
---
id: 32d0be7d-d88e-4760-8d91-35d3a233a99a
kind: CASE
slug: eager-toone-nplus1-without-access
title: Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/32d0be7d-d88e-4760-8d91-35d3a233a99a/edit"
assets:
- key: eager-lazy-query-sequence
file: ../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg
evidence:
- ../../../final/evidence/terminal/explain/highlights-child-plan-A.txt
- ../../../final/evidence/terminal/explain/toone-pages-plan.txt
- ../../../final/evidence/terminal/explain/toone-users-plan.txt
---
# Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1
@ManyToOne의 기본값인 EAGER는 로딩 시점 계약이지 루트 SQL의 JOIN 보장이 아니다. 파생 쿼리에서는 행마다 2차 SELECT가 나갔다. getUser()와 getPage()를 한 번도 호출하지 않은 조회에서도 Page 조회가 N번 실행됐다. 같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. N+1을 가르는 것은 fetch 타입이 아니라 조회 방식이다.
## 관계
- **Fetch Type과 Fetch Strategy 구분**
이 현상을 기준으로 정리한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
이 조회 방식을 프로젝션으로 바꾼 뒤 남은 행 수를 다룬 기록이다.
- **JPA N+1 정량 진단 기준**
엔티티별 fetch 통계로 확인한 방법이다.
## 문제
이 조회의 총 PreparedStatement에는 컬렉션 조회를 빼고도 남는 몫이 있었다. 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.
엔티티에는 fetch를 따로 명시하지 않았다. @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 사용했다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.
## 결론
Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N을 따라 10, 100, 1,000으로 늘고 User fetch는 3, 20, 20에서 멈췄다.
같은 @ManyToOne(EAGER)인데 증가 곡선이 정반대였다. Page는 아이템마다 달라 N번 조회되고, User는 소수 풀을 재사용해 한 번 로드한 대상이 1차 캐시에 남는다. N+1이 생길 가능성은 EAGER라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.
getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 조회 코드를 작성하지 않았는데 EAGER 기본값 때문에 생긴 N+1이다. 같은 조건에서 LAZY 컬렉션은 0이었다.
pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였다.
같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. 지연이냐 즉시냐가 아니라, 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.
## 검증 환경
Java 21
Spring Boot 4.0.0
Hibernate ORM 7.1.8.Final
PostgreSQL : postgres:16-alpine (Testcontainers)
시더 카디널리티
feed_item : N
page : N (아이템당 1개, 전부 다름)
user : max(3, min(20, N/5+1)) (소수 풀 재사용)
측정 지표
getEntityFetchCount() : 2차 SELECT로 로드된 엔티티 인스턴스 수
getEntityStatistics(PageJpaEntity).getFetchCount() : 엔티티별 fetch 수
## 재현 조건
1. seed(N)으로 N ∈ {10, 100, 1000} 데이터를 만들고 loadFeed(0, N)을 실행한다.
2. getEntityStatistics(PageJpaEntity)와 getEntityStatistics(UserJpaEntity)의 getFetchCount()를 각각 읽는다.
3. entityFetch = pageFetch + userFetch가 성립하는지 확인한다.
4. 회계 항등식으로 교차 검증한다. 총 PreparedStatement 컬렉션 N content 1 count 1 = entityFetch.
5. 접근 0회 확인: seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 상태에서 fetch 수를 읽는다.
6. 반복되는 ToOne 부모 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
## 본문
<!-- body:start -->
## 측정한 loadFeed 구현
```java label="FeedQueryAdapter.loadFeed — 엔티티 조회 후 메모리에서 DTO 매핑"
@Override
public List<FeedSummary> loadFeed(int page, int size) {
return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream()
.map(fi -> new FeedSummary(
fi.getId().toString(),
fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩)
fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩)
fi.getFirstHighlightedAt(),
fi.getHighlights().stream() // 컬렉션 (지연 로딩)
.map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt()))
.toList()))
.toList();
}
```
엔티티를 조회한 뒤 메모리에서 DTO로 옮긴다. user·page는 즉시 로딩이고 highlights는 지연 로딩이다.
## 같은 EAGER가 정반대 곡선을 그린다
:::evidence key="eager-lazy-query-sequence" alt="loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스." caption=" " zoom="true"
:::
| N | Page fetch | User fetch | ToOne 합(entityFetch) | 초기화 컬렉션 | 총 PreparedStatement |
|---:|---:|---:|---:|---:|---:|
| 10 | 10 | 3 | 13 | 10 | 25 |
| 100 | 100 | 20 | 120 | 100 | 222 |
| 1,000 | 1,000 | 20 | 1,020 | 1,000 | 2,022 |
검산: `10+3=13` · `100+20=120` · `1000+20=1020`. 회계 항등식으로도 `25102=13` · `2221002=120` · `202210002=1020`.
| 연관 | 데이터 분포 | 1차 캐시로 걸러지나 | N=10 / 100 / 1,000 |
|---|---|---|---|
| User (EAGER ToOne) | 소수 풀 재사용(≤20명) | 그렇다 | 3 / 20 / 20 |
| Page (EAGER ToOne) | 아이템당 1개(전부 다름) | 아니다 | 10 / 100 / 1,000 |
| highlights (지연 로딩 컬렉션) | 아이템당 컬렉션 | 해당 없음 | 10 / 100 / 1,000 |
EAGER의 2차 SELECT 구조가 추가 조회의 가능성을 만든다. 실제 실행 횟수는 Persistence Context 안에서 서로 다른 연관 대상이 몇 개인지가 정한다.
## 필드에 접근하지 않아도 조회가 나간다
seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
| 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 |
|---|---|---|---:|
| 0회 | Page | @ManyToOne (EAGER) | 100 (= N) |
| 0회 | User | @ManyToOne (EAGER) | 20 (풀 dedup) |
| 0회 | highlights | @OneToMany (LAZY) | 0 |
EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다.
## 같은 실행계획, 정반대 비용
```text label="반복되는 ToOne 부모 쿼리 — seed(100) 직후"
-- pages
Index Scan using pk_pages on pages
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1)
Buffers: shared hit=2 Execution Time: 0.021 ms
-- users
Index Scan using pk_users on users
(cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1)
Buffers: shared hit=2 Execution Time: 0.022 ms
```
두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 단건 계획이 이미 Index Scan이므로 인덱스를 더해도 해결되지 않는다.
## 핵심은 지연이냐 즉시냐가 아니다
fetch 계약과 실제 사용을 교차하면 네 칸이 나온다. 네 칸 모두 이 랩에서 잰 값이다. LAZY 자리는 같은 조회의 highlights 컬렉션이 증인이다.
| fetch 계약 | 접근하지 않을 때 | 접근할 때(loadFeed) |
|---|---|---|
| EAGER — User·Page (`@ManyToOne`) | 나간다 · Page 100 · User 20 | 나간다 · Page N번 |
| LAZY — highlights (`@OneToMany`) | 안 나간다 · 0 | 나간다 · N번 |
네 칸 중 세 칸에서 추가 조회가 난다. 나지 않는 칸은 「LAZY이면서 접근하지 않음」 하나뿐인데, 그것은 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 이 칸에 들어가지 않는다.
EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 실제로 같은 조회에서 LAZY인 highlights가 EAGER인 Page와 똑같이 10 / 100 / 1,000번 조회됐다. 같은 증상이 서로 다른 fetch 타입에서 나왔으므로 타입이 왕복 수를 가르는 기준이 아니다.
왕복 수를 정하는 것은 조회 방식이다. 루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 타입과 무관하게 N번이 된다. 줄이려면 fetch join, 배치, 프로젝션처럼 조회 방식 자체를 바꿔야 한다.
## 한 번의 하이라이트 조회가 읽는 행 수
왕복 수와 별개로 그 한 번이 읽어 오는 행 수도 확인했다.
```text label="Plan A — 반복되는 하이라이트 자식 쿼리, 대량 시드 직후 ANALYZE 실행 전"
Index Scan using ix_highlights_feed_items_created on highlights
(cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1)
Index Cond: (feed_item_id = '2b5b931f-...'::uuid)
Buffers: shared hit=14
Planning Time: 0.086 ms
Execution Time: 0.173 ms
```
이 조회도 pk 조회들처럼 인덱스를 타고 0.173 ms에 끝났다. 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 ORDER BY와 LIMIT이 없고, 그 아이템의 하이라이트를 전부 읽는다. 하이라이트가 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다.
추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
## 지표 이름을 정확히 읽는다
getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초기화된 엔티티 수다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 있어, 회귀 가드는 시더 카디널리티와 무관하게 성립하는 엔티티별 `pageFetch == N`으로 고정하고 합계는 회계 항등식으로 교차 검증했다.
이 절의 지연은 앞 기록과 같은 loadFeed 호출을 잰 것이라 별도 지연 축이 아니다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐이다.
<!-- body:end -->
<!-- local-preview:start — Studio로 전송되지 않는다. 본문은 body:start~body:end 사이만이다. -->
## 로컬 미리보기
본문 「같은 EAGER가 정반대 곡선을 그린다」 아래 `:::evidence key="eager-lazy-query-sequence"` 자리에 들어갈 그림이다.
![loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스.](../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg)
<!-- local-preview:end -->
@@ -0,0 +1,168 @@
---
id: 7ed75172-fd56-42bf-956a-8f9fc1cca235
kind: CASE
slug: fetch-join-multibag-and-row-explosion
title: Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit"
assets:
- key: cartesian-row-multiplication
file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg
evidence:
- ../../../final/evidence/terminal/explain/l3-cartesian-join-plan.txt
---
# Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 비용이 전송 행수와 메모리로 옮겨 갔다.
## 관계
- **Fetch Join · Batch · Projection 선택 기준**
이 실패에서 나온 선택 기준이다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 시도가 풀려던 문제다.
- **Collection Fetch Join Pagination의 In-memory Paging**
한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
## 문제
컬렉션 N+1과 ToOne의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
MultipleBagFetchException을 재현하려면 fetch join할 두 번째 bag이 필요했다. 기준선 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
## 결론
두 bag을 동시에 fetch join하면 쿼리 생성 시점에 거부된다. bag은 순서 컬럼이 없는 List라, feed_item 한 행이 highlights h개 × mentions m개로 늘어난 곱집합을 원래 컬렉션으로 되돌릴 수 없기 때문이다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
컬렉션을 하나만 fetch join하면 예외는 없지만 부모가 자식 수만큼 반복된 행이 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
Hibernate 6 이상은 fetch join의 루트 엔티티를 자동으로 중복 제거한다. 그래서 결과 리스트 크기는 N이고, 카테시안은 SQL과 전송 단계에만 남는다. 리스트 크기로는 이 문제가 보이지 않는다.
같은 N=100에서 기준선 222개가 121개로 줄었지만 그중 120개는 여전히 ToOne 2차 SELECT였고, 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보이는 구간이다.
## 검증 환경
Java 21
Spring Boot 4.0.0
Hibernate ORM 7.1.8.Final
PostgreSQL : postgres:16-alpine (Testcontainers)
추가한 것
마이그레이션 : V7__feed_mentions.sql
엔티티 : FeedItemMentionJpaEntity
부모 매핑 : @OneToMany List mentions
feed_item_mentions : 대리키 id + UNIQUE(feed_item_id, mentioned_user_id)
측정 방식
전송 행수는 resultList.size()가 아니라 조인 카디널리티로 측정
SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
이 절의 쿼리는 원시 JPQL이라 Spring Data count가 없다
## 재현 조건
1. highlights와 mentions를 동시에 join fetch하는 JPQL로 createQuery를 호출하고 예외를 확인한다. 원인 체인을 클래스명 문자열로 펼쳐 MultipleBagFetchException 포함 여부를 본다.
2. highlights만 join fetch하는 JPQL을 N ∈ {10, 100, 1000}에서 실행한다.
3. 결과 리스트 크기와 별도로 조인 카디널리티를 count(*)로 측정해 비교한다.
4. 조인 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인해 조인 노드의 actual rows를 본다.
5. 기존 기준선 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 유지되는지 확인한다.
## 본문
<!-- body:start -->
## 실패 하나 — 두 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" alt="왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림." caption=" " zoom="true"
:::
| N | 전송 행수(조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement |
|---:|---:|---:|---:|---:|---:|---:|
| 10 | 1,285 | 10 | 10 | 1,285 | 128.5× | 14 |
| 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×로 줄었지만 절대 전송 행수는 계속 하이라이트 총합이었다.
## 쿼리 수만 보면 개선처럼 보인다
| 구분 | 기준선 loadFeed | highlights fetch join | 결과 |
|---|---:|---:|---|
| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 |
| Page count | 1 | 0 | 원시 JPQL이라 Spring Data count 없음 |
| highlights 컬렉션 | 100 | 0 | N개 컬렉션 SELECT가 조인으로 접힘 |
| ToOne(User+Page) | 120 | 120 | 그대로 — highlights만 fetch join했으므로 |
| 합 | 222 | 121 | |
222개가 121개로 줄어든 주된 이유는 컬렉션 N개가 루트 조인 하나로 합쳐졌기 때문이다. 121개 중 120개는 여전히 ToOne 2차 SELECT였다.
## 조인이 행을 곱하는 것을 실행계획에서
```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)
Hash Cond: (h.feed_item_id = fi.id)
-> Seq Scan on highlights h (actual ... rows=1961 loops=1)
-> Hash (actual ... rows=100 loops=1)
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
Execution Time: 0.959 ms
```
부모 feed_items는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 사실은 리스트 크기로는 보이지 않고 실행계획에서 드러난다.
추정 rows=4202와 실제 rows=1961의 오차는 대량 시드 직후 ANALYZE를 실행하지 않은 통계 문제다.
## 측정 정정
처음에는 distinct 없는 결과 리스트 크기가 전송 행수와 같을 것으로 예상했다. 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
카테시안은 SQL과 전송 단계에 그대로 남아 있다. 이 문제는 EXPLAIN의 actual rows나 조인 count로 확인해야 한다.
## 이 실패를 남긴 이유
distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지 않고 실패를 별도 테스트에 남겼다. fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인하기 위해서다.
<!-- body:end -->
<!-- local-preview:start — Studio로 전송되지 않는다. 본문은 body:start~body:end 사이만이다. -->
## 로컬 미리보기
본문 「실패 하나 — 두 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"` 자리에 들어갈 그림이다.
![왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg)
<!-- local-preview:end -->
@@ -0,0 +1,188 @@
---
id: 4c9c3b90-bc89-4300-9334-088ea95d37d8
kind: CASE
slug: projection-row-over-fetch
title: Projection 이후에도 1,509행을 읽은 Row Over-fetch
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4c9c3b90-bc89-4300-9334-088ea95d37d8/edit"
assets:
- key: projection-row-over-fetch
file: ../../../final/assets/tech-log-studio/projection-row-over-fetch.svg
evidence:
- ../../../final/evidence/terminal/explain/l6-parent-projection.txt
---
# Projection 이후에도 1,509행을 읽은 Row Over-fetch
DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로 줄고 쿼리도 2개로 고정됐다. 그런데 자식 IN 쿼리는 페이지 부모 20개의 하이라이트를 전부 가져와 1,509행이었다. 화면에 필요한 것은 부모당 최신 3개, 최대 60행이었다.
## 관계
- **화면 조회는 Read Projection을 사용한다**
이 관측에서 나온 결정이다.
- **Top-N-per-group 선택 기준**
남은 행 과조회를 푼 다음 단계의 기준이다.
- **Fetch Join · Batch · Projection 선택 기준**
왕복과 적재를 각각 어느 전략이 푸는지 정리한 기록이다.
## 문제
Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
## 결론
프로젝션은 하이드레이트한 엔티티를 0개로 만들었다. SELECT new 캐리어는 영속 엔티티 대신 스칼라 값으로 record를 만들므로 1차 캐시·더티체킹·지연 프록시도 생기지 않는다. join도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다.
발행 쿼리는 N과 관계없이 2개로 고정됐다. 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 페이지 부모가 최대 20개라 자식 IN도 한 번만 실행된다.
남은 문제는 행 수였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지에서 자식 행은 1,509개였고 화면에 필요한 것은 60개였다.
필요한 컬럼만 선택하면 EXPLAIN의 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
## 검증 환경
Java 21
Spring Boot 4.0.0
Hibernate ORM 7.1.8.Final
PostgreSQL : postgres:16-alpine (Testcontainers)
격리
프로젝션 측정은 배치 설정이 없는 별도 IT 클래스
loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드
측정 지표
entitiesLoaded : Statistics.getEntityLoadCount()
prepared : Statistics.getPrepareStatementCount()
collectionFetch : Statistics.getCollectionFetchCount()
## 재현 조건
1. 부모 스칼라 프로젝션과 자식 IN 스칼라 프로젝션 두 쿼리로 loadFeedProjection을 구현한다.
2. seed 1,000에서 loadFeedProjection(0, 20)을 실행하고 getEntityLoadCount()를 읽는다.
3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다.
4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다.
5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다.
6. 부모 프로젝션과 엔티티 페이징의 EXPLAIN width를 비교한다.
## 본문
<!-- body:start -->
## 두 개의 스칼라 프로젝션
```java label="loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로"
// (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에
select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt)
from FeedItemJpaEntity f join f.user u join f.page p
order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT
// (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑
select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
```
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
## 엔티티 로드가 0으로 줄어든다
| 지표 | 배치 | 프로젝션 |
|---|---:|---:|
| entitiesLoaded (seed 1,000) | 1,569 | 0 |
| prepared (N=1,000) | 23 | 2 |
| collectionFetch (N=1,000) | 10 | 0 |
## N이 늘어도 쿼리는 2개다
| N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) |
|---:|---:|---:|---:|
| 10 | 25 | 5 | 2 |
| 100 | 222 | 5 | 2 |
| 1,000 | 2,022 | 23 | 2 |
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
## 남은 비용 — 페이지당 전량
:::evidence key="projection-row-over-fetch" alt="왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수가 그대로 남는 것을 보여 주는 그림." caption=" " zoom="true"
:::
| 항목 | 값 |
|---|---:|
| 페이지 부모 | 20 |
| 자식 IN이 반환한 행 | 1,509 |
| 화면에 필요한 행 | 60 (부모당 3) |
단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다.
## width는 좁아지지 않았다
```text label="seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN"
-- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다)
Limit (... rows=20 width=2088) (actual ... rows=20 loops=1)
-> Sort Sort Method: top-N heapsort Memory: 27kB
-> Hash Join (fi.page_id = p.id) ← pages 조인
-> Hash Join (fi.user_id = u.id) ← users 조인
-> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다
-- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음)
Hash Semi Join (... rows=1509 loops=1)
```
프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다.
## 배치와 프로젝션은 다른 것을 줄인다
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다.
<!-- body:end -->
<!-- local-preview:start — Studio로 전송되지 않는다. 본문은 body:start~body:end 사이만이다. -->
## 로컬 미리보기
본문 「두 개의 스칼라 프로젝션
```java label="loadFeedProjection — 부모(A)와 자식(B)을 각각 스칼라로"
// (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용, 페이징은 엔티티에
select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt)
from FeedItemJpaEntity f join f.user u join f.page p
order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT
// (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑
select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
```
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
## 엔티티 로드가 0으로 줄어든다
| 지표 | 배치 | 프로젝션 |
|---|---:|---:|
| entitiesLoaded (seed 1,000) | 1,569 | 0 |
| prepared (N=1,000) | 23 | 2 |
| collectionFetch (N=1,000) | 10 | 0 |
## N이 늘어도 쿼리는 2개다
| N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) |
|---:|---:|---:|---:|
| 10 | 25 | 5 | 2 |
| 100 | 222 | 5 | 2 |
| 1,000 | 2,022 | 23 | 2 |
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 두 개로 유지된다.
## 남은 비용 — 페이지당 전량」 아래 `:::evidence key="projection-row-over-fetch"` 자리에 들어갈 그림이다.
![왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수가 그대로 남는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/projection-row-over-fetch.svg)
<!-- local-preview:end -->
@@ -0,0 +1,143 @@
---
id: e6715e81-6dbd-4287-8e19-946c334f38fb
kind: CASE
slug: visibility-or-breaks-keyset-index
title: Visibility OR이 Keyset Index를 깨뜨린 문제
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e6715e81-6dbd-4287-8e19-946c334f38fb/edit"
assets:
- key: keyset-vs-offset
file: ../../../final/assets/tech-log-studio/keyset-vs-offset.svg
evidence:
- ../../../final/evidence/terminal/explain/l15-keyset-no-index.txt
- ../../../final/evidence/terminal/explain/l15-offset-deep-page.txt
---
# Visibility OR이 Keyset Index를 깨뜨린 문제
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 공개 범위 세 분기를 OR로 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌으며, 사라졌던 Sort 노드가 다시 나타났다.
## 관계
- **Feed Visibility Query Pattern**
이 문제를 세 가지 방식으로 비교한 기준이다.
- **Keyset Pagination 설계 기준**
이 기록이 이어받은 앞 단계의 기준이다.
- **feed_visible을 Production CQRS로 승격할 것인가**
이 문제의 해법 중 하나가 남긴 판단이다.
## 문제
keyset 페이징으로 페이지 깊이 문제를 풀었다. 깊은 페이지에서 OFFSET은 2,000행을 훑고 20행만 남겼지만 keyset은 Index Only Scan으로 20행만 읽었고 buffers는 1이었다.
실서비스 피드는 조회 사용자에 따라 공개 범위를 판정해야 한다. public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기다. 이 필터를 keyset과 같은 쿼리에 얹었다.
## 결론
가시성 조건을 추가하자 정렬키 인덱스를 더 이상 사용하지 못했다. 플래너는 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스의 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 각각 다른 조건이라 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 필요했는데, 가시성 OR이 그 전제를 깨뜨렸다.
## 검증 환경
Java 21
Spring Boot 4.0.0
Hibernate ORM 7.1.8.Final
PostgreSQL : postgres:16-alpine (Testcontainers)
쿼리 : 통합 테스트 안의 native SQL
정렬키 인덱스 : 테스트 안에서 CREATE / DROP
ix_feed_items_keyset : feed_items (first_highlighted_at DESC, id DESC)
기존 인덱스의 한계
ix_feed_items_visibility_sort : (visibility, first_highlighted_at DESC, id)
선두 컬럼이 visibility라 가시성 필터가 없는 keyset 쿼리에는 맞지 않는다
시드 : seed 2,000
## 재현 조건
1. 정렬키 전용 인덱스를 만들고 keyset 쿼리가 Index Only Scan으로 20행만 읽는 것을 확인한다.
2. 같은 keyset 쿼리에 가시성 세 분기를 OR로 추가한다. public, MENTIONED이면서 EXISTS로 멘션 확인, PRIVATE이면서 user_id가 조회자.
3. EXPLAIN (ANALYZE, BUFFERS)로 정렬키 인덱스 사용 여부와 Sort 노드 유무를 확인한다.
4. 깊은 페이지에서 OFFSET과 keyset의 훑은 행을 대조한다. 훑은 행은 Limit 하위의 actual rows로 계산한다.
## 본문
<!-- body:start -->
## 가시성을 얹기 전 — 인덱스로 커서 이후만
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림." caption=" " zoom="true"
:::
```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)
Index Cond: (ROW(first_highlighted_at, id) < ROW('...'::timestamptz, '...'::uuid))
Heap Fetches: 20
```
| 변형 | 플랜 | 훑은 행 | buffers | exec |
|---|---|---:|---:|---:|
| OFFSET | `Limit`←`Sort`←`Seq Scan`(2,000) | 2,000 | 141 | 0.996 ms |
| 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 문법이 아니라 정렬키 인덱스가 비용을 줄인다.
## 가시성 OR을 얹은 뒤
```text label="keyset + 가시성 OR/EXISTS"
Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장
-> Bitmap Heap Scan on feed_items
-> BitmapOr
-> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='PUBLIC' AND ROW(...) < cursor)
-> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='MENTIONED' AND ...)
-> BitmapAnd (visibility='PRIVATE' ∩ user_id = me)
SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS)
```
정렬키 인덱스 `ix_feed_items_keyset`이 계획에서 사라지고 `ix_feed_items_visibility_sort`를 분기별로 스캔한 BitmapOr가 대신 들어왔다. bitmap으로 합치는 과정에서 인덱스가 주던 정렬 순서를 잃어 상위 20행을 만들기 위한 Sort가 다시 필요해졌다.
## 왜 하나의 쿼리로는 순서를 유지하지 못하나
```sql label="세 분기를 하나의 OR로 묶은 형태"
SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
WHERE (fi.visibility='PUBLIC'
OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m
WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20;
```
세 분기는 조건이 서로 다르다. visibility 값 비교, 멘션 테이블 조인, user_id 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 동시에 만족하는 단일 접근 경로가 없다.
## 커서에 tie-break가 필요한 이유
`first_highlighted_at`이 같은 행도 안정적으로 넘기려면 커서에 id까지 포함해야 한다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다.
정렬키, 커서, 인덱스의 컬럼과 방향이 모두 일치해야 Index Only Scan이 성립한다. 가시성 OR은 이 일치를 깨뜨린다.
## 다음 선택
세 분기를 UNION ALL로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 앞의 것은 요청할 때마다 세 분기를 스캔하고, 뒤의 것은 조회를 단일 Index Only Scan으로 바꾸는 대신 읽기 모델 갱신 비용을 만든다.
<!-- body:end -->
<!-- local-preview:start — Studio로 전송되지 않는다. 본문은 body:start~body:end 사이만이다. -->
## 로컬 미리보기
본문 「가시성을 얹기 전 — 인덱스로 커서 이후만」 아래 `:::evidence key="keyset-vs-offset"` 자리에 들어갈 그림이다.
![위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림.](../../../final/assets/tech-log-studio/keyset-vs-offset.svg)
<!-- local-preview:end -->