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 -->
@@ -0,0 +1,48 @@
---
id: 08a74b35-10c3-4874-8fbc-209b0b6e942e
kind: PROJECT_DECISION
slug: batch-fetch-for-entity-graph
title: Entity Graph 조회에는 Batch Fetch를 사용한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/08a74b35-10c3-4874-8fbc-209b0b6e942e/edit"
decisionStatus: PROPOSED
---
# Entity Graph 조회에는 Batch Fetch를 사용한다
엔티티를 그래프로 조회해야 하는 경로에서는 컬렉션 fetch join 대신 배치 페치를 쓴다. 엔티티만 페이징해 DB LIMIT을 살리고 지연 연관은 부모 키를 모아 IN으로 채운다.
## 근거
- **Fetch Join · Batch · Projection 선택 기준**
세 전략의 역할을 나눈 기준이다.
- **Collection Fetch Join Pagination의 In-memory Paging**
fetch join과 페이징이 함께 서지 못하는 것을 확인한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
배치가 남긴 엔티티 과적재를 확인한 기록이다.
## 결정문
엔티티 그래프가 필요한 조회에서는 컬렉션을 fetch join하지 않고 배치 페치 크기를 설정해 지연 연관을 IN으로 묶는다.
배치 설정의 적용 범위를 명시한다. 세션 전체에 거는 설정은 기존 측정에 영향을 주므로 격리된 범위에 둔다.
## 판단 이유
fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고, 그 때문에 DB가 부모 기준 LIMIT을 적용할 수 없었다. 배치는 부모만 먼저 페이징하고 자식은 별도 쿼리로 가져오므로 두 문제가 함께 풀린다.
측정에서 총 획득 statement가 크게 줄었다. 왕복은 부모 수를 배치 크기로 나눈 올림값이 된다. 컬렉션뿐 아니라 즉시 로딩 연관도 같은 배치에 묶였다.
로드한 부모 엔티티도 데이터셋 전체가 아니라 페이지 크기에서 멈췄다. 인메모리가 아니라 DB에서 LIMIT으로 부모를 먼저 자른 결과다.
실행계획에서도 부모 페이징에 Limit 노드가 붙고 자식 IN은 부모와 자식을 곱하지 않는 준조인으로 나타났다. 앞 단계에서 본 카테시안과 인메모리 페이징이 모두 사라졌다.
## 영향
- 지표 해석이 달라진다. 배치를 적용하면 초기화 컬렉션 수가 SQL 수와 같지 않다. 획득 statement 수와 컬렉션 수를 함께 보고 판단해야 한다.
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 기준선 측정을 유지하려면 설정 범위를 격리해야 한다.
- 엔티티는 여전히 통째로 하이드레이트된다. 화면에 필요하지 않은 컬럼까지 영속 객체로 올라온다. 이 비용은 배치가 풀지 않는다.
- 배치 크기를 정해야 한다. 크기가 크면 IN 목록이 길어지고 작으면 왕복이 늘어난다.
- 특정 연관에만 배치를 걸 수도 있지만 그러면 매핑 자체가 바뀐다. 기준선과 비교하려면 설정으로 두는 편이 낫다.
@@ -0,0 +1,51 @@
---
id: 7f248f68-ce2b-43ec-94ce-82324d0bd1a7
kind: PROJECT_DECISION
slug: keep-read-model-as-cqrs-lite
title: 현재 Read Model은 CQRS-lite로 유지한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7f248f68-ce2b-43ec-94ce-82324d0bd1a7/edit"
decisionStatus: PROPOSED
---
# 현재 Read Model은 CQRS-lite로 유지한다
읽기 경로를 쓰기 애그리거트와 분리하되 저장소는 나누지 않는다. 전용 조회 포트와 읽기 DTO, 읽기 최적 쿼리를 같은 저장소 위에 두고, 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남긴다.
## 근거
- **feed_visible을 Production CQRS로 승격할 것인가**
저장소 분리를 열어 둔 판단이다.
- **화면 조회는 Read Projection을 사용한다**
읽기 모델을 모델 수준에서 분리한 결정이다.
- **Feed Visibility Query Pattern**
사전계산이 어느 지점에서 읽기 모델 설계가 되는지 정리한 기준이다.
## 결정문
읽기 경로는 전용 조회 포트와 유스케이스, 어댑터로 분리한다. 읽기 최적 쿼리는 요청 시점에 실행하고 별도 물리 저장소를 두지 않는다.
사전계산 테이블을 상시 유지하는 구조는 현재 계약의 범위를 넘는 것으로 보고 채택하지 않는다.
## 판단 이유
문제가 N+1을 줄이는 SQL에서 화면에 맞는 읽기 모델을 설계하는 일로 넓어졌다. 쓰기 애그리거트로 읽기를 하려는 데서 조회 비용이 나왔기 때문이다.
읽기 모델을 분리하는 방법은 두 층이 있다. 모델만 분리하는 것과 저장소까지 분리하는 것이다. 모델 분리는 같은 저장소 위에서 전용 포트와 쿼리로 끝나고 동기화 비용이 없다.
저장소 분리는 조회 계획을 가장 단순하게 만들지만 쓰기 변경을 투영에 반영해야 한다. 가시성은 보안에 걸린 조건이라 투영이 어긋나면 노출 사고가 된다. 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다.
현재 트래픽에서 그 비용이 필요한지 아직 확인하지 않았다. 지금까지의 측정은 단일 스레드 로컬 값이라 이 판단의 근거가 되지 못한다.
모델 분리만으로 하이드레이트 엔티티를 0으로, 발행 쿼리를 데이터 규모와 무관한 상수로 만들었다. 이 범위에서 얻을 수 있는 개선을 먼저 취했다.
## 영향
- 조회 요청마다 읽기 최적 쿼리가 실행된다. 사전계산 방식보다 조회 비용이 크다.
- 가시성 조건은 요청 시점에 계산한다. 분기가 여럿이면 그 비용이 매 요청에 붙는다.
- 읽기 전용 계약이 하나 늘어난다. 화면 요구가 바뀌면 이 계약도 바뀐다.
- 저장소가 하나라 정합성 문제가 없다. 투영 갱신, 지연, 복구를 설계하지 않아도 된다.
- 고트래픽 읽기에서 사전계산이 실제로 필요해지면 계약과 가드레일을 함께 개정해야 한다. 이 변경은 현재 범위를 넘는다.
- 경계를 지키는 검사가 필요하다. 조회 포트가 엔티티를 노출하지 않는지, 의존 방향이 맞는지 확인한다.
@@ -0,0 +1,49 @@
---
id: 1dbce381-f0dc-4d49-ad68-bd31d205677e
kind: PROJECT_DECISION
slug: keyset-for-feed-pagination
title: Feed Pagination은 Keyset을 사용한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/1dbce381-f0dc-4d49-ad68-bd31d205677e/edit"
decisionStatus: PROPOSED
---
# Feed Pagination은 Keyset을 사용한다
피드 목록의 페이징은 OFFSET이 아니라 이전 페이지의 마지막 정렬키를 커서로 넘기는 방식을 쓴다. 정렬키와 같은 컬럼·같은 방향의 인덱스를 함께 둔다.
## 근거
- **Keyset Pagination 설계 기준**
이 결정을 규칙으로 편 기준이다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
깊이별 비용과 인덱스 전제를 확인한 기록이다.
- **Highlight 없는 FeedItem을 허용할 것인가**
커서 설계가 기다리는 판단이다.
## 결정문
피드 목록은 (정렬 시각, 식별자)를 커서로 사용해 그 지점 이후만 조회한다. 정렬키 전용 인덱스를 같은 컬럼과 방향으로 둔다.
전체 페이지 수를 요구하지 않는 화면에서는 전체 건수 count를 발행하지 않는다.
## 판단 이유
OFFSET은 정렬 순서에서 앞의 행을 만든 뒤 버린다. 깊은 페이지에서는 결과 20행을 만들려고 2,000행을 읽었다. 무한 스크롤에서는 뒤로 갈수록 이 비용이 계속 늘어난다.
커서 방식은 읽는 행이 페이지 깊이와 무관하게 페이지 크기로 유지됐다. 정렬키 인덱스가 있을 때 커서 이후만 인덱스에서 읽었고 읽은 블록도 최소였다.
인덱스를 제거하면 커서 방식도 전량을 스캔했다. 문법이 아니라 인덱스가 비용을 줄인다. 기존 인덱스는 선두 컬럼이 달라 이 쿼리에 쓰이지 않았고 정렬키 전용 인덱스가 따로 필요했다.
정렬 시각이 같은 행을 안정적으로 넘기려면 식별자까지 커서에 담아야 한다. 시각만 쓰면 경계에서 행이 빠지거나 중복된다.
## 영향
- 임의 페이지로 점프할 수 없다. 앞뒤로 이어서 넘기는 탐색만 가능하다.
- 전체 페이지 수를 화면에 표시할 수 없다. 필요하면 count를 별도로 다뤄야 한다.
- 정렬 기준마다 인덱스가 필요하다. 정렬 기준이 늘면 인덱스 수와 쓰기 비용이 함께 는다.
- 정렬키가 null일 수 있으면 정렬 위치와 커서 표현을 먼저 정의해야 한다. 이 판단이 아직 열려 있다.
- 필터가 붙으면 인덱스 전제가 깨질 수 있다. 가시성 조건을 얹었을 때 정렬키 인덱스가 쓰이지 않고 정렬이 다시 생겼다. 필터를 포함한 설계가 따로 필요하다.
- 커서를 클라이언트에 노출하므로 인코딩과 위변조 처리를 정해야 한다.
@@ -0,0 +1,48 @@
---
id: ae6c9bea-d3a3-46e1-bbd4-8d580d336394
kind: PROJECT_DECISION
slug: measure-plan-on-real-postgresql
title: Query Plan은 실제 PostgreSQL에서 측정한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit"
decisionStatus: PROPOSED
---
# Query Plan은 실제 PostgreSQL에서 측정한다
조회 성능 측정은 인메모리 대체 DB가 아니라 운영과 같은 PostgreSQL에서 실행한다. 실행계획과 인덱스 동작이 측정 대상이므로 DB는 대체재가 아니라 측정 대상의 일부다.
## 근거
- **PostgreSQL Query Plan 측정 기준**
이 결정을 규칙으로 편 기준이다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
실제 엔진에서 실행계획과 통계 차이를 관측한 기록이다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
부분 인덱스와 정렬 인덱스 기능에 기댄 측정 기록이다.
## 결정문
퍼시스턴스 조회 측정은 Testcontainers로 띄운 실제 PostgreSQL에서 수행한다. 인메모리 대체 DB로 실행계획이나 인덱스 동작을 판단하지 않는다.
스키마는 운영 마이그레이션을 그대로 적용하고 엔티티와의 불일치를 조기에 잡는다.
## 판단 이유
비용 기반 옵티마이저는 가능한 계획의 비용을 추정해 고른다. 그 추정값도, 고를 수 있는 선택지도 엔진마다 다르다. 비용 상수, 수집하는 통계, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류가 모두 갈린다.
이 프로젝트의 측정은 이 축들에 직접 걸린다. 추정 행수와 실제 행수가 500배 차이 난 관측은 통계 수집 방식에 달렸고, 순차 스캔과 인덱스 스캔의 판정은 비용 모델과 선택도 추정의 산물이며, 가시성 조건과 정렬 페이징은 부분 인덱스와 정렬 인덱스 기능에 기댄다.
다른 엔진에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작이 재현되지 않는다. 세 지점에서 체계적으로 틀린 결론에 이른다.
측정 대상이 계획과 인덱스 동작인 이상 DB를 바꾸면 측정 자체가 달라진다.
## 영향
- 측정 실행에 컨테이너 런타임이 필요하다. Docker가 없는 환경에서는 이 테스트가 비활성화된다.
- 인메모리 DB보다 기동과 실행이 느리다. 컨테이너를 클래스당 하나로 공유해 비용을 줄였다.
- 재현성을 위해 이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있다.
- 스키마 검증만으로 모든 드리프트를 막지 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 정책은 따로 확인해야 한다.
- 측정값은 warm cache 상태의 로컬 값이다. 운영 지연으로 옮겨 읽을 수 없다.
@@ -0,0 +1,48 @@
---
id: 5e4d033c-d6fe-4257-a4dc-1ade44473c72
kind: PROJECT_DECISION
slug: no-collection-fetch-join-with-pagination
title: Collection Fetch Join과 Pagination을 같이 사용하지 않는다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit"
decisionStatus: PROPOSED
---
# Collection Fetch Join과 Pagination을 같이 사용하지 않는다
컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 응답은 한 페이지지만 비용은 데이터셋 전체에 비례한다.
## 근거
- **Collection Fetch Join Pagination의 In-memory Paging**
이 동작을 실행계획과 로드 수로 확인한 기록이다.
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
컬렉션 fetch join이 행을 곱하는 것을 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
대신 무엇을 쓸지 정한 기준이다.
## 결정문
컬렉션을 fetch join하는 쿼리에 firstResult나 maxResults를 적용하지 않는다.
페이징이 필요한 목록 조회에서는 엔티티만 페이징해 DB LIMIT이 정상 발행되게 하고, 지연 연관은 배치나 별도 쿼리로 채운다.
## 판단 이유
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다.
Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 자른다. 발행된 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
측정에서 반환 목록은 페이지 크기로 고정됐지만 로드한 부모 엔티티는 데이터셋 전체였다. 초과 적재 배수는 데이터가 커질수록 늘었다. 작은 데이터셋에서는 두 값이 같아 문제가 드러나지 않는다.
엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 상위 몇 행만 취하는 정렬로 바뀐다. 전체 정렬과 상위 몇 행 정렬의 차이가 계획 수준에서 나타난다.
## 영향
- 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식 조회를 위한 쿼리가 따로 필요하다.
- 엔티티만 페이징하면 지연 연관의 N+1이 돌아온다. 배치나 프로젝션을 함께 적용해야 한다.
- 이 실수를 조기에 발견하려면 컬렉션 fetch join에 페이징이 걸릴 때 실패시키는 설정을 켤 수 있다. 근본 해결은 아니지만 안전장치가 된다.
- 회귀 가드는 경고 코드 번호만 비교하지 않는다. 버전에 따라 코드가 달라질 수 있어 문구도 함께 확인한다.
- 작은 데이터셋으로만 검증하면 이 문제를 놓친다. 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 봐야 한다.
@@ -0,0 +1,48 @@
---
id: 4e3200c8-eff5-4442-ae84-ae7b7fa92c8b
kind: PROJECT_DECISION
slug: query-strategy-behind-port
title: Query Strategy는 FeedQueryPort 뒤에서 소유한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4e3200c8-eff5-4442-ae84-ae7b7fa92c8b/edit"
decisionStatus: PROPOSED
---
# Query Strategy는 FeedQueryPort 뒤에서 소유한다
조회 전략은 퍼시스턴스 어댑터의 책임으로 둔다. 상위 계층에는 조회 조건과 반환 형태만 드러내고 fetch join, 배치, 프로젝션, 윈도우 함수 중 무엇을 쓰는지는 포트 뒤에 감춘다.
## 근거
- **Fetch Join · Batch · Projection 선택 기준**
포트 뒤에서 교체한 전략들의 선택 기준이다.
- **Collection Fetch Join Pagination의 In-memory Paging**
전략을 바꿔 가며 실패를 격리한 기록이다.
- **화면 조회는 Read Projection을 사용한다**
같은 포트 뒤에서 구현을 바꾼 결정이다.
## 결정문
조회 경로는 컨트롤러에서 유스케이스를 거쳐 조회 포트로 이어지고, 퍼시스턴스 어댑터가 그 포트를 구현한다. 조회 전략의 변경은 어댑터 안에서 끝낸다.
포트는 엔티티 타입을 노출하지 않는다. 컨트롤러는 엔티티를 의존하거나 반환하지 않는다.
## 판단 이유
이 프로젝트에서 조회 전략을 여섯 번 바꿨다. 엔티티 매핑, fetch join, 배치, 프로젝션, 윈도우와 LATERAL, 커서 페이징이다. 전략마다 SQL 형태와 반환 구조가 달랐다.
전략이 상위 계층에 드러나 있었다면 매번 유스케이스와 웹 계층까지 함께 고쳐야 했다. 포트 뒤에 두었기 때문에 상위 계층은 그대로 두고 어댑터만 바꿔 가며 비교할 수 있었다.
엔티티 연관 게터를 좁게 열어 둔 것도 같은 경계다. 연관 게터가 열려 있으면 상위 계층이 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트에 의존하게 된다.
포트가 엔티티를 노출하지 않으면 조회 방식이 바뀌어도 계약이 유지된다.
## 영향
- 어댑터 안에 네이티브 SQL이 들어간다. 표준 JPQL로 표현되지 않는 윈도우 함수와 LATERAL을 써야 하기 때문이다. 이 코드는 포트 뒤에 머문다.
- 반환 형태를 바꾸려면 포트 계약을 바꿔야 한다. 화면 요구가 바뀌면 계약도 함께 바뀐다.
- 전략별 실패를 프로덕션 코드에 섞지 않고 통합 테스트에 격리할 수 있었다. 다음 단계와 전후를 같은 기준으로 비교하는 데 필요했다.
- 어댑터가 조회 성능의 책임을 모두 가진다. 성능 문제의 원인을 찾을 때 이 경계 안을 먼저 본다.
- 경계를 지키는 검사를 자동화해야 한다. 포트가 엔티티를 노출하지 않는지, 의존 방향이 맞는지 확인하는 검사가 필요하다.
@@ -0,0 +1,48 @@
---
id: 30a37f34-b406-4061-b924-e22e0be0c3bf
kind: PROJECT_DECISION
slug: read-projection-for-screen-query
title: 화면 조회는 Read Projection을 사용한다
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/30a37f34-b406-4061-b924-e22e0be0c3bf/edit"
decisionStatus: PROPOSED
---
# 화면 조회는 Read Projection을 사용한다
화면에 내보내는 조회는 엔티티를 하이드레이트하지 않고 필요한 스칼라 값만 캐리어로 받는다. 엔티티 그래프 조회는 쓰기 경로에 남기고 읽기 경로는 프로젝션으로 분리한다.
## 근거
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
프로젝션의 효과와 남은 비용을 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
배치와 프로젝션이 서로 다른 비용을 줄인다는 기준이다.
- **Query Strategy는 FeedQueryPort 뒤에서 소유한다**
이 구현을 감춘 경계다.
## 결정문
화면 조회 경로에서는 필요한 컬럼만 선택해 캐리어 record로 받는다. 영속 엔티티를 만들지 않는다.
부모와 자식을 각각 스칼라로 조회하고 애플리케이션에서 조립한다. 이 구현은 조회 포트 뒤에 둔다.
## 판단 이유
배치를 적용한 뒤에도 엔티티는 통째로 하이드레이트됐다. 페이지 20건을 조회하는데 부모와 연관을 합해 천 개가 넘는 영속 객체가 올라왔다. 화면에는 일부 컬럼만 필요했다.
캐리어 생성자 표현식은 영속 엔티티 대신 스칼라 값으로 record를 만든다. 1차 캐시, 더티체킹, 지연 프록시가 생기지 않는다. 컬럼을 읽기 위한 조인이 있어도 그 대상 엔티티를 만들지 않는다.
측정에서 하이드레이트한 엔티티가 0이 됐고 발행 쿼리도 데이터 규모와 관계없이 두 개로 고정됐다. 부모 스칼라 쿼리와 자식 IN 쿼리다.
이 효과는 배치 설정 여부와 무관하게 성립한다. 배치는 왕복을 줄이고 프로젝션은 적재를 없앤다. 두 전략은 서로를 대신하지 않는다.
## 영향
- 반환 형태가 화면 요구에 묶인다. 화면이 바뀌면 캐리어와 쿼리도 바뀐다.
- 여러 컬렉션을 담는 응답은 생성자 표현식 한 번으로 만들 수 없다. 부모와 자식을 따로 조회해 조립해야 한다.
- 프로젝션의 이득은 실행계획에서 확인되지 않는다. 필요한 컬럼만 골라도 계획의 행폭 추정치는 오히려 넓어질 수 있다. 엔티티 로드 수로 확인해야 한다.
- 자식 조회의 행수는 프로젝션이 줄이지 않는다. 부모당 상한이 필요하면 별도 SQL 형태로 풀어야 한다.
- 읽기 경로와 쓰기 경로의 모델이 갈린다. 같은 저장소를 쓰더라도 조회 전용 계약이 하나 늘어난다.
@@ -0,0 +1,86 @@
---
id: e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff
kind: QUESTION
slug: cardinality-estimate-after-analyze
title: ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit"
questionStatus: OPEN
---
# ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
반복되는 하이라이트 조회의 실행계획에서 추정 행수는 1이고 실제 행수는 500이었다. 대량 데이터를 넣은 직후 통계를 갱신하지 않아 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
추정과 실제의 차이를 기록하는 기준이다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 실행계획이 나온 기록이다.
## 사실
- 대량 시드 직후 측정한 계획에서 추정 rows는 1, 실제 rows는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 인덱스로 처리했고 실행시간은 0.173 ms였다.
- 읽은 블록은 모두 캐시에서 왔다. 디스크 읽기는 0이었다.
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id별 자식 수가 크게 다르다. 상한 500, 하한 1이다.
- fetch join 조인 계획에서도 추정 4,202와 실제 1,961의 차이가 있었다.
- 시드 직후 통계 갱신 명령을 실행하지 않았다.
## 가정
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정이 실제에 가까워진다.
- 추정이 달라지면 플래너가 다른 계획을 고를 수 있다.
- 편중이 큰 컬럼은 기본 통계 대상 수로 부족할 수 있다.
## 미지수
- 통계를 갱신한 뒤 추정 행수가 실제에 얼마나 가까워지는가.
- 추정이 바뀌면 스캔 방식이 바뀌는가. 인덱스에서 순차 스캔으로, 또는 그 반대로 뒤집히는가.
- 편중이 큰 컬럼에 통계 대상 수를 늘리면 추정이 더 좋아지는가.
- 실행시간과 읽은 블록 수가 달라지는가.
- 이 차이가 지금까지의 결론을 바꾸는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 후 통계 갱신을 절차에 넣을 것인가.
## 제약
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 비교가 흐려진다.
- 지금까지 기록한 실행계획은 모두 갱신 전 값이다. 갱신 후 값과 섞어 읽지 않도록 표기를 구분해야 한다.
- 실행하기 전에는 수치를 채우지 않는다.
## 선택지
### 1. 통계를 갱신하고 전후를 비교한다
같은 데이터에서 갱신 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
측정이 한 번 더 필요하지만 가설을 닫을 수 있다.
### 2. 통계 대상 수까지 조절해 본다
편중이 큰 컬럼의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
변수가 하나 더 늘어 비교가 복잡해진다.
### 3. 갱신 전 값만 두고 넘어간다
왕복 수와 전송 행수에 관한 결론은 통계와 무관하다. 추정 차이를 한계로만 적고 진행한다.
플래너가 다른 계획을 고를 가능성을 확인하지 못한 채 남는다.
## 다음 검증
1. 대량 시드 직후 현재 계획을 다시 기록한다. 갱신 전 값임을 명시한다.
2. 통계를 갱신한다.
3. 같은 쿼리를 같은 실행 안에서 다시 EXPLAIN한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
4. 두 계획을 나란히 두고 무엇이 달라졌는지 적는다.
5. 계획이 바뀌었다면 지금까지의 결론 중 영향을 받는 항목이 있는지 확인한다.
6. 운영 절차에 대량 적재 후 통계 갱신을 넣을지 판단한다.
@@ -0,0 +1,87 @@
---
id: 6cbe963f-86f8-4df6-be5a-900712970d01
kind: QUESTION
slug: concurrency-stability
title: 실제 동시 트래픽에서도 이 구조가 안정적인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/6cbe963f-86f8-4df6-be5a-900712970d01/edit"
questionStatus: OPEN
---
# 실제 동시 트래픽에서도 이 구조가 안정적인가
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 조회 횟수의 증가 형태를 확인한 것이다. 처리량, 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라 조회 구조가 실제 부하를 견디는지 아직 모른다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
측정 범위와 도구 선택을 정한 기준이다.
- **feed_visible을 Production CQRS로 승격할 것인가**
부하 결과가 필요한 다른 판단이다.
## 사실
- 측정은 단일 스레드에서 이미 열린 테스트 트랜잭션 안의 어댑터 호출만 쟀다. HTTP 종단을 거치지 않았다.
- 지연 값은 warm cache 상태의 로컬 비교값이다. 표본은 7회 중 앞 2회를 버린 5개다.
- 표본이 적어 백분위수 대신 중앙값과 최댓값으로 기록했다.
- 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, 자식 전송은 1,509행에서 최대 60행으로 줄었다.
- 깊은 페이지 조회는 2,000행 대신 20행을 읽었다.
- 문서 첫머리에 고트래픽 처리량, 커넥션 풀 안정성, 동시성이 범위 밖임을 명시했다.
## 가정
- 왕복 수가 줄면 같은 트래픽에서 DB 부하도 줄어든다.
- 요청당 왕복이 처리량에 곱해지므로 왕복 감소는 처리량 한계를 올린다.
- 단일 스레드에서 확인한 조회 형태는 동시 실행에서도 유지된다.
## 미지수
- 목표 처리량에서 커넥션 풀이 포화되는가. 풀 크기와 대기 시간은 어떻게 되는가.
- 동시 실행에서 지연 분포가 어떻게 되는가. 꼬리 지연이 어디까지 늘어나는가.
- 배치 크기 설정이 동시 실행에서 어떻게 작동하는가. 세션마다 독립인가.
- 깊은 페이지 요청이 섞이면 전체 지연에 어떤 영향을 주는가.
- 캐시가 차갑거나 통계가 갱신되지 않은 상태에서 계획이 달라지는가.
- 어느 지표를 운영 알람 기준으로 삼을 것인가.
## 제약
- 단일 스레드 값으로는 동시성 질문에 답할 수 없다. 도구를 바꿔야 한다.
- 안정적인 꼬리 지연을 말하려면 워밍업 후 반복 횟수를 크게 늘린 독립 세트가 여러 개 필요하다.
- 부하 테스트 환경이 운영과 다르면 결과를 그대로 옮길 수 없다. 데이터 규모와 하드웨어를 맞춰야 한다.
- 로컬에서 확인한 것을 운영에서 확인한 것으로 승격하지 않는다.
## 선택지
### 1. 부하 테스트와 APM으로 확인한다
목표 처리량을 정하고 종단 지연, 처리량, 커넥션 풀 지표를 함께 잰다. 조회 구조의 개선이 부하에서도 나타나는지 본다.
환경 구성과 데이터 준비에 시간이 든다.
### 2. 반복 횟수를 늘린 지연 측정부터 한다
같은 단일 스레드 조건에서 표본을 크게 늘려 꼬리 지연을 먼저 안정화한다. 동시성은 아직 다루지 않는다.
동시성 질문에는 여전히 답하지 못한다.
### 3. 현재 범위를 명시하고 운영 판단은 미룬다
조회 형태 개선까지만 주장하고 처리량은 다루지 않는다. 필요해질 때 부하 테스트를 연다.
운영에 올린 뒤 문제를 발견할 위험이 남는다.
## 다음 검증
1. 목표 처리량과 허용 지연을 먼저 정한다. 기준이 없으면 결과를 판정할 수 없다.
2. 운영과 비슷한 데이터 규모를 준비한다. 편중 분포를 유지한다.
3. 부하 테스트로 종단 지연과 처리량을 측정한다. 커넥션 풀 사용률과 대기 시간을 함께 본다.
4. 개선 전후 구조를 같은 조건에서 비교한다. 왕복 감소가 처리량으로 이어지는지 확인한다.
5. 깊은 페이지와 얕은 페이지 요청을 섞어 지연 분포를 본다.
6. 운영 알람으로 쓸 지표를 정한다.
@@ -0,0 +1,83 @@
---
id: 5159c415-232d-424a-970a-b0db52746767
kind: QUESTION
slug: isolate-round-trip-and-row-volume
title: Round Trip과 Row Volume을 독립 측정할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5159c415-232d-424a-970a-b0db52746767/edit"
questionStatus: OPEN
---
# Round Trip과 Row Volume을 독립 측정할 것인가
현재 데이터셋은 N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, DB 왕복이 동시에 늘어난다. 지연이 늘어난 원인을 어느 하나에 돌릴 수 없다. 변수를 하나씩 격리한 데이터셋을 만들지 결정하지 않았다.
## 관계
- **JPA N+1 정량 진단 기준**
왕복과 행수를 다른 축으로 세는 기준이다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
왕복 수와 한 번에 읽는 행 수를 같은 조회에서 확인한 기록이다.
## 사실
- 하이라이트 개수는 순위 기반 편중 분포로 생성된다. 상한 500, 하한 1이다.
- 하이라이트 총량은 N에 정비례하지 않는다. N=10에서 1,285개인데 N=1,000에서 2,917개다.
- 조회 수는 하이라이트 총량이 아니라 부모 수 N에 정비례한다.
- N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, 왕복이 함께 늘어난다.
- 지연은 단일 스레드에서 7회 반복하고 앞 2회를 버린 뒤 5개 표본의 중앙값과 최댓값으로 기록했다.
- 격리 데이터셋 세 종류를 계획했지만 아직 실행하지 않았다.
## 가정
- 왕복 수와 전송 행수는 지연에 서로 다른 방식으로 기여한다.
- 부모 수만 바꾸고 자식 수를 고정하면 왕복의 기여를 분리할 수 있다.
- 부모 수를 고정하고 자식 수만 바꾸면 과조회의 기여를 분리할 수 있다.
## 미지수
- 격리 데이터셋을 추가로 유지할 가치가 있는가. 시더와 테스트가 늘어난다.
- 부모 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- 자식 수만 바꾼 데이터셋에서 지연이 전송 행수에 어떻게 붙는가.
- 두 기여를 분리해도 전략 선택이 달라지는가. 이미 배치와 프로젝션으로 둘 다 줄였다.
- 편중 분포를 유지한 데이터셋과 격리 데이터셋을 모두 유지할 것인가, 격리 데이터셋으로 대체할 것인가.
## 제약
- 현재 지연 값은 단일 스레드·warm cache 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 이 한계는 그대로다.
- 실행하기 전에는 수치를 채우지 않는다. 예상값으로 표를 메우지 않는다.
- 시더가 복잡해지면 기존 측정의 재현성에 영향을 줄 수 있다. 기존 데이터셋은 유지한 채 추가해야 한다.
## 선택지
### 1. 세 데이터셋을 모두 만든다
부모 수만 바꾼 것, 자식 수만 바꾼 것, 편중을 유지한 것 세 가지를 유지한다. 각 변수의 기여를 따로 볼 수 있다.
시더와 테스트가 늘어나고 실행 시간도 길어진다.
### 2. 편중 데이터셋만 유지하고 격리는 하지 않는다
전략 선택이 이미 정해졌다면 원인 분해가 결정을 바꾸지 않는다. 현재 데이터셋으로 회귀만 지킨다.
나중에 지연 원인을 따져야 할 때 다시 만들어야 한다.
### 3. 필요할 때만 한시적으로 만든다
특정 판단이 필요해지는 시점에 격리 데이터셋을 만들고 측정한 뒤 남기지 않는다.
측정 시점마다 시더를 다시 맞춰야 해서 재현성이 떨어진다.
## 다음 검증
1. 부모 수만 바꾼 데이터셋을 만든다. 부모마다 자식을 정확히 1개씩 둔다.
2. 부모 수를 고정하고 자식 수만 바꾼 데이터셋을 만든다.
3. 두 데이터셋에서 왕복 수, 전송 행수, 지연을 각각 측정한다.
4. 지연이 어느 변수에 어떻게 붙는지 확인한다.
5. 분해 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할 필요가 있는지 다시 판단한다.
@@ -0,0 +1,83 @@
---
id: b099ca65-bf9f-4d61-814c-74722453fa3c
kind: QUESTION
slug: nullable-first-highlighted-at
title: Highlight 없는 FeedItem을 허용할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit"
questionStatus: OPEN
---
# Highlight 없는 FeedItem을 허용할 것인가
정렬키 first_highlighted_at이 nullable이라 하이라이트 없는 FeedItem이 존재할 수 있는 스키마다. 시더는 하이라이트가 만든 FeedItem만 넣어 항상 값이 차지만, 허용 여부를 정하지 않으면 정렬과 커서 비교식의 경계 동작이 정의되지 않는다.
## 관계
- **Keyset Pagination 설계 기준**
정렬키의 null 처리가 커서 설계에 걸리는 지점이다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
같은 정렬키 인덱스를 다루는 기록이다.
## 사실
- 현재 스키마의 first_highlighted_at은 timestamptz nullable이다. NOT NULL이 아니다.
- 시더는 하이라이트가 만든 FeedItem만 생성하므로 이 값을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- FeedItem은 (user, page) 조합당 하나이고 UNIQUE(user_id, page_id) 제약이 있다.
- keyset 커서는 (first_highlighted_at, id)를 비교식으로 사용한다.
- 정렬키 전용 인덱스는 (first_highlighted_at DESC, id DESC)로 만들었다.
## 가정
- 하이라이트가 하나도 없는 FeedItem이 생기는 경로가 실제로 있을 수 있다.
- null이 섞이면 커서 비교식이 경계에서 행을 빠뜨리거나 중복시킬 수 있다.
- 정렬 위치를 정의하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
## 미지수
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 존재하는가. 존재한다면 어떤 상황인가.
- NOT NULL로 좁힐 것인가, null을 허용하고 정렬 위치를 정의할 것인가.
- null을 허용한다면 정렬에서 어디에 두는가. 그 위치를 인덱스가 지원하는가.
- 커서가 null을 만났을 때 비교식을 어떻게 표현하는가.
- 부분 인덱스 조건을 쓴다면 null 행이 인덱스에서 빠지는데 그 행은 어떻게 조회되는가.
- FeedItem 생성 시점과 첫 하이라이트 시점이 다를 수 있는가.
## 제약
- 결정 시점은 keyset 페이징을 프로덕션에 반영하기 전이다. 커서 비교식과 인덱스 정의가 이 결정에 달려 있다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 없어야 한다. 마이그레이션 전에 확인이 필요하다.
- 현재 시더로는 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 검증할 수 있다.
## 선택지
### 1. NOT NULL로 좁힌다
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키를 NOT NULL로 정의한다. 커서 비교식이 단순해지고 인덱스도 그대로 쓸 수 있다.
대신 하이라이트 없는 FeedItem을 만드는 경로가 나중에 필요해지면 스키마와 생성 흐름을 다시 바꿔야 한다.
### 2. null을 허용하고 정렬 위치를 정의한다
정렬에서 null을 어디에 둘지 명시하고 인덱스도 같은 위치로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
생성 흐름은 자유로워지지만 커서 표현과 인덱스 정의가 복잡해진다.
### 3. 부분 인덱스로 null 행을 제외한다
정렬키가 있는 행만 인덱스에 담는다. 피드 목록에는 하이라이트가 있는 항목만 노출한다는 정책이 된다.
인덱스는 작아지지만 null 행을 조회하는 별도 경로가 필요하다.
## 다음 검증
1. 도메인에서 하이라이트 없는 FeedItem이 생기는 경로가 있는지 확인한다.
2. 기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다.
3. null이 섞인 데이터셋을 만들어 현재 커서 비교식이 경계에서 어떻게 동작하는지 재현한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 행·같은 순서인지 대조한다.
5. 정렬 위치를 정의한 경우 인덱스가 그 순서를 그대로 주는지 실행계획으로 확인한다.
@@ -0,0 +1,89 @@
---
id: 5088ce14-b096-41d3-abba-64b7afb48bb9
kind: QUESTION
slug: promote-feed-visible-to-cqrs
title: feed_visible을 Production CQRS로 승격할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5088ce14-b096-41d3-abba-64b7afb48bb9/edit"
questionStatus: OPEN
---
# feed_visible을 Production CQRS로 승격할 것인가
사용자별 가시성을 미리 계산한 테이블은 조회를 커버링 인덱스 하나로 만들었다. 상시 유지하려면 원본 변경을 투영에 동기화해야 하고, 이는 별도 물리 읽기 저장소를 두는 결정이 된다.
## 관계
- **Feed Visibility Query Pattern**
세 방식을 비교한 기준이다.
- **현재 Read Model은 CQRS-lite로 유지한다**
지금 유지하기로 한 범위다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
사전계산이 풀려던 문제다.
## 사실
- 사전계산 조회는 커버링 인덱스의 단일 스캔이었다. OR도 조인도 정렬도 없었다.
- 단일 OR은 후보 1,500을 훑고 상위 20을 정렬로 만들었다. UNION 분해는 분기별로 스캔했다.
- 세 방식은 같은 조회 사용자에게 같은 항목 집합을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 깊은 페이지에서 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 읽었다.
- 현재 구현은 사전계산 테이블을 테스트 안에서 만들고 지운다. 상시 유지하지 않는다.
- 현재 읽기 경로는 쓰기와 같은 저장소 위에 읽기 전용 포트·DTO·쿼리만 분리한 형태다.
## 가정
- 고트래픽 읽기에서는 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 원본 변경마다 투영 갱신이 필요하다.
- 투영이 어긋나면 사용자가 볼 수 없는 항목을 보거나 볼 수 있는 항목을 놓친다.
## 미지수
- 현재 트래픽에서 단일 OR이나 UNION 분해로 충분한가. 사전계산이 필요한 임계가 어디인가.
- 동기화를 어떤 방식으로 하는가. 도메인 이벤트인가 아웃박스인가.
- 투영 갱신이 늦어졌을 때 허용 가능한 지연은 얼마인가.
- 가시성이 바뀌는 사건이 무엇인가. 아이템 공개 범위 변경, 멘션 추가·삭제, 사용자 삭제까지 포함하는가.
- 사용자 수만큼 늘어나는 저장 공간이 감당 가능한가.
- 투영이 어긋났을 때 어떻게 발견하고 복구하는가.
- 이 변경이 현재 정한 계약의 범위를 넘는가. 넘는다면 계약과 가드레일을 어떻게 개정하는가.
## 제약
- 현재 계약에서 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남겨 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이다. 투영이 어긋나면 노출 사고가 된다. 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지의 측정은 단일 스레드 로컬 값이다. 고트래픽에서 어느 방식이 필요한지는 이 측정으로 답할 수 없다.
## 선택지
### 1. 현재 범위를 유지하고 요청 시 조회로 푼다
단일 OR이나 UNION 분해로 조회한다. 동기화 비용이 없고 정합성 문제도 없다.
고트래픽에서 조회 비용이 그대로 남는다.
### 2. 사전계산을 상시 유지하는 읽기 저장소로 승격한다
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 조회 비용이 가장 낮다.
동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다. 계약 개정도 필요하다.
### 3. 일부만 사전계산한다
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청 시 조회한다.
두 경로를 함께 운영해야 하고 어느 구간을 투영에 둘지 정하는 기준이 필요하다.
## 다음 검증
1. 부하 테스트로 현재 조회 방식이 목표 트래픽을 견디는지 확인한다. 이 판단은 단일 스레드 측정으로 대신할 수 없다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행에 영향을 주는지 정리한다.
3. 사용자 수와 아이템 수를 곱한 투영 크기를 계산한다.
4. 동기화 지연의 허용치를 정한다. 가시성은 보안 조건이므로 이 값이 설계를 좌우한다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 복구 절차를 정의한다.
6. 위 결과를 보고 계약을 개정할지 판단한다.
@@ -0,0 +1,89 @@
---
id: 635fcedd-d402-4297-bcf3-9fcdf4200d28
kind: REFERENCE
slug: feed-visibility-query-pattern
title: Feed Visibility Query Pattern
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/635fcedd-d402-4297-bcf3-9fcdf4200d28/edit"
---
# Feed Visibility Query Pattern
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기를 만든다. 하나의 OR로 묶는 방식, 분기를 UNION으로 나누는 방식, 사용자별로 미리 계산하는 방식이 같은 결과를 다른 비용으로 만든다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
단일 OR이 정렬 인덱스를 못 쓰는 것을 확인한 기록이다.
- **feed_visible을 Production CQRS로 승격할 것인가**
사전계산 방식이 남긴 판단이다.
- **Keyset Pagination 설계 기준**
이 조건과 함께 서야 하는 페이징 기준이다.
## 목적
세 분기는 조건의 성격이 다르다. 값 비교, 다른 테이블과의 관계 확인, 소유자 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 한 접근 경로로 만족시킬 수 없다.
정렬과 페이징을 함께 요구하면 이 차이가 실행계획에서 드러난다.
## 규칙
### 1. 단일 OR은 정렬 순서를 잃는다
세 분기를 하나의 조건으로 묶으면 플래너가 분기별로 스캔한 뒤 bitmap으로 합친다. 이 과정에서 인덱스가 주던 순서가 사라져 상위 몇 행을 만들기 위한 정렬이 다시 필요해진다.
관계 확인 조건은 hashed SubPlan으로 처리될 수 있다.
### 2. UNION 분해는 분기마다 자기 인덱스를 태운다
세 분기를 각각 정렬이 보장되는 쿼리로 만들고 병합하면 전체 재정렬이 사라진다. 관계 확인 조건도 조인으로 바뀐다.
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기 수만큼 접근이 늘어 buffers가 단일 OR보다 클 수 있다.
### 3. 사전계산은 조회를 단일 인덱스 스캔으로 바꾼다
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 읽는다. OR도 조인도 정렬도 없다.
대신 원본이 바뀔 때 이 투영을 갱신해야 하고 사용자 수만큼 저장 공간이 늘어난다.
### 4. 세 방식이 같은 결과를 내는지 먼저 확인한다
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다.
### 5. 분기별 선택도에 맞는 인덱스를 따로 둔다
선택도가 낮은 분기는 전용 인덱스나 부분 인덱스가 유리하다. 관계 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다.
부모를 찾는 인덱스와 조회자를 찾는 인덱스는 컬럼 순서가 다르다.
### 6. buffers만으로 우열을 정하지 않는다
UNION은 분기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬을 없앤다. 무엇을 줄이려는지에 따라 선택이 달라진다.
훑는 후보 수, 정렬 유무, buffers를 함께 본다.
### 7. 사전계산을 상시 유지하면 읽기 모델이 된다
미리 계산한 테이블을 계속 유지하려면 원본 변경을 투영에 반영해야 한다. 이 시점에 조회 최적화가 아니라 읽기 모델 설계 문제가 된다.
## 적용 조건
- 조회 사용자에 따라 보이는 항목이 달라지는 목록을 만들 때
- 가시성 조건과 정렬·페이징을 함께 요구할 때
- 고트래픽 읽기에서 조회 비용을 줄여야 할 때
## 예외
- 분기가 하나뿐이면 단일 조건이 가장 단순하다. 이 기준은 분기가 셋 이상일 때 적용한다.
- 쓰기가 잦고 읽기가 드물면 사전계산의 갱신 비용이 이득을 넘는다.
- 조회 사용자 수가 매우 많으면 사용자별 투영의 저장 공간을 먼저 계산한다.
## 예시
- 단일 OR : 분기별 스캔을 bitmap으로 합침, 정렬 재수행, 관계 조건은 hashed SubPlan
- UNION 분해 : 분기별 정렬 스트림을 병합, 전체 재정렬 없음, 관계 조건은 조인
- 사전계산 : 커버링 인덱스 하나, OR·조인·정렬 없음
- 갱신 비용 : 사전계산만 있음
- 저장 공간 : 사전계산은 조회 사용자 수에 비례
@@ -0,0 +1,95 @@
---
id: db99cbc5-9123-4599-b368-39ff3170e81d
kind: REFERENCE
slug: fetch-strategy-selection
title: Fetch Join · Batch · Projection 선택 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit"
---
# Fetch Join · Batch · Projection 선택 기준
세 전략은 서로 다른 비용을 줄인다. fetch join은 왕복을 접지만 행을 곱하고, batch는 왕복을 묶지만 엔티티를 그대로 만들고, 프로젝션은 적재를 없애지만 행수를 줄이지 않는다.
## 관계
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
fetch join의 한계를 확인한 기록이다.
- **Collection Fetch Join Pagination의 In-memory Paging**
fetch join과 페이징이 함께 서지 못하는 것을 확인한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
프로젝션이 남기는 비용을 확인한 기록이다.
## 목적
쿼리 수만 보고 전략을 고르면 비용이 다른 축으로 옮겨 간 것을 놓친다. 컬렉션 fetch join은 쿼리 수를 크게 줄이면서 전송 행수와 메모리를 키운다.
무엇을 줄이려는지 먼저 정하고 그 축을 재는 지표로 전후를 비교한다.
## 규칙
### 1. 컬렉션 fetch join은 두 개 이상 쓰지 않는다
순서 컬럼이 없는 List 두 개를 동시에 fetch join하면 곱집합을 원래 컬렉션으로 되돌릴 수 없어 쿼리 생성 시점에 거부된다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
### 2. 컬렉션 fetch join은 행을 곱한다
컬렉션 하나만 fetch join해도 부모 한 행이 자식 수만큼 반복된다. 전송 행수는 자식 총합이 된다.
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기로는 이 증가가 보이지 않는다. 조인 카디널리티나 실행계획의 actual rows로 확인한다.
### 3. 컬렉션 fetch join과 페이징을 같이 쓰지 않는다
부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다. Hibernate는 이를 피하려고 SQL에서 LIMIT을 빼고 전체를 읽은 뒤 메모리에서 자른다.
응답은 한 페이지지만 로드한 부모는 전체다. 발행 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
### 4. fetch join은 ToOne에 쓴다
ToOne은 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join이 적합하다.
### 5. 컬렉션에는 batch fetch를 쓴다
엔티티만 페이징해 DB LIMIT이 정상 작동하게 한 뒤, 지연 연관은 부모 키를 모아 IN으로 채운다. 배치 크기가 B면 왕복은 부모 수를 B로 나눈 올림값이 된다.
배치는 부모와 자식을 곱하지 않는다. 실행계획에서 semi-join으로 나타난다.
### 6. 화면 조회에는 프로젝션을 쓴다
필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않는다. 1차 캐시, 더티체킹, 지연 프록시도 생기지 않는다.
join이 있어도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 이 동작은 배치 설정 여부와 관계없이 성립한다.
### 7. 프로젝션의 효과는 실행계획이 아니라 ORM 층에서 확인한다
필요한 컬럼만 골라도 EXPLAIN의 width가 줄지 않을 수 있다. 조인 대상의 행폭이 반영되고 width가 실제 전송 바이트가 아니라 타입의 평균폭 추정치이기 때문이다.
프로젝션의 이득은 엔티티 로드 수로 확인한다.
### 8. 세 전략이 남기는 비용을 적는다
fetch join은 행 폭증과 페이징 불가를 남긴다. batch는 엔티티 과적재를 남긴다. 프로젝션은 부모당 자식 전량 조회를 남긴다.
남은 비용을 적어야 다음 단계가 무엇을 풀어야 하는지 이어진다.
## 적용 조건
- 연관을 포함한 목록 조회를 설계할 때
- N+1을 확인하고 fetch 전략을 고를 때
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 남았는지 정리할 때
## 예외
- 컬렉션이 하나이고 페이징이 없으며 자식 수가 작다면 컬렉션 fetch join이 단순하다. 자식 수가 커질 수 있는 구조에는 쓰지 않는다.
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 측정을 유지하려면 별도 설정 범위로 격리한다.
## 예시
- 컬렉션 두 개 fetch join : 쿼리 생성 시점 거부
- 컬렉션 한 개 fetch join : 전송 행수 = 자식 총합
- 컬렉션 fetch join + 페이징 : DB LIMIT 없음, 부모 전체 로드
- ToOne fetch join : 행 곱하지 않음, 적합
- batch fetch : 왕복 = 부모 수 / 배치 크기 올림
- 프로젝션 : 엔티티 로드 0, 쿼리 상수, 자식 행수는 그대로
@@ -0,0 +1,90 @@
---
id: 51095f6e-2cc8-439c-8648-065033614215
kind: REFERENCE
slug: fetch-type-vs-fetch-strategy
title: Fetch Type과 Fetch Strategy 구분
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit"
---
# Fetch Type과 Fetch Strategy 구분
EAGER와 LAZY는 연관이 언제 로딩돼야 하는지를 정하는 계약이다. 어떤 SQL로 가져올지는 정하지 않는다. N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 전략으로 푼다.
## 관계
- **필드 접근 없이 발생한 EAGER ToOne N+1**
이 구분을 실제 측정으로 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
전략을 고르는 기준이다.
- **JPA N+1 정량 진단 기준**
두 축을 나눠 측정하는 방법이다.
## 목적
즉시 로딩이면 한 번에 가져올 것이라고 읽기 쉽다. 실제로는 파생 쿼리에서 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우는 경우가 있다.
이 구분을 세워야 애너테이션을 바꾸는 것과 조회 방식을 바꾸는 것이 서로 다른 작업이라는 점이 드러난다.
## 규칙
### 1. EAGER는 로딩 시점 계약이지 JOIN 보장이 아니다
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이다. 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다.
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 provider, 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
### 2. 기본값을 명시적으로 확인한다
@ManyToOne과 @OneToOne의 기본값은 EAGER다. @OneToMany와 @ManyToMany의 기본값은 LAZY다.
fetch를 적지 않은 코드에도 기본값이 적용된다. 코드에 조회가 보이지 않는다는 것이 조회가 나가지 않는다는 뜻은 아니다.
### 3. 접근 여부와 fetch 계약을 교차해서 본다
EAGER는 접근하지 않아도 나간다. 사용하지 않는 연관까지 조회하는 낭비가 된다.
LAZY는 접근할 때 나간다. 접근하면 같은 N+1이 시점만 달라져 다시 생긴다.
매핑 루프에서 연관을 실제로 사용한다면 EAGER를 LAZY로 바꿔도 N+1은 남는다.
### 4. 실제 증가 폭은 서로 다른 연관 대상 수가 정한다
같은 EAGER ToOne이라도 증가 곡선이 갈린다. 소수를 재사용하는 연관은 1차 캐시가 재조회를 걸러 서로 다른 대상 수만큼만 조회된다. 부모마다 다른 연관은 부모 수만큼 조회된다.
N+1이 생길 가능성은 fetch 계약이 만들고, 실제 실행 횟수는 Persistence Context 안에서 서로 다른 대상이 몇 개인지가 정한다.
### 5. 컬렉션 접근은 반복문 없이도 반복된다
지연 로딩 컬렉션은 접근하는 순간 조회한다. 부모가 N개면 접근과 조회도 N번이다.
스트림이나 매핑 함수 뒤에 있으면 명시적인 반복문이 보이지 않는다. 반복이 사라진 것이 아니라 표현이 바뀐 것이다.
### 6. 타입이 아니라 전략을 바꾼다
fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이려면 fetch join, batch fetch, 프로젝션처럼 조회 방식 자체를 바꾼다.
## 적용 조건
- 연관 매핑을 정하거나 바꿀 때
- N+1의 원인을 애너테이션에서 찾으려 할 때
- EAGER를 LAZY로 바꾸는 것으로 문제가 풀린다고 판단하기 전에
## 예외
- 단건 조회에서 provider가 JOIN을 선택하는 구현이 있다. 그 동작에 의존하려면 사용하는 provider와 버전에서 확인한 뒤 적는다.
- 연관을 전혀 사용하지 않는다면 LAZY로 바꾸는 것만으로 낭비가 사라진다. 이때는 전략 변경이 아니라 타입 변경이 맞는 해법이다.
## 예시
- EAGER : 반환 시점까지 로딩. SQL 형태는 보장하지 않음
- LAZY : 접근 시점에 로딩
- @ManyToOne 기본값 : EAGER
- @OneToMany 기본값 : LAZY
- 접근 0회 EAGER : 조회 나감 (낭비)
- 접근 0회 LAZY : 조회 안 나감
- 접근함 EAGER / LAZY : 둘 다 N+1, 시점만 다름
@@ -0,0 +1,90 @@
---
id: 06788903-3dfa-4f70-b159-f1224384fd0b
kind: REFERENCE
slug: keyset-pagination-design
title: Keyset Pagination 설계 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit"
---
# Keyset Pagination 설계 기준
OFFSET은 건너뛸 행까지 만든 뒤 버린다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽는다. 다만 커서와 같은 순서의 정렬키 인덱스가 있어야 이 이점이 생긴다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
이 기준의 전제가 깨지는 조건을 확인한 기록이다.
- **Feed Pagination은 Keyset을 사용한다**
이 기준에서 나온 결정이다.
- **PostgreSQL Query Plan 측정 기준**
깊이별 비용을 실행계획으로 확인하는 기준이다.
## 목적
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊으면 전량 스캔과 정렬로 떨어진다.
페이지 깊이와 무관하게 읽는 행수를 일정하게 유지하려면 커서 방식이 필요하다.
## 규칙
### 1. 커서에 정렬키를 모두 담는다
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 빠지거나 중복된다.
### 2. tie-break 컬럼을 정렬과 커서에 넣는다
정렬키에 중복이 있을 수 있으면 유일한 컬럼을 마지막 정렬키로 더한다. 커서에도 같이 담는다.
### 3. 정렬키, 커서, 인덱스의 컬럼과 방향을 일치시킨다
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort가 다시 생긴다. 방향까지 같아야 한다.
### 4. 정렬키 전용 인덱스를 확인한다
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 필터가 없는 정렬 쿼리라면 정렬키만으로 된 인덱스가 필요하다.
인덱스가 없으면 keyset도 전량을 스캔한다. keyset 문법이 아니라 인덱스가 비용을 줄인다.
### 5. 깊이별로 훑은 행을 측정한다
OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
한 페이지만 재면 차이가 보이지 않는다. 깊이를 바꿔 가며 곡선으로 확인한다.
### 6. 결과가 OFFSET과 같은지 검증한다
커서로 넘긴 페이지가 같은 순서의 같은 행을 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다.
### 7. 필터를 얹으면 전제가 깨질 수 있다
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 합치면서 정렬 순서를 잃는다. 이때 Sort가 다시 나타난다.
필터가 있는 keyset은 필터를 포함한 인덱스 설계나 쿼리 분해가 함께 필요하다.
### 8. 정렬키에 null이 있을 수 있는지 먼저 정한다
정렬키가 nullable이면 null의 정렬 위치와 커서 표현을 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
## 적용 조건
- 무한 스크롤이나 깊은 페이지를 지원할 때
- 정렬 순서가 고정돼 있고 인덱스를 만들 수 있을 때
- 전체 페이지 수가 필요하지 않을 때
## 예외
- 임의 페이지 점프가 필요하면 커서만으로는 부족하다. OFFSET을 함께 두거나 다른 탐색을 설계한다.
- 전체 건수를 화면에 표시해야 하면 count를 별도로 다룬다. 커서 결과에는 전체 건수가 없다.
- 정렬 기준이 자주 바뀌면 기준마다 인덱스가 필요하다. 인덱스 수와 쓰기 비용을 함께 본다.
## 예시
- OFFSET 훑은 행 : offset + 페이지 크기
- keyset 훑은 행 : 페이지 크기 (깊이 무관)
- 커서 : (정렬키, tie-break) 조합
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향
- 인덱스 없는 keyset : 전량 스캔, 이점 없음
- 필터 추가 : 분기가 갈리면 Sort 재등장
@@ -0,0 +1,104 @@
---
id: b0b55ac9-c0a3-4c01-ba84-0aa478923ace
kind: REFERENCE
slug: nplus1-quantitative-diagnosis
title: JPA N+1 정량 진단 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit"
---
# JPA N+1 정량 진단 기준
N+1을 쿼리 로그의 인상이 아니라 지표로 확인한다. Hibernate Statistics의 지표는 이름이 뜻하는 것이 서로 달라서, SQL 실행 횟수로 바꿔 읽으면 배치를 적용한 뒤 결론이 어긋난다.
## 관계
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
엔티티별 fetch 통계로 ToOne 쪽을 확인한 기록이다.
- **PostgreSQL Query Plan 측정 기준**
같은 측정에서 실행계획을 다루는 기준이다.
## 목적
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계에는 목록 루트, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 섞여 있다.
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회되는지 확정할 수 있다. 그래야 fetch 전략을 바꿨을 때 무엇이 줄었는지 말할 수 있다.
## 규칙
### 1. 지표 이름이 뜻하는 것을 그대로 읽는다
getCollectionFetchCount()는 초기화된 컬렉션 수다. 실행된 SELECT SQL 수가 아니다.
getPrepareStatementCount()는 획득한 PreparedStatement 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수다. 실행된 SELECT SQL 수가 아니다.
### 2. 등식이 성립하는 조건을 함께 적는다
batch나 subselect가 없을 때만 초기화 컬렉션 수와 자식 SELECT 수가 같다. 이 조건에서만 컬렉션 수를 SQL 수로 바꿔 읽을 수 있다.
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 등식이 깨진다. 배치 적용 여부는 prepared와 collectionFetch를 함께 보고 판단한다.
### 3. 총계를 형태별로 가르고 검산한다
총 PreparedStatement를 다음처럼 나눈다.
content 1
count 1
distinct ToOne 대상 수
N ToOne (아이템마다 다른 연관)
N 컬렉션 초기화
파생값과 직접 측정값이 일치하는지 교차 검증한다. 회계 항등식은 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 뺀 값이 entityFetch와 같은지 보는 것이다.
### 4. 회귀 가드는 시더 카디널리티와 무관한 값으로 고정한다
합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있다. 엔티티별 지표로 고정하는 편이 안정적이다. 예를 들어 아이템마다 다른 연관은 pageFetch == N이 성립한다.
합계는 회귀 가드가 아니라 교차 검증에 쓴다.
### 5. count 쿼리가 언제 나오는지 안다
Page를 반환하면 Spring Data가 전체 건수 count를 한 번 더 실행한다. offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다.
같은 코드라도 pageSize와 반환 건수의 관계에 따라 총계가 달라진다. 측정값을 비교할 때 이 조건을 맞춘다.
### 6. 캐시가 결과를 먹지 않게 한다
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 흡수한다. 지연 반복 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출한다. clear 비용은 측정 구간 밖에 둔다.
쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다.
### 7. 증가 기준이 무엇인지 명시한다
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 테이블이 100만 행이어도 이 왕복 수는 늘지 않는다.
전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
### 8. 왕복과 행수를 다른 축으로 센다
한 조회에 두 위반이 함께 있을 수 있다. 부모 수에 비례하는 왕복과, 한 번의 왕복에서 자식을 전부 읽는 과조회다.
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다. 한쪽을 고쳐 놓고 다른 쪽이 해결됐다고 적지 않는다.
## 적용 조건
- ORM 조회에서 쿼리 발생량이 데이터 규모를 따라 늘어나는지 확인할 때
- fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때
- N+1 회귀를 테스트로 고정할 때
## 예외
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다.
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
## 예시
- 초기화 컬렉션 수 : 실행된 SELECT 수가 아니라 초기화된 컬렉션 수
- 총 PreparedStatement : 획득한 statement 수, SQL 실행 수와 다를 수 있음
- 회계 항등식 : 총계 − 컬렉션 N content 1 count 1 = entityFetch
- 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관)
- 측정 규율 : 매 반복 전 em.clear, stats.clear 직후 1회만 읽기
@@ -0,0 +1,103 @@
---
id: e8c2e9ea-cd87-46f8-9469-849dbd433d86
kind: REFERENCE
slug: postgresql-query-plan-measurement
title: PostgreSQL Query Plan 측정 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit"
---
# PostgreSQL Query Plan 측정 기준
실행계획과 인덱스 동작을 측정하려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 인덱스 기능이 엔진마다 달라서 다른 엔진의 계획을 그대로 옮겨 읽으면 체계적으로 틀린 결론에 이른다.
## 관계
- **Query Plan은 실제 PostgreSQL에서 측정한다**
이 기준에서 나온 결정이다.
- **JPA N+1 정량 진단 기준**
같은 측정에서 쿼리 수를 다루는 기준이다.
- **ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가**
통계 축에서 남은 질문이다.
## 목적
쿼리 수만으로는 보이지 않는 것이 있다. 한 쿼리가 실어 나르는 행수, 정렬 방식, 인덱스 사용 여부, 읽은 블록 수다.
이 값을 확인하려면 엔진이 실제로 고른 계획을 봐야 한다.
## 규칙
### 1. 운영과 같은 엔진에서 측정한다
비용 기반 옵티마이저는 가능한 계획의 비용을 추정해 고른다. 추정값도, 고를 수 있는 선택지도 엔진마다 다르다.
네 축이 갈린다. 비용 상수로 표현되는 비용 모델, 수집하는 통계의 종류, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류와 기능이다.
인메모리 대체 DB에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작이 재현되지 않는다.
### 2. 스키마를 운영 마이그레이션과 같게 맞춘다
같은 마이그레이션을 적용하고 엔티티와 스키마의 불일치를 조기에 잡는다.
다만 스키마 검증만으로 모든 드리프트를 막을 수 없다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이므로 따로 확인한다.
### 3. EXPLAIN은 ANALYZE와 BUFFERS를 함께 쓴다
추정만으로는 실제 행수를 알 수 없다. 실제 실행 결과와 읽은 블록 수를 함께 본다.
### 4. 추정 행수와 실제 행수의 차이를 기록한다
둘이 크게 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있다. 대량 데이터를 넣은 직후에 특히 그렇다.
이 차이를 발견하면 통계를 갱신한 뒤 다시 측정하고 전후를 비교한다.
### 5. warm cache 결과를 cold 실행시간으로 읽지 않는다
읽은 블록이 모두 캐시에서 왔다면 디스크 접근이 없는 값이다. 캐시 상태를 함께 기록한다.
### 6. Execution Time을 애플리케이션 지연과 합산하지 않는다
Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결과 전달, DTO 매핑, 직렬화, HTTP를 포함하지 않는다. 같은 지표가 아니다.
### 7. 여러 방식을 비교할 때는 같은 실행에서 잰다
캐시 상태를 맞추려면 같은 테스트 실행 안에서 연속으로 측정한다. 실행을 나누면 캐시 차이가 비교에 섞인다.
### 8. 인덱스 의존을 확인하려면 토글한다
어떤 방식이 빠른 이유가 문법인지 인덱스인지 가르려면 인덱스를 제거한 뒤 같은 쿼리를 다시 잰다. 측정이 끝나면 복구한다.
### 9. 측정 도구의 정밀도를 주장 강도에 맞춘다
방향성만 확인하는 값에 더 엄밀한 도구를 붙인다고 근거가 강해지지 않는다. 오히려 측정보다 정밀한 결론처럼 보인다.
표본이 적으면 백분위수로 부르지 않고 중앙값과 최댓값으로 적는다.
### 10. 재현 조건을 함께 남긴다
이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 측정 시작 시 엔진 버전과 주요 플래너 설정을 함께 기록한다.
## 적용 조건
- 인덱스 설계나 쿼리 형태를 바꾸고 효과를 확인할 때
- 스캔 방식이나 정렬 방식이 바뀌었는지 확인할 때
- 여러 SQL 표현의 비용을 비교할 때
## 예외
- 쿼리 발생 횟수만 확인하면 되는 단계에서는 실행계획까지 필요하지 않다.
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
- 안정적인 꼬리 지연이 필요하면 반복 횟수를 크게 늘린 독립 세트가 필요하다.
## 예시
- 엔진 : 운영과 같은 것. 인메모리 대체 금지
- 명령 : EXPLAIN (ANALYZE, BUFFERS)
- 캐시 : warm인지 cold인지 기록
- 추정 vs 실제 : 차이가 크면 통계 갱신 후 재측정
- 비교 : 같은 실행 안에서 연속 측정
- 인덱스 의존 : DROP 후 재측정, 끝나면 복구
- Execution Time : 애플리케이션 지연과 다른 지표
@@ -0,0 +1,93 @@
---
id: bf5f2462-0e94-4723-bdc8-f7dd709b2dbb
kind: REFERENCE
slug: top-n-per-group-selection
title: Top-N-per-group 선택 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit"
---
# Top-N-per-group 선택 기준
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑 세 가지가 같은 결과를 만들지만 읽는 행수가 다르다.
## 관계
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
이 기준이 풀려던 문제다.
- **PostgreSQL Query Plan 측정 기준**
세 방식을 실행계획으로 비교한 기준이다.
- **Fetch Join · Batch · Projection 선택 기준**
앞 단계에서 왕복과 적재를 푼 기준이다.
## 목적
자식 조회에 LIMIT을 붙이면 최종 결과 집합 전체에 적용되어 부모 하나만 채워진다. 그룹당 상한은 다른 표현이 필요하다.
세 방식은 결과가 같으므로 정확성만으로 고를 수 없다. 읽는 행수와 buffers로 갈린다.
## 규칙
### 1. 단순 LIMIT은 그룹당 상한이 아니다
LIMIT은 최종 결과 집합에 적용된다. 부모 20개를 조회하면서 LIMIT 3을 붙이면 3행만 남아 부모 하나만 채워진다.
이 오작동은 결과 행수가 적어 정상처럼 보일 수 있다. 커버한 부모 수를 함께 확인한다.
### 2. 세 가지 표현을 구분한다
윈도우 함수는 부모별로 순번을 매기고 상위 몇 개를 남긴다. 순번을 만들려고 파티션 전체를 읽는다.
LATERAL은 부모마다 상관 서브쿼리를 실행하고 인덱스에서 필요한 개수만 읽고 멈춘다.
애플리케이션 그룹핑은 자식을 한 번에 가져온 뒤 코드에서 자른다. 자르기 전에 전량이 전송된다.
### 3. 작은 K에는 LATERAL이 유리하다
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개만 읽고 멈춘다. 그룹이 크고 K가 작을수록 읽지 않는 행이 많아진다.
### 4. K가 그룹 크기에 가까우면 윈도우로 수렴한다
K가 그룹 크기에 가까워지면 LATERAL도 대부분을 읽는다. 이때는 더 단순한 윈도우 함수를 고를 수 있다.
K를 바꿔 가며 buffers를 재면 어느 지점에서 뒤집히는지 볼 수 있다.
### 5. LATERAL의 이점은 인덱스에서 나온다
LATERAL 문법 자체가 빠른 것이 아니다. 부모별 정렬 인덱스가 있어야 상위 K개를 바로 찾는다.
인덱스가 없으면 부모마다 자식 테이블을 스캔하고 대부분을 필터로 버린다. 인덱스 유무를 토글해 확인한다.
### 6. 애플리케이션 그룹핑은 전송량을 줄이지 않는다
코드에서 자르면 결과는 맞지만 DB가 전달한 행은 전량이다. 전송량이 문제인 상황에서는 해법이 아니다.
### 7. 표준 JPQL로 표현되지 않는다
윈도우 함수와 LATERAL은 표준 JPQL에 없다. native SQL로 내려가야 한다. 이 결정을 기록에 남긴다.
### 8. 반환 행수와 커버한 부모를 함께 검증한다
세 방식이 같은 결과를 만드는지 먼저 확인한 뒤 실행계획을 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 본다.
## 적용 조건
- 목록 응답에 부모별 자식 상위 몇 개를 포함해야 할 때
- 자식 전량 조회가 전송량 문제를 만들 때
- 그룹 크기가 크고 필요한 개수가 작을 때
## 예외
- 그룹 크기가 작아 전량을 읽어도 부담이 없으면 애플리케이션 그룹핑이 단순하다.
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL의 이점이 사라진다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
## 예시
- 순진 LIMIT 3 : 전체에 적용, 부모 1개만 채워짐
- 윈도우 : 부모별 순번 뒤 상위 K, 파티션 전량 읽음
- LATERAL : 부모마다 인덱스에서 K개 읽고 멈춤
- 2단계 : 자식 전량 전송 뒤 코드에서 그룹핑
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 급증
- 선택 : 작은 K는 LATERAL, K가 그룹 크기에 근접하면 윈도우
@@ -0,0 +1,238 @@
{
"project": "n+1liner",
"ssot": "final/document.md",
"generatedAt": "2026-09-04",
"note": "글감 목록이다. file 이 있으면 이미 쓴 기록이고, 없으면 아직 쓰지 않은 글감이다.",
"topics": {
"jpa-feed-query-performance": {
"topic": "jpa-feed-query-performance",
"kinds": {
"case": [
{
"title": "Collection Fetch Join Pagination의 In-memory Paging",
"slug": "collection-fetch-join-in-memory-paging",
"file": "jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.md",
"status": "게시 전",
"studioId": "c1158754-e3d2-47b8-bb41-81787c0ca84b",
"assets": 1,
"evidence": 2
},
{
"title": "Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1",
"slug": "eager-toone-nplus1-without-access",
"file": "jpa-feed-query-performance/case/case-eager-toone-nplus1-without-access.md",
"status": "게시 전",
"studioId": "32d0be7d-d88e-4760-8d91-35d3a233a99a",
"assets": 1,
"evidence": 3
},
{
"title": "Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증",
"slug": "fetch-join-multibag-and-row-explosion",
"file": "jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md",
"status": "게시 전",
"studioId": "7ed75172-fd56-42bf-956a-8f9fc1cca235",
"assets": 1,
"evidence": 1
},
{
"title": "Projection 이후에도 1,509행을 읽은 Row Over-fetch",
"slug": "projection-row-over-fetch",
"file": "jpa-feed-query-performance/case/case-projection-row-over-fetch.md",
"status": "게시 전",
"studioId": "4c9c3b90-bc89-4300-9334-088ea95d37d8",
"assets": 1,
"evidence": 1
},
{
"title": "Visibility OR이 Keyset Index를 깨뜨린 문제",
"slug": "visibility-or-breaks-keyset-index",
"file": "jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md",
"status": "게시 전",
"studioId": "e6715e81-6dbd-4287-8e19-946c334f38fb",
"assets": 1,
"evidence": 2
}
],
"concept": [],
"reference": [
{
"title": "Feed Visibility Query Pattern",
"slug": "feed-visibility-query-pattern",
"file": "jpa-feed-query-performance/reference/reference-feed-visibility-query-pattern.md",
"status": "게시 전",
"studioId": "635fcedd-d402-4297-bcf3-9fcdf4200d28",
"assets": 0,
"evidence": 0
},
{
"title": "Fetch Join · Batch · Projection 선택 기준",
"slug": "fetch-strategy-selection",
"file": "jpa-feed-query-performance/reference/reference-fetch-strategy-selection.md",
"status": "게시 전",
"studioId": "db99cbc5-9123-4599-b368-39ff3170e81d",
"assets": 0,
"evidence": 0
},
{
"title": "Fetch Type과 Fetch Strategy 구분",
"slug": "fetch-type-vs-fetch-strategy",
"file": "jpa-feed-query-performance/reference/reference-fetch-type-vs-fetch-strategy.md",
"status": "게시 전",
"studioId": "51095f6e-2cc8-439c-8648-065033614215",
"assets": 0,
"evidence": 0
},
{
"title": "Keyset Pagination 설계 기준",
"slug": "keyset-pagination-design",
"file": "jpa-feed-query-performance/reference/reference-keyset-pagination-design.md",
"status": "게시 전",
"studioId": "06788903-3dfa-4f70-b159-f1224384fd0b",
"assets": 0,
"evidence": 0
},
{
"title": "JPA N+1 정량 진단 기준",
"slug": "nplus1-quantitative-diagnosis",
"file": "jpa-feed-query-performance/reference/reference-nplus1-quantitative-diagnosis.md",
"status": "게시 전",
"studioId": "b0b55ac9-c0a3-4c01-ba84-0aa478923ace",
"assets": 0,
"evidence": 0
},
{
"title": "PostgreSQL Query Plan 측정 기준",
"slug": "postgresql-query-plan-measurement",
"file": "jpa-feed-query-performance/reference/reference-postgresql-query-plan-measurement.md",
"status": "게시 전",
"studioId": "e8c2e9ea-cd87-46f8-9469-849dbd433d86",
"assets": 0,
"evidence": 0
},
{
"title": "Top-N-per-group 선택 기준",
"slug": "top-n-per-group-selection",
"file": "jpa-feed-query-performance/reference/reference-top-n-per-group-selection.md",
"status": "게시 전",
"studioId": "bf5f2462-0e94-4723-bdc8-f7dd709b2dbb",
"assets": 0,
"evidence": 0
}
],
"question": [
{
"title": "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가",
"slug": "cardinality-estimate-after-analyze",
"file": "jpa-feed-query-performance/question/question-cardinality-estimate-after-analyze.md",
"status": "게시 전",
"studioId": "e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff",
"assets": 0,
"evidence": 0
},
{
"title": "실제 동시 트래픽에서도 이 구조가 안정적인가",
"slug": "concurrency-stability",
"file": "jpa-feed-query-performance/question/question-concurrency-stability.md",
"status": "게시 전",
"studioId": "6cbe963f-86f8-4df6-be5a-900712970d01",
"assets": 0,
"evidence": 0
},
{
"title": "Round Trip과 Row Volume을 독립 측정할 것인가",
"slug": "isolate-round-trip-and-row-volume",
"file": "jpa-feed-query-performance/question/question-isolate-round-trip-and-row-volume.md",
"status": "게시 전",
"studioId": "5159c415-232d-424a-970a-b0db52746767",
"assets": 0,
"evidence": 0
},
{
"title": "Highlight 없는 FeedItem을 허용할 것인가",
"slug": "nullable-first-highlighted-at",
"file": "jpa-feed-query-performance/question/question-nullable-first-highlighted-at.md",
"status": "게시 전",
"studioId": "b099ca65-bf9f-4d61-814c-74722453fa3c",
"assets": 0,
"evidence": 0
},
{
"title": "feed_visible을 Production CQRS로 승격할 것인가",
"slug": "promote-feed-visible-to-cqrs",
"file": "jpa-feed-query-performance/question/question-promote-feed-visible-to-cqrs.md",
"status": "게시 전",
"studioId": "5088ce14-b096-41d3-abba-64b7afb48bb9",
"assets": 0,
"evidence": 0
}
],
"decision": [
{
"title": "Entity Graph 조회에는 Batch Fetch를 사용한다",
"slug": "batch-fetch-for-entity-graph",
"file": "jpa-feed-query-performance/decision/decision-batch-fetch-for-entity-graph.md",
"status": "게시 전",
"studioId": "08a74b35-10c3-4874-8fbc-209b0b6e942e",
"assets": 0,
"evidence": 0
},
{
"title": "현재 Read Model은 CQRS-lite로 유지한다",
"slug": "keep-read-model-as-cqrs-lite",
"file": "jpa-feed-query-performance/decision/decision-keep-read-model-as-cqrs-lite.md",
"status": "게시 전",
"studioId": "7f248f68-ce2b-43ec-94ce-82324d0bd1a7",
"assets": 0,
"evidence": 0
},
{
"title": "Feed Pagination은 Keyset을 사용한다",
"slug": "keyset-for-feed-pagination",
"file": "jpa-feed-query-performance/decision/decision-keyset-for-feed-pagination.md",
"status": "게시 전",
"studioId": "1dbce381-f0dc-4d49-ad68-bd31d205677e",
"assets": 0,
"evidence": 0
},
{
"title": "Query Plan은 실제 PostgreSQL에서 측정한다",
"slug": "measure-plan-on-real-postgresql",
"file": "jpa-feed-query-performance/decision/decision-measure-plan-on-real-postgresql.md",
"status": "게시 전",
"studioId": "ae6c9bea-d3a3-46e1-bbd4-8d580d336394",
"assets": 0,
"evidence": 0
},
{
"title": "Collection Fetch Join과 Pagination을 같이 사용하지 않는다",
"slug": "no-collection-fetch-join-with-pagination",
"file": "jpa-feed-query-performance/decision/decision-no-collection-fetch-join-with-pagination.md",
"status": "게시 전",
"studioId": "5e4d033c-d6fe-4257-a4dc-1ade44473c72",
"assets": 0,
"evidence": 0
},
{
"title": "Query Strategy는 FeedQueryPort 뒤에서 소유한다",
"slug": "query-strategy-behind-port",
"file": "jpa-feed-query-performance/decision/decision-query-strategy-behind-port.md",
"status": "게시 전",
"studioId": "4e3200c8-eff5-4442-ae84-ae7b7fa92c8b",
"assets": 0,
"evidence": 0
},
{
"title": "화면 조회는 Read Projection을 사용한다",
"slug": "read-projection-for-screen-query",
"file": "jpa-feed-query-performance/decision/decision-read-projection-for-screen-query.md",
"status": "게시 전",
"studioId": "30a37f34-b406-4061-b924-e22e0be0c3bf",
"assets": 0,
"evidence": 0
}
]
}
}
}
}