docs(n+1liner): adopt the decomposition contract and strip evaluative prose

- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 ·
  KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d
  (문서가 인용한 IT 7종이 그 커밋에 있다)
- 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를
  통과하지 못해 Case 로 빼지 않았다
- 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision
- 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은
  절반이 있는 문장은 평가만 뺐다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent b25357c48a
commit 62520a4dce
28 changed files with 1818 additions and 838 deletions
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="230" viewBox="0 0 660 230" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">프로젝션은 엔티티 적재를 없애지만 자식 행수는 그대로 남는다</title>
<title id="diagram-title">프로젝션은 엔티티 적재를 없애지만 자식 행수는 줄지 않는다</title>
<desc id="diagram-description">왼쪽 배치 방식은 페이지를 조회하면서 엔티티를 대량으로 하이드레이트한다. 가운데 프로젝션은 필요한 스칼라 값만 캐리어로 받아 엔티티를 만들지 않고 발행 쿼리도 상수로 고정한다. 다만 오른쪽처럼 자식 조회는 페이지 부모의 자식을 전부 가져오므로 행수는 줄지 않는다. 화면이 필요로 하는 것은 부모당 상위 몇 개뿐이다.</desc>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,5 @@
EXPLAIN (ANALYZE, BUFFERS) 원문 25건. 실제 PostgreSQL 16(Testcontainers)에서 조회 전략마다 뜬 실행계획을
그대로 받아 적었다. 파일 이름의 접두사가 SSOT 의 측정 랩과 짝이다 — l3 은 9절 카테시안 조인, l4 는 10절
인메모리 페이징, l5 는 11절 배치, l6 은 12절 프로젝션, l14 는 13절 Top-N, l15 는 14절 keyset,
l16 은 15절 가시성, crown 은 16절 통합이다. highlights-child-plan-A 와 toone-*-plan 은 6·7절의 반복 쿼리다.
계획은 warm 캐시에서 잰 값이라 cold 실행시간으로 읽지 않는다.
@@ -0,0 +1,6 @@
측정 수치 csv 18건. 각 파일이 SSOT 본문 표 하나의 원본이다. l1 은 6절 조회 증가와 시드 편중 분포,
l2 는 7절 ToOne 분해, l3 은 9절 전송 행수, l4 는 10절 인메모리 페이징과 비용 곡선, l5 는 11절 배치,
l6 은 12절 프로젝션, l14 는 13절 Top-N 과 인덱스 토글과 K 곡선, l15 는 14절 깊이 곡선,
l16 은 15절 가시성 3안, crown 은 16절 통합이다.
행수·쿼리 수처럼 결정적인 값과 buffers·지연처럼 환경에 좌우되는 값이 한 파일에 섞여 있다.
뒤의 것은 같은 실행 안에서의 상대 대조로만 읽는다.
@@ -4,6 +4,7 @@ kind: CASE
slug: collection-fetch-join-in-memory-paging
title: Collection Fetch Join Pagination의 In-memory Paging
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
@@ -14,6 +15,10 @@ assets:
evidence:
- ../../../final/evidence/raw/explain/l4-entity-paging-limit.txt
- ../../../final/evidence/raw/explain/l5-entity-paging-limit.txt
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#10-2
- final/document.md#10-4
---
# Collection Fetch Join Pagination의 In-memory Paging
@@ -27,23 +32,23 @@ evidence:
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
이 기록이 이어받은 앞 단계다.
- **Fetch Join · Batch · Projection 선택 기준**
이 실패가 배치 선택으로 이어진 기준이다.
이 실패가 배치 선택으로 이어다.
## 문제
컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커졌다. 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.
컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커져서, 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.
반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였다. 반환 크기만으로는 실제 적재량을 알 수 없어 메모리에 올린 부모 엔티티 수를 따로 측정했다.
## 결론
returned는 페이지 크기에 고정됐지만 feedItemLoaded는 N을 따라 늘었다. over-fetch 배수는 1.0배에서 50.0배로 커졌다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 같았고 문제가 보이지 않았다.
returned는 페이지 크기에 고정됐지만 feedItemLoaded는 N과 같은 수로 늘었다. over-fetch 배수는 1.0배에서 50.0배로 커졌다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 같았고 문제가 보이지 않았다.
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘려 일부 부모의 하이라이트가 누락될 수 있다. Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 자른다.
발행된 SQL에 Limit 노드가 없다는 것 자체가 DB가 페이징을 하지 않았다는 증거다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
발행된 SQL에 Limit 노드가 없다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
지연은 예상과 달리 기준선보다 낮았다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N을 따라 1.5 MB에서 10.0 MB로 늘었는데, 기준선의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
지연은 예상과 달리 처음 구현한 loadFeed보다 낮았는데, 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N=10의 1.5 MB에서 N=1,000의 10.0 MB로 늘었지만, 그 loadFeed의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
## 검증 환경
@@ -84,7 +89,7 @@ feedItemLoaded : EntityStatistics.getLoadCount()
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
```
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다.
## 응답은 한 페이지인데 부모는 전부 로드한다
@@ -96,7 +101,7 @@ feedItemLoaded : EntityStatistics.getLoadCount()
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
getLoadCount()를 사용한 이유는 fetch join 쿼리 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
fetch join 쿼리 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
## 경고 코드가 알려진 것과 달랐다
@@ -104,7 +109,7 @@ getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
```
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
## 비용은 페이지가 아니라 데이터셋에 비례한다
@@ -114,11 +119,13 @@ HHH90003004: firstResult/maxResults specified with collection fetch; applying in
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
returned 페이지 크기로 고정인데도 지연과 할당 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
returned 페이지 크기로 고정인데도 지연과 할당 N이 커질수록 함께 올랐다.
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였다. 같은 실행의 used heap 델타 22,016 KB는 스레드 누적 할당 19,995 KB보다 컸다.
## 발행 SQL에 LIMIT이 없다
@@ -141,13 +148,11 @@ Limit (... rows=20 ...) (actual ... rows=20 loops=1)
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
```
(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취다.
(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취하므로, 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.
전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.
## 엔티티만 페이징하고 배치로 하이라이트를 채우기
## 다음 선택
fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행되지만, highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했다. 다음 단계의 전후 차이를 같은 기준으로 비교하기 위해서다.
@@ -157,7 +162,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
## 로컬 미리보기
본문 「무대 — 페이징 한 줄만 추가
본문 「무대 — 페이징 한 줄만 추가」 절의 코드
```java label="통합 테스트 안에서 세운 무대 (프로덕션 아님)"
"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 한 bag fetch join
@@ -165,7 +170,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
// + .setFirstResult(0).setMaxResults(20) // ← 방아쇠: 페이징
```
새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.
앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다.
## 응답은 한 페이지인데 부모는 전부 로드한다
@@ -177,7 +182,7 @@ fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
getLoadCount()를 사용한 이유는 fetch join 쿼리 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
fetch join 쿼리 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
## 경고 코드가 알려진 것과 달랐다
@@ -185,7 +190,7 @@ getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
```
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
## 비용은 페이지가 아니라 데이터셋에 비례한다
@@ -195,11 +200,13 @@ HHH90003004: firstResult/maxResults specified with collection fetch; applying in
| 100 | 13.890 ms | 16.062 ms | 약 3.0 MB |
| 1,000 | 79.452 ms | 83.526 ms | 약 10.0 MB |
returned 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
returned 페이지 크기로 고정인데도 지연과 할당은 N이 커질수록 함께 올랐다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였다. 같은 실행의 used heap 델타 22,016 KB는 스레드 누적 할당 19,995 KB보다 컸다.
## 발행 SQL에 LIMIT이 없다」 아래 `:::evidence key="in-memory-paging"` 자리에 들어갈 그림이다.
@@ -4,6 +4,7 @@ kind: CASE
slug: eager-toone-nplus1-without-access
title: Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
@@ -15,38 +16,43 @@ evidence:
- ../../../final/evidence/raw/explain/highlights-child-plan-A.txt
- ../../../final/evidence/raw/explain/toone-pages-plan.txt
- ../../../final/evidence/raw/explain/toone-users-plan.txt
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#7-2
- final/document.md#7-3
- final/document.md#7-4
---
# Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1
@ManyToOne의 기본값인 EAGER는 로딩 시점 계약이지 루트 SQL의 JOIN 보장이 아니다. 파생 쿼리에서는 행마다 2차 SELECT가 나갔다. getUser()와 getPage()를 한 번도 호출하지 않은 조회에서도 Page 조회가 N번 실행됐다. 같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. N+1을 가르는 것은 fetch 타입이 아니라 조회 방식이다.
@ManyToOne의 기본값인 EAGER는 연관을 반환 시점까지 로딩해 둔다는 계약이지 루트 SQL의 JOIN으로 가져온다는 보장이 아니다. 파생 쿼리에서는 루트를 먼저 조회한 뒤 행마다 2차 SELECT가 나갔고, getUser()와 getPage()를 한 번도 호출하지 않은 조회에서도 Page 조회가 N번 실행됐다. 지연 로딩인 highlights도 같은 조회에서 접근하는 순간 N번 나갔다. 왕복 수를 가른 것은 fetch 타입이 아니라 루트를 먼저 조회하고 연관을 행마다 채우는 조회 방식이다.
## 관계
- **Fetch Type과 Fetch Strategy 구분**
이 현상을 기준으로 정리한 기록이다.
로딩 시점 계약과 실제 조회 방식을 나눠 정리한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
이 조회 방식을 프로젝션으로 바꾼 뒤 남은 행 수를 다룬 기록이다.
- **JPA N+1 정량 진단 기준**
엔티티별 fetch 통계로 확인한 방법이다.
엔티티별 fetch 통계로 추가 조회를 세는 방법을 적은 기록이다.
## 문제
이 조회의 총 PreparedStatement에 컬렉션 조회를 빼남는 몫이 있었다. 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.
이 조회의 총 PreparedStatement에 컬렉션 조회를 빼도 User·Page 연관 조회가 남았고, 시더 카디널리티로 역산하면 13 / 120 / 1,020이었다.
엔티티에는 fetch를 따로 명시하지 않았다. @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 사용했다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.
엔티티에는 fetch를 따로 명시하지 않 @ManyToOne은 즉시 로딩, @OneToMany는 지연 로딩이라는 JPA 기본값을 그대로 썼다. 즉시 로딩이면 한 번에 가져올 것이라고 예상했는데 실제로는 그렇지 않았다.
## 결론
Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N을 따라 10, 100, 1,000으로 늘고 User fetch는 3, 20, 20에서 멈췄다.
Hibernate 엔티티별 fetch 통계로 직접 읽은 값이 역산한 파생값과 정확히 일치했다. Page fetch는 N이 커지는 만큼 같이 늘어 10, 100, 1,000이 됐고, User fetch는 3에서 20까지 늘었다가 N=1,000에서도 20에 머물렀다.
같은 @ManyToOne(EAGER)인데 증가 곡선이 정반대였다. Page는 아이템마다 달라 N번 조회고, User는 소수 풀을 재사용해 한 번 로드한 대상이 1차 캐시에 남다. N+1이 생길 가능성은 EAGER라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.
둘 다 @ManyToOne(EAGER)인데 Page는 아이템마다 달라 N번 조회고, User는 소수 풀을 재사용해 한 번 로드한 대상이 1차 캐시에 남아 다시 조회되지 않았다. N+1이 생길 가능성은 EAGER라는 코드에서 나오지만 실제 증가 폭은 서로 다른 연관 대상 수가 정한다.
getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 조회 코드를 작성하지 않았는데 EAGER 기본값 때문에 생긴 N+1이다. 같은 조건에서 LAZY 컬렉션은 0이었다.
getUser()와 getPage()를 한 번도 호출하지 않은 순수 JPQL 조회에서도 Page 2차 SELECT가 N번 나왔다. 연관을 읽는 코드를 지 않았는데 EAGER 기본값 때문에 생긴 N+1이고, 같은 조건에서 지연 로딩 컬렉션은 0이었다.
pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였다.
pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같았다. 왕복 수를 가른 것은 실행계획이 아니라 반복 횟수였고, Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됐다.
같은 조회에서 LAZY인 highlights도 접근하는 순간 N번 조회됐다. 지연이냐 즉시냐가 아니라, 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.
지연 로딩인 highlights도 같은 조회에서 접근하는 순간 N번 나갔다. 지연이냐 즉시냐가 아니라 루트를 먼저 조회한 뒤 연관을 행마다 채우는 조회 방식이 N+1을 만든다.
## 검증 환경
@@ -74,7 +80,7 @@ getEntityStatistics(PageJpaEntity).getFetchCount() : 엔티티별 fetch 수
4. 회계 항등식으로 교차 검증한다. 총 PreparedStatement 컬렉션 N content 1 count 1 = entityFetch.
5. 접근 0회 확인: seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 상태에서 fetch 수를 읽는다.
5. seed(100) 뒤 순수 JPQL로 feed_items만 조회하고 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않은 채, 접근 0회에서 fetch 수를 읽는다.
6. 반복되는 ToOne 부모 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인한다.
@@ -100,13 +106,15 @@ public List<FeedSummary> loadFeed(int page, int size) {
}
```
엔티티 조회한 뒤 메모리에서 DTO로 옮다. user·page는 즉시 로딩이고 highlights는 지연 로딩이다.
피드 아이템을 엔티티 조회한 뒤 메모리에서 DTO로 옮기는 코드다. user·page는 즉시 로딩(EAGER)이고 highlights는 지연 로딩(LAZY)이다.
## 같은 EAGER가 정반대 곡선을 그린다
## EAGER 연관 관계에서 나간 추가 조회
:::evidence key="eager-lazy-query-sequence" alt="loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스." caption=" " zoom="true"
:::
`getEntityFetchCount()`는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초기화된 엔티티 수를 센다. 아래 표의 ToOne 합이 이 값이고, Page fetch와 User fetch는 `getEntityStatistics(...).getFetchCount()`로 엔티티마다 따로 읽었다.
| N | Page fetch | User fetch | ToOne 합(entityFetch) | 초기화 컬렉션 | 총 PreparedStatement |
|---:|---:|---:|---:|---:|---:|
| 10 | 10 | 3 | 13 | 10 | 25 |
@@ -115,17 +123,21 @@ public List<FeedSummary> loadFeed(int page, int size) {
검산: `10+3=13` · `100+20=120` · `1000+20=1020`. 회계 항등식으로도 `25102=13` · `2221002=120` · `202210002=1020`.
Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 있어, 회귀 가드는 시더 카디널리티와 무관하게 성립하는 엔티티별 `pageFetch == N`으로 고정하고 합계는 위 검산으로 교차 확인했다.
| 연관 | 데이터 분포 | 1차 캐시로 걸러지나 | N=10 / 100 / 1,000 |
|---|---|---|---|
| User (EAGER ToOne) | 소수 풀 재사용(≤20명) | 그렇다 | 3 / 20 / 20 |
| Page (EAGER ToOne) | 아이템당 1개(전부 다름) | 아니다 | 10 / 100 / 1,000 |
| highlights (지연 로딩 컬렉션) | 아이템당 컬렉션 | 해당 없음 | 10 / 100 / 1,000 |
EAGER의 2차 SELECT 구조가 추가 조회의 가능성을 만든다. 실제 실행 횟수는 Persistence Context 안에 서로 다른 연관 대상이 몇 개지가 정한다.
EAGER의 2차 SELECT 구조가 추가 조회의 가능성을 만들고, 실제로 몇 번 실행되는지는 Persistence Context 안에 서로 다른 연관 대상이 몇 개 있는지가 정한다. User는 서로 다른 대상이 20개 이하라 약 20회에서 멈췄고, Page는 아이템마다 달라 N회로 갈렸다.
이 측정은 앞 기록과 같은 loadFeed 호출을 잰 것이다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 나눠 셌다.
## 필드에 접근하지 않아도 조회가 나간다
seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
지연 로딩 컬렉션은 매핑 루프가 접근하는 순간 조회를 냈다. ToOne도 그런지 아니면 접근과 무관하게 나가는지는 매핑이 남아 있으면 갈라 볼 수 없다. 그래서 loadFeed 대신 아무것도 매핑하지 않는 순수 JPQL로 feed_items만 조회하고, seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지 않았다.
| 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 |
|---|---|---|---:|
@@ -133,9 +145,9 @@ seed(100)에서 getUser()·getPage()·getHighlights()를 한 번도 호출하지
| 0회 | User | @ManyToOne (EAGER) | 20 (풀 dedup) |
| 0회 | highlights | @OneToMany (LAZY) | 0 |
EAGER인 Page와 User는 한 번도 읽지 않았는데 조회가 나갔다. LAZY인 highlights는 나가지 않았다.
한 필드도 읽지 않았는데 EAGER인 Page와 User는 조회가 나갔고, 지연 로딩인 highlights는 나가지 않았다. EAGER는 사용 여부와 관계없이 미리 로딩하고, 지연 로딩은 접근할 때 로딩한다.
## 같은 실행계획, 정반대 비용
## 반복되는 ToOne 부모 쿼리의 실행계획
```text label="반복되는 ToOne 부모 쿼리 — seed(100) 직후"
-- pages
@@ -148,26 +160,28 @@ Index Scan using pk_users on users
Buffers: shared hit=2 Execution Time: 0.022 ms
```
두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 단건 계획이 이미 Index Scan이므로 인덱스를 더해도 해결되지 않는다.
`WHERE id = ?`는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져온다. 개별 쿼리는 빨랐지만 Page는 이 빠른 계획을 N번 반복했고, 단건 계획이 이미 Index Scan이 인덱스를 더하는 것으로는 왕복이 줄지 않는다.
## 핵심은 지연이냐 즉시냐가 아니다
인덱스로 줄지 않는다는 말은 이 반복되는 ToOne 쿼리에 한정된다. 같은 랩의 기준 목록 쿼리는 Seq Scan과 Sort로 도는데 그쪽은 정렬 인덱스와 쿼리의 문제라 fetch 전략으로 풀리지 않는다. 그 목록 계획이 실제 병목인지는 아직 단정하지 않았다. N=1,000은 인덱스 효과를 판단하기에 작아서, 이후 keyset 페이징 랩에서 검증한다.
fetch 계약과 실제 사용을 교차하면 네 칸이 나온다. 네 칸 모두 이 랩에서 잰 값이다. LAZY 자리는 같은 조회의 highlights 컬렉션이 증인이다.
## fetch 계약과 접근 여부로 갈라 본 네 경우
fetch 계약과 실제 접근 여부를 교차하면 네 경우가 나오고, 네 경우 모두 이 랩에서 잰 값이다. 지연 로딩 쪽 두 경우는 같은 조회의 highlights 컬렉션에서 읽었다.
| fetch 계약 | 접근하지 않을 때 | 접근할 때(loadFeed) |
|---|---|---|
| EAGER — User·Page (`@ManyToOne`) | 나간다 · Page 100 · User 20 | 나간다 · Page N번 |
| LAZY — highlights (`@OneToMany`) | 안 나간다 · 0 | 나간다 · N번 |
세 칸에서 추가 조회가 다. 나지 않는 칸은 「LAZY이면서 접근하지 않음」 하나뿐인데, 그것은 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 이 칸에 들어가지 않는다.
경우에서 추가 조회가 다. 나지 않은 것은 지연 로딩이면서 접근하지 않는 경우 하나뿐인데, 그 연관을 화면에서 쓰지 않는다는 뜻이다. loadFeed는 매핑 과정에서 user·page·highlights를 모두 쓰므로 여기에 들어가지 않는다.
EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 실제로 같은 조회에서 LAZY인 highlights EAGER인 Page와 똑같이 10 / 100 / 1,000번 조회됐다. 같은 증상이 서로 다른 fetch 타입에서 나왔으므로 타입이 왕복 수를 가르는 기준이 아니다.
EAGER를 LAZY로 바꾸면 조회 시점만 뒤로 밀린다. 같은 조회에서 지연 로딩인 highlights도 N=100이면 EAGER인 Page와 똑같이 100번 나갔으니, fetch 타입이 왕복 수를 가르않는다.
왕복 수를 정하는 것은 조회 방식이다. 루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 타입과 무관하게 N번이 된다. 줄이려면 fetch join, 배치, 프로젝션처럼 조회 방식 자체를 바꿔야 한다.
루트를 먼저 조회한 뒤 연관을 행마다 채우는 파생 쿼리에서는 fetch 타입과 무관하게 N번이 되므로, 줄이려면 fetch join·배치·프로젝션처럼 조회 방식 자체를 바꿔야 한다.
## 한 번의 하이라이트 조회가 읽는 행 수
왕복 수와 별개로 그 한 번이 읽어 오는 행 수도 확인했다.
왕복 수와 별개로, 그 한 번이 읽어 오는 행 수도 실행계획으로 살펴보았다.
```text label="Plan A — 반복되는 하이라이트 자식 쿼리, 대량 시드 직후 ANALYZE 실행 전"
Index Scan using ix_highlights_feed_items_created on highlights
@@ -178,15 +192,9 @@ Planning Time: 0.086 ms
Execution Time: 0.173 ms
```
이 조회도 pk 조회들처럼 인덱스를 타고 0.173 ms에 끝났다. 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 ORDER BY와 LIMIT이 없고, 그 아이템의 하이라이트를 전부 읽는다. 하이라이트가 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다.
이 조회도 pk 조회들처럼 인덱스를 타고 0.173 ms에 끝났다. 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 ORDER BY와 LIMIT이 없 그 아이템의 하이라이트를 전부 읽는데, 하이라이트가 가장 많은 아이템은 500행이었고 화면에 필요한 것은 최신 3개다.
추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
## 지표 이름을 정확히 읽는다
getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초기화된 엔티티 수다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 있어, 회귀 가드는 시더 카디널리티와 무관하게 성립하는 엔티티별 `pageFetch == N`으로 고정하고 합계는 회계 항등식으로 교차 검증했다.
이 절의 지연은 앞 기록과 같은 loadFeed 호출을 잰 것이라 별도 지연 축이 아니다. 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐이다.
추정 rows=1과 실제 rows=500은 500배 차이가 난다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 feed_item_id별 편중을 담지 못했다는 가설을 세웠고, 아직 검증하지 않았다.
<!-- body:end -->
@@ -194,7 +202,7 @@ getEntityFetchCount()는 실행된 SELECT SQL 수가 아니라 2차 fetch로 초
## 로컬 미리보기
본문 「같은 EAGER가 정반대 곡선을 그린다」 아래 `:::evidence key="eager-lazy-query-sequence"` 자리에 들어갈 그림이다.
본문 「EAGER 연관 관계에서 나간 추가 조회」의 `:::evidence key="eager-lazy-query-sequence"` 구획에 들어갈 그림이다.
![loadFeed 매핑, Hibernate, PostgreSQL 세 참가자 사이에서 루트 SELECT가 먼저 실행되고, fetch join되지 않은 EAGER user와 page가 별도의 2차 SELECT로 채워진 뒤, 매핑이 getHighlights에 접근하는 순간 지연 로딩 컬렉션 SELECT가 실행되는 순서를 보여 주는 시퀀스.](../../../final/assets/tech-log-studio/eager-lazy-query-sequence.svg)
@@ -4,6 +4,7 @@ kind: CASE
slug: fetch-join-multibag-and-row-explosion
title: Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
@@ -13,11 +14,16 @@ assets:
file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg
evidence:
- ../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#9-2
- final/document.md#9-3
- final/document.md#9-5
---
# Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 비용이 전송 행수와 메모리로 옮겨 갔다.
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 한 쿼리가 나르는 행수와 메모리 적재량은 커졌다.
## 관계
@@ -26,23 +32,23 @@ evidence:
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 시도가 풀려던 문제다.
- **Collection Fetch Join Pagination의 In-memory Paging**
한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
컬렉션 하나만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
## 문제
컬렉션 N+1과 ToOne의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
컬렉션 N+1과 User·Page 연관의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
MultipleBagFetchException을 재현하려면 fetch join할 두 번째 bag이 필요했다. 기준선 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
MultipleBagFetchException을 재현하려면 fetch join할 컬렉션이 둘 이상 필요했다. 처음 만든 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
## 결론
두 bag을 동시에 fetch join하면 쿼리 생성 시점에 거부된다. bag은 순서 컬럼이 없는 List라, feed_item 한 행이 highlights h개 × mentions m개로 늘어난 곱집합을 원래 컬렉션으로 되돌릴 수 없기 때문이다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
bag은 순서 컬럼이 없는 List 매핑이다. highlights와 mentions가 둘 다 bag이면 feed_item 한 행이 highlights h개 × mentions m개로 늘어난다. Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 쿼리 생성 시점에 예외를 던진다. 데이터가 0건이어도 예외는 그대로 나온다. 실행 결과가 아니라 매핑 단계에서 막기 때문이다.
컬렉션을 하나만 fetch join하면 예외는 지만 부모 자식 수만큼 반복된 행이 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
컬렉션을 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
Hibernate 6 이상 fetch join의 루트 엔티티를 자동으로 중복 제거한다. 그래서 결과 리스트 크기는 N이고, 카테시안은 SQL과 전송 단계에만 남는다. 리스트 크기이 문제가 보이지 않는다.
Hibernate 6 이상 fetch join의 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 N이다. 곱으로 늘어난 행은 SQL과 전송 단계를 그대로 지나가지만 리스트 크기드러나지 않는다.
같은 N=100에서 기준선 222개 121개로 줄었지만 그중 120개는 여전히 ToOne 2차 SELECT였고, 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보이는 구간이다.
같은 N=100에서 PreparedStatement는 손대지 않은 loadFeed의 222개에서 121개로 줄었다. 그중 120개는 여전히 ToOne 2차 SELECT였고, 남은 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보여서 항목별로 다시 나눠 세어 보았다.
## 검증 환경
@@ -72,13 +78,17 @@ SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id
4. 조인 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인해 조인 노드의 actual rows를 본다.
5. 기존 기준선 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 유지되는지 확인한다.
5. 앞 단계에서 loadFeed를 재던 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 그대로 유지되는지 확인한다.
## 본문
<!-- body:start -->
## 실패 하나 — 두 bag 동시 fetch join
## 실패 하나 — 컬렉션 둘을 동시 fetch join하면 거부된다
bag은 순서 컬럼(`@OrderColumn`)이 없는 `List` 매핑이다. `highlights``mentions`가 둘 다 bag인 상태에서 연관을 전부 한 쿼리에 합쳐 보았다.
`mentions`는 이 예외를 재현하려고 이 단계에서 붙인 두 번째 bag이다. 목표 스키마의 `feed_item_mentions``(feed_item_id, mentioned_user_id)`를 복합 기본키로 쓰지만, 여기서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 대리키 `id`를 두고 `UNIQUE(feed_item_id, mentioned_user_id)`를 걸었다. 복합 기본키가 주던 유일성은 이 제약이 대신 보장한다.
```java label="연관 전부 fetch join — 컬렉션 둘을 동시에"
select distinct f from FeedItemJpaEntity f
@@ -86,13 +96,17 @@ select distinct f from FeedItemJpaEntity f
join fetch f.mentions
```
이 쿼리는 실행까지 가지 못하고 `createQuery` 시점에 거부됐다. 콘솔에 찍힌 원인 체인은 이렇다.
```text label="실제로 나온 예외 원인 체인"
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
```
MultipleBagFetchExceptionIllegalArgumentException으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
`MultipleBagFetchException`은 `IllegalArgumentException`으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약해서, 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
## 실패 둘 — 한 bag만 fetch join
## 실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다
컬렉션을 `highlights` 하나만 fetch join하면 예외는 나지 않는다. 대신 `feed_items`와 `highlights`의 조인이 부모 한 행을 자식 수만큼 반복해 내보내므로, 쿼리 수가 아니라 DB가 애플리케이션에 전달한 조인 행수를 쟀다.
:::evidence key="cartesian-row-multiplication" alt="왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림." caption=" " zoom="true"
:::
@@ -103,10 +117,12 @@ MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다.
| 100 | 1,961 | 100 | 100 | 1,961 | 19.6× | 121 |
| 1,000 | 2,917 | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 |
전송 행수는 언제나 시드 하이라이트 총량과 같았다. 편중 분포에서 뒤쪽 아이템 하이라이트가 하나뿐이 폭발 배수는 128.5×에서 2.9×로 줄었지만 절대 전송 행수는 계속 하이라이트 총합이었다.
편중 분포 뒤쪽 아이템에는 하이라이트가 하나뿐이어서 폭발 배수는 128.5×에서 2.9×로 줄었다. 그래도 전송 행수는 세 N에서 모두 시드 하이라이트 총량과 정확히 같았다.
## 쿼리 수만 보면 개선처럼 보인다
같은 N=100 데이터에서 PreparedStatement를 항목별로 나눠 세어 보았다.
| 구분 | 기준선 loadFeed | highlights fetch join | 결과 |
|---|---:|---:|---|
| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 |
@@ -115,9 +131,11 @@ MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다.
| ToOne(User+Page) | 120 | 120 | 그대로 — highlights만 fetch join했으므로 |
| 합 | 222 | 121 | |
222개가 121개로 줄어든 주된 이유는 컬렉션 N개가 루트 조인 하나로 합쳐졌기 때문이다. 121개 중 120개는 여전히 ToOne 2차 SELECT였다.
222개가 121개로 줄어든 주된 이유는 `highlights` 컬렉션 SELECT 100개가 루트 조인 하나로 접혔기 때문이다. 나머지 1개 차이는 원시 JPQL에 Spring Data count가 없어서 생겼다. 121개 중 120개는 여전히 User·Page를 읽는 ToOne 2차 SELECT였다.
## 조인이 행을 곱하는 것을 실행계획에서
## 조인이 곱한 행을 실행계획에서 확인하기
시드(100) 직후 같은 형태의 쿼리를 `EXPLAIN (ANALYZE, BUFFERS)`로 실행했다.
```text label="seed(100) 직후 fetch join 조인의 EXPLAIN"
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
@@ -128,19 +146,21 @@ Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 row
Execution Time: 0.959 ms
```
부모 feed_items는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 사실은 리스트 크기로는 보이지 않고 실행계획에서 드러난다.
부모 `feed_items`는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나지만 그 하나가 1,961행을 실어 나르고, 리스트 크기 100에는 이 수가 잡히지 않는다.
추정 rows=4202와 실제 rows=1961의 오차는 대량 시드 직후 ANALYZE를 실행하지 않 통계 문제다.
추정 `rows=4202`와 실제 `rows=1961`의 오차는 대량 시드 직후 `ANALYZE`를 실행하지 않아 생긴 통계 문제다.
## 측정 정정
## 측정 정정 — 리스트 크기는 N이었다
처음에는 distinct 없는 결과 리스트 크기가 전송 행수와 같을 것으로 예상했다. 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
처음에는 `distinct` 없이 받은 결과 리스트 크기가 전송 행수와 같을 것으로 예상했지만, 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
카테시안은 SQL과 전송 단계에 그대로 남아 있다. 이 문제는 EXPLAIN의 actual rows나 조인 count로 확인해야 한다.
리스트 크기가 N으로 접혀도 SQL이 만든 곱과 DB가 실어 보낸 행수는 줄지 않는다. 그래서 리스트 크기 대신 조인 카디널리티를 `count(*)`로 셌다. 이 문제는 `EXPLAIN`의 actual rows나 조인 count로 확인해야 한다.
쿼리 수는 줄었는데 전송량은 커졌으므로, 이 단계부터는 쿼리 수와 전송 행수를 같이 쟀다.
## 이 실패를 남긴 이유
distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지 않고 실패를 별도 테스트에 남겼다. fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인하기 위해서다.
`.distinct()`를 붙이거나 `List`를 `Set`으로 바꾸거나 `@BatchSize`로 바로 우회하지 않고, 실패한 쿼리를 별도 통합 테스트에 남겼다. 그래야 fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문이다.
<!-- body:end -->
@@ -148,21 +168,8 @@ distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지
## 로컬 미리보기
본문 「실패 하나 — 두 bag 동시 fetch join
```java label="연관 전부 fetch join — 컬렉션 둘을 동시에"
select distinct f from FeedItemJpaEntity f
join fetch f.highlights
join fetch f.mentions
```
```text label="실제로 나온 예외 원인 체인"
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
```
MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
## 실패 둘 — 한 bag만 fetch join」 아래 `:::evidence key="cartesian-row-multiplication"` 자리에 들어갈 그림이다.
본문 「실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다」 절의 첫 문단 다음
`:::evidence key="cartesian-row-multiplication"`에 들어갈 그림이다.
![왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg)
@@ -4,6 +4,7 @@ kind: CASE
slug: projection-row-over-fetch
title: Projection 이후에도 1,509행을 읽은 Row Over-fetch
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
@@ -13,6 +14,11 @@ assets:
file: ../../../final/assets/tech-log-studio/projection-row-over-fetch.svg
evidence:
- ../../../final/evidence/raw/explain/l6-parent-projection.txt
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#12-2
- final/document.md#12-4
- final/document.md#12-6
---
# Projection 이후에도 1,509행을 읽은 Row Over-fetch
@@ -30,9 +36,9 @@ DTO 프로젝션으로 하이드레이트한 엔티티가 1,569개에서 0개로
## 문제
Batch Fetch로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
배치로 왕복 수와 페이징 문제를 풀었지만 엔티티는 여전히 통째로 하이드레이트했다. seed 1,000의 첫 페이지 20건에서 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다.
화면에는 일부 컬럼만 필요했다. 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
화면에는 일부 컬럼만 필요했으므로, 적재 대상을 줄이려고 필요한 스칼라 값만 조회하는 프로젝션을 추가했다.
## 결론
@@ -53,7 +59,7 @@ PostgreSQL : postgres:16-alpine (Testcontainers)
격리
프로젝션 측정은 배치 설정이 없는 별도 IT 클래스
loadFeedProjection은 loadFeed를 두고 추가한 sibling 메서드
loadFeedProjection은 loadFeed를 두고 추가한 별도 메서드
측정 지표
entitiesLoaded : Statistics.getEntityLoadCount()
@@ -68,7 +74,7 @@ collectionFetch : Statistics.getCollectionFetchCount()
3. N ∈ {10, 100, 1000}에서 prepared가 항상 2인지 확인한다.
4. 프로젝션 결과가 기준선 loadFeed와 같은 형태인지 대조한다.
4. 프로젝션 결과가 처음 구현한 loadFeed와 같은 형태인지 대조한다.
5. 자식 IN 쿼리가 반환한 행수를 세어 화면에 필요한 60행과 비교한다.
@@ -90,6 +96,8 @@ select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
```
부모 쿼리에는 컬렉션 조인을 넣지 않았다. user와 page는 컬럼을 읽기 위한 조인이라 행이 곱해지지 않고, setMaxResults가 만드는 LIMIT도 DB에서 그대로 적용된다.
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
## 엔티티 로드가 0으로 줄어든다
@@ -108,11 +116,11 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
| 100 | 222 | 5 | 2 |
| 1,000 | 2,022 | 23 | 2 |
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 개로 유지다.
처음 구현한 loadFeed의 쿼리 수는 N에 비례해고 배치는 배치 크기 단위로 늘었다. 프로젝션은 2개로 유지되었다.
## 남은 비용 — 페이지당 전량
:::evidence key="projection-row-over-fetch" alt="왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수가 그대로 남는 것을 보여 주는 그림." caption=" " zoom="true"
:::evidence key="projection-row-over-fetch" alt="왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수는 줄지 않는 것을 보여 주는 그림." caption=" " zoom="true"
:::
| 항목 | 값 |
@@ -123,7 +131,7 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
단순한 IN 쿼리의 LIMIT은 최종 결과 집합 전체에 적용되므로 부모별 상위 N개를 만들 수 없다.
## width 좁아지지 않았
## width 좁아질 것으로 예상했지만 아니었
```text label="seed(100) — (a) 부모 스칼라 프로젝션 / (b) 자식 스칼라 IN"
-- (a) Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다)
@@ -136,13 +144,15 @@ Limit (... rows=20 width=2088) (actual ... rows=20 loops=1)
Hash Semi Join (... rows=1509 loops=1)
```
프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인해야 한다.
부모 페이징에 Limit이 붙었고, 자식 IN은 semi-join이라 부모와 자식을 곱하지 않았다.
어긋난 것은 width다. 필요한 컬럼만 선택하면 width도 줄어들 것으로 예상했지만 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다. 그래서 프로젝션의 효과는 SQL 플랜의 width가 아니라 ORM 층의 엔티티 로드 수에서 확인했다.
## 배치와 프로젝션은 다른 것을 줄인다
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 두 효과는 서로를 대신하지 않는다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 대상을 줄인다. 프로젝션이 엔티티를 만들지 않는 동작은 배치 설정 여부와 관계없이 성립한다.
기존 loadFeed를 바로 교체하지 않고 sibling 메서드로 둔 이유는 앞 단계의 기준선을 다시 측정하기 위해서다. 기준선부터 배치까지의 테스트도 다시 실행해 결과가 유지되는지 확인했다.
기존 loadFeed를 바로 교체하 앞 단계의 을 다시 측정할 수 없어서, loadFeedProjection을 별도 메서드로 추가하고 같은 데이터로 비교했다. 처음 구현부터 배치까지의 테스트도 다시 실행해 기존 결과가 유지되는지 확인했다.
<!-- body:end -->
@@ -162,6 +172,8 @@ select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt)
from HighlightJpaEntity h where h.feedItem.id in (:pageIds)
```
부모 쿼리에는 컬렉션 조인을 넣지 않았다. user와 page는 컬럼을 읽기 위한 조인이라 행이 곱해지지 않고, setMaxResults가 만드는 LIMIT도 DB에서 그대로 적용된다.
FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으로 만들 수 없었다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했다.
## 엔티티 로드가 0으로 줄어든다
@@ -180,10 +192,10 @@ FeedSummary의 마지막 인자가 리스트라 생성자 표현식 한 번으
| 100 | 222 | 5 | 2 |
| 1,000 | 2,022 | 23 | 2 |
기준선의 쿼리 수는 N을 따라 늘고 배치는 배치 크기 단위로 늘었다. 프로젝션은 개로 유지다.
처음 구현한 loadFeed의 쿼리 수는 N에 비례해고 배치는 배치 크기 단위로 늘었다. 프로젝션은 2개로 유지되었다.
## 남은 비용 — 페이지당 전량」 아래 `:::evidence key="projection-row-over-fetch"` 자리에 들어갈 그림이다.
![왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수가 그대로 남는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/projection-row-over-fetch.svg)
![왼쪽 엔티티 적재에서 가운데 스칼라 프로젝션으로 넘어가면서 엔티티 생성이 사라지지만, 오른쪽 자식 조회는 페이지 부모의 자식을 전부 가져와 행수는 줄지 않는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/projection-row-over-fetch.svg)
<!-- local-preview:end -->
@@ -4,6 +4,7 @@ kind: CASE
slug: visibility-or-breaks-keyset-index
title: Visibility OR이 Keyset Index를 깨뜨린 문제
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
@@ -14,34 +15,39 @@ assets:
evidence:
- ../../../final/evidence/raw/explain/l15-keyset-no-index.txt
- ../../../final/evidence/raw/explain/l15-offset-deep-page.txt
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#14-2
- final/document.md#14-3
- final/document.md#14-5
---
# Visibility OR이 Keyset Index를 깨뜨린 문제
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 공개 범위 세 분기를 OR로 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌으며, 사라졌던 Sort 노드가 다시 나타났다.
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 가시성 세 분기를 OR로 얹자 플래너가 인덱스를 쓰지 못하고 BitmapOr로 떨어졌, 사라졌던 Sort 노드가 다시 나타났다.
## 관계
- **Feed Visibility Query Pattern**
이 문제를 세 가지 방식으로 비교한 기준이다.
가시성 세 분기를 단일 OR, UNION 분해, 사전계산으로 처리했을 때 실행계획이 어떻게 갈리는지 정리했다.
- **Keyset Pagination 설계 기준**
이 기록이 이어받은 앞 단계의 기준이다.
커서와 정렬키 인덱스의 컬럼과 방향을 어떻게 맞추는지 앞 단계에서 정했다.
- **feed_visible을 Production CQRS로 승격할 것인가**
이 문제의 해법 중 하나가 남긴 판단이다.
사전계산을 프로덕션에서 상시 유지할지는 아직 답하지 않았다.
## 문제
keyset 페이징으로 페이지 깊이 문제를 풀었다. 깊은 페이지에서 OFFSET은 2,000행을 훑고 20행만 남겼지만 keyset은 Index Only Scan으로 20행만 읽었고 buffers는 1이었다.
실서비스 피드는 조회 사용자에 따라 공개 범위를 판정해야 한다. public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기다. 이 필터를 keyset과 같은 쿼리에 얹었다.
실서비스 피드는 조회하는 사용자마다 보이는 아이템이 다르다. 이 가시성은 public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기로 갈린다. 세 분기를 keyset과 같은 쿼리에 얹었다.
## 결론
가시성 조건을 추가하자 정렬키 인덱스 더 이상 사용하지 못했다. 플래너는 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
가시성 조건을 추가하자 플래너는 정렬키 인덱스 ix_feed_items_keyset을 더 이상 지 못했다. 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스가 주던 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 각각 다른 조건이라 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 visibility 값 비교, 멘션 테이블 조인, user_id 비교로 조건이 다르기 때문에 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 필요했는데, 가시성 OR이 그 전제를 깨뜨렸다.
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 있어야 했는데, 가시성 OR이 그 전제를 깨뜨렸다.
## 검증 환경
@@ -74,11 +80,13 @@ ix_feed_items_visibility_sort : (visibility, first_highlighted_at DESC, id)
<!-- body:start -->
## 가시성을 얹기 전 — 인덱스로 커서 이후만
## 가시성을 얹기 전 — 커서 이후 20행만 읽기
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림." caption=" " zoom="true"
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉜다. 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉜다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어진다." caption=" " zoom="true"
:::
seed 2,000에서 100번째 페이지(offset 1980)를 요청하고 두 방식의 실행계획을 대조했다.
```text label="keyset + 정렬키 인덱스, 깊은 페이지"
Limit (rows=20) Buffers: shared hit=1 read=2
-> Index Only Scan using ix_feed_items_keyset on feed_items fi (actual rows=20)
@@ -92,9 +100,17 @@ Limit (rows=20) Buffers: shared hit=1 read=2
| keyset + 인덱스 | `Limit`←`Index Only Scan` | 20 | 1 | 0.076 ms |
| keyset 인덱스 | `Limit`←`Sort`←`Seq Scan`(filter) | 20 | 141 | 0.373 ms |
인덱스를 제거하면 keyset도 Seq Scan으로 전량을 훑는다. keyset 문법이 아니라 정렬키 인덱스가 비용을 줄인다.
깊은 페이지에서 `OFFSET`은 `Seq Scan`과 `Sort`로 2,000행을 만든 뒤 20행만 남겼다. 같은 페이지를 커서로 요청한 keyset은 정렬키 인덱스에서 20행만 읽었고 `buffers`는 1이었다.
## 가시성 OR을 얹은 뒤
커서로 넘긴 두 번째 페이지는 `OFFSET`의 두 번째 페이지와 같은 20개 식별자를 같은 순서로 반환했다.
인덱스를 제거하자 keyset도 `Seq Scan`으로 떨어졌다. 결과는 그대로 20행이었지만 테이블 2,000행을 읽고 1,980행을 필터로 걸러냈기 때문에 `buffers`는 `OFFSET`과 같은 141이 됐다. 비용을 줄인 것은 keyset 문법이 아니라 커서와 같은 순서의 정렬키 인덱스였다.
다만 이 정렬키 인덱스는 통합 테스트 안에서 만들고 지웠다. 운영 스키마에는 아직 들어가 있지 않다.
## 가시성 OR을 얹은 뒤의 실행계획
같은 커서에 가시성 세 분기를 얹고 실행계획을 다시 받았다. 플래너는 조건이 여러 분기로 갈릴 때 분기마다 인덱스에서 비트맵을 만들어 `BitmapOr`로 합치고 `Bitmap Heap Scan`으로 테이블을 한 번 훑는다. 비트맵에는 행의 순서가 남지 않는다.
```text label="keyset + 가시성 OR/EXISTS"
Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장
@@ -106,7 +122,7 @@ Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등
SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS)
```
정렬키 인덱스 `ix_feed_items_keyset`이 계획에서 사라지고 `ix_feed_items_visibility_sort`를 분기별로 스캔한 BitmapOr가 대신 들어왔다. bitmap으로 합치는 과정에서 인덱스가 주던 정렬 순서를 잃 상위 20행을 만들기 위한 Sort가 다시 필요해졌다.
정렬키 인덱스 `ix_feed_items_keyset`이 계획에서 사라지고 `ix_feed_items_visibility_sort`를 분기별로 스캔한 `BitmapOr`가 대신 들어왔다. 세 분기를 비트맵으로 합치면서 인덱스가 주던 정렬 순서를 잃었기 때문에, 상위 20행을 만드는 `Sort`가 다시 붙었다.
## 왜 하나의 쿼리로는 순서를 유지하지 못하나
@@ -119,17 +135,15 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20;
```
세 분기는 조건이 서로 다르다. visibility 값 비교, 멘션 테이블 조인, user_id 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 동시에 만족하는 단일 접근 경로가 없다.
세 분기는 `visibility` 값 비교, 멘션 테이블 조인, `user_id` 비교로 조건이 서로 다르다. 하나의 인덱스는 선두 컬럼 순서 하나만 주므로 셋을 동시에 만족하는 접근 경로가 없다.
## 커서에 tie-break가 필요한 이유
keyset이 `Index Only Scan`으로 성립하려면 정렬키·커서·인덱스의 컬럼과 방향이 모두 일치해야 한다. `first_highlighted_at`이 같은 행까지 안정적으로 넘기려면 커서에 `id`를 함께 넣어야 하고, 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다. 가시성 OR은 이 일치를 깨뜨린다.
`first_highlighted_at`이 같은 행도 안정적으로 넘기려면 커서에 id까지 포함해야 한다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다.
## 다음 단계 — UNION 분해와 사전계산
정렬키, 커서, 인덱스의 컬럼과 방향이 모두 일치해야 Index Only Scan이 성립한다. 가시성 OR은 이 일치를 깨뜨린다.
세 분기를 `UNION ALL`로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 처음에는 단일 OR이 `Seq Scan`으로 떨어지고 `UNION` 분해가 읽는 블록을 줄일 것으로 예상했다.
## 다음 선택
세 분기를 UNION ALL로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 앞의 것은 요청할 때마다 세 분기를 스캔하고, 뒤의 것은 조회를 단일 Index Only Scan으로 바꾸는 대신 읽기 모델 갱신 비용을 만든다.
실제 단일 OR은 `Seq Scan`이 아니라 `BitmapOr`와 top-N `Sort`, hashed SubPlan을 썼다. `UNION` 분해는 요청할 때마다 세 분기를 각각 스캔했고, 그래서 읽은 블록은 단일 OR보다 많았다. 읽은 블록을 가장 줄인 방식은 `UNION` 분해가 아니라 사전계산이었다. 사전계산은 조회를 단일 `Index Only Scan`으로 바꾸는 대신, 피드·멘션·가시성이 바뀔 때마다 읽기 모델을 갱신해야 하고 조회 사용자 수만큼 저장 공간도 늘어난다.
<!-- body:end -->
@@ -137,8 +151,8 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
## 로컬 미리보기
본문 「가시성을 얹기 전 — 인덱스로 커서 이후만」 아래 `:::evidence key="keyset-vs-offset"` 자리에 들어갈 그림이다.
본문 「가시성을 얹기 전 — 커서 이후 20행만 읽기」 아래 `:::evidence key="keyset-vs-offset"` 자리에 들어갈 그림이다.
![위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림.](../../../final/assets/tech-log-studio/keyset-vs-offset.svg)
![위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉜다. 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉜다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어진다.](../../../final/assets/tech-log-studio/keyset-vs-offset.svg)
<!-- local-preview:end -->
@@ -4,46 +4,53 @@ kind: PROJECT_DECISION
slug: batch-fetch-for-entity-graph
title: Entity Graph 조회에는 Batch Fetch를 사용한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/08a74b35-10c3-4874-8fbc-209b0b6e942e/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#11-1
- final/document.md#11-5
---
# Entity Graph 조회에는 Batch Fetch를 사용한다
엔티티를 그래프로 조회해야 하는 경로에서는 컬렉션 fetch join 대신 배치 페치를 쓴다. 엔티티만 페이징해 DB LIMIT을 살리고 지연 연관은 부모 키를 모아 IN으로 채운다.
엔티티를 그래프로 조회해야 하는 경로에서는 컬렉션 fetch join하지 않고 배치 페치를 쓴다. 엔티티만 페이징해 DB LIMIT을 발행하게 하고, 지연 연관은 부모 키를 모아 IN으로 채운다.
## 근거
- **Fetch Join · Batch · Projection 선택 기준**
세 전략의 역할을 나눈이다.
fetch join · 배치 · 프로젝션을 각각 어느 조회에 쓸지 나눠 둔이다.
- **Collection Fetch Join Pagination의 In-memory Paging**
fetch join 페이징이 함께 서지 못하는 것을 확인한 기록이다.
컬렉션을 fetch join한 채 페이징을 걸면 Hibernate가 SQL에서 LIMIT을 빼고 조인 결과를 전부 읽은 뒤 메모리에서 페이지를 자른다. 그 동작을 확인한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
배치가 남긴 엔티티 과적재를 확인한 기록이다.
배치를 적용해도 엔티티가 통째로 올라온다는 것을 확인한 기록이다.
## 결정문
엔티티 그래프가 필요한 조회에서는 컬렉션을 fetch join하지 않고 배치 페치 크기를 설정해 지연 연관을 IN으로 묶는다.
엔티티 그래프가 필요한 조회에서는 컬렉션을 fetch join하지 않고 hibernate.default_batch_fetch_size를 설정해 지연 연관을 IN으로 묶는다.
배치 설정의 적용 범위를 명시한다. 세션 전체에 거는 설정은 기존 측정에 영향을 주므로 격리된 범위에다.
설정은 세션 전체에 걸리므로 적용 범위를 함께 정한다. 앞서 잰 값을 그대로 두려면 새 테스트 클래스처럼 격리된 범위에만 건다.
## 판단 이유
fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고, 그 때문에 DB가 부모 기준 LIMIT을 적용할 수 없었다. 배치는 부모만 먼저 페이징하고 자식은 별도 쿼리로 가져오므로 두 문제가 함께 풀린다.
fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했 때문에 DB가 부모 기준으로 LIMIT을 적용할 수 없었다. 배치는 부모만 먼저 페이징하고 자식은 부모 키를 모아 IN으로 따로 가져오므로, 컬렉션 N+1과 페이징이 한꺼번에 풀린다.
측정에서 총 획득 statement가 크게다. 왕복은 부모 수를 배치 크기로 나눈 올림값이 된다. 컬렉션뿐 아니라 즉시 로딩 연관도 같은 배치에 묶였다.
고친 것은 loadFeed가 아니라 세션 설정 한다. findAllBy(Pageable)로 엔티티를 페이징하고 매핑하면서 지연 연관에 접근하는 코드는 그대로 두었고, 앞서 N+1을 만들었던 그 코드가 이 설정 아래에서는 배치로 동작했다. 애플리케이션 코드는 바꾸지 않았다.
로드한 부모 엔티티도 데이터셋 전체가 아니라 페이지 크기에서 멈췄다. 인메모리가 아니라 DB에서 LIMIT으로 부모를 먼저 자른 결과다.
N=1,000에서 총 PreparedStatement가 2,022개에서 23개로 줄었다. 초기화되지 않은 프록시를 배치 크기만큼씩 모아 한 번에 로드하므로, 자식 조회는 부모 수를 배치 크기로 나눈 올림값만큼만 나간다. 컬렉션뿐 아니라 user·page 즉시 로딩 연관도 같은 배치에 묶였다.
실행계획에서도 부모 페이징에 Limit 노드가 붙고 자식 IN은 부모와 자식을 곱하지 않는 준조인으로 나타났다. 앞 단계에서 본 카테시안과 인메모리 페이징이 모두 사라졌다.
같은 N=1,000에서 로드한 부모 엔티티는 데이터셋 전체가 아니라 페이지 크기인 20에서 멈췄다. 인메모리가 아니라 DB에서 LIMIT으로 부모를 먼저 자른 결과다.
엔티티만 페이징한 SQL의 실행계획에는 Limit 노드가 붙었고, 자식 IN 조회는 부모와 자식을 곱하지 않는 준조인으로 나타났다. 앞 단계에서 본 카테시안 곱과 인메모리 페이징이 여기서는 나타나지 않았다.
## 영향
- 지표 해석이 달라진다. 배치를 적용하면 초기화 컬렉션 수가 SQL 수와 같지 않다. 획득 statement 수와 컬렉션 수를 함께 보고 판단해야 한다.
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 기준선 측정을 유지하려면 설정 범위를 격리해야 한다.
- 엔티티는 여전히 통째로 하이드레이트된다. 화면에 필요하지 않은 컬럼까지 영속 객체로 올라온다. 이 비용은 배치가 풀지 않는다.
- 배치를 적용하면 getCollectionFetchCount()가 초기화 컬렉션 수가 아니라 여러 컬렉션을 함께 채운 fetch SELECT 연산 수를 센다. 처음에는 이 값을 초기화된 컬렉션 수로만 보고 배치를 걸어도 N으로 유지될 것이라고 예상했지만 10 / 100 / 1,000에서 1 / 1 / 10으로 줄었고, 그 결과에 맞춰 지표를 다시 해석했다. 배치가 걸렸는지는 PreparedStatement 수와 이 값을 함께 보고 판단한다.
- default_batch_fetch_size는 세션 전체에 걸린다. 앞서 loadFeed로 잰 값과 그대로 비교하려면 새 IT 클래스처럼 격리된 범위에만 걸어야 한다.
- 엔티티는 여전히 통째로 하이드레이트된다. seed 1,000의 첫 페이지 20건을 조회하자 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다. 화면에 필요하지 않은 컬럼까지 올라오는 이 비용은 배치가 줄이지 못한다.
- 배치 크기를 정해야 한다. 크기가 크면 IN 목록이 길어지고 작으면 왕복이 늘어난다.
- 특정 연관에만 배치를 걸 수도 있지만 그러면 매핑 자체가 바뀐다. 기준선과 비교하려면 설정으로 는 편이 낫다.
- 특정 컬렉션에만 @BatchSize(size=100)를 붙일 수도 있지만 그러면 엔티티 매핑 자체가 바뀌어 앞의 측정과 나란히 놓을 수 없다. 매핑을 건드리지 않고 비교하려면 세션 설정으로 는 편이 낫다.
@@ -4,49 +4,56 @@ kind: PROJECT_DECISION
slug: keep-read-model-as-cqrs-lite
title: 현재 Read Model은 CQRS-lite로 유지한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7f248f68-ce2b-43ec-94ce-82324d0bd1a7/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#17-1
- final/document.md#17-2
---
# 현재 Read Model은 CQRS-lite로 유지한다
읽기 경로를 쓰기 애그리거트와 분리하되 저장소는 나누지 않는다. 전용 조회 포트와 읽기 DTO, 읽기 최적 쿼리를 같은 저장소 위에 두고, 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남긴다.
CQRS(Command Query Responsibility Segregation, 명령과 조회의 책임 분리)는 쓰기 모델과 읽기 모델을 갈라 두는 설계다. 여기서는 그중 모델만 갈라, 읽기 경로를 쓰기 애그리거트인 FeedItem에서 떼어 내되 저장소는 나누지 않는다. 읽기 전용 조회 포트(FeedReadModelQueryPort)와 읽기 DTO, 읽기 최적 쿼리를 쓰기와 같은 저장소 위에 두고, 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남긴다.
## 근거
- **feed_visible을 Production CQRS로 승격할 것인가**
저장소 분리를 열어 둔 판단이다.
사용자별 가시성을 미리 계산한 테이블을 상시 유지할지, 그래서 저장소까지 나눌지를 아직 답하지 않았다.
- **화면 조회는 Read Projection을 사용한다**
읽기 모델을 모델 수준에서 분리한 결정이다.
엔티티를 하이드레이트하지 않고 필요한 스칼라 값만 받기로 해서, 읽기 경로를 모델 수준에서 갈랐다.
- **Feed Visibility Query Pattern**
사전계산이 어느 지점에서 읽기 모델 설계가 되는지 정리한 기준이다.
공개·멘션·비공개 세 분기를 단일 OR, UNION 분해, 사전계산으로 비교했다. 사전계산을 상시 유지하면 그때부터 읽기 모델 설계가 된다.
## 결정문
읽기 경로는 전용 조회 포트와 유스케이스, 어댑터로 분리한다. 읽기 최적 쿼리는 요청 시점에 실행하고 별도 물리 저장소를 두지 않는다.
읽기 경로는 읽기 전용 조회 포트(FeedReadModelQueryPort)와 유스케이스(GetFeedReadModelUseCase), 어댑터(FeedReadModelQueryAdapter)로 분리한다. 읽기 최적 쿼리는 요청 시점에 실행하고 별도 물리 저장소를 두지 않는다.
사전계산 테이블을 상시 유지하는 구조는 현재 계약의 범위를 넘는 것으로 보고 채택하지 않는다.
사전계산 테이블(feed_visible)을 상시 유지하는 구조는 현재 계약의 범위를 넘는 것으로 보고 채택하지 않는다.
## 판단 이유
문제가 N+1을 줄이는 SQL에서 화면에 맞는 읽기 모델을 설계하는 일로 넓어졌다. 쓰기 애그리거트로 읽기를 하려 데서 조회 비용이 나왔기 때문이다.
쓰기 애그리거트인 FeedItem으로 화면 조회까지 하려 데서 N+1이 나왔다.
읽기 모델을 분리하는 방법은 두 층이 있다. 모델만 분리하는 것과 저장소까지 분리하는 것이다. 모델 분리는 같은 저장소 위에 전용 포트와 쿼리로 끝나고 동기화 비용이 없다.
읽기 모델을 분리하는 층은 둘이다. 모델만 분리하면 같은 저장소 위에 읽기 전용 포트와 읽기 최적 쿼리를 두는 것으로 끝나서, 쓰기 변경을 읽기 쪽에 맞추는 동기화가 아예 없다.
저장소 분리 조회 계획 가장 단순하게 만들지만 쓰기 변경을 투영에 반영해야 한다. 가시성은 보안에 걸린 조건이라 투영이 어긋나면 노출 사고가 된다. 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다.
저장소까지 분리하면 조회가 뷰어별로 미리 펼친 feed_visible 하나를 읽는 단일 Index Only Scan이 되어 실행계획 가장 단순해지지만, 쓰기 변경을 투영에 반영해야 한다. 가시성은 보안에 걸린 조건이라 투영이 어긋나면 노출 사고가 된다. 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다.
현재 트래픽에서 그 비용이 필요한지 아직 확인하지 않았다. 지금까지의 측정은 단일 스레드 로컬 값이라 이 판단의 근거가 되지 못한다.
그 설계를 지금 감수할 만한지 아직 확인하지 않았다. 지금까지 잰 값은 단일 스레드·warm-cache 로컬 비교값이라 고트래픽 처리량도 동시성도 측정 범위 밖이고, 별도 부하 테스트로 확인해야 한다.
모델 분리만으로 하이드레이트 엔티티를 0으로, 발행 쿼리를 데이터 규모와 무관한 상수로 만들었다. 이 범위에서 얻을 수 있는 개선을 먼저 취했다.
모델 분리한 것으로도 FeedReadModelUseCaseIT의 N=10과 100에서 엔티티 로드가 0, 발행 쿼리가 2개, 부모당 하이라이트가 최대 3개였다. 프로젝션만 했을 때 남아 있던 자식 1,509행도 60행 이하로 줄었다.
발행 쿼리 수는 Hibernate Statistics가 센 값이다. 이 지표에 window 쿼리가 잡히도록 그 쿼리는 JdbcTemplate 대신 Hibernate Session으로 실행했다.
## 영향
- 조회 요청마다 읽기 최적 쿼리 실행다. 사전계산 방식보다 조회 비용이 크다.
- 가시성 조건은 요청 시점에 계산한다. 분기가 여럿이면 그 비용이 매 요청에 붙는다.
- 읽기 전용 계약이 하나 늘어난다. 화면 요구가 바뀌면 이 계약도 바뀐다.
- 저장소가 하나라 정합성 문제가 없다. 투영 갱신, 지연, 복구를 설계하지 않아도 된다.
- 고트래픽 읽기에서 사전계산이 실제로 필요해지면 계약과 가드레일을 함께 개정해야 한다. 이 변경은 현재 범위를 넘다.
- 경계를 지키는 검사가 필요하다. 조회 포트가 엔티티를 노출하지 않는지, 의존 방향이 맞는지 확인한다.
- 조회 요청마다 읽기 최적 쿼리 실행다. 결과를 미리 펼쳐 두는 사전계산과 달리 조회 계산이 매 요청에 들어간다.
- 가시성은 공개·멘션·비공개 세 분기라 요청 시점에 셋을 다시 판정한다. 단일 OR은 후보 1,500개를 훑어 buffers가 122였고, 뷰어별로 미리 펼친 feed_visible은 1이었다.
- 읽기 전용 조회 포트(FeedReadModelQueryPort) 계약이 하나 늘어난다. 화면 요구하는 형태가 바뀌면 이 계약도 같이 바뀐다.
- 쓰기와 읽기가 같은 저장소를 보므로 투영이 어긋날 일이 없다. 투영 갱신, 지연 허용치, 복구 절차를 설계하지 않아도 된다.
- 고트래픽 읽기에서 feed_visible 사전계산이 실제로 필요해지면 별도 물리 읽기 저장소와 쓰기→읽기 동기화(도메인 이벤트나 아웃박스)를 추가해야 한다. 이 변경은 현재 계약의 범위를 넘으므로 계약과 가드레일을 함께 개정한다.
- 조회 포트가 엔티티를 노출하지 않는지 의존 방향은 ArchUnit 검사(query_ports_do_not_leak…)가 계속 확인한다. 이번 구현에서는 이 검사도 ./gradlew check도 통과했다.
@@ -4,47 +4,52 @@ kind: PROJECT_DECISION
slug: keyset-for-feed-pagination
title: Feed Pagination은 Keyset을 사용한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/1dbce381-f0dc-4d49-ad68-bd31d205677e/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#14-1
- final/document.md#14-4
---
# Feed Pagination은 Keyset을 사용한다
피드 목록의 페이징은 OFFSET이 아니라 이전 페이지의 마지막 정렬키를 커서로 넘기는 방식을 쓴다. 정렬키와 같은 컬럼·같은 방향의 인덱스를 함께 둔다.
피드 목록의 페이징은 OFFSET이 아니라 이전 페이지의 마지막 정렬키를 커서로 넘기는 keyset 방식을 쓴다. 정렬키와 같은 컬럼, 같은 방향의 인덱스를 함께 둔다.
## 근거
- **Keyset Pagination 설계 기준**
이 결정을 규칙으로 편 기준이다.
커서에 정렬키를 모두 담고 정렬키·커서·인덱스의 컬럼과 방향을 맞추라는 규칙을 여기서 가져왔다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
이별 비용과 인덱스 전제를 확인한 기록이다.
은 페이지에서 OFFSET이 2,000행을 훑는 동안 keyset은 커서 이후 20행만 읽은 것을 여기서 실행계획으로 확인했다.
- **Highlight 없는 FeedItem을 허용할 것인가**
커서 설계가 기다리는 판단이다.
정렬 시각이 null인 FeedItem을 허용할지 정하지 않아 커서 비교식이 경계에서 어떻게 동작하는지도 정의되지 않았다.
## 결정문
피드 목록은 (정렬 시각, 식별자)를 커서로 사용해 그 지점 이후만 조회한다. 정렬키 전용 인덱스를 같은 컬럼 방향으로 둔다.
피드 목록은 (정렬 시각, 식별자)를 커서로 삼아 그 지점 이후만 조회한다. 정렬키 전용 인덱스를 커서와 같은 컬럼, 같은 방향으로 둔다.
전체 페이지 수를 요구하지 않는 화면에서는 전체 건수 count를 발행하지 않는다.
전체 페이지 수를 요구하지 않는 화면에서는 전체 건수를 세는 count 쿼리를 발행하지 않는다.
## 판단 이유
OFFSET은 정렬 순서에서 앞의 행을 만든 뒤 버린다. 깊은 페이지에서는 결과 20행을 만들려고 2,000행을 읽었다. 무한 스크롤에서는 뒤로 갈수록 이 비용이 계속 늘어난다.
OFFSET은 정렬 순서에서 앞의 행을 만든 뒤 버리기 때문에, 100번째 페이지에서는 결과 20행을 만들려고 2,000행을 읽었다. 무한 스크롤에서는 페이지를 넘길수록 읽고 버리는 행이 계속 늘어난다.
커서 방식은 읽는 행이 페이지 깊이와 무관하게 페이지 크기로 유지됐다. 정렬키 인덱스가 있을 때 커서 이후만 인덱스에서 읽었 읽은 블록도 최소였다.
keyset은 페이지어져도 읽은 행이 페이지 크기인 20행에서 늘지 않았다. 정렬키 인덱스가 있을 때 커서 이후 20행만 인덱스에서 읽었다. 읽은 블록은 1개였다.
인덱스를 제거하면 커서 방식도 전량을 스캔했다. 문법이 아니라 인덱스가 비용을 줄인다. 기존 인덱스는 선두 컬럼이 달라 이 쿼리에 쓰이지 않았고 정렬키 전용 인덱스 따로 필요했다.
인덱스를 제거하자 keyset도 2,000행을 훑었다. keyset 문법만으로 읽는 행이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 있어야 했다. 기존 인덱스는 선두 컬럼이 가시성이라 가시성 필터가 없는 이 쿼리에 쓰이지 않았고, 정렬키만으로 된 인덱스 따로 만들어야 했다. 그 인덱스는 측정하는 동안 테스트 코드가 만들고 지웠다. 프로덕션에 반영할 때는 V8 마이그레이션으로 추가할 수 있다.
정렬 시각이 같은 행을 안정적으로 넘기려면 식별자까지 커서에 담아야 한다. 시각만 쓰면 경계에서 행이 빠지거나 중복다.
정렬 시각이 같은 행을 안정적으로 넘기려면 식별자까지 커서에 담아야 한다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다.
## 영향
- 임의 페이지로 점프할 수 없다. 앞뒤로 이어서 넘기는 탐색만 가능하다.
- 전체 페이지 수를 화면에 표시할 수 없다. 필요하면 count를 별도로 다뤄야 한다.
- 정렬 기준마다 인덱스가 필요하다. 정렬 기준 늘면 인덱스 수와 쓰기 비용이 함께 는다.
- 정렬키가 null일 수 있으면 정렬 위치와 커서 표현을 먼저 정해야 한다. 이 판단이 아직 열려 있다.
- 필터가 붙으면 인덱스 전제가 깨질 수 있다. 가시성 조건을 얹었을 때 정렬키 인덱스않고 정렬이 다시 생겼다. 필터를 포함한 설계가 따로 필요하다.
- 커서 클라이언트에 노출하므로 인코딩과 위변조 처리를 정해야 한다.
- 임의 페이지로 바로 건너뛸 수 없다. 앞뒤로 이어서 넘기는 탐색만 다.
- 전체 페이지 수를 화면에 표시하려면 전체 건수를 세는 count 쿼리를 따로 발행해야 한다.
- 정렬 기준마다 인덱스가 하나씩 필요하므로, 정렬 기준면 인덱스도 같은 수로 늘고 쓰기마다 갱신할 인덱스가 많아진다.
- 정렬 시각이 null인 행을 허용한다면 정렬에서 그 행을 어디에 둘지와 커서에 무엇을 담을지를 먼저 정해야 한다. 스키마를 정할 때 이 판단을 keyset 페이징 단계 이전에 내리기로 적어 두었지만 아직 정하지 않았다.
- 가시성 조건을 같은 쿼리에 얹자 플래너가 정렬키 인덱스 쓰지 못하고 세 분기를 각각 스캔해 합친 뒤 다시 정렬했다. 필터를 얹은 페이징은 따로 설계해야 한다.
- 커서 클라이언트로 나가므로 어떻게 인코딩할지와 위변조를 어떻게 막을지 정해야 한다.
@@ -4,46 +4,54 @@ kind: PROJECT_DECISION
slug: measure-plan-on-real-postgresql
title: Query Plan은 실제 PostgreSQL에서 측정한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/ae6c9bea-d3a3-46e1-bbd4-8d580d336394/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-1
- final/document.md#4-6
---
# Query Plan은 실제 PostgreSQL에서 측정한다
조회 성능 측정은 인메모리 대체 DB가 아니라 운영과 같은 PostgreSQL에서 실행한다. 실행계획과 인덱스 동작이 측정 대상이므로 DB는 대체재가 아니라 측정 대상의 일부다.
조회 성능 측정은 H2 같은 인메모리 DB가 아니라 운영과 같은 PostgreSQL에서 실행한다.
## 근거
- **PostgreSQL Query Plan 측정 기준**
이 결정을 규칙으로 편 기준이다.
이 결정을 실제로 잴 때 지킬 규칙으로 폈다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
실제 엔진에서 실행계획과 통계 차이를 관측한 기록이다.
실제 엔진에서 실행계획과 통계 차이를 관측다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
부분 인덱스와 정렬 인덱스 기능에 기 측정 기록이다.
부분 인덱스와 정렬 인덱스 기능에 기대어 측정다.
## 결정문
퍼시스턴스 조회 측정은 Testcontainers로 띄운 실제 PostgreSQL에서 수행한다. 인메모리 대체 DB로 실행계획이나 인덱스 동작을 판단하지 않는다.
퍼시스턴스 조회 측정은 Testcontainers로 띄운 실제 PostgreSQL에서 수행한다. H2 같은 인메모리 DB로 실행계획이나 인덱스 동작을 판단하지 않는다.
스키마는 운영 마이그레이션을 그대로 적용하고 엔티티와의 불일치를 조기에 잡는다.
스키마는 운영 마이그레이션인 Flyway V6__feed.sql을 그대로 적용하고, ddl-auto=validate로 엔티티가 기대하는 테이블·컬럼·타입의 기본 불일치를 조기에 잡는다.
## 판단 이유
비용 기반 옵티마이저는 가능한 계획의 비용을 추정해 고른다. 그 추정값도, 고를 수 있는 선택지도 엔진마다 다르다. 비용 상수, 수집하는 통계, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류가 모두 갈린다.
비용 기반 옵티마이저는 가능한 여러 계획의 비용을 추정해 가장 싼 것을 고른다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다르다. 갈리는 축은 넷이다. 비용 상수, ANALYZE가 모으는 통계, 저장 구조와 가시성 처리, 수 있는 인덱스 종류와 기능이 저마다 다르다.
이 프로젝트의 측정은 이 축들에 직접 걸린다. 추정 행수 실제 행수 500배 차이 난 관측은 통계 수집 방식에 달렸고, 순차 스캔과 인덱스 스캔의 판정은 비용 모델과 선택도 추정의 산물이며, 가시성 조건과 정렬 페이징은 부분 인덱스와 정렬 인덱스 기능에 기댄다.
추정 행수 1과 실제 행수 500이 500배로 벌어진 관측은 통계를 어떻게 수집하느냐에 달렸다. 순차 스캔과 인덱스 스캔 중 무엇을 고르는지는 비용 모델과 선택도 추정이 정하므로, 엔진이 바뀌면 다른 임계에서 갈린다. 가시성 조건과 정렬 페이징은 부분 인덱스와 정렬 인덱스 기능에 기댄다.
다른 엔진에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작이 재현되지 않는다. 세 지점에서 체계적으로 틀린 결론에 이른다.
다른 엔진에서 재면 순차 스캔과 인덱스 스캔의 선택이 뒤집히고, 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, PostgreSQL 특유의 가시성 맵과 index-only scan 동작이 재현되지 않는다.
측정 대상이 계획과 인덱스 동작인 이상 DB를 바꾸면 측정 자체가 달라진다.
실행계획은 엔진이 실제로 고른 것 자체가 필요해서 PostgreSQL의 EXPLAIN (ANALYZE, BUFFERS)로 읽었다. APM(애플리케이션 성능 모니터링)은 운영을 관측하는 데 더 적합하다.
부르는 지점도 골랐다. 퍼시스턴스 어댑터 FeedQueryAdapter를 JPA 슬라이스에서 직접 호출하고 HTTP는 거치지 않는다. 이유는 둘이다. N+1은 조회 계층의 현상이므로 웹·보안·직렬화 노이즈를 배제하고 쿼리 행동만 관찰한다. 그리고 슬라이스 트랜잭션이 열려 있어야 지연 로딩이 결정적으로 재현된다.
## 영향
- 측정 실행에 컨테이너 런타임이 필요하다. Docker가 없는 환경에서는 이 테스트가 비활성화된다.
- 인메모리 DB보다 기동과 실행이 느리다. 컨테이너를 클래스당 하나 공유해 비용을 줄였다.
- 재현성을 위해 이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있다.
- 스키마 검증만으로 모든 드리프트를 막지 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 정책은 따로 확인해야 한다.
- 측정값은 warm cache 상태의 로컬 값이다. 운영 지연으로 옮겨 읽을 수 없다.
- 측정을 돌리려면 컨테이너 런타임이 있어야 한다. @Testcontainers(disabledWithoutDocker = true)를 걸어 두어 Docker가 없는 환경에서는 이 테스트가 비활성화된다.
- 인메모리 DB보다 기동과 실행이 느리다. 컨테이너 필드를 static으로 두어 테스트 메서드마다 새로 띄우지 않고 FeedPersistenceIT 실행 동안 하나 공유한다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료한다.
- 재현성을 더 높이려면 postgres:16-alpine 태그보다 patch 버전이나 digest(@sha256:...)를 고정하는 편이 낫다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문이다.
- ddl-auto=validate만으로 모든 드리프트를 막지 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이라 마이그레이션 검증과 카탈로그 조회로 따로 확인한다.
- 측정이 열린 테스트 트랜잭션 안에 있어야 하므로, 격리 JVM과 steady-state를 전제하는 JMH(Java Microbenchmark Harness)나 HTTP 종단을 전제하는 도구는 이 지점을 잡지 못한다.
- 측정값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값이다. 절대값보다 N에 따른 증가 방향만 확인했으므로 운영 지연으로 옮겨 읽을 수 없다.
@@ -4,25 +4,30 @@ kind: PROJECT_DECISION
slug: no-collection-fetch-join-with-pagination
title: Collection Fetch Join과 Pagination을 같이 사용하지 않는다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#10-5
- final/document.md#9-6
---
# Collection Fetch Join과 Pagination을 같이 사용하지 않는다
컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 응답은 한 페이지지만 비용은 데이터셋 전체에 비례한다.
컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 반환 목록만 한 페이지일 뿐 메모리에 올라오는 부모 엔티티는 데이터셋 전체다.
## 근거
- **Collection Fetch Join Pagination의 In-memory Paging**
이 동작을 실행계획과 로드 수로 확인한 기록이다.
발행 SQL에 Limit 노드가 없다는 것과 부모 엔티티 로드 수를 이 기록에서 함께 쟀다.
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
컬렉션 fetch join이 행을 곱하는 것을 확인한 기록이다.
부모 100행짜리 조인이 실제로는 1,961행을 실어 나른다는 것을 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
대신 무엇을 쓸지 정한 기이다.
컬렉션 fetch join을 버린 뒤 배치와 프로젝션 중 무엇을 쓸지 정한 기이다.
## 결정문
@@ -32,18 +37,20 @@ decisionStatus: PROPOSED
## 판단 이유
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 부모 기준 LIMIT을 걸면 조인 행에서 잘 일부 부모의 자식이 누락다.
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 페이지 크기 20으로 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 자식이 누락될 수 있다.
Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 자른다. 발행된 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
Hibernate는 이 누락을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 페이지를 자른다. seed(100)에서 실행계획을 떠 보니 이 쿼리에는 Limit 노드가 없었고, 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환했다.
측정에서 반환 목록은 페이지 크기로 고정됐지만 로드한 부모 엔티티는 데이터셋 전체였다. 초과 적재 배수는 데이터가 커질수록 늘었다. 작은 데이터셋에서는 두 값이 같아 문제가 드러나지 않다.
N=1,000에서 반환 목록은 페이지 크기인 20에 머물렀지만 메모리에 올라온 부모 엔티티는 1,000개, 반환의 50.0배였다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 10으로 같았고 문제가 보이지 않다.
엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 상위 몇 행만 취하는 정렬로 바뀐다. 전체 정렬과 상위 몇 행 정렬의 차이가 계획 수준에서 나타난다.
엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 정렬 방식이 quicksort에서 top-N heapsort로 바뀌고, 상위 20행만 가져온다.
문제의 쿼리는 FeedQueryAdapter가 아니라 통합 테스트 안의 원시 JPQL로만 실행했고, 늘어난 코드는 그 테스트의 측정 메서드뿐이다.
## 영향
- 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식 조회를 위한 쿼리 따로 필요하다.
- 엔티티만 페이징하면 지연 연관의 N+1이 돌아온다. 배치나 프로젝션을 함께 적용해야 한다.
- 이 실수를 조기에 발견하려면 컬렉션 fetch join에 페이징이 걸릴 때 실패시키는 설정을 켤 수 있다. 근본 해결은 아니지만 안전장치가 된다.
- 회귀 가드는 경고 코드 번호만 비교하지 않는다. 버전에 따라 코드가 달라질 수 있어 문구도 함께 확인한다.
- 작은 데이터셋으로만 검증하면 이 문제를 놓친다. 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 봐야 한다.
- 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식 쿼리 따로 내서 채워야 한다.
- 엔티티만 페이징하면 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아오므로, 페이지 부모 키를 모아 한 번에 조회하는 배치나 프로젝션을 함께 적용해야 한다.
- hibernate.query.fail_on_pagination_over_collection_fetch를 true로 바꾸면 같은 쿼리가 곧바로 실패한다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 쓸 수 있다.
- 이 랩의 Hibernate ORM 7.1.8은 널리 알려진 HHH000104가 아니라 HHH90003004를 남겼다. 그래서 회귀 가드는 경고 코드 번호만 비교하지 않고 collection fetch라는 문구도 함께 확인한다.
- 작은 데이터셋으로만 검증하면 이 문제를 놓치므로, 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 다.
@@ -4,46 +4,50 @@ kind: PROJECT_DECISION
slug: query-strategy-behind-port
title: Query Strategy는 FeedQueryPort 뒤에서 소유한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/4e3200c8-eff5-4442-ae84-ae7b7fa92c8b/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#5-2
---
# Query Strategy는 FeedQueryPort 뒤에서 소유한다
조회 전략은 퍼시스턴스 어댑터의 책임으로 둔다. 상위 계층에는 조회 조건과 반환 형태만 드러내고 fetch join, 배치, 프로젝션, 윈도우 함수 중 무엇을 쓰는지는 포트 뒤에 감춘다.
조회 전략은 퍼시스턴스 어댑터 `FeedQueryAdapter`의 책임으로 둔다. 상위 계층에 드러내는 것은 조회 사용자·페이지 크기와 반환할 `FeedSummary`뿐이고, fetch join·배치 페치·DTO 프로젝션·윈도우 함수 중 무엇을 쓰는지는 `FeedQueryPort` 뒤에 감춘다.
## 근거
- **Fetch Join · Batch · Projection 선택 기준**
포트 뒤에서 교체한 전략들의 선택 기준이다.
여기서 비교한 세 전략을 모두 `FeedQueryAdapter` 안에서 갈아 끼웠다.
- **Collection Fetch Join Pagination의 In-memory Paging**
전략을 바꿔 가며 실패를 격리한 기록이다.
컬렉션 fetch join에 페이징을 붙이자 Hibernate가 전체 조인 결과를 읽고 메모리에서 페이지를 잘랐는데, 이 실패도 어댑터 안에서 끝났다.
- **화면 조회는 Read Projection을 사용한다**
같은 포트 뒤에서 구현을 바꾼 결정이다.
화면 조회를 DTO 프로젝션으로 바꿀 때도 포트 뒤 구현만 갈아 끼웠다.
## 결정문
조회 경로는 컨트롤러에서 유스케이스를 거쳐 조회 포트로 이어지고, 퍼시스턴스 어댑터가 그 포트를 구현한다. 조회 전략의 변경은 어댑터 안에서 끝낸다.
조회 경로는 FeedController에서 GetFeedUseCase를 거쳐 FeedQueryPort로 이어지고, 퍼시스턴스 어댑터 FeedQueryAdapter가 그 포트를 구현한다. 조회 전략을 바꾸는 일은 이 어댑터 안에서 끝낸다.
포트는 엔티티 타입을 노출하지 않는다. 컨트롤러는 엔티티를 의존하거나 반환하지 않는다.
쿼리 포트는 엔티티 타입을 노출하지 않는다. 컨트롤러는 엔티티를 의존하거나 반환하지 않는다.
## 판단 이유
이 프로젝트에서 조회 전략을 여섯 번 바꿨다. 엔티티 매핑, fetch join, 배치, 프로젝션, 윈도우와 LATERAL, 커서 페이징이다. 전략마다 SQL 형태와 반환 구조가 달다.
이 프로젝트에서 조회 전략을 여섯 번 바꿨다. 엔티티 매핑, fetch join, 배치 페치, DTO 프로젝션, 윈도우 함수와 LATERAL, 커서 페이징이고, 바꿀 때마다 발행되는 SQL 형태와 반환 구조가 달라졌다.
전략이 상위 계층에 드러나 있었다면 매번 유스케이스와 웹 계층까지 함께 고쳐야 했다. 포트 뒤에 두었기 때문에 상위 계층은 그대로 두고 어댑터만 바꿔 가며 비교할 수 있었다.
전략이 상위 계층에 드러나 있었다면 여섯 번 모두 GetFeedUseCase와 웹 계층까지 함께 고쳐야 했다. 포트 뒤에 두었기 때문에 상위 계층은 그대로 두고 어댑터만 갈아 끼우며 비교할 수 있었다.
엔티티 연관 게터를 좁게 열어 둔 것도 같은 경계다. 연관 게터가 열려 있으면 상위 계층이 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트에 의존하게 된다.
FeedItemJpaEntity의 연관 게터를 public 대신 package-private로 좁힌 것도 같은 이유에서다. 연관 게터가 열려 있으면 상위 계층이 엔티티 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트에 의존한다. 좁혀 두면 같은 패키지의 어댑터와 매퍼만 그래프를 순회할 수 있다. 빌드가 강제하는 규칙은 아니고 방어적 캡슐화 관례다.
포트가 엔티티 노출하지 않으면 조회 방식뀌어도 계약이 유지된다.
쿼리 포트가 엔티티 타입을 노출하지 않으면 어댑터 안에서 조회 방식꿔도 상위 계층이 보는 계약은 바뀌지 않는다.
## 영향
- 어댑터 안에 네이티브 SQL이 들어간다. 표준 JPQL로 표현되지 않는 윈도우 함수와 LATERAL을 써야 하기 때문이다. 이 코드는 포트 뒤에 머문다.
- 반환 형태를 바꾸려면 포트 계약을 바꿔야 한다. 화면 요구가 바뀌면 계약도 함께 바다.
- 전략 실패를 프로덕션 코드에 섞지 않고 통합 테스트에 격리할 수 있었다. 다음 단계와 전후를 같은 기준으로 비교하는 데 필요했다.
- 어댑터가 조회 성능의 책임 모두 진다. 성능 문제의 원인을 찾을 때 이 경계 안을 먼저 본다.
- 경계를 지키는 검사를 자동화해야 한다. 포트가 엔티티 노출하지 않는지, 의존 방향이 맞는지 확인하는 검사가 필요하다.
- 윈도우 함수와 LATERAL은 표준 JPQL(Java Persistence Query Language)로 표현되지 않아서 어댑터 안에 네이티브 SQL이 들어간다. 이 SQL은 포트 밖으로 나가지 않는다.
- 화면 요구가 바뀌어 반환 형태가 달라지면 FeedQueryPort 계약까지 함께 바꿔야 한다.
- 전략마다 나온 실패를 프로덕션 코드에 섞지 않고 통합 테스트 안에서만 재현했다. loadFeed를 건드리지 않고 loadFeedProjection을 따로 둔 덕분에 앞 단계를 다시 측정해 전후를 같은 조건으로 비교할 수 있었다.
- 조회 성능의 책임은 FeedQueryAdapter가 모두 진다. 조회가 느려지면 이 어댑터 안부터 본다.
- 쿼리 포트가 엔티티 타입을 노출하지 않는지 의존 방향은 ArchUnit 검사(query_ports_do_not_leak…)가 확인한다. 이번 구현에서는 이 검사와 ./gradlew check가 모두 통과했다.
@@ -4,11 +4,16 @@ kind: PROJECT_DECISION
slug: read-projection-for-screen-query
title: 화면 조회는 Read Projection을 사용한다
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/30a37f34-b406-4061-b924-e22e0be0c3bf/edit"
decisionStatus: PROPOSED
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#12-1
- final/document.md#12-5
---
# 화면 조회는 Read Projection을 사용한다
@@ -18,32 +23,34 @@ decisionStatus: PROPOSED
## 근거
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
프로젝션의 효과와 남은 비용을 확인한 기록이다.
하이드레이트한 엔티티는 0개가 됐지만 자식 IN 쿼리가 페이지 부모 20개의 하이라이트 1,509행을 모두 읽은 것을 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
배치와 프로젝션이 서로 다른 비용을 줄인다는이다.
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 엔티티 수를 줄이므로, 어느 조회에 무엇을 쓸지 나눠 둔이다.
- **Query Strategy는 FeedQueryPort 뒤에서 소유한다**
구현을 감춘 경계다.
프로젝션 구현을 퍼시스턴스 어댑터 안에 두고 상위 계층에는 조회 조건과 반환 형태만 드러낸 결정이다.
## 결정문
화면 조회 경로에서는 필요한 컬럼만 선택해 캐리어 record로 받는다. 영속 엔티티를 만들지 않는다.
부모와 자식을 각각 스칼라로 조회하고 애플리케이션에서 조립한다. 이 구현은 조회 포트 뒤에 둔다.
부모와 자식을 각각 스칼라로 조회하고 애플리케이션에서 조립한다. 이 구현은 FeedQueryPort 뒤에 둔다.
## 판단 이유
배치를 적용한 뒤에도 엔티티는 통째로 하이드레이트됐다. 페이지 20건을 조회하는데 부모와 연관을 합해 천 개가 넘는 영속 객체 올라왔다. 화면에 일부 컬럼만 필요했다.
배치를 적용한 뒤에도 엔티티는 통째로 하이드레이트됐다. seed 1,000의 첫 페이지 20건을 조회하자 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체 올라왔다. 화면에 필요한 것은 사용자 이름, 페이지 제목, URL 같은 일부 컬럼이었다.
캐리어 생성자 표현식은 영속 엔티티 대신 스칼라 값으로 record를 만든다. 1차 캐시, 더티체킹, 지연 프록시 생기지 않는다. 컬럼을 읽기 위한 조인이 있어도 그 대상 엔티티를 만들지 않는다.
SELECT new FeedItemProjectionRow(…) 생성자 표현식은 영속 엔티티 대신 스칼라 값으로 record를 만든다. 영속 엔티티가 없으니 1차 캐시, 더티체킹, 지연 프록시 생기지 않는다. 사용자 이름을 읽으려고 건 join f.user u도 컬럼 값을 가져오는 경로일 뿐이라 User 엔티티를 만들지 않는다.
측정에서 하이드레이트한 엔티티가 0이 됐고 발행 쿼리도 데이터 규모와 관계없이 두 개로 고정됐다. 부모 스칼라 쿼리와 자식 IN 쿼리다.
seed 1,000의 같은 첫 페이지 20건을 loadFeedProjection으로 조회하자 하이드레이트한 엔티티가 1,569개에서 0개로 줄었다. 발행한 PreparedStatement도 23개에서 2개가 됐는데, 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개다. 엔티티 컬렉션을 초기화하지 않으니 여러 컬렉션을 함께 채우던 조회 연산도 10번에서 0번이 됐다. N을 10, 100, 1,000으로 바꿔 다시 재도 PreparedStatement는 2개였다. 페이지 부모가 최대 20개라 자식 IN 쿼리도 한 번만 실행됐기 때문이다.
이 효과는 배치 설정 여부와 무관하게 성립한다. 배치는 왕복을 줄이고 프로젝션은 적재를 없앤다. 두 전략은 서로를 대신하지 않는다.
위 수치는 loadFeedProjection을 직접 부른 결과다. 프로덕션 경로에서는 이 프로젝션을 FeedQueryPort의 읽기 계약으로 노출하고 부모당 최신 3개를 뽑는 window 쿼리와 묶어 N이 10과 100일 때 다시 쟀다. 엔티티 로드 0과 발행 쿼리 2개는 거기서도 같았다.
배치는 SQL 왕복 횟수를 줄이고 프로젝션은 적재할 엔티티 수를 줄인다. 프로젝션이 엔티티를 만들지 않는 것은 배치 설정 여부와 관계없이 성립하는 동작이다.
## 영향
- 반환 형태가 화면 요구에 묶인다. 화면이 바뀌면 캐리어와 쿼리도 바뀐다.
- 여러 컬렉션을 담는 응답은 생성자 표현식 한 번으로 만들 수 없다. 부모와 자식을 따로 조회해 조립해야 한다.
- 프로젝션의 이득은 실행계획에서 확인되지 않는다. 필요한 컬럼만 골라도 계획의 행폭 추정치는 오히려 넓어질 수 있다. 엔티티 로드 수 확인해야 한다.
- 자식 조회의 행수는 프로젝션이 줄이지 않는다. 부모당 상한이 필요하면 별도 SQL 형태로 풀어야 한다.
- 읽기 경로와 쓰기 경로의 모델이 갈린다. 같은 저장소를 쓰더라도 조회 전용 계약이 하나 늘어난다.
- 반환 형태가 화면 요구에 묶이므로, 화면이 바뀌면 캐리어 record와 그것을 채우는 두 쿼리도 함께 바뀐다.
- FeedSummary의 마지막 인자가 HighlightSummary 목록이라 생성자 표현식 한 번으로 만들 수 없다. 목록을 담는 응답은 부모와 자식을 각각 스칼라로 조회한 뒤 feedItemId로 묶어 메모리에서 조립해야 한다.
- 프로젝션의 이득은 실행계획에 나타나지 않는다. 필요한 컬럼만 골랐는데도 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸다. users와 pages 조인의 행폭이 반영되고, PostgreSQL의 width는 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다. 효과는 Statistics.getEntityLoadCount()가 센 엔티티 로드 수에서 확인한다.
- 자식 IN 쿼리는 페이지 부모의 하이라이트를 전부 가져온다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였고, 화면에 필요한 것은 부모당 최신 3개로 최대 60개였다. 단순한 IN 쿼리의 LIMIT은 부모별로 적용되지 않으므로, 부모당 상한은 윈도우 함수나 LATERAL 같은 별도 SQL로 풀어야 한다.
- 같은 저장소를 쓰더라도 읽기 경로에 조회 전용 포트와 캐리어 record가 따로 생긴다. 쓰기 애그리거트인 FeedItem과 분리된 읽기 모델을 하나 더 유지해야 한다.
@@ -4,84 +4,86 @@ kind: QUESTION
slug: cardinality-estimate-after-analyze
title: ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#6-4
- final/document.md#4-6
---
# ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
반복되는 하이라이트 조회의 실행계획에서 추정 행수 1이고 실제 행수는 500이었다. 대량 데이터를 넣은 직후 통계를 갱신하지 않아 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
플래너가 계획을 고르기 전에 조건에 걸리는 행이 몇 개일지 어림한 값이 추정 행수(cardinality estimate)이고, PostgreSQL은 `ANALYZE`로 컬럼 분포 통계를 모아 이 값을 낸다. 반복되는 하이라이트 조회의 실행계획에서 추정 행수 1이었는데 실제로는 500행이 나왔다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id` 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
추정과 실제의 차이를 기록하는 기준이다.
추정 행수와 실제 행수가 벌어지면 통계를 갱신한 뒤 다시 재고 전후를 비교하라고 적어 두었다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 실행계획이 나온 기록이다.
이 실행계획`EXPLAIN`으로 확인하고 500배 차이를 처음 적었다.
## 사실
- 대량 시드 직후 측정한 계획에서 추정 rows는 1, 실제 rows는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 인덱스로 처리했고 실행시간은 0.173 ms였다.
- 읽은 블록 모두 캐시에서 왔다. 디스크 읽기는 0이었다.
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id 자식 수가 크게 다르다. 상한 500, 하한 1이다.
- fetch join 조인 계획에서도 추정 4,202 실제 1,961의 차이가 있었다.
- 시드 직후 통계 갱신 명령을 실행하지 않았다.
- 대량 시드 직후 계획에서 추정 행수는 1인데 실제 행수는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 ix_highlights_feed_items_created 인덱스로 처리했고(Index Scan) 실행시간은 0.173 ms였다.
- 읽은 블록 14개가 모두 캐시에 있었고 디스크 읽기는 0이었다(shared hit=14, read=0).
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id마다 자식 행이 500개에서 1개까지 벌어진다.
- fetch join 계획의 Hash Join 노드에서도 추정 rows=4202 실제 1,961로 어긋났다.
- 대량 시드 직후 ANALYZE를 실행하지 않았다.
## 가정
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정 실제에 가까워진다.
- 추정 달라지면 플래너가 다른 계획을 고를 수 있다.
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정 행수가 실제에 가까워진다.
- 추정 행수가 달라지면 플래너가 다른 계획을 고를 수 있다.
- 편중이 큰 컬럼은 기본 통계 대상 수로 부족할 수 있다.
## 미지수
- 통계를 갱신한 뒤 추정 행수가 실제에 얼마나 가까워지는가.
- 추정 바뀌면 스캔 방식이 바뀌는가. 인덱스에서 순차 스캔으로, 또는 그 반대로 뒤집히는가.
- 편중이 큰 컬럼에 통계 대상 수를 늘리면 추정이 더 좋아지는가.
- 실행시간 읽은 블록 가 달라지는가.
- 이 차이가 지금까지의 결론는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 후 통계 갱신을 절차에 넣을 것인가.
- ANALYZE highlights를 실행하고 같은 조회를 다시 EXPLAIN하면 추정 행수가 1에서 얼마로 바뀌는가.
- 추정 행수가 바뀌면 Index Scan이 Seq Scan으로, 또는 그 반대로 뒤집히는가.
- highlights.feed_item_id의 통계 대상 수를 늘리면 추정 행수가 실제 500에 더 가까워지는가.
- 실행시간 0.173 ms와 읽은 블록 14개가 달라지는가.
- 통계를 갱신하면 지금까지의 결론 중 무엇이는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 뒤 ANALYZE를 절차에 넣을 것인가.
## 제약
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 비교가 흐려진다.
- 지금까지 기록한 실행계획은 모두 갱신 전 값이다. 갱신 후 값과 섞어 읽지 않도록 표기를 구분해야 한다.
- 실행하기 전에는 수치를 채우지 않는다.
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 읽은 블록 수를 나란히 놓을 수 없다.
- 지금까지 기록한 실행계획은 모두 ANALYZE 전 값이다. 갱신 후 값과 섞지 않도록 ANALYZE 전을 Plan A, 후를 Plan B로 나눠 적는다.
## 선택지
### 1. 통계를 갱신하고 전후를 비교한다
같은 데이터에서 갱신 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
측정이 한 번 더 필요하지만 가설을 닫을 수 있다.
같은 데이터에서 ANALYZE highlights 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
### 2. 통계 대상 수까지 조절해 본다
편중이 큰 컬럼의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
highlights.feed_item_id의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
변수가 하나 더 늘어 비교가 복잡해진다.
변수가 하나 더 늘어 갱신 전후 비교와 통계 대상 수 비교가 섞인다.
### 3. 갱신 전 값만 두고 넘어간다
왕복 수와 전송 행수에 관한 결론은 통계와 무관하다. 추정 차이를 한계로만 적고 진행한다.
플래너가 다른 계획을 고를 가능성을 확인하지 못한 채 남는다.
플래너가 다른 계획을 고르는지는 확인하지 못한다.
## 다음 검증
1. 대량 시드 직후 현재 계획을 다시 기록한다. 갱신 전 값임을 명시한다.
1. 대량 시드 직후 계획을 다시 기록한다. ANALYZE 전 값이므로 Plan A로 적는다.
2. 통계를 갱신한다.
2. ANALYZE highlights를 실행한다.
3. 같은 쿼리를 같은 실행 안에서 다시 EXPLAIN다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
3. 같은 쿼리를 같은 테스트 실행 안에서 EXPLAIN (ANALYZE, BUFFERS)로 다시 잰다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
4. 두 계획을 나란히 두고 무엇이 달라졌는지 적는다.
4. Plan A와 Plan B를 나란히 두고 무엇이 달라졌는지 적는다.
5. 계획이 바뀌었다면 지금까지의 결론 중 영향을 받는 항목이 있는지 확인한다.
6. 운영 절차에 대량 적재 후 통계 갱신을 넣을지 판단한다.
6. 운영 절차에 대량 적재 뒤 ANALYZE를 넣을지 판단한다.
@@ -4,85 +4,88 @@ kind: QUESTION
slug: concurrency-stability
title: 실제 동시 트래픽에서도 이 구조가 안정적인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/6cbe963f-86f8-4df6-be5a-900712970d01/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-5
- final/document.md#18
---
# 실제 동시 트래픽에서도 이 구조가 안정적인가
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 조회 횟수의 증가 형태를 확인한 것이다. 처리량, 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라 조회 구조가 실제 부하를 견디는지 아직 모른다.
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 아이템 수 N을 키울 때 쿼리 수와 읽는 행수가 얼마나 늘어나는지만 확인했다. 여러 요청이 겹칠 때의 처리량 커넥션 풀 안정성은 이 테스트가 재지 않았으므로, 조회 구조가 실제 부하를 견디는지 아직 모른다.
한 요청만 놓고 보면 왕복 수는 페이지 크기에 묶여 있다. 그 요청당 왕복이 트래픽에 곱해진 값은 요청을 동시에 보내야 나오고, 그 측정은 하지 않았다.
## 관계
- **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행 읽었다.
- 문서 첫머리에 고트래픽 처리량, 커넥션 풀 안정성, 동시성이 범위 밖임을 명시했다.
- 측정은 단일 스레드에서 이미 열린 테스트 트랜잭션 안의 어댑터 호출만 쟀고, HTTP 종단 거치지 않았다.
- 지연 값은 DB 캐시가 이미 채워진 상태에서 잰 로컬 비교값이고, 7회 반복 중 앞 2회를 워밍업으로 버린 5개가 표본이다.
- 표본이 5개뿐이어서 p50·p99 대신 중앙값과 최댓값으로 적었다.
- N=1,000에서 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, 자식 전송은 1,509행에서 최대 60행으로 줄었다.
- 100번째 페이지에서 OFFSET은 20행을 만들려고 2,000행을 읽었고 keyset은 20행 읽었다.
- 고트래픽 처리량 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라고 문서 첫머리에 적어 두었다.
- 커넥션 풀과 Little's Law는 이번 조회 문제를 해결한 뒤 별도 주제로 검증할 수 있다고 다음 단계에 함께 적어 두었다.
## 가정
- 왕복 수가 줄면 같은 트래픽에서 DB 부하도 줄어든다.
- 요청 왕복이 처리량에 곱해지므로 왕복 감소는 처리량 한계다.
- 단일 스레드에서 확인한 조회 형태는 동시 실행에서도 유지된다.
- 요청마다 반복되는 왕복이 처리량에 곱해지므로, 왕복이 줄면 처리량 한계라간다.
- 단일 스레드에서 잰 요청당 쿼리 수와 읽는 행수는 여러 요청이 동시에 돌아도 달라지지 않는다.
## 미지수
- 목표 처리량에서 커넥션 풀이 포화되는가. 풀 크기와 대기 시간은 어떻게 되는가.
- 동시 실행에서 지연 분포가 어떻게 되는가. 꼬리 지연 어디까지 늘어나는가.
- 배치 크기 설정이 동시 실행에서 어떻게 작동하는가. 세션마다 독립인가.
- 깊은 페이지 요청이 섞이면 전체 지연에 어떤 영향을 주는가.
- 캐시가 차갑거나 통계가 갱신되지 않은 상태에서 계획이 달라지는가.
- 어느 지표를 운영 알람 기준으로 삼을 것인가.
- 목표 처리량에서 커넥션 풀이 포화되는가. 포화되지 않으려면 풀을 몇 개로 잡아야 하고 요청은 얼마나 기다리는가.
- 요청이 동시에 몰릴 때 지연이 어느 구간에 몰리는가. 꼬리 지연 어디까지 벌어지는가.
- hibernate.default_batch_fetch_size=100은 세션 전체에 걸리는 설정인데, 여러 세션이 동시에 도는 동안에도 세션마다 따로 적용되는가.
- 깊은 페이지 요청이 섞이면 전체 지연이 얼마나 밀리는가.
- DB 캐시가 비어 있거나 ANALYZE를 돌리지 않아 통계가 낡은 상태에서 실행계획이 달라지는가.
- 운영 알람 기준으로 어느 지표를 삼을 것인가.
## 제약
- 단일 스레드 값으로는 동시성 질문에 답할 수 없다. 도구를 바꿔야 한다.
- 안정적인 꼬리 지연을 말하려면 워밍업 후 반복 횟수를 크게 늘린 독립 세트가 여러 개 필요하다.
- 부하 테스트 환경이 운영과 다르면 결과를 그대로 옮길 수 없다. 데이터 규모와 하드웨어를 맞춰야 한다.
- 로컬에서 확인한 것을 운영에서 확인한 것으로 승격하지 않는다.
- 단일 스레드에서 잰 값으로는 동시 실행을 말할 수 없어서, 부하 테스트와 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링)으로 도구를 바꿔야 한다.
- 안정적인 꼬리 지연(p99)을 말하려면 워밍업 뒤 100회 넘게 반복한 독립 세트가 여러 개 있어야 한다.
- 부하 테스트 환경의 데이터 규모와 하드웨어를 운영에 맞추지 않으면 거기서 나온 결과를 운영으로 그대로 옮길 수 없다.
## 선택지
### 1. 부하 테스트와 APM으로 확인한다
목표 처리량을 정하고 종단 지연, 처리량, 커넥션 풀 지표를 함께 잰다. 조회 구조의 개선이 부하에서도 나타나는지 본다.
환경 구성과 데이터 준비에 시간이 든다.
목표 처리량을 먼저 정하고 HTTP 종단 지연 처리량, 커넥션 풀 사용률을 함께 잰다. 왕복이 줄어든 만큼 처리량이 올라가는지 부하에서 확인한다.
### 2. 반복 횟수를 늘린 지연 측정부터 한다
같은 단일 스레드 조건에서 표본을 크게 늘려 꼬리 지연을 먼저 안정화한다. 동시성은 아직 다루지 않는다.
동시성 질문에는 여전히 답하지 못한다.
같은 단일 스레드 조건에서 반복을 100회 넘게 늘려 꼬리 지연부터 안정시킨다. 동시성은 아직 다루지 않는다.
### 3. 현재 범위를 명시하고 운영 판단은 미룬다
조회 형태 개선까지만 주장하고 처리량은 다루지 않는다. 필요해때 부하 테스트를 연다.
쿼리 수와 읽는 행수가 줄었다는 데까지만 주장하고 처리량은 다루지 않는다. 필요해지면 그때 부하 테스트를 연다.
운영에 올린 뒤 문제를 발견할 위험이 남는다.
운영에 올린 뒤에야 문제를 발견할 수 있다.
## 다음 검증
1. 목표 처리량과 허용 지연을 먼저 정한다. 기준이 없으면 결과를 판정할 수 없다.
1. 목표 처리량과 허용 지연을 먼저 정한다. 이 두 값이 없으면 부하 결과가 통과인지 실패인지 판정할 수 없다.
2. 운영과 비슷한 데이터 규모를 준비한다. 편중 분포를 유지한다.
2. 운영과 비슷한 데이터 규모를 준비하되 하이라이트 개수의 Zipf 형태 편중은 그대로 둔다.
3. 부하 테스트로 종단 지연과 처리량을 측정한다. 커넥션 풀 사용률과 대기 시간을 함께 본다.
3. 부하 테스트로 HTTP 종단 지연과 처리량을 측정하고, 커넥션 풀 사용률과 대기 시간을 함께 본다.
4. 개선 전후 구조를 같은 조건에서 비교한다. 왕복 감소가 처리량으로 이어지는지 확인한다.
4. 개선 전후 구조를 같은 조건에서 비교해 왕복이 줄어든 것이 처리량으로 이어지는지 확인한다.
5. 깊은 페이지 얕은 페이지 요청을 섞어 지연 분포를 본다.
5. 깊은 페이지 요청과 얕은 페이지 요청을 섞어 지연이 어디까지 벌어지는지 본다.
6. 운영 알람으로 쓸 지표를 정한다.
@@ -4,81 +4,86 @@ kind: QUESTION
slug: isolate-round-trip-and-row-volume
title: Round Trip과 Row Volume을 독립 측정할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5159c415-232d-424a-970a-b0db52746767/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-5
- final/document.md#6-2
---
# Round Trip과 Row Volume을 독립 측정할 것인가
현재 데이터셋은 N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, DB 왕복이 동시에 늘어난다. 지연이 늘어난 원인을 어느 하나에 돌릴 수 없다. 변수를 하나씩 격리한 데이터셋을 만들지 정하지 않았다.
현재 데이터셋은 N을 키우면 반환 FeedItem 수, Highlight 총 행수, 엔티티 생성량, 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개 표본의 중앙값과 최댓값으로 기록했다.
- 하이라이트 총량은 N에 정비례하지 않는다. N=10에서 이미 1,285개이고 그중 500개가 첫 아이템 하나에서 나오며, N을 100배인 1,000으로 키워도 2,917개에 그친다.
- 컬렉션 초기화 수(collectionFetches)는 하이라이트 총량이 아니라 FeedItem 수 N에 정비례한다.
- 왕복은 FeedItem 수에 비례해 늘고, 전송 행수 엔티티 생성은 한 FeedItem이 가진 Highlight 수에 비례해 늘어난다.
- 지연은 단일 스레드에서 7회 반복하고 앞 2회를 워밍업으로 버린 뒤 남은 5개 표본의 중앙값과 최댓값으로 기록했다.
- 격리 데이터셋 세 종류를 계획했지만 아직 실행하지 않았다.
## 가정
- 왕복 수와 전송 행수는 지연 서로 다른 방식으로 기여한다.
- 부모 수만 바꾸고 자식 수를 고정하면 왕복의 기여를 분리할 수 있다.
- 부모 수를 고정하고 자식 수만 바꾸면 과조회의 기여를 분리할 수 있다.
- 왕복 수와 전송 행수는 지연 서로 다른 방식으로 늘린다.
- FeedItem 수만 바꾸고 FeedItem마다 Highlight를 1개로 고정하면 왕복이 지연에 얼마나 붙는지 따로 잴 수 있다.
- FeedItem 수를 고정하고 Highlight 수만 바꾸면 한 번에 읽어 오는 행수가 지연을 얼마나 늘리는지 따로 볼 수 있다.
## 미지수
- 격리 데이터셋을 추가로 유지할 가치가 있는가. 시더와 테스트가 늘어난다.
- 부모 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- 자식 수만 바꾼 데이터셋에서 지연이 전송 행수에 어떻게 붙는가.
- 두 기여를 분리해도 전략 선택이 달라지는가. 이미 배치와 프로젝션으로 둘 다 줄였다.
- FeedItem 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- Highlight 수만 바꾼 데이터셋에서 지연이 전송 행수에 비례하는가, 아니면 어느 행수부터 더 가파르게 늘어나는가.
- 왕복과 행수를 갈라 재도 전략 선택이 달라지는가. 이미 배치로 왕복을 2,022개에서 23개로, 프로젝션으로 엔티티 로드를 1,569개에서 0개로 줄였다.
- 편중 분포를 유지한 데이터셋과 격리 데이터셋을 모두 유지할 것인가, 격리 데이터셋으로 대체할 것인가.
## 제약
- 현재 지연 값은 단일 스레드·warm cache 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 이 한계는 그대로다.
- 실행하기 전에는 수치를 채우지 않는다. 예상값으로 표를 메우지 않는다.
- 시더가 복잡해지면 기존 측정의 재현성에 영향을 줄 수 있다. 기존 데이터셋은 유지한 채 추가해야 한다.
- 현재 지연 값은 단일 스레드에서 같은 JVM으로, DB 캐시가 데워진 상태에서 잰 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 측정 조건이 같아서 이 값으로는 여전히 N에 따른 증가 방향만 말할 수 있다.
- 시더가 복잡해지면 지금까지 잰 값을 다시 재현하지 못할 수 있다. 기존 데이터셋은 그대로 두고 새 데이터셋을 더한다.
- 편중 분포는 실제 라이너 데이터와 같다고 주장하려고 만든 것이 아니라 일부 페이지에 하이라이트가 매우 많을 수 있다는 조건을 통제된 방식으로 재현하려고 만든 합성 분포다. 하이라이트 개수를 균일하게 두면 한 아이템에 수백 개가 몰려서 생기는 전송량과 메모리 압박도, 아이템마다 최신 3개를 뽑아야 하는 필요도 데이터셋에서 사라진다.
## 선택지
### 1. 세 데이터셋을 모두 만든다
부모 수만 바꾼 것, 자식 수만 바꾼 것, 편중 유지한 것 세 가지를 유지한다. 각 변수의 기여를 따로 수 있다.
FeedItem 수만 바꾼 것, Highlight 수만 바꾼 것, 편중 분포를 유지한 것 세 가지를 함께 둔다. 왕복과 전송 행수가 지연을 각각 얼마나 늘리는지 따로 수 있다.
시더와 테스트가 늘어나고 실행 시간도 길어진다.
### 2. 편중 데이터셋만 유지하고 격리는 하지 않는다
전략 선택이 이미 정해졌다면 원인 분해가 결정지 않는다. 현재 데이터셋으로 회귀만 지킨다.
전략 선택이 이미 정해졌다면 원인을 갈라도 결정지 않는다. 현재 데이터셋으로 회귀만 지킨다.
나중에 지연 원인을 따져야 할 때 다시 만들어야 한다.
### 3. 필요할 때만 한시적으로 만든다
특정 판단이 필요해지는 시점에 격리 데이터셋을 만들 측정한 뒤 남기지 않는다.
왕복과 행수를 갈라 봐야 하는 판단이 생겼을 때 격리 데이터셋을 만들 측정하고 남기지 않는다.
측정 시점마다 시더를 다시 맞춰야 해서 재현성이 떨어진다.
측정할 때마다 시더를 다시 맞춰야 해서 같은 값을 다시 얻기 어렵다.
## 다음 검증
1. 부모 수만 바꾼 데이터셋을 만든다. 부모마다 자식을 정확히 1개씩 둔다.
1. FeedItem을 10 / 100 / 1,000으로 바꾸고 FeedItem마다 Highlight를 정확히 1개씩 두는 데이터셋을 만든다.
2. 부모 수를 고정하고 자식 수만 바꾼 데이터셋을 만든다.
2. FeedItem을 20개로 고정하고 Highlight를 1 / 10 / 100 / 500으로 바꾸는 데이터셋을 만든다.
3. 두 데이터셋에서 왕복 수, 전송 행수, 지연을 각각 측정한다.
4. 지연이 어느 변수에 어떻게 붙는지 확인한다.
4. 왕복 수가 늘 때와 전송 행수가 늘 때 지연이 각각 얼마나 늘어나는지 비교한다.
5. 분해 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할 필요가 있는지 다시 판단한다.
5. 갈라 잰 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할지 다시 판단한다.
@@ -4,72 +4,77 @@ kind: QUESTION
slug: nullable-first-highlighted-at
title: Highlight 없는 FeedItem을 허용할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#3-1
- final/document.md#14-4
---
# Highlight 없는 FeedItem을 허용할 것인가
정렬키 first_highlighted_at nullable이라 하이라이트 없는 FeedItem이 존재할 수 있는 스키마다. 시더는 하이라이트가 만든 FeedItem만 넣어 항상 값이 차지만, 허용 여부를 정하지 않으면 정렬과 커서 비교식의 경계 동작이 정의되지 않는다.
정렬키 first_highlighted_at은 지금 null을 허용한다. 하이라이트가 하나도 없는 FeedItem이 스키마 위에서는 존재할 수 있뜻이다. FeedSeedFixture.seed(N)이 하이라이트가 만든 FeedItem만 넣다 보니 지금까지 쓴 데이터셋에는 이 컬럼이 빈 행이 없었다. 허용할지 말지를 정하기 전에는 정렬에서 그 행을 어디에 둘지도, 커서 비교식에 무엇을 담을지도 정할 수 없다.
## 관계
- **Keyset Pagination 설계 기준**
정렬키 null 처리가 커서 설계에 걸리는 지점이다.
정렬키 null이 섞이면 여기서 세운 커서 설계가 그대로 성립하지 않는다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
같은 정렬키 인덱스를 다루는 기록이다.
거기서 플래너가 쓰지 못한 인덱스가 이 질문의 정렬키 인덱스와 같은 ix_feed_items_keyset이다.
## 사실
- 현재 스키마의 first_highlighted_at은 timestamptz nullable이다. NOT NULL이 아니다.
- 시더는 하이라이트가 만든 FeedItem만 생성하므로 이 을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- first_highlighted_at은 timestamptz이고 null을 허용한다. NOT NULL 제약이 붙어 있지 않다.
- FeedSeedFixture.seed(N)은 하이라이트가 만든 FeedItem만 생성하므로 이 컬럼을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- FeedItem은 (user, page) 조합당 하나이고 UNIQUE(user_id, page_id) 제약이 있다.
- keyset 커서는 (first_highlighted_at, id)를 비교식으로 사용한다.
- 정렬키 전용 인덱스 (first_highlighted_at DESC, id DESC)로 만들었다.
- keyset 커서는 (first_highlighted_at, id)를 비교식에 담는다. 시각이 같은 행도 안정적으로 넘기려고 id까지 함께 넣었다.
- 정렬키 전용 인덱스 ix_feed_items_keyset은 (first_highlighted_at DESC, id DESC)로 만들었다.
## 가정
- 하이라이트가 하나도 없는 FeedItem이 생기는 경로가 실제로 있을 수 있다.
- 하이라이트가 하나도 없는 FeedItem이 실제로 만들어지는 경로가 도메인에 있을 수 있다.
- null이 섞이면 커서 비교식이 경계에서 행을 빠뜨리거나 중복시킬 수 있다.
- 정렬 위치를 정하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
- 정렬 위치를 정하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
## 미지수
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 존재하는가. 존재한다면 어떤 상황인가.
- NOT NULL로 좁힐 것인가, null을 허용하고 정렬 위치를 정의할 것인가.
- null을 허용한다면 정렬에서 어디에 두는가. 그 위치를 인덱스가 지원하는가.
- 커서가 null을 만났을 때 비교식을 어떻게 표현하는가.
- 부분 인덱스 조건을 쓴다면 null 행이 인덱스에서 빠지는데 그 행은 어떻게 조회되는가.
- FeedItem 생성과 첫 하이라이트 시이 다를 수 있는가.
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 는가. 있다면 FeedItem을 만드는 코드 중 어느 것인가.
- 스키마를 NOT NULL로 좁히는가, null을 허용하고 정렬 위치를 정하는가.
- null을 허용한다면 정렬에서 맨 앞과 맨 뒤 중 어디에 두는가. 그 순서를 (first_highlighted_at DESC, id DESC) 인덱스가 그대로 주는가.
- 커서에 담긴 시각이 null인 페이지를 넘길 때 (first_highlighted_at, id) 비교식에 무엇을 넣는가.
- 부분 인덱스 조건을 면 null 행이 인덱스에서 빠지는데, 그 행은 어느 쿼리로 읽는가.
- FeedItem을 만든과 첫 하이라이트가 생긴이 다를 수 있는가.
## 제약
- 결정 시점은 keyset 페이징을 프로덕션에 반영하기 전이다. 커서 비교식과 인덱스 정의가 이 결정에 달려 있다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 없어야 한다. 마이그레이션 전에 확인이 필요하다.
- 현재 시더로는 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 검증할 수 있다.
- 커서 비교식과 정렬키 인덱스 정의가 이 결정에 달려 있어서, keyset 페이징을 프로덕션에 반영하기 전에 정해야 한다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 하나도 없어야 하므로, 마이그레이션을 돌리기 전에 세어 봐야 한다.
- 지금 데이터셋에는 null 행이 없어 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 확인할 수 있다. 시더가 이 컬럼의 값을 시간에 흩어 놓은 것은 시간순 페이징과 정렬 인덱스를 실험할 바탕을 마련하려는 구성이다. 값이 비는 행을 만드는 것은 그 구성에 들어 있지 않다.
## 선택지
### 1. NOT NULL로 좁힌다
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키 NOT NULL로 정의한다. 커서 비교식이 단순해지고 인덱스도 그대로 쓸 수 있다.
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키 NOT NULL을 건다. 커서는 지금처럼 (first_highlighted_at, id) 두 값만 비교하면 되고 인덱스도 정의를 바꾸지 않는다.
대신 하이라이트 없는 FeedItem을 만드는 경로가 나중에 필요해지면 스키마와 생성 흐름을 다시 바꿔야 한다.
### 2. null을 허용하고 정렬 위치를 정의한다
정렬에서 null을 어디에 둘지 명시하고 인덱스도 같은 위치로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
정렬에서 null을 맨 앞에 둘지 맨 뒤에 둘지 쿼리에 적고, 인덱스도 같은 순서로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
생성 흐름은 자유로워지지만 커서 표현과 인덱스 정의가 복잡해진다.
FeedItem을 만드는 흐름은 하이라이트 없이도 되지만, 커서에 무엇을 담을지와 인덱스를 어느 순서로 만들지를 둘 다 따로 정해야 한다.
### 3. 부분 인덱스로 null 행을 제외한다
정렬키가 있는 행만 인덱스에 담는다. 피드 목록에 하이라이트가 있는 항목만 노출한다는 정책이 된다.
정렬키가 채워진 행만 인덱스에 담는다. 피드 목록에 하이라이트가 있는 항목만 내보낸다고 정하는 것과 같다.
인덱스는 작아지지만 null 행을 조회하는 별도 경로가 필요하다.
인덱스는 작아지지만 null 행을 읽는 쿼리를 따로 둬야 한다.
## 다음 검증
@@ -77,8 +82,8 @@ FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키를 NOT
2. 기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다.
3. null이 섞인 데이터셋을 만들어 현재 커서 비교식이 경계에서 어떻게 동작하는지 재현한다.
3. null이 섞인 데이터셋을 만들어 지금 커서 비교식이 경계에서 행을 빠뜨리는지 중복시키는지 재현한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 행·같은 순서인지 대조한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 20행·같은 순서인지 FeedKeysetIT.l15KeysetWalkMatchesOffsetPages와 같은 방식으로 대조한다.
5. 정렬 위치를 정한 경우 인덱스가 그 순서를 그대로 주는지 실행계획로 확인한다.
5. null 정렬 위치를 정한 경우 ix_feed_items_keyset이 그 순서를 그대로 주는지, 실행계획에 Sort가 다시 나오는지로 확인한다.
@@ -4,87 +4,93 @@ kind: QUESTION
slug: promote-feed-visible-to-cqrs
title: feed_visible을 Production CQRS로 승격할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5088ce14-b096-41d3-abba-64b7afb48bb9/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#15-5
- final/document.md#17-3
- final/document.md#18
---
# feed_visible을 Production CQRS로 승격할 것인가
사용자별 가시성을 미리 계산한 테이블은 조회 커버링 인덱스 하나로 만들었다. 상시 유지하려면 원본 변경을 투영에 동기화해야 하고, 이는 별도 물리 읽기 저장소를 두는 결정이 된다.
사용자별 가시성을 미리 펼친 feed_visible 테이블을 두자 피드 조회 커버링 인덱스 하나만 읽는 단일 스캔으로 바뀌었다. 이 테이블을 상시 유지하려면 원본이 바뀔 때마다 투영을 갱신해야 하고, 그러면 쓰기와 분리된 물리 읽기 저장소를 두는 풀 CQRS(Command Query Responsibility Segregation, 명령과 조회를 다른 모델로 나누는 설계)로 넘어간다. 그 비용을 이번 범위에서 바로 받아들일지는 따로 판단해야 할 일로 두었다.
## 관계
- **Feed Visibility Query Pattern**
세 방식을 비교한 기준이다.
단일 OR, UNION 분해, 사전계산 세 방식을 같은 결과 집합으로 비교했다.
- **현재 Read Model은 CQRS-lite로 유지한다**
지금 유지하기로 한 범위다.
이번 범위에서는 저장소를 나누지 않고 읽기 모델만 분리하기로 했다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
사전계산이 풀려던 문제다.
keyset으로 푼 페이지 조회에 가시성 세 분기를 얹자 BitmapOr와 재정렬이 다시 나타났고, 사전계산은 이것을 없애려던 방식이다.
## 사실
- 사전계산 조회는 커버링 인덱스의 단일 스캔이었다. OR도 조인도 정렬도 없었다.
- 단일 OR은 후보 1,500을 훑 상위 20을 정렬로 만들었다. UNION 분해는 분기별로 스캔했다.
- 세 방식은 같은 조회 사용자에게 같은 항목 집합을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 깊은 페이지에서 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 었다.
- 현재 구현은 사전계산 테이블을 테스트 안에서 만들고 지운다. 상시 유지하지 않는다.
- 현재 읽기 경로는 쓰기와 같은 저장소 위에 읽기 전용 포트·DTO·쿼리만 분리한 형태다.
- 사전계산 조회는 feed_visible 커버링 인덱스 하나만 읽었다. OR도 조인도 정렬도 없이 20행에 접근했고 buffers는 1이었다.
- 단일 OR은 세 분기를 BitmapOr로 합쳐 후보 1,500을 훑은 뒤 정렬로 상위 20을 냈고 buffers는 122였다. UNION 분해는 요청할 때마다 세 분기를 각각 스캔했고 buffers는 200으로 단일 OR보다 컸다.
- 세 방식은 seed 2,000에서 뷰어 user008에게 같은 20개 항목을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 가장 깊은 페이지에서 ix_feed_visible 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 었다.
- feed_visible은 FeedVisibilityIT와 FeedCrownIT 안에서 만들고 지우는 실험용 테이블이라 상시 유지하지 않는다.
- 지금 읽기 경로는 쓰기와 같은 저장소를 쓰면서 읽기 전용 포트·DTO·쿼리만 분리한 CQRS-lite다.
## 가정
- 고트래픽 읽기에서는 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 원본 변경마다 투영 갱신이 필요하다.
- 투영이 어긋나면 사용자가 볼 수 없는 항목을 보거나 볼 수 있는 항목을 놓친다.
- 고트래픽 읽기에서는 세 방식의 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 피드·멘션·가시성이 바뀔 때마다 투영을 다시 써야 한다.
- 투영이 어긋나면 사용자가 볼 수 없어야 할 항목이 화면에 나오거나 볼 수 있는 항목이 빠진다.
## 미지수
- 현재 트래픽에서 단일 OR이나 UNION 분해로 충분한가. 사전계산이 필요한 임계가 어디인가.
- 동기화를 어떤 방식으로 하는가. 도메인 이벤트인가 아웃박스인가.
- 투영 갱신이 늦어졌을 때 허용 가능한 지연은 얼마인가.
- 지금 트래픽 단일 OR이나 UNION 분해로 감당할 수 있는가. 사전계산이 필요해지는 읽기 요청량이 어디부터인가.
- 쓰기 변경을 투영으로 어떻게 옮기는가. 도메인 이벤트로 보내는가, 아웃박스 테이블을 거치는가.
- 투영 갱신이 늦어졌을 때 지연을 어디까지 허용하는가.
- 가시성이 바뀌는 사건이 무엇인가. 아이템 공개 범위 변경, 멘션 추가·삭제, 사용자 삭제까지 포함하는가.
- 사용자 수만큼 늘어나는 저장 공간이 감당 가능한가.
- 투영이 어긋났을 때 어떻게 발견하고 복구하는가.
- 이 변경이 현재 정한 계약의 범위를 넘는가. 넘는다면 계약과 가드레일을 어떻게 개정하는가.
- 저장 공간은 사용자 수만큼 늘어나는데 그 크기를 감당할 수 있는가.
- 투영이 원본과 어긋났을 때 무엇으로 발견하고 어떻게 되돌리는가.
- 승격이 현재 계약의 범위를 넘는다는 것은 확인했고, 계약과 가드레일의 어느 조항을 어떻게 고칠지는 아직 정하지 않았다.
## 제약
- 현재 계약에서 별도 물리 읽기 저장소 에스컬레이션 대상으로 남겨 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이다. 투영이 어긋나면 노출 사고가 된다. 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지의 측정은 단일 스레드 로컬 값이다. 고트래픽에서 어느 방식이 필요한지는 이 측정으로 답할 수 없다.
- 현재 계약 별도 물리 읽기 저장소 에스컬레이션 대상으로 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이 투영이 어긋나면 볼 수 없어야 할 항목이 노출된다. 그래서 동기화 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지 잰 값은 모두 단일 스레드 로컬 측정이라, 고트래픽에서 어느 방식이 필요한지는 이 으로 답할 수 없다.
## 선택지
### 1. 현재 범위를 유지하고 요청 시 조회로 푼다
단일 OR이나 UNION 분해로 조회한다. 동기화 비용이 없고 정합성 문제도 없다.
단일 OR이나 UNION 분해로 요청할 때마다 조회한다. 투영을 두지 않으니 동기화 비용도, 원본과 어긋날 위험도 없다.
고트래픽에서 조회 비용이 그대로 남는다.
요청마다 가시성 세 분기를 다시 풀기 때문에 조회 비용은 줄지 않는다. 로컬에서 잰 buffers는 단일 OR 122, UNION 분해 200이었고 고트래픽에서 이 값이 어떤 부하가 되는지는 재지 않았다.
### 2. 사전계산을 상시 유지하는 읽기 저장소로 승격한다
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 조회 비용이 가장 낮다.
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 세 방식 가운데 이것만 buffers 1로 20행을 읽었다.
동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다. 계약 개정도 필요하다.
대신 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 하고 계약도 함께 개정해야 한다.
### 3. 일부만 사전계산한다
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청 조회한다.
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청할 때 조회한다.
두 경로를 함께 운영해야 하고 어느 구간을 투영에 둘지 정하는 기준이 필요하다.
조회 경로를 함께 운영해야 하고, 어느 구간을 투영에 둘지 무엇을 보고 가를지 먼저 정해야 한다.
## 다음 검증
1. 부하 테스트로 현재 조회 방식이 목표 트래픽을 견디는지 확인한다. 이 판단은 단일 스레드 측정으로 대신할 수 없다.
1. 부하 테스트로 지금의 조회 방식이 목표 트래픽을 견디는지 확인한다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행에 영향을 주는지 정리한다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행을 건드리는지 정리한다.
3. 사용자 수와 아이템 수를 곱 투영 크기를 계산한다.
3. 사용자 수와 아이템 수를 곱 투영이 몇 행까지 커지는지 계산한다.
4. 동기화 지연 허용치를 정한다. 가시성 보안 조건이므로 이 값이 설계를 좌우한다.
4. 동기화 지연을 어디까지 허용할지 정한다. 가시성 보안 조건이 이 값을 정해야 동기화 방식을 고를 수 있다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 복구 절차를 정의한다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 되돌리는 절차를 정의한다.
6. 위 결과를 보고 계약을 개정할지 판단한다.
6. 다섯 가지 결과를 보고 계약을 개정할지 판단한다.
@@ -4,70 +4,75 @@ kind: REFERENCE
slug: feed-visibility-query-pattern
title: Feed Visibility Query Pattern
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/635fcedd-d402-4297-bcf3-9fcdf4200d28/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#15-1
- final/document.md#15-4
---
# Feed Visibility Query Pattern
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기를 만든다. 하나의 OR로 묶는 방식, 분기를 UNION으로 나누는 방식, 사용자별로 미리 계산하는 방식 같은 결과를 다른 비용으로 만든다.
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기로 조건이 나뉜다. 세 분기를 하나의 OR로 묶는 방식, UNION으로 나누는 방식, 사용자별로 미리 펼쳐 두는 방식 같은 항목 집합을 돌려주지만 실행계획과 읽는 블록 수가 다르다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
단일 OR이 정렬 인덱스를 못 쓰는 것을 확인한 기록이다.
keyset 쿼리에 이 세 분기를 얹자 정렬 인덱스가 계획에서 빠지는 것을 확인한 기록이다.
- **feed_visible을 Production CQRS로 승격할 것인가**
사전계산 방식이 남긴 판단이다.
사전계산 테이블을 실험 밖에서 상시 유지할지는 이 질문이 다룬다.
- **Keyset Pagination 설계 기준**
이 조건과 함께 서야 하는 페이징 기준이다.
가시성 조건을 얹기 전에 커서와 정렬키 인덱스를 어떻게 맞출지 정한 기준이다.
## 목적
세 분기는 조건의 성격이 다르다. 값 비교, 다른 테이블과의 관계 확인, 소유자 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 한 접근 경로로 만족시킬 수 없다.
세 분기가 보는 것이 서로 다르다. 공개 분기는 visibility 값만 비교하고, 멘션 분기는 feed_item_mentions를 뒤져 조회자가 멘션됐는지 확인하며, 비공개 분기는 항목의 user_id가 조회자인지 본다. 하나의 인덱스는 선두 컬럼 순서 하나만 주기 때문에 셋을 한 접근 경로로 만족시킬 수 없다.
정렬과 페이징을 함께 요구하면 이 차이가 실행계획에서 드러난다.
여기에 정렬과 페이징까지 요구하면 세 분기를 어떻게 합치느냐에 따라 Sort 노드가 붙기도 하고 사라지기도 한다.
## 규칙
### 1. 단일 OR은 정렬 순서를 잃는다
### 1. 단일 OR로 묶으면 Sort 노드가 다시 붙는다
세 분기를 하나의 조건으로 묶으면 플래너가 분기별로 스캔한 뒤 bitmap으로 합친다. 이 과정에서 인덱스가 주던 순서가 사라 상위 행을 만들기 위한 정렬이 다시 필요해진다.
세 분기를 하나의 조건으로 묶으면 플래너가 분기마다 인덱스를 스캔해 BitmapOr로 합치고 Bitmap Heap Scan으로 테이블을 훑는다. 이 과정에서 인덱스가 주던 순서가 사라지기 때문에 상위 20행을 만드는 top-N Sort가 다시 붙는다.
관계 확인 조건은 hashed SubPlan으로 처리될 수 있다.
멘션 여부를 EXISTS로 확인하는 분기는 이 계획에서 hashed SubPlan으로 처리다.
### 2. UNION 분해는 분기마다 자기 인덱스를 태운
### 2. UNION으로 나누면 전체 재정렬이 사라진
세 분기를 각각 정렬이 보장되는 쿼리로 만들고 병합하면 전체 재정렬이 사라진다. 관계 확인 조건도 조인으로 바뀐다.
세 분기를 각각 정렬이 보장되는 쿼리로 만들어 Merge Append로 병합하면 전체를 다시 정렬하지 않는다. 분기마다 자기 조건에 맞는 인덱스를 쓰고 멘션 조건도 SubPlan 대신 Hash Join으로 바뀐다.
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기 수만큼 접근이 늘어 buffers가 단일 OR보다 수 있다.
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기가 셋이면 스캔도 세 번 나가므로 읽는 블록이 단일 OR보다 많아질 수 있다.
### 3. 사전계산은 조회를 단일 인덱스 스캔으로 바꾼
### 3. 미리 펼쳐 두면 조회가 커버링 인덱스 하나로 끝난
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 읽는다. OR도 조인도 정렬도 없다.
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 Index Only Scan으로 읽는다. OR도 조인도 정렬도 없다.
대신 원본이 바뀔 때 이 투영을 갱신해야 하고 사용자 수만큼 저장 공간이 늘어난다.
대신 원본이 바뀔 때마다 이 투영을 갱신해야 하고 저장 공간은 조회 사용자 수만큼 늘어난다.
### 4. 세 방식이 같은 결과를 내는지 먼저 확인한다
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다.
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다. 피드에서는 FeedVisibilityIT의 l16ThreeApproachesReturnSameVisibleSet이 seed 2,000에서 조회 사용자 user008에게 세 방식이 같은 20개 항목을 돌려주는지 먼저 맞춰 봤다.
### 5. 분기 선택도에 맞는 인덱스를 따
### 5. 분기마다 선택도가 다르니 인덱스도 분기별
선택도가 낮은 분기는 전용 인덱스나 부분 인덱스가 유리하다. 관계 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다.
비공개 분기는 조회자 자신의 항목만 남기므로 부분 인덱스가 맞고, 공개 분기는 선택도가 높아 UNION 안에서도 비트맵으로 훑은 뒤 top-N Sort로 상위를 냈다.
부모를 찾는 인덱스와 조회자를 찾는 인덱스는 컬럼 순서가 다르다.
멘션 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다. V7이 만든 feed_item_mentions 인덱스는 선두 컬럼이 feed_item_id라 항목에 달린 멘션을 찾는 데는 맞지만 조회자를 멘션한 항목을 찾는 쿼리에는 맞지 않아, (mentioned_user_id, feed_item_id) 순서인 ix_mentions_user를 따로 만들어 비교했다.
### 6. buffers만으로 우열을 정하지 않는다
### 6. buffers 하나만 보고 방식을 고르지 않는다
UNION기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬을 없앤다. 무엇을 줄이려는지에 따라 선택이 달라진다.
단일 OR의 buffers는 122였는데 UNION 분해는 200으로 오히려 컸다. 분기를 나눈 만큼 스캔이 늘었기 때문이다. 훑는 후보로 보면 단일 OR의 1,500이 UNION 분해에서 60 이하로 줄었고, 사전계산은 20행만 읽으면서 buffers도 1이었다.
훑는 후보 수, 정렬 유무, buffers를 함께 본다.
buffers 하나로 줄 세우면 UNION 분해가 뒤로 밀리지만 훑은 행수와 재정렬 유무로는 앞선다. 무엇을 줄이려는지 정한 뒤 훑는 후보 수, 정렬 유무, buffers를 함께 본다. 피드에서는 셋을 그렇게 견주고 보통은 UNION 분해를, 고트래픽 읽기가 극단이면 사전계산을 고르는 것으로 정리했다.
### 7. 사전계산을 상시 유지하면 읽기 모델이 된
### 7. 사전계산을 상시 유지하면 읽기 모델 설계로 넘어간
미리 계산한 테이블을 계속 유지하려면 원본 변경을 투영에 반영해야 한다. 이 시점에 조회 최적화가 아니라 읽기 모델 설계 문제가 된다.
미리 계산한 테이블을 실험 밖에서 계속 유지하려면 피드·멘션·가시성이 바뀔 때마다 투영에 반영해야 한다.
## 적용 조건
@@ -83,8 +88,9 @@ UNION은 분기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬
## 예시
- 단일 OR : 분기별 스캔을 bitmap로 합침, 정렬 재수행, 관계 조건은 hashed SubPlan
- UNION 분해 : 분기별 정렬 스트림을 병합, 전체 재정렬 없음, 관계 조건은 조인
- 사전계산 : 커버링 인덱스 하나, OR·조인·정렬 없음
- 단일 OR : 분기별 스캔을 BitmapOr로 합침, top-N Sort 재등장, 멘션 조건은 hashed SubPlan, buffers 122
- UNION 분해 : 분기별 정렬 스트림을 Merge Append로 병합, 전체 재정렬 없음, 멘션 조건은 Hash Join, buffers 200
- 사전계산 : 커버링 인덱스 하나를 Index Only Scan, OR·조인·정렬 없음, buffers 1
- 훑는 후보 : 단일 OR 1,500, UNION 분해 60 이하, 사전계산 20
- 갱신 비용 : 사전계산만 있음
- 저장 공간 : 사전계산은 조회 사용자 수에 비례
- 저장 공간 : 사전계산은 조회 사용자 수만큼 늘어남
@@ -4,93 +4,97 @@ kind: REFERENCE
slug: fetch-strategy-selection
title: Fetch Join · Batch · Projection 선택 기준
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#9-4
- final/document.md#11-5
- final/document.md#12-5
---
# Fetch Join · Batch · Projection 선택 기준
세 전략은 서로 다른 비용을 줄인다. fetch join은 왕복을 접지만 행을 곱하고, batch는 왕복을 묶지만 엔티티를 그대로 만들고, 프로젝션은 적재를 없애지만 행수를 줄이지 않는다.
세 전략은 줄이는 것이 서로 다르다. fetch join은 쿼리 수를 1+N에서 1로 줄이지만 전송 행을 자식 수만큼 부풀리고, 배치는 왕복을 배치 크기 단위로 묶지만 엔티티는 통째로 하이드레이트하고, 프로젝션은 엔티티를 하나도 만들지 않지만 부모당 자식 행은 전부 가져온다.
## 관계
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
fetch join의 한계를 확인한 기록이다.
컬렉션 둘을 동시에 fetch join하면 거부되고, 하나만 걸면 행이 늘어난다.
- **Collection Fetch Join Pagination의 In-memory Paging**
fetch join 페이징이 함께 서지 못하는 것을 확인한 기록이다.
컬렉션 fetch join 페이징을 걸자 DB LIMIT이 빠지고 부모가 전부 메모리에 올라왔다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
프로젝션이 남기는 비용을 확인한 기록이다.
프로젝션으로 엔티티 로드는 0이 되었지만 자식 행 1,509개는 줄지 않았다.
## 목적
쿼리 수만 보고 전략을 고르면 비용이 다른 축으로 옮겨 간 것을 놓친다. 컬렉션 fetch join은 쿼리 수를 크게 줄이면서 전송 행수와 메모리를 키운다.
컬렉션 fetch join은 쿼리 수를 1+N에서 1로 줄이지만, 부모 100행짜리 조회가 조인 뒤 1,961행을 실어 날랐다. 배치는 왕복을 2,022개에서 23개로 줄였지만 엔티티 1,569개를 하이드레이트했다.
무엇을 줄이려는지 먼저 정하고 그는 지표로 전후를 비교한다.
무엇을 줄이려는지 먼저 정하고 그는 지표로 전후를 비교한다. 왕복은 PreparedStatement 수로, 메모리에 올린 부모 수는 feedItemLoaded로, 영속 엔티티 수는 Statistics.getEntityLoadCount()로 잰다.
## 규칙
### 1. 컬렉션 fetch join은 두 개 이상 쓰지 않는다
순서 컬럼이 없는 List 두 개를 동시에 fetch join하면 곱집합을 원래 컬렉션으로 되돌릴 수 없어 쿼리 생성 시점에 거부된다. 데이터가 0건이어도 발생하는 매핑 단계 거부다.
순서 컬럼(@OrderColumn)이 없는 List를 bag이라고 한다. bag 두 개를 동시에 fetch join하면 부모 한 행이 두 자식 개수의 곱만큼 늘어나는데, Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 createQuery 시점에 MultipleBagFetchException을 던진다. 데이터가 0건이어도 매핑 단계에서 거부다.
### 2. 컬렉션 fetch join은 행을 곱한다
컬렉션 하나만 fetch join해도 부모 한 행이 자식 수만큼 반복된다. 전송 행수는 자식 총합이 된다.
컬렉션 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복된다. seed(100)에서 부모 feed_items는 100행인데 조인이 실어 나른 행은 자식 총합인 1,961행이었다.
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 이 증가가 보이지 않는다. 조인 카디널리티나 실행계획의 actual rows로 확인한다.
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 100이라 이 증가가 보이지 않는다. 실행계획의 actual rows로 확인한다.
### 3. 컬렉션 fetch join과 페이징을 같이 쓰지 않는다
부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다. Hibernate는 이를 피하려고 SQL에서 LIMIT을 빼고 전체를 읽은 뒤 메모리에서 자른다.
조인 행에 부모 기준 LIMIT을 그대로 걸면 일부 부모의 자식이 잘려 나가므로, Hibernate는 SQL에서 LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 내고 경고를 남긴다.
응답은 한 페이지지만 로드한 부모는 전체다. 발행 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
setMaxResults(20)을 걸어도 반환 목록만 20건이었고 메모리에 올린 부모는 N건 전부였다. 발행 SQL에 Limit 노드가 없다.
### 4. fetch join은 ToOne에 쓴다
ToOne 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join이 적합하다.
ToOne 연관은 부모 한 행에 자식이 하나라 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join은 여기에 쓴다.
### 5. 컬렉션에는 batch fetch를 쓴다
### 5. 컬렉션에는 배치 페치를 쓴다
엔티티만 페이징 DB LIMIT이 정상 작동하게 한 뒤, 지연 연관은 부모 키를 모아 IN으로 채운다. 배치 크기가 B면 왕복은 부모 수를 B로 나눈 올림값이 된다.
fetch join을 빼고 엔티티만 페이징하면 DB LIMIT이 다시 적용되고, 지연 연관은 부모 키를 모아 IN으로 채운다. hibernate.default_batch_fetch_size를 B로 두면 초기화되지 않은 프록시를 최대 B개씩 모아 ceil(N/B)번에 로드한다. N=1,000에서 PreparedStatement는 2,022개에서 23개로 줄었고 메모리에 올린 부모는 페이지 크기인 20에 머물렀다.
배치는 부모와 자식을 곱하지 않는다. 실행계획에서 semi-join으로 나타다.
배치는 부모와 자식을 곱하지 않는다. 실행계획에는 Hash Semi Join으로 나타났고 자식 행 1,509개만 돌아왔다.
### 6. 화면 조회에는 프로젝션을 쓴다
필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않는다. 1차 캐시, 더티체킹, 지연 프록시도 생기지 않는다.
SELECT new Carrier(...)로 필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않으므로 1차 캐시, 더티체킹, 지연 프록시도 생기지 않는다. 배치까지 적용하고도 1,569개였던 엔티티 로드가 프로젝션에서는 0개가 되었다.
join이 있어도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 이 동작은 배치 설정 여부와 관계없이 성립다.
조인이 있어도 컬럼을 읽 경로일 뿐 엔티티를 만들지 않는다. 배치 설정 여부와 관계없이 성립하는 동작이다.
### 7. 프로젝션의 효과는 실행계획이 아니라 ORM 층에서 확인한다
필요한 컬럼만 골라도 EXPLAIN의 width 줄지 않을 수 있다. 조인 대상의 행폭이 반영되고 width가 실제 전송 바이트가 아니라 타입의 평균폭 추정치이기 때문이다.
필요한 컬럼만 골라도 EXPLAIN의 width 줄지 않을 수 있다. 이 랩에서는 부모 프로젝션의 width가 2088로 엔티티 조회의 1194보다 오히려 컸는데, users와 pages 조인의 행폭이 반영되고 PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
프로젝션의 이득은 엔티티 로드 수로 확인한다.
프로젝션의 이득은 Statistics.getEntityLoadCount()로 확인한다.
### 8. 세 전략이 남기는 비용을 적는다
### 8. 전략을 바꾼 뒤 무엇이 줄지 않았는지 적는다
fetch join은 행 폭증과 페이징 불가를 남긴다. batch는 엔티티 과적재를 남긴다. 프로젝션은 부모당 자식 전량 조회를 남긴다.
남은 비용을 적어야 다음 단계가 무엇을 풀어야 하는지 이어진다.
fetch join은 쿼리 수를 1로 줄이는 대신 행을 곱했고 페이징을 막았다. 배치는 왕복과 페이징을 풀었지만 FeedItem·User·Page·Highlight를 합해 1,569개를 하이드레이트했다. 프로젝션은 그 1,569개를 0개로 만들었지만 페이지 부모 20개의 자식 1,509행을 전부 가져왔다.
## 적용 조건
- 연관을 포함한 목록 조회를 설계할 때
- N+1을 확인하고 fetch 전략을 고를 때
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 았는지 정리할 때
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 줄지 않았는지 정리할 때
## 예외
- 컬렉션이 하나이고 페이징이 없으며 자식 수가 작다면 컬렉션 fetch join이 단순하다. 자식 수가 커질 수 있는 구조에는 쓰지 않는다.
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 측정을 유지하려면 별도 설정 범위로 격리한다.
- hibernate.default_batch_fetch_size는 세션 전체에 영향을 준다. 앞서 잰 값을 다시 재려면 이 랩처럼 별도 테스트 클래스(FeedBatchFetchIT)에만 설정한다.
## 예시
- 컬렉션 두 개 fetch join : 쿼리 생성 시점 거부
- 컬렉션 한 개 fetch join : 전송 행수 = 자식 총합
- 컬렉션 fetch join + 페이징 : DB LIMIT 없음, 부모 전체 로드
- ToOne fetch join : 행 곱하지 않음, 적합
- batch fetch : 왕복 = 부모 수 / 배치 크기 올림
- 프로젝션 : 엔티티 로드 0, 쿼리 상수, 자식 행수는 그대로
- 컬렉션 두 개 fetch join : createQuery 시점에 MultipleBagFetchException
- 컬렉션 한 개 fetch join : 부모 100행, 조인 행 1,961행
- 컬렉션 fetch join + 페이징 : Limit 노드 없음, 반환 20건에 부모 로드 N건
- ToOne fetch join : 행 곱하지 않음
- 배치 페치 : 왕복 ceil(N/B), N=1,000에서 2,022개 → 23개
- 프로젝션 : 엔티티 로드 1,569개 → 0개, 쿼리 2개, 자식 행 1,509개
@@ -4,20 +4,25 @@ kind: REFERENCE
slug: fetch-type-vs-fetch-strategy
title: Fetch Type과 Fetch Strategy 구분
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#5-4
- final/document.md#7-5
---
# Fetch Type과 Fetch Strategy 구분
EAGER와 LAZY 연관 언제 로딩돼야 하는지를 정하는 계약이다. 어떤 SQL로 가져올지는 정하지 않는다. N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 전략으로 푼다.
즉시 로딩(EAGER)과 지연 로딩(LAZY)은 연관 언제 로딩할지 정하는 계약이지, 어떤 SQL로 가져올지는 정하지 않는다. 그래서 N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 조회 전략으로 푼다.
## 관계
- **필드 접근 없이 발생한 EAGER ToOne N+1**
이 구분을 실제 측정으로 확인한 기록이다.
연관 필드를 한 번도 읽지 않았는데 2차 SELECT가 아이템 수만큼 나간 것을 잰 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
전략을 고르는 기준이다.
- **JPA N+1 정량 진단 기준**
@@ -25,60 +30,60 @@ EAGER와 LAZY는 연관이 언제 로딩돼야 하는지를 정하는 계약이
## 목적
즉시 로딩이면 한 번에 가져올 것이라고 읽기 쉽다. 실제로는 파생 쿼리에서 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우는 경우가 있다.
즉시 로딩이면 루트와 연관을 한 번에 가져온다고 읽기 쉽다. 실제로는 파생 쿼리 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우기도 한다.
이 구분을 세워야 애너테이션을 바꾸는 것과 조회 방식을 바꾸는 것이 서로 다른 작업이라는 점이 드러난다.
애너테이션의 fetch 값을 바꾸면 조회가 언제 나갈지가 달라지고, 조회 방식을 바꾸면 몇 번 나갈지가 달라진다.
## 규칙
### 1. EAGER는 로딩 시점 계약이지 JOIN 보장이 아니다
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이다. 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이지, 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다.
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다. 이 프로젝트의 findAllBy가 그랬다. 루트인 feed_items를 먼저 가져오고 user와 page는 행마다 한 번씩 조회했다.
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 provider, 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 JPA 구현체(provider)와 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
### 2. 기본값을 명시적으로 확인한다
### 2. fetch를 적지 않은 연관의 기본값을 확인한다
@ManyToOne과 @OneToOne의 기본값은 EAGER다. @OneToMany와 @ManyToMany의 기본값은 LAZY다.
@ManyToOne과 @OneToOne의 기본값은 EAGER이고, @OneToMany와 @ManyToMany의 기본값은 LAZY다.
fetch를 적지 않은 코드에도 기본값이 적용된다. 코드에 조회가 보이지 않는다는 것이 조회가 나가지 않는다는 뜻은 아니다.
fetch를 적지 않은 연관에도 기본값이 걸리므로, 코드에 조회가 보이지 않는다고 해서 조회가 나가지 않는은 아니다.
### 3. 접근 여부와 fetch 계약을 교차해서 본다
EAGER는 접근하지 않아도 나간다. 사용하지 않는 연관까지 조회하는 낭비가 된다.
EAGER 연관은 코드에서 한 번도 읽지 않아도 조회가 나간다. 아무 필드도 매핑하지 않고 feed_items만 조회한 측정에서 아이템 100개에 Page 2차 SELECT가 100번 나갔고, 같은 조건에서 LAZY인 highlights는 한 번도 나가지 않았다. 쓰지도 않는 연관 조회하는 낭비다.
LAZY는 접근할 때 나간다. 접근하면 같은 N+1이 시점만 달라져 다시 생긴다.
LAZY는 접근할 때 나가므로 매핑에서 그 연관을 실제로 읽으면 같은 N+1이 시점만 밀려 다시 생긴다. 매핑 루프에서 user와 page를 쓰는 조회라면 EAGER를 LAZY로 바꿔도 쿼리 수는 줄지 않는다.
매핑 루프에서 연관을 실제로 사용한다면 EAGER를 LAZY로 바꿔도 N+1은 남는다.
### 4. 조회 횟수는 서로 다른 연관 대상 수로 센다
### 4. 실제 증가 폭은 서로 다른 연관 대상 수가 정한다
부모마다 다른 연관은 부모 수만큼 조회되고, 소수를 돌려 쓰는 연관은 한 번 로드한 대상이 1차 캐시에 남아 서로 다른 대상 수만큼만 조회된다. 같은 @ManyToOne(EAGER)인 Page와 User를 두고 한 번에 가져오는 아이템 수 N을 10, 100, 1,000으로 바꿔 가며 쟀다. Page fetch는 N과 같은 수인 10, 100, 1,000이었고 User fetch는 3, 20, 20으로 20에서 멈췄다.
같은 EAGER ToOne이라도 증가 곡선이 갈린다. 소수를 재사용하는 연관은 1차 캐시가 재조회를 걸러 서로 다른 대상 수만큼만 조회된다. 부모마다 다른 연관은 부모 수만큼 조회된다.
N+1이 생길 가능성은 fetch 계약이 만들지만, 실제로 몇 번 조회되는지는 Persistence Context 안에 서로 다른 대상이 몇 개 있는지가 정한다.
N+1이 생길 가능성은 fetch 계약이 만들고, 실제 실행 횟수는 Persistence Context 안에서 서로 다른 대상이 몇 개인지가 정한다.
### 5. 반복문이 없어도 컬렉션 조회는 아이템마다 나간다
### 5. 컬렉션 접근은 반복문 없이도 반복된다
지연 로딩 컬렉션 접근하는 순간 조회하므로, 아이템이 N개면 접근도 조회도 N번이다.
지연 로딩 컬렉션 접근하는 순간 조회한다. 부모가 N개면 접근과 조회도 N번이다.
컬렉션 접근이 스트림이나 매핑 함수 뒤에 있으면 코드에 for가 보이지 않는다.
스트림이나 매핑 함수 뒤에 있으면 명시적인 반복문이 보이지 않는다. 반복이 사라진 것이 아니라 표현이 바뀐 것이다.
이 프로젝트의 매핑에도 하이라이트를 위한 명시적인 for가 없고 getHighlights().stream()만 있었다. 처음에는 그 코드만 보고 조회가 N번 나간다고 알아차리기 어려웠다.
### 6. 타입이 아니라 전략을 바꾼다
### 6. 왕복 수를 줄이려면 조회 방식을 바꾼다
fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이려면 fetch join, batch fetch, 프로젝션처럼 조회 방식 자체를 바꾼다.
fetch 타입을 바꾸면 조회가 나가는 시점만 달라진다. 왕복 수를 줄이려면 fetch join, 배치 페치, 프로젝션처럼 조회 방식 자체를 바꾼다. 배치 페치는 N=1,000에서 총 PreparedStatement를 2,022개에서 23개로 줄였다.
## 적용 조건
- 연관 매핑을 정하거나 바꿀 때
- N+1의 원인을 애너테이션에서 찾으려 할 때
- EAGER를 LAZY로 바꾸는 것으로 문제가 풀린다고 판단하기 전에
- EAGER를 LAZY로 바꾸면 N+1이 풀린다고 판단하기 전에
## 예외
- 단건 조회에서 provider가 JOIN을 선택하는 구현이 있다. 그 동작에 의존하려면 사용하는 provider와 버전에서 확인한 뒤 적는다.
- 연관을 전혀 사용하지 않는다면 LAZY로 바꾸는 것만으로 낭비가 사라진다. 이때는 전략 변경이 아니라 타입 변경이 맞는 해법이다.
- 단건 조회에서는 구현체가 JOIN을 고르기도 한다. 그 동작에 기대려면 쓰는 구현체와 버전에서 직접 확인한 뒤 적는다.
- 연관을 어디에서도 읽지 않는다면 LAZY로 바꾸는 것만으로 그 조회가 사라진다. 이때는 조회 방식을 손대지 않고 fetch 값만 바꾸면 된다.
## 예시
@@ -86,6 +91,7 @@ fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이
- LAZY : 접근 시점에 로딩
- @ManyToOne 기본값 : EAGER
- @OneToMany 기본값 : LAZY
- 접근 0회 EAGER : 조회 나감 (낭비)
- 접근 0회 LAZY : 조회 안 나감
- 접근 0회 EAGER : 아이템 100개에서 Page fetch 100, User fetch 20
- 접근 0회 LAZY : highlights fetch 0
- 접근함 EAGER / LAZY : 둘 다 N+1, 시점만 다름
- 같은 EAGER의 조회 수 : N=10, 100, 1,000에서 Page fetch 10, 100, 1,000 / User fetch 3, 20, 20
@@ -4,70 +4,79 @@ kind: REFERENCE
slug: keyset-pagination-design
title: Keyset Pagination 설계 기준
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#14-1
- final/document.md#14-4
---
# Keyset Pagination 설계 기준
OFFSET은 건너뛸 행까지 만든 뒤 버린다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽는다. 다만 커서와 같은 순서의 정렬키 인덱스가 있어야 이 이점이 생긴다.
OFFSET은 정렬 순서에서 앞의 offset행을 만든 뒤 버리기 때문에 페이지가 깊어질수록 훑는 행이 늘어난다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽으므로 깊이와 상관없이 페이지 크기만큼만 훑는다. 다만 커서와 같은 순서의 정렬키 인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
이 기준의 전제가 깨지는 조건을 확인한 기록이다.
가시성 조건을 같은 쿼리에 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌다.
- **Feed Pagination은 Keyset을 사용한다**
이 기준에서 나온 결정이다.
이 기준을 따라 부모 피드 페이징을 OFFSET에서 커서로 바꿨다.
- **PostgreSQL Query Plan 측정 기준**
깊이별 비용을 실행계획으로 확인하는 기준이다.
깊이별로 훑은 행과 읽은 블록 수를 EXPLAIN으로 잴 때 그 기준을 따랐다.
## 목적
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊으면 전량 스캔과 정렬로 떨어진다.
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊어지면 아예 Seq Scan과 Sort로 전량을 훑는다.
페이지 깊이와 무관하게 읽는 행수를 일정하게 유지하려면 커서 방식이 필요하다.
페이지 깊이와 무관하게 읽는 행수를 페이지 크기에 고정하려면 이전 페이지의 마지막 행을 커서로 넘겨야 한다.
## 규칙
### 1. 커서에 정렬키를 모두 담는다
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 빠지거나 중복다.
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 행이 빠지거나 중복될 수 있다.
### 2. tie-break 컬럼을 정렬과 커서에 넣는다
정렬키에 중복이 있을 수 있으면 유일한 컬럼을 마지막 정렬키로 더한다. 커서에도 같이 담는다.
정렬키에 중복이 있을 수 있으면 값이 유일한 컬럼을 마지막 정렬키로 더하고 커서에도 같이 담는다. 피드의 커서는 first_highlighted_at과 id 두 컬럼이었는데, 시각이 같은 행도 안정적으로 넘기려면 tie-break인 id까지 커서에 포함해야 한다.
### 3. 정렬키, 커서, 인덱스의 컬럼과 방향을 일치시킨다
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort가 다시 생긴다. 방향까지 같아야 한다.
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort 노드가 다시 나타난다. 컬럼 목록뿐 아니라 내림차순·오름차순 방향까지 같아야 한다.
### 4. 정렬키 전용 인덱스를 확인한다
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 필터가 없는 정렬 쿼리라면 정렬키만으로 된 인덱스가 필요하다.
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 피드에서도 V6가 만든 ix_feed_items_visibility_sort는 선두 컬럼이 visibility라 가시성 필터가 없는 keyset 쿼리에 맞지 않았고, first_highlighted_at DESC와 id DESC만으로 된 ix_feed_items_keyset을 따로 만들어 썼다.
인덱스가 없으면 keyset도 전량을 스캔한다. keyset 문법이 아니라 인덱스가 비용을 줄인다.
스키마에 인덱스가 있어도 선두 컬럼이 맞물리지 않으면 정렬에는 쓰이지 못한다.
인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다. offset 1980의 깊은 페이지에서 정렬키 인덱스가 있을 때는 Index Only Scan으로 20행만 읽었고 읽은 블록은 1개였지만, 인덱스를 지우자 같은 쿼리가 Seq Scan으로 떨어지면서 141개가 됐다.
### 5. 깊이별로 훑은 행을 측정한다
OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
OFFSET은 offset에 페이지 크기를 더한 만큼 훑 keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
한 페이지만 재면 차이가 보이지 않는다. 깊이를 바꿔 가며 곡선으로 확인한다.
한 페이지만 재면 차이가 드러나지 않으니 깊이를 바꿔 가며 곡선으로 확인한다. seed 2,000에 페이지 크기 20으로 재면 1페이지에서는 둘 다 20행이다. OFFSET이 훑은 행은 50페이지(offset 980)에서 1,000행, 100페이지(offset 1980)에서 2,000행까지 늘었고 keyset은 세 페이지 모두 20행이었다.
### 6. 결과가 OFFSET과 같은지 검증한다
커서로 넘긴 페이지가 같은 순서의 같은 행을 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다.
커서로 넘긴 페이지가 OFFSET으로 넘긴 페이지와 같은 행을 같은 순서로 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다. 피드에서는 FeedKeysetIT의 l15KeysetWalkMatchesOffsetPages가 두 방식의 두 번째 페이지를 맞춰 보고 같은 20개 id, 같은 순서인지 확인했다.
### 7. 필터를 얹으면 전제가 깨질 수 있다
### 7. 필터를 얹으면 정렬키 인덱스를 못 쓸 수 있다
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 합치면서 정렬 순서를 잃는다. 이때 Sort가 다시 나타난다.
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 BitmapOr로 합치면서 인덱스의 정렬 순서를 잃고, 사라졌던 Sort 노드가 다시 나타난다. 피드에서도 가시성 3분기를 keyset과 같은 쿼리에 얹자 플래너가 ix_feed_items_keyset을 더 이상 쓰지 못했다.
필터가 있는 keyset은 필터 포함한 인덱스 설계나 쿼리 분해가 함께 필요하다.
필터가 있는 keyset은 필터까지 포함한 인덱스 설계하거나, 분기를 UNION ALL로 나누거나, 뷰어별 결과를 미리 계산하는 방법을 함께 다.
### 8. 정렬키에 null이 있을 수 있는지 먼저 정한다
정렬키 nullable이면 null의 정렬 위치와 커서 표현을 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
정렬키 null이 들어갈 수 있으면 null을 어디에 정렬할지와 커서에 어떻게 담을지를 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
피드의 first_highlighted_at은 아직 NOT NULL이 아니다. NOT NULL로 좁힐지 NULLS LAST 같은 null 정렬 위치를 정의할지는 keyset 페이징 단계 이전에 결론 내기로 하고 미결정으로 뒀다.
## 적용 조건
@@ -78,14 +87,14 @@ OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페
## 예외
- 임의 페이지 점프가 필요하면 커서만으로는 부족하다. OFFSET을 함께 두거나 다른 탐색을 설계한다.
- 전체 건수를 화면에 표시해야 하면 count를 별도로 다룬다. 커서 결과에는 전체 건수가 없다.
- 전체 건수를 화면에 표시해야 하면 count 쿼리를 따로 실행한다. 커서 결과에는 전체 건수가 없다.
- 정렬 기준이 자주 바뀌면 기준마다 인덱스가 필요하다. 인덱스 수와 쓰기 비용을 함께 본다.
## 예시
- OFFSET 훑은 행 : offset + 페이지 크기
- keyset 훑은 행 : 페이지 크기 (깊이 무관)
- 커서 : (정렬키, tie-break) 조합
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향
- 인덱스 없는 keyset : 전량 스캔, 이점 없음
- 필터 추가 : 분기가 갈리면 Sort 재등장
- OFFSET 훑은 행 : offset + 페이지 크기 (offset 1980에서 2,000행)
- keyset 훑은 행 : 페이지 크기 (offset 1980에서도 20행)
- 커서 : 정렬키 tie-break 조합 (first_highlighted_at, id)
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향 (ix_feed_items_keyset)
- 인덱스 없는 keyset : Seq Scan으로 전량 훑음, 읽은 블록 141개 (인덱스가 있으면 1개)
- 필터 추가 : 분기가 갈리면 BitmapOr로 떨어지고 Sort 재등장
@@ -4,102 +4,105 @@ kind: REFERENCE
slug: nplus1-quantitative-diagnosis
title: JPA N+1 정량 진단 기준
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#6-1
- final/document.md#7-1
---
# JPA N+1 정량 진단 기준
N+1 쿼리 로그의 인상이 아니라 지표로 확인한다. Hibernate Statistics의 지표는 이름이 뜻하는 것이 서로 달라서, SQL 실행 횟수로 바꿔 읽으면 배치를 적용한 뒤 결론이 어긋난다.
N+1 쿼리 총계가 아니라 Hibernate Statistics의 지표를 나눠 읽어 확인한다. 세 지표는 세는 단위가 서로 달라서, 모두 SQL 실행 횟수로 바꿔 읽으면 Batch Fetch를 적용한 뒤 등식이 깨지고 결론이 어긋난다.
## 관계
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
엔티티별 fetch 통계로 ToOne 쪽을 확인한 기록이다.
엔티티별 fetch 통계를 직접 읽어 여기서 역산한 ToOne 조회 수를 확인했다.
- **PostgreSQL Query Plan 측정 기준**
같은 측정에서 실행계획을 다루는 기준이다.
같은 측정에서 뽑은 실행계획을 어떻게 읽을지는 그 기록이 정한다.
## 목적
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계에 목록 루트, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 섞여 있다.
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계 하나에 목록 루트 쿼리, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 함께 들어 있기 때문이다. N=1,000에서 나온 총 PreparedStatement 2,022건도 이 네 가지가 섞인 값이다.
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회는지 확정할 수 있다. 그래야 fetch 전략을 바꿨을 때 무엇이 줄었는지 말할 수 있다.
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회는지 확정다.
## 규칙
### 1. 지표 이름이 뜻하는 것을 그대로 읽는다
getCollectionFetchCount()는 초기화된 컬렉션 수다. 실행된 SELECT SQL 수가 아니다.
세 지표는 모두 Hibernate Statistics에서 읽는다. 스택에 이미 있는 도구라 의존성을 더하지 않지만, SQL 형태별 정확한 실행 수는 주지 않는다.
getPrepareStatementCount()는 획득한 PreparedStatement 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
getCollectionFetchCount()는 지연 로딩이 초기화한 컬렉션 개수를 센다. 실행된 SELECT SQL 수가 아니다.
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수다. 실행된 SELECT SQL 수가 아니다.
getPrepareStatementCount()는 JDBC에서 얻은 문장 객체(PreparedStatement) 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
### 2. 등식이 성립하는 조건을 함께 적는다
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수이고, ToOne 연관 조회를 이 지표로 읽는다. 역시 실행된 SELECT SQL 수가 아니다.
batch나 subselect가 없을 때만 초기화 컬렉션 수와 자식 SELECT 수가 같다. 이 조건에서만 컬렉션 수를 SQL 수로 바꿔 읽을 수 있다.
### 2. 컬렉션 수와 SELECT 수가 같아지는 조건을 함께 적는다
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 등식이 깨진다. 배치 적용 여부는 prepared와 collectionFetch를 함께 보고 판단한다.
Batch Fetch나 subselect가 없어야 컬렉션 하나를 초기화할 때 SQL이 하나 나가므로, 그때만 초기화된 컬렉션 수와 자식 SELECT 수가 같다.
### 3. 총계를 형태별로 가르고 검산한다
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨진다. 배치가 걸렸는지는 총 PreparedStatement와 초기화 컬렉션 수를 함께 보고 판단한다.
총 PreparedStatement를 다음처럼 나눈다.
### 3. 총계를 SQL 형태별로 가르고 검산한다
content 1
count 1
distinct ToOne 대상 수
N ToOne (아이템마다 다른 연관)
N 컬렉션 초기화
총 PreparedStatement는 목록 루트 쿼리(content) 1건, 페이지 count 1건, 서로 다른 ToOne 대상 수, 아이템마다 달라 N번 나가는 ToOne, 컬렉션 초기화 N건으로 갈린다. N=1,000에서는 1 + 1 + 20 + 1,000 + 1,000 = 2,022였다.
파생값 직접 측정값이 일치하는지 교차 검증한다. 회계 항등식은 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 뺀 값이 entityFetch와 같은지 보는 것이다.
이렇게 역산한 파생값 직접 측정한 값과 맞는지 교차 검증한다. 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 빼면 entityFetch와 같아야 하고, N=1,000에서는 2,022 1,000 2 = 1,020으로 엔티티별 fetch 통계에서 직접 읽은 값과 일치했다.
처음 나눌 때는 count 1건을 빼지 않아 ToOne 조회 수를 1,021로 적었다. 페이지 count가 ToOne 쪽에 섞인 값이었고, 그래서 분해 항목에 count를 따로 둔다.
### 4. 회귀 가드는 시더 카디널리티와 무관한 값으로 고정한다
합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있다. 엔티티별 지표로 고정하는 편이 안정적이다. 예를 들어 아이템마다 다른 연관 pageFetch == N이 성립다.
합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있어서 회귀 가드는 엔티티별 지표로 고정한다. 아이템마다 다른 연관이면 pageFetch == N이 성립하고, 이 값은 시더가 몇 개를 심었는지와 무관하다.
합계는 회귀 가드가 아니라 교차 검증에 쓴다.
같은 측정에서 User fetch는 N=10일 때 3, N=100과 N=1,000일 때 모두 20이었다. 시더가 만드는 사용자 수가 20에서 멈추고 한 번 조회한 사용자는 1차 캐시에 남아 다시 조회되지 않기 때문에 N과 함께 늘지 않았다. 합계는 회귀 가드가 아니라 교차 검증에 쓴다.
### 5. count 쿼리가 언제 나오는지 안
### 5. count 쿼리가 실행되는 조건을 맞춘 뒤 비교한
Page를 반환하면 Spring Data가 전체 건수 count를 한 번 더 실행한다. offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다.
Page를 반환하면 Spring Data가 전체 페이지 수를 알려주려고 count를 한 번 더 실행한다. 다만 offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다.
같은 코드라도 pageSize 반환 건수의 관계에 따라 총계가 달라진다. 측정값을 비교할 때 이 조건 맞춘다.
그래서 같은 코드라도 총계가 count 1건만큼 달라진다. pageSize 반환 건수 N과 같게 맞춘 측정에서는 2,022건 안에 count 1건이 들어 있었고, 1건을 pageSize 10으로 조회한 라운드트립 스모크에서는 count가 생략되어 총 4건이 나왔다. 측정값을 비교할 때 이 조건부터 맞춘다.
### 6. 캐시가 결과를 먹지 않게 한
### 6. 반복마다 1차 캐시를 비우고 잰
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 흡수한다. 지연 반복 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출한다. clear 비용은 측정 구간 밖에 다.
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹어서 두 번째 반복부터 값이 섞인다. 지연 반복 측정하는 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출하고, 그래서 매 호출이 실제로 DB를 때리면서 em.clear() 자체의 비용은 측정 구간 밖에 놓인다.
쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다.
쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다.
### 7. 증가 기준이 무엇인지 명시한
### 7. N을 전체 테이블 크기가 아니라 한 요청의 부모 엔티티 수로 센
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 테이블이 100만 행이어도 이 왕복 수는 늘지 않는다.
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 부모 하나마다 컬렉션을 한 번씩 초기화하기 때문에, 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않는다.
전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
대신 전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
### 8. 왕복과 행수를 다른 축으로 센다
한 조회에 두 위반이 함께 있을 수 있다. 부모 수에 비례하는 왕복과, 한 번의 왕복에서 자식을 전부 읽는 과조회다.
하이라이트가 아무리 많아도 조회량이 그에 비례해 늘지 않아야 한다는 요구는 한 조회에가지 방식으로 동시에 깨질 수 있다. 하나는 부모 수만큼 DB를 왕복하는 N+1이고, 다른 하나는 한 번의 왕복에서 그 부모의 자식 행을 전부 읽어 오는 과조회다.
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다. 한쪽을 고쳐 놓고 다른 쪽이 해결됐다고 적지 않는다.
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다.
## 적용 조건
- ORM 조회에서 쿼리 발생량이 데이터 규모를 따라 늘어나는지 확인할 때
- ORM 조회에서 쿼리 발생량이 N에 비례해 늘어나는지 확인할 때
- fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때
- N+1 회귀를 테스트로 고정할 때
## 예외
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다.
- 운영 종단 지연이나 처리량 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다. 그 단계는 Batch Fetch가 컬렉션 수와 SQL 수의 등식을 깨뜨리는 때로 미리 정해 두었다.
- 운영 종단 지연이나 처리량, 커넥션 풀 상태가 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링)으로 확인한다.
## 예시
- 초기화 컬렉션 수 : 실행된 SELECT 수가 아니라 초기화된 컬렉션 수
- 총 PreparedStatement : 획득한 statement, SQL 실행 수와 다를 수 있
- 초기화 컬렉션 수 : 지연 로딩이 채운 컬렉션 개수. 실행된 SELECT 수가 아니
- 총 PreparedStatement : JDBC에서 얻은 문장 객체. SQL 실행 수와 다를 수 있
- 회계 항등식 : 총계 − 컬렉션 N content 1 count 1 = entityFetch
- 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관)
- 측정 규율 : 매 반복 전 em.clear, stats.clear 직후 1회만 읽기
- 측정 규율 : 매 반복 전 em.clear(), stats.clear() 직후 1회만 읽기
@@ -4,15 +4,22 @@ kind: REFERENCE
slug: postgresql-query-plan-measurement
title: PostgreSQL Query Plan 측정 기준
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-1
- final/document.md#4-6
- final/document.md#4-7
- final/document.md#6-4
---
# PostgreSQL Query Plan 측정 기준
실행계획과 인덱스 동작을 측정하려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 인덱스 기능이 엔진마다 달라서 다른 엔진의 계획을 그대로 옮겨 읽으면 체계적으로 틀린 결론에 이른다.
실행계획과 인덱스 동작을 려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 쓸 수 있는 인덱스 기능이 엔진마다 달라서, 인메모리 DB에서 잰 계획은 운영 엔진이 고를 계획이 아니다.
## 관계
@@ -21,65 +28,69 @@ studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd4
- **JPA N+1 정량 진단 기준**
같은 측정에서 쿼리 수를 다루는 기준이다.
- **ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가**
통계 축에서 남은 질문이다.
통계를 갱신하면 추정 행수가 어떻게 달라지는지는 아직 재지 않았다.
## 목적
쿼리 수만으로는 보이지 않는 것이 있다. 한 쿼리가 실어 나르는 행수, 정렬 방식, 인덱스 사용 여부, 읽은 블록 수다.
쿼리가 몇 개 나갔는지만 세면 그 한 쿼리가 무엇을 얼마나 읽는지는 보이지 않는다. 반복되는 하이라이트 조회를 EXPLAIN으로 열어 보니 feed_item_id를 Index Scan으로 찾아 들어가는데도 한 번에 rows=500을 읽고 있었고, 응답에 필요한 것은 그중 최신 3개뿐이었다.
이 값을 확인하려면 엔진이 실제로 고른 계획을 봐야 한다.
한 쿼리가 실어 나르는 행수, 스캔과 정렬 방식, 인덱스를 탔는지, 읽은 블록 수는 엔진이 실제로 고른 계획에만 적혀 있다.
## 규칙
### 1. 운영과 같은 엔진에서 측정한다
비용 기반 옵티마이저는 가능한 계획 비용을 추정해 고른다. 추정값도, 고를 수 있는 선택지도 엔진마다 다르다.
비용 기반 옵티마이저는 고를 수 있는 계획마다 비용을 추정해 가장 싼 것을 고른다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다르다.
축이 갈린다. 비용 상수로 표현되는 비용 모델, 수집하는 통계의 종류, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류와 기능이다.
가지가 갈린다. 비용 모델은 PostgreSQL에서 random_page_cost=4와 seq_page_cost=1 같은 상수로 랜덤 접근을 순차 접근보다 비싸게 잡는데, 이 상수와 추정 규칙이 엔진마다 다르다. 나머지 셋은 ANALYZE가 수집하는 통계의 종류, heap과 가시성 맵을 거치는 저장 구조, 부분 인덱스와 표현식 인덱스처럼 쓸 수 있는 인덱스 기능이다.
인메모리 대체 DB에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작 재현되지 않는다.
H2 같은 인메모리 DB에서 재면 Seq Scan과 Index Scan 사이의 선택이 뒤집히고, 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라진다. 가시성 맵과 Index Only Scan의 heap 재방문 같은 PostgreSQL 특유의 동작 재현되지 않는다.
### 2. 스키마를 운영 마이그레이션과 같게 맞춘다
같은 마이그레이션을 적용하고 엔티티와 스키마의 불일치를 조기에 잡는다.
운영에 쓰는 Flyway 마이그레이션을 그대로 적용하고, ddl-auto=validate로 엔티티가 기대하는 테이블·컬럼·타입이 스키마와 어긋난 것을 조기에 잡는다.
다만 스키마 검증만으로 모든 드리프트를 막을 수 없다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이로 따로 확인한다.
다만 validate가 모든 드리프트를 막지는 못한다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이라 마이그레이션 검증과 카탈로그 조회로 따로 확인한다.
### 3. EXPLAIN은 ANALYZE와 BUFFERS를 함께 쓴다
추정만으로는 실제 행수를 알 수 없다. 실제 실행 결과와 읽은 블록 수를 함께 본다.
옵션 없이 EXPLAIN만 하면 계획과 추정값만 나온다. ANALYZE를 붙이면 쿼리를 실제로 실행해 노드마다 actual time과 실제 rows, loops가 함께 찍힌다. BUFFERS를 붙이면 Buffers: shared hit처럼 그 노드가 읽은 블록 수가 따라 붙는다. 추정과 실제를 대조하고 무엇을 얼마나 읽었는지 보려면 두 옵션이 다 필요하다.
### 4. 추정 행수와 실제 행수의 차이를 기록한다
둘이 크게 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있다. 대량 데이터를 넣은 직후에 특히 그렇다.
둘이 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있고, 대량으로 데이터를 넣은 직후에 특히 그렇다. 위 계획도 추정은 rows=1인데 실제는 rows=500이라 500배가 벌어졌다. 대량 시드 직후 ANALYZE를 실행하지 않아 통계가 feed_item_id별 편중을 반영하지 못했다는 가설을 세웠다.
차이를 발견하면 통계를 갱신한 뒤 다시 측정하고 전후를 비교한다.
차이를 발견하면 통계를 갱신한 뒤 다시 고 전후를 비교한다. 이 가설을 검증할 ANALYZE highlights 뒤의 재측정은 아직 실행하지 않았다.
### 5. warm cache 결과를 cold 실행시간으로 읽지 않는다
### 5. 캐시가 채워진 상태에서 잰 값을 디스크를 읽은 값으로 읽지 않는다
읽은 블록이 모두 캐시에서 왔다면 디스크 접근이 없는 값이다. 캐시 상태를 함께 기록한다.
Buffers에 shared hit만 찍히고 read=0이면 읽은 블록이 모두 버퍼 캐시에 있었다는 뜻이어서 디스크에서 읽어 오는 시간이 그 실행시간에 빠져 있다. 위 조회를 잴 때도 Buffers는 shared hit=14, read=0이었다. 캐시가 채워진 상태(warm)에서 쟀는지 아닌지를 계획과 함께 적는다.
### 6. Execution Time을 애플리케이션 지연과 합산하지 않는다
Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결과 전달, DTO 매핑, 직렬화, HTTP를 포함하지 않다. 같은 지표가 아니다.
Execution Time은 PostgreSQL executor 안에서 쓴 시간에 가깝 ORM 엔티티 생성, JDBC 결과 전달, DTO 매핑, 직렬화, HTTP는 여기에 들어 있지 않다. 같은 조회에서 계획의 Execution Time은 0.173 ms였고 N=1,000에서 피드 한 번 로딩은 194 ms였는데, 두 값은 재는 구간이 서로 다르므로 더하거나 나란히 비교하지 않는다.
### 7. 여러 방식을 비교할 때는 같은 실행에서 잰다
캐시 상태를 맞추려면 같은 테스트 실행 안에서 연속으로 측정한다. 실행을 나누면 캐시 차이가 비교에 섞인다.
비교할 SQL을 같은 테스트 실행 안에서 연속으로 EXPLAIN (ANALYZE, BUFFERS)로 잰다. 실행을 나누면 어느 쪽이 먼저 캐시를 채웠는지가 달라지기 때문에 그 차이가 buffers와 실행시간 비교에 섞인다. 그래서 buffers와 실행시간은 같은 실행 안에서 상대 비교로만 읽는다.
### 8. 인덱스 의존을 확인하려면 토글한
### 8. 인덱스에 기댄 결과인지 보려면 인덱스를 지우고 다시 잰
어떤 방식이 빠른 이유가 문인지 인덱스인지 가르려면 인덱스를 제거한 뒤 같은 쿼리를 다시 잰다. 측정이 끝나면 복구한다.
어떤 방식이 빠른 이유가 SQL 문법 때문인지 인덱스 때문인지 가르려면 인덱스를 지운 뒤 같은 쿼리를 다시 잰다. 실제로 인덱스를 지웠을 때 같은 쿼리가 Seq Scan으로 떨어졌고 읽는 블록 수도 늘었다. 측정이 끝나면 인덱스를 되돌린다.
다만 Seq Scan이 찍혔다는 것만으로 그 계획을 곧바로 문제로 판정하지는 않는다. 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다.
### 9. 측정 도구의 정밀도를 주장 강도에 맞춘다
방향성만 확인하는 값에 엄밀한 도구를 붙인다고 근거가 강해지지 않는다. 오히려 측정보다 정밀한 결론처럼 보다.
지연을 더 엄밀하게 재는 JMH(Java Microbenchmark Harness), 종단 지연을 보는 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링) 같은 전용 도구가 있다. 다만 방향성만 확인하는 값에 엄밀를 붙인다고 근거가 강해지지고, 오히려 측정한 데이터보다 정밀한 결론처럼 보일 수 있다.
표본이 적으면 백분위수로 부르지 않고 중앙값과 최댓값으로 적다.
같은 이유로 표본이 적으면 p50이나 p99 같은 백분위수로 부르지 않는다. 이 측정도 5회를 재고 중앙값과 최댓값으로 적다.
### 10. 재현 조건을 함께 남긴다
이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 측정 시작 시 엔진 버전과 주요 플래너 설정을 함께 기록한다.
같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문에, 컨테이너 이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다.
측정을 시작할 때 엔진 버전과 주요 플래너 설정도 함께 기록한다. 이 측정은 Testcontainers로 띄운 PostgreSQL 16에서 했다.
## 적용 조건
@@ -91,14 +102,14 @@ Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결
- 쿼리 발생 횟수만 확인하면 되는 단계에서는 실행계획까지 필요하지 않다.
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
- 안정적인 꼬리 지연이 필요하면 반복 횟수를 크게 늘린 독립 세트가 필요하다.
- p99 같은 안정적인 꼬리 지연이 필요하면 워밍업 뒤 100회 넘게 반복하는 독립 세트나 JMH로 따로 잰다.
## 예시
- 엔진 : 운영과 같은 것. 인메모리 대체 금지
- 엔진 : 운영과 같은 PostgreSQL, H2 같은 인메모리 DB로 대체하지 않는다
- 명령 : EXPLAIN (ANALYZE, BUFFERS)
- 캐시 : warm인지 cold인지 기록
- 추정 vs 실제 : 차이가 크면 통계 갱신 후 재측정
- 비교 : 같은 실행 안에서 연속 측정
- 인덱스 의존 : DROP 후 재측정, 끝나면 복구
- Execution Time : 애플리케이션 지연과 다른 지표
- 캐시 : 버퍼 캐시가 채워진 상태(warm)였는지 함께 기록
- 추정 실제 : rows=1과 rows=500처럼 벌어지면 통계 갱신하고 다시 잰다
- 비교 : 같은 테스트 실행 안에서 연속으로 측정
- 인덱스 의존 : 인덱스를 지우고 다시 잰 뒤 되돌린다
- Execution Time : 애플리케이션 지연과 재는 구간이 다르
@@ -4,91 +4,102 @@ kind: REFERENCE
slug: top-n-per-group-selection
title: Top-N-per-group 선택 기준
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit"
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#13-1
- final/document.md#13-6
---
# Top-N-per-group 선택 기준
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑 세 가지가 같은 결과를 만들지만 읽는 행수가 다르다.
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑이 부모 20개에서 똑같은 60행을 돌려줬지만, LATERAL은 인덱스에서 부모당 3개만 읽어 buffers가 204였고 나머지 둘은 1,509행을 모두 읽어 430이었다.
## 관계
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
이 기준이 풀려던 문제다.
프로젝션으로 엔티티 로드는 0이 되었지만 페이지 부모 20개의 자식 1,509행은 줄지 않았다. 그 1,509행을 60행으로 깎으려고 이 기준을 만들었다.
- **PostgreSQL Query Plan 측정 기준**
세 방식을 실행계획으로 비교한 기준이다.
세 방식의 EXPLAIN (ANALYZE, BUFFERS)를 어떤 조건에서 재고 어느 값을 읽을지는 그 기록이 정한다.
- **Fetch Join · Batch · Projection 선택 기준**
앞 단계에서 왕복과 적재를 푼 기준이다.
왕복은 배치가 2,022개에서 23개로, 엔티티 적재는 프로젝션이 1,569개에서 0개로 앞 단계에서 풀었다. 여기서는 남은 자식 1,509행을 60행까지 깎는다.
## 목적
자식 조회에 LIMIT을 붙이면 최종 결과 집합 전체에 적용되어 부모 하나만 채워진다. 그룹당 상한은 다른 표현이 필요하다.
자식 조회에 LIMIT 3을 붙이면 부모별 그룹이 아니라 최종 결과 집합 전체에 적용된다. 부모 20개를 조회했는데 반환은 3행이었고 하이라이트가 들어간 부모는 1개였다. 그룹당 상한은 다른 표현으로 써야 한다.
방식은 결과가 같으므로 정확성만으로 고를 수 없다. 읽는 행수와 buffers로 갈린다.
표현은 모두 부모 20개에서 60행을 돌려주므로 정확성만으로 고를 수 없다. 갈리는 곳은 그 60행을 만들기까지 읽은 행수와 buffers인데, 윈도우와 2단계는 1,509행을 읽어 430이었고 LATERAL은 204였다.
## 규칙
### 1. 단순 LIMIT은 그룹당 상한이 아니
### 1. 자식 쿼리 끝에 붙인 LIMIT은 부모별로 적용되지 않는
LIMIT은 최종 결과 집합에 적용된다. 부모 20개를 조회하면서 LIMIT 3을 붙이면 3행만 남아 부모 하나만 채워진다.
LIMIT은 부모별 그룹이 아니라 최종 결과 집합에 적용되므로, 부모 20개를 조회하면서 LIMIT 3을 붙이면 가장 최신 하이라이트를 가진 부모 하나만 3개를 받고 나머지 부모에는 하이라이트가 들어가지 않는다.
이 오작동은 결과 행수가 적어 정상처럼 보일 수 있다. 커버한 부모 수를 함께 확인한다.
반환이 3행뿐이라 결과가 작아 보일 뿐 오작동인지는 드러나지 않는다. 커버한 부모 수를 같이 세면 20이 아니라 1로 나온다.
### 2. 세 가지 표현을 구분한
### 2. 윈도우 · LATERAL · 앱 그룹핑이 각각 어디까지 읽는지 적는
윈도우 함수는 부모별로 순번을 매기고 상위 몇 개를 남긴다. 순번을 만들려고 파티션 전체 읽는다.
윈도우 함수는 PARTITION BY feed_item_id로 부모마다 순번을 매기고 순번 3 이하만 남긴다. 자르는 곳은 DB지만 순번을 매기려고 파티션 전체인 1,509행을 먼저 읽는다.
LATERAL은 부모마다 상관 서브쿼리를 실행하고 인덱스에서 필요한 개수만 읽고 멈춘다.
LATERAL은 부모마다 상관 서브쿼리를 실행해 부모별 정렬 인덱스에서 K개를 읽고 멈춘다. 실행계획에는 Nested Loop 아래 Index Scan과 Limit 3이 부모 수만큼 반복되어 loops=20으로 찍힌다.
애플리케이션 그룹핑은 자식을 한 번에 가져온 뒤 코드에서 자른다. 자르기 전에 전량이 전송된다.
애플리케이션 그룹핑은 자식을 한 번에 모아 오는 쿼리로 가져온 뒤 코드에서 부모별로 자른다. 자르기 전에 1,509행이 모두 전송되고, 실행계획은 윈도우와 같은 Hash Semi Join이다.
### 3. 작은 K에는 LATERAL이 유리하
### 3. 그룹이 크고 K가 작으면 LATERAL을 쓴
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개 읽고 멈춘다. 그룹이 크고 K가 작을수록 읽지 않는 행이 많아진다.
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개 읽고 멈추므로, 그룹이 수록 읽지 않고 넘어가는 행이 많아진다. 이 랩의 피드는 부모 하나에 하이라이트가 500개인데 화면에 필요한 K는 3이었고, K=3에서 buffers는 LATERAL 114, 윈도우 162였다.
### 4. K가 그룹 크기에 가까우면 윈도우로 수렴한
### 4. K가 그룹 크기에 가까우면 더 단순한 윈도우 함수를 고른
K가 그룹 크기에 가까워지면 LATERAL도 대부분는다. 이때는 더 단순한 윈도우 함수를 고를 수 있다.
K를 3 · 50 · 500으로 바꾸자 반환 행수는 60 · 695 · 1,509였다. K가 그룹 크기인 500에 가까워지면 LATERAL도 부모의 하이라이트를 대부분 읽으므로, 이 구간에서는 더 단순한 윈도우 함수를 골라도 된다.
K를 바꿔 가며 buffers를 재면 어느 지점에서 뒤집히는지 볼 수 있다.
다만 이 랩에서는 세 K 모두 LATERAL의 buffers가 윈도우보다 작았다. 114 대 162, 155 대 216, 171 대 269였고, 어느 K에서 윈도우가 앞서는지는 재지 않았다.
### 5. LATERAL의 이점은 인덱스에서 나온
### 5. LATERAL을 쓰기 전에 부모별 정렬 인덱스가 있는지 확인한
LATERAL 문법 자체가 빠른 것이 아니다. 부모 정렬 인덱스가 있어야 상위 K개를 바로 찾다.
LATERAL 문법 자체가 빠른 것이 아니라, 부모 키와 정렬 컬럼을 함께 담은 복합 인덱스 ix_highlights_feed_items_created로 부모별 상위 3개를 바로 찾을 수 있어서 빨랐다.
인덱스가 없으면 부모마다 자식 테이블을 스캔하고 대부분을 필터로 버린다. 인덱스 유무를 토글해 확인한다.
같은 LATERAL 쿼리를 두고 인덱스를 지웠다가 다시 만들며 재자 자식 접근이 Index Scan에서 Seq Scan으로 바뀌었고, Rows Removed by Filter가 부모 한 번마다 2842였다. buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 늘었다.
### 6. 애플리케이션 그룹핑은 전송량을 줄이지 않는다
이 랩에서 그 인덱스는 마이그레이션 V6__feed.sql부터 있었다. LATERAL을 고르면서 새로 만든 것이 아니라 이미 있는 인덱스에 맞는 표현을 고른 것이다.
코드에서 자르면 결과는 맞지만 DB가 전달한 행은 전량이다. 전송량이 문제인 상황에서는 해법이 아니다.
### 6. 애플리케이션 그룹핑은 전송 행수를 줄이지 않는다
### 7. 표준 JPQL로 표현되지 않는다
코드에서 자르면 부모당 3개라는 결과는 맞지만, DB가 넘긴 행은 60이 아니라 1,509였고 buffers도 윈도우와 같은 430이었다. 줄이려는 것이 전송 행수라면 이 방식으로는 줄지 않는다.
윈도우 함수와 LATERAL은 표준 JPQL에 없다. native SQL로 내려가야 한다. 이 결정을 기록에 남긴다.
### 7. 윈도우 함수와 LATERAL은 native SQL로 내려가야 쓸 수 있다
### 8. 반환 행수와 커버한 부모를 함께 검증한다
표준 JPQL(Java Persistence Query Language)에는 윈도우 함수도 LATERAL도 없고, Hibernate 6 이상의 HQL(Hibernate Query Language)도 LATERAL은 지원하지 않는다. 두 표현을 쓰려면 native SQL로 내려간다.
세 방식결과를 만드는지 먼저 확인한 뒤 실행계획을 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 본다.
native SQL을 JdbcTemplate으로 실행하는 별도 테스트 클래스 FeedTopNIT에만 두어 프로덕션 코드 변경이 0이었다.
### 8. 같은 결과가 나오는지 먼저 맞춘 뒤 실행계획을 비교한다
세 표현이 같은 결과를 만드는지부터 확인하고 실행계획은 그다음에 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 봐야 순진한 LIMIT 3처럼 행수만 작고 부모를 못 채우는 경우가 걸러진다.
이 랩에서는 윈도우와 LATERAL이 60행에 부모 20, 부모당 3으로 같았고, 2단계는 코드에서 자르기 전 1,509행에 부모당 전량이었으며, 순진한 LIMIT 3은 3행에 부모 1이었다. 셋의 결과를 맞춘 뒤 캐시 상태를 맞추려고 같은 테스트 실행 안에서 EXPLAIN (ANALYZE, BUFFERS)를 돌렸다.
## 적용 조건
- 목록 응답에 부모별 자식 상위 몇 개를 포함해야 할 때
- 자식 전 조회가 전송량 문제를 만들
- 그룹 크기가 크고 필요한 개수가 작을 때
- 부모의 자식부 가져오는 조회가 전송 행수 때문에 문제가 될
- 그룹 크기가 크고 화면에 필요한 개수가 작을 때
## 예외
- 그룹 크기가 작아 전량을 읽어도 부담이 없으면 애플리케이션 그룹핑이 단순하다.
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL의 이점이 사라진다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL은 부모마다 Seq Scan을 돌고 buffers가 4,446까지 올라간다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
## 예시
- 순진 LIMIT 3 : 전체에 적용, 부모 1개만 채워짐
- 윈도우 : 부모별 순번 뒤 상위 K, 파티션 전량 읽음
- LATERAL : 부모마다 인덱스에서 K개 읽고 멈
- 2단계 : 자식 전량 전송 뒤 코드에서 그룹핑
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 급증
- 선택 : 작은 K는 LATERAL, K가 그룹 크기에 근접하면 윈도우
- 순진 LIMIT 3 : 부모 20개 조회에 반환 3행, 채워진 부모 1개
- 윈도우 : 파티션 1,509행을 읽어 순번을 매긴 뒤 60행, buffers 430
- LATERAL : 부모마다 인덱스에서 3개 읽고 멈춰 60행, buffers 204
- 2단계 : 자식 1,509행을 모두 전송 뒤 코드에서 그룹핑, buffers 430
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 168에서 4,446으로
- K 곡선 : K가 3 · 50 · 500일 때 반환 60 · 695 · 1,509, LATERAL buffers가 세 K 모두 윈도우보다 작음
File diff suppressed because it is too large Load Diff