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
@@ -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과 분리된 읽기 모델을 하나 더 유지해야 한다.