From 8d32625fd0791dc8f643f63365fcb271738fda73 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 30 Jul 2026 16:40:01 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .run/n+1liner/final/document.md | 583 +++--- .run/n+1liner/final/document.pre-humanize.md | 1765 +++++++++++++++++ src/claridoc/__pycache__/lint.cpython-312.pyc | Bin 35934 -> 37004 bytes .../__pycache__/report.cpython-312.pyc | Bin 8278 -> 9564 bytes .../style_contracts.cpython-312.pyc | Bin 9750 -> 11324 bytes .../__pycache__/mock.cpython-312.pyc | Bin 27789 -> 32060 bytes tests/__pycache__/test_cli.cpython-312.pyc | Bin 3426 -> 5244 bytes .../__pycache__/test_pipeline.cpython-312.pyc | Bin 7327 -> 13205 bytes .../test_repository_contracts.cpython-312.pyc | Bin 0 -> 2552 bytes .../__pycache__/test_schemas.cpython-312.pyc | Bin 5556 -> 6188 bytes 10 files changed, 2056 insertions(+), 292 deletions(-) create mode 100755 .run/n+1liner/final/document.pre-humanize.md create mode 100644 tests/__pycache__/test_repository_contracts.cpython-312.pyc diff --git a/.run/n+1liner/final/document.md b/.run/n+1liner/final/document.md index 2fad312..9fd1357 100755 --- a/.run/n+1liner/final/document.md +++ b/.run/n+1liner/final/document.md @@ -3,10 +3,10 @@ 제가 만들려던 것은 페이지에 하이라이트가 아무리 많아도 조회량이 폭증하지 않는 피드 API였습니다. 처음에는 엔티티를 조회한 뒤 DTO로 바꾸는 구현으로도 충분해 보였습니다. 그런데 데이터를 늘려 보니 화면에 필요한 행보다 훨씬 많은 엔티티와 쿼리가 생겼습니다. 그래서 실제 PostgreSQL에서 SQL과 -실행계획을 측정하고, 한 전략이 남긴 문제를 다음 전략으로 풀어 갔습니다. 이 문서는 그 과정과 +실행계획을 측정했습니다. 한 전략이 남긴 문제는 다음 전략으로 풀어 갔습니다. 이 문서는 그 과정과 마지막에 남은 비용을 함께 기록한 글입니다. -> **측정의 범위와 한계** — 아래 수치는 **단일 스레드 퍼시스턴스 통합 테스트**(`@DataJpaTest` + 실제 PostgreSQL)에서 SQL shape와 데이터 규모에 따른 **조회 횟수의 증가 형태**를 측정했습니다. 지연(latency) 값은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 **단일 스레드·warm-cache 로컬 비교값**이라 HTTP 종단 지연도 운영 p99도 아닙니다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이며, 별도 부하 테스트로 확인해야 합니다. +> **측정의 범위와 한계** — 아래 수치는 **단일 스레드 퍼시스턴스 통합 테스트**(`@DataJpaTest` + 실제 PostgreSQL)에서 SQL shape와 데이터 규모에 따른 **조회 횟수의 증가 형태**를 측정했습니다. 지연(latency) 값은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 **단일 스레드·warm-cache 로컬 비교값**이라 HTTP 종단 지연도 운영 p99도 아닙니다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖입니다. 별도 부하 테스트로 확인해야 합니다. --- @@ -22,8 +22,8 @@ 기능 요구사항(FR)만 보면 평범한 조회입니다. 제가 해결해야 했던 부분은 고트래픽에서도 조회량이 데이터 규모에 비례해 늘지 않게 만드는 비기능 요구사항(NFR)이었습니다. 다만 최초 구현에는 FR 전체를 -한꺼번에 넣지 않았습니다. 공개 범위 판정·최신 3개 제한·mentioned 관계·커서 페이징을 제외하고, -**조회 문제를 드러내기 위한 기능적 기준선**부터 만들었습니다(§5.3). +한꺼번에 넣지 않았습니다. 공개 범위 판정·최신 3개 제한·mentioned 관계·커서 페이징을 제외하고 +조회 문제를 드러내기 위한 기능적 기준선부터 만들었습니다. --- @@ -31,7 +31,7 @@ 최종 조회 구조를 먼저 정하고 구현하지는 않았습니다. 기준선을 측정하자 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 동시에 드러났습니다. 둘은 순서대로 생긴 문제가 아니라 같은 구현에서 갈라진 -문제였습니다. 저는 두 문제를 Fetch Join으로 한꺼번에 풀어 보려 했고, 그 시도가 다중 컬렉션과 +문제였습니다. 저는 두 문제를 Fetch Join으로 한꺼번에 풀어 보려 했습니다. 그 시도가 다중 컬렉션과 페이징 문제를 다시 만들었습니다. 이후 Batch Fetch → DTO Projection → 아이템별 Top-3 → Keyset Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. @@ -55,7 +55,7 @@ Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. ### 3.1 관계와 스키마 -- 한 **user**는 여러 **feed_item**을 가집니다. +- 한 **user**에게는 **feed_item**이 여럿 있습니다. - 한 **page**에는 여러 **feed_item**이 딸립니다. - 한 **feed_item**에는 **highlights**가 여럿입니다. @@ -73,15 +73,15 @@ Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. [Editable source](assets/diagrams/baseline-schema/baseline-schema.drawio) · [Grounded VizSpec](.techviz/baseline-schema/spec.json) -위 ERD는 제가 처음 만든 기준선(L1) 스키마입니다. `FeedItem`은 `(user, page)` 조합당 하나입니다. -같은 사용자가 같은 페이지에 하이라이트를 여러 개 만들어도 피드 아이템은 하나이며, 이 정의를 +위 ERD는 제가 처음 만든 기준선 스키마입니다. `FeedItem`은 `(user, page)` 조합당 하나입니다. +같은 사용자가 같은 페이지에 하이라이트를 여러 개 만들어도 피드 아이템은 하나입니다. 이 정의를 `UNIQUE(user_id, page_id)` 제약으로 옮겼습니다. 과제 완료 목표 모델에는 `feed_item_mentions`(피드 아이템 ↔ mentioned 사용자) 관계도 필요했습니다. 공개 범위가 핵심 요구사항이므로 최종 스키마에는 반드시 들어갑니다. 다만 퍼시스턴스 계층의 -테이블·엔티티·시더는 공개 범위 단계보다 앞선 §9에서 추가했습니다. `MultipleBagFetchException`을 +테이블·엔티티·시더는 공개 범위 단계보다 앞선 9절에서 추가했습니다. `MultipleBagFetchException`을 재현하려면 fetch join할 두 번째 bag이 필요했기 때문입니다. 도메인·응답 매핑·공개 범위 판정은 뒤 -단계에 남겨 두었습니다. 따라서 현재 기준선 그림과 최종 스키마는 구분해서 읽어야 합니다. +단계에 남겨 두었습니다. 현재 기준선 그림과 최종 스키마는 구분해서 읽어야 합니다. @@ -101,7 +101,7 @@ Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. > - **질문:** 하이라이트 없는 FeedItem이 존재할 수 있는가? > - **현재 상태:** 미결정 · 현재 스키마: `first_highlighted_at timestamptz`(nullable, NOT NULL 아님). 시더는 하이라이트가 만든 FeedItem이므로 항상 값을 채웁니다. > - **영향:** 정렬 / keyset cursor의 null 처리(`NULLS LAST`·커서 위치) / 부분 인덱스 predicate / FeedItem 생성 lifecycle. -> - **결정 시점:** keyset 페이징 단계(L15) 이전. NOT NULL로 좁힐지, null 정렬 위치를 정의할지를 그때 결론 냅니다. +> - **결정 시점:** keyset 페이징 단계 이전. NOT NULL로 좁힐지, null 정렬 위치를 정의할지를 그때 결론 냅니다. ### 3.2 식별자는 `ResourceId` 값 객체로 생성한다 @@ -120,7 +120,7 @@ void registerFeedLike(UserId userId, FeedItemId feedItemId) { ... } registerFeedLike(feedItemId, userId); // 컴파일 실패 (타입 불일치) ``` -**② 도메인 제약의 자가 검증.** 생성 경로가 곧 신뢰 경계입니다. `FeedItemId`가 존재한다는 것 자체가 "유효한 형식"을 보장합니다. 다만 이 정규식이 보장하는 것은 **8-4-4-4-12 hex의 UUID 문자열 형태**뿐입니다. UUID version이 7인지, variant가 RFC 규격인지는 검사하지 않습니다. "신규 ID가 UUIDv7 정책을 따른다"는 조건은 값 객체가 아니라 `IdFactory`가 보장합니다. version까지 강제하려면 값 객체에서 `UUID.fromString(value).version() == 7`을 검사해야 합니다. +**② 도메인 제약의 자가 검증.** 생성 경로가 곧 신뢰 경계입니다. `FeedItemId`가 존재한다는 것 자체가 "유효한 형식"을 보장합니다. 다만 이 정규식이 보장하는 것은 8-4-4-4-12 hex의 UUID 문자열 형태뿐입니다. UUID version이 7인지, variant가 RFC 규격인지는 검사하지 않습니다. "신규 ID가 UUIDv7 정책을 따른다"는 조건은 값 객체가 아니라 `IdFactory`가 보장합니다. version까지 강제하려면 값 객체에서 `UUID.fromString(value).version() == 7`을 검사해야 합니다. ```java @ValueObject @@ -136,7 +136,7 @@ public record FeedItemId(String value) implements ResourceId { } ``` -**③ 식별자 규격의 캡슐화.** ID 정책이 ULID → UUIDv7로 바뀌어도 비즈니스 로직은 타입만 보므로 **도메인 호출부의 변경을 줄입니다**. 그렇다고 값 객체 하나만 고치면 끝나는 것은 아닙니다. ID 생성 `IdFactory`, DB 컬럼 타입, 변환 매퍼, 커서 인코딩, 인덱스 크기·정렬 특성, 마이그레이션도 함께 영향을 받습니다. 값 객체는 이런 변경이 도메인 로직 전반으로 번지는 일을 줄여 줍니다. +**③ 식별자 규격의 캡슐화.** ID 정책이 ULID → UUIDv7로 바뀌어도 비즈니스 로직은 타입만 보므로 도메인 호출부의 변경을 줄입니다. 그렇다고 값 객체 하나만 고치면 끝나는 것은 아닙니다. ID 생성 `IdFactory`, DB 컬럼 타입, 변환 매퍼, 커서 인코딩, 인덱스 크기·정렬 특성, 마이그레이션도 함께 영향을 받습니다. 값 객체는 이런 변경이 도메인 로직 전반으로 번지는 일을 줄여 줍니다. **④ 생성 정책 교체.** `IdFactory` 구현을 교체하면 다른 ID 정책으로 바꿀 수 있습니다. @@ -162,10 +162,10 @@ public class FeedItemJpaEntity extends AuditableEntity { // 클래스는 publi 연관 게터가 열려 있으면 상위 계층이 엔티티 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트·DB 스펙에 의존하게 됩니다. package-private로 좁히면 같은 패키지의 어댑터·매퍼만 그래프를 순회할 수 있습니다. > **흔한 오해 ①**: "엔티티 클래스를 package-private로 강제합니다." -> **실제**: package-private인 것은 클래스가 아니라 연관 게터이며, 이는 규칙이 아니라 방어적 캡슐화 관례입니다. 엔티티가 계층 밖으로 새는 것은 "컨트롤러가 엔티티를 의존/반환하지 않는다", "쿼리 포트가 엔티티 타입을 노출하지 않는다"는 경계 규칙이 막습니다. +> **실제**: package-private인 것은 클래스가 아니라 연관 게터입니다. 규칙이 아니라 방어적 캡슐화 관례입니다. 엔티티가 계층 밖으로 새는 것은 "컨트롤러가 엔티티를 의존/반환하지 않는다", "쿼리 포트가 엔티티 타입을 노출하지 않는다"는 경계 규칙이 막습니다. > **흔한 오해 ②**: "JPA 엔티티 클래스는 반드시 public이어야 합니다." -> **실제**: Jakarta Persistence 규격은 엔티티에 top-level(또는 static inner)·non-final·무인자 생성자 등을 요구하지만, 클래스 자체가 public이길 요구하지는 않습니다. 이 프로젝트에서는 도구 호환성을 단순하게 유지하려고 엔티티 클래스를 public으로 두었습니다. 연관 게터를 package-private로 좁혀도 매핑되는 이유는 이 엔티티가 **field access**(`@Id`가 필드에 붙음)를 사용하기 때문입니다. property access였다면 영속 속성 게터는 public/protected여야 합니다. +> **실제**: Jakarta Persistence 규격은 엔티티에 top-level(또는 static inner)·non-final·무인자 생성자 등을 요구하지만 클래스 자체가 public이길 요구하지는 않습니다. 이 프로젝트에서는 도구 호환성을 단순하게 유지하려고 엔티티 클래스를 public으로 두었습니다. 연관 게터를 package-private로 좁혀도 매핑되는 이유는 이 엔티티가 field access(`@Id`가 필드에 붙음)를 사용하기 때문입니다. property access였다면 영속 속성 게터는 public/protected여야 합니다. --- @@ -192,11 +192,11 @@ class FeedPersistenceIT { } ``` -- **실제 PostgreSQL 16**(Testcontainers)을 사용했습니다. 컨테이너 필드가 `static`이므로 테스트 메서드마다 새로 띄우지 않고 **`FeedPersistenceIT` 실행 동안 하나를 공유**합니다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료합니다. 각 테스트의 데이터는 `@DataJpaTest` 트랜잭션 롤백과 명시적인 `em.clear()`로 격리했습니다. H2 같은 인메모리 DB를 쓰지 않은 이유는 N+1의 쿼리 수뿐 아니라 EXPLAIN 실행계획(Index/Seq Scan)과 인덱스 동작도 DB 엔진마다 다르기 때문입니다. 인메모리 DB에서 재면 운영 환경인 PostgreSQL과 다른 계획이 나와 잘못된 결론에 이를 수 있습니다. 엔진마다 계획이 달라지는 이유는 §4.6에서 다시 설명합니다. 재현성을 더 높이려면 `postgres:16-alpine` 태그보다 patch 버전이나 digest(`@sha256:...`)를 고정하는 편이 낫습니다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문입니다. +- **실제 PostgreSQL 16**(Testcontainers)을 사용했습니다. 컨테이너 필드가 `static`이므로 테스트 메서드마다 새로 띄우지 않고 `FeedPersistenceIT` 실행 동안 하나를 공유합니다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료합니다. 각 테스트의 데이터는 `@DataJpaTest` 트랜잭션 롤백과 명시적인 `em.clear()`로 격리했습니다. H2 같은 인메모리 DB를 쓰지 않은 이유는 N+1의 쿼리 수뿐 아니라 EXPLAIN 실행계획(Index/Seq Scan)과 인덱스 동작도 DB 엔진마다 다르기 때문입니다. 인메모리 DB에서 재면 운영 환경인 PostgreSQL과 다른 계획이 나와 잘못된 결론에 이를 수 있습니다. 엔진마다 계획이 달라지는 이유는 4.6절에서 다시 설명합니다. 재현성을 더 높이려면 `postgres:16-alpine` 태그보다 patch 버전이나 digest(`@sha256:...`)를 고정하는 편이 낫습니다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문입니다. - 스키마는 운영 마이그레이션과 같게 맞췄습니다. Flyway `V6__feed.sql`을 그대로 적용하고 `ddl-auto=validate`로 엔티티가 기대하는 테이블·컬럼·타입의 기본 불일치를 조기에 잡았습니다. 다만 `validate`만으로 모든 드리프트를 막을 수는 없습니다. 인덱스 구성, 부분 인덱스 predicate, check 제약, FK 삭제 정책, 컬럼 순서 등은 검증 범위 밖이므로 마이그레이션 검증과 catalog 조회로 따로 확인합니다. - 퍼시스턴스 어댑터(`FeedQueryAdapter`)를 JPA 슬라이스에서 직접 호출합니다. HTTP를 거치지 않습니다. 이유는 둘입니다. 하나, N+1은 조회 계층의 현상이므로 웹·보안·직렬화 노이즈를 배제하고 순수한 쿼리 행동만 관찰합니다. 둘, 슬라이스 트랜잭션이 열려 있어 지연 로딩이 결정적으로 재현됩니다. -- **측정 도구**는 추가 라이브러리 없이 세 가지를 사용했습니다. 전용 도구 대신 이 조합을 고른 이유는 §4.7에서 설명합니다. - - Hibernate `Statistics` — **획득한 PreparedStatement 수**(`getPrepareStatementCount`), **초기화된 컬렉션 수**(`getCollectionFetchCount`), 엔티티 로드 수를 줍니다. 이는 SQL **shape별 정확한 실행 횟수**가 아닙니다. shape별 실행 횟수를 원문 SQL 수준에서 확정하려면 SQL 로그·`StatementInspector`·datasource-proxy·p6spy·PostgreSQL statement logging 중 하나로 **별도로 수집**해야 합니다(§6.1). +- **측정 도구**는 추가 라이브러리 없이 세 가지를 사용했습니다. 전용 도구 대신 이 조합을 고른 이유는 4.7절에서 설명합니다. + - Hibernate `Statistics` — **획득한 PreparedStatement 수**(`getPrepareStatementCount`), **초기화된 컬렉션 수**(`getCollectionFetchCount`), 엔티티 로드 수를 줍니다. 이는 SQL shape별 정확한 실행 횟수가 아닙니다. shape별 실행 횟수를 원문 SQL 수준에서 확정하려면 SQL 로그·`StatementInspector`·datasource-proxy·p6spy·PostgreSQL statement logging 중 하나로 별도로 수집해야 합니다. - `System.nanoTime` — 지연. - `EXPLAIN (ANALYZE, BUFFERS)` — 실행계획. @@ -214,7 +214,7 @@ seed(N): page = pages[i], # 1:1: 아이템 전용 페이지 visibility = (i%10 <6 ? PUBLIC : i%10 <8 ? MENTIONED : PRIVATE) # 6:2:2 } - highlightCount = max(1, round(500 / (i+1)^1.15)) # 순위가 낮을수록 많음 (§4.3) + highlightCount = max(1, round(500 / (i+1)^1.15)) # 순위가 낮을수록 많음 highlight[i] = highlightCount 개 생성 ``` @@ -223,9 +223,9 @@ seed(N): | **feed_item** | **N** | 루프를 N번 돈다 (`N ∈ {10, 100, 1000}`) | | **page** | **N** | `pages[i]` — 아이템마다 전용 페이지(1:1) | | **user** | **max(3, min(20, N/5+1))** | 소수만 만들고 `users[i % size]`로 **돌려 씁니다**. N=10→3명, N=100·1000→20명 | -| **highlight** | **Σ Zipf-like** | 아이템마다 순위 기반으로 개수가 다름(§4.3). N=10→**1,285** · N=100→**1,961** · N=1,000→**2,917** | +| **highlight** | **Σ Zipf-like** | 아이템마다 순위 기반으로 개수가 다름. N=10→**1,285** · N=100→**1,961** · N=1,000→**2,917** | -핵심은 user와 page가 같은 `@ManyToOne`인데 개수가 정반대라는 데 있습니다. user는 소수를 공유하고 page는 아이템마다 하나씩 만들었습니다. 이 비대칭 덕분에 뒤에서 같은 즉시 로딩인데도 조회 수가 달라지는 현상을 확인할 수 있습니다(§6.3). +핵심은 user와 page가 같은 `@ManyToOne`인데 개수가 정반대라는 데 있습니다. user는 소수를 공유하고 page는 아이템마다 하나씩 만들었습니다. 이 비대칭 덕분에 뒤에서 같은 즉시 로딩인데도 조회 수가 달라지는 현상을 확인할 수 있습니다. ### 4.3 하이라이트 개수는 왜 Zipf 형태의 편중 분포로 만드나 @@ -236,7 +236,7 @@ seed(N): highlightCount(i) = max(1, round(500 / (i+1)^1.15)) // 상한 500, 하한 1 ``` -Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고, 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고, floor를 적용한 truncated Zipf-like 분포에 가깝습니다. +Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고 floor를 적용한 truncated Zipf-like 분포에 가깝습니다. 공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv)): @@ -261,24 +261,24 @@ Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고, 고전적 왜 균일·정규분포가 아니라 편중 분포인가: - 균일(모두 3개)이면 과제의 "페이지에 하이라이트가 아무리 많아도"라는 조건을 재현하지 못합니다. 머리(수백 개)가 만드는 전송량·메모리 압박도, 아이템별 최신 3개(Top-N)를 뽑아야 하는 필요성도 사라집니다. - 정규분포는 평균 근처로 몰려 "극단적으로 많은 소수"가 없습니다. 역시 머리가 안 생깁니다. -- "무거운 머리 + 긴 꼬리"를 재현하는 방법은 여러 가지입니다(log-normal, negative binomial, Pareto, 경험적 히스토그램 등). 그중 순위 기반으로 파라미터 하나(`s`)만 바꾸면 편중 강도를 조절할 수 있는 Zipf-like 형태를 골랐습니다. +- "무거운 머리 + 긴 꼬리"를 재현하는 방법은 여러 가지입니다(log-normal, negative binomial, Pareto, 경험적 히스토그램 등). 그중 Zipf-like 형태를 골랐습니다. 순위 기반이라 파라미터 하나(`s`)만 바꾸면 편중 강도를 조절할 수 있기 때문입니다. -이 분포 때문에 하이라이트 총량은 N에 정비례하지 않습니다. N=10에서 이미 1,285개인데(0번 아이템 혼자 500개), N을 100배(1,000)로 키워도 2,917개에 그칩니다. 꼬리 아이템은 1개씩만 더할 뿐 머리가 총량을 지배하기 때문입니다. 반면 조회 수(`collectionFetches`)는 하이라이트 총량이 아니라 아이템 수 N에 정비례합니다. 이 대비가 §6의 핵심입니다. +이 분포 때문에 하이라이트 총량은 N에 정비례하지 않습니다. N=10에서 이미 1,285개인데(0번 아이템 혼자 500개), N을 100배(1,000)로 키워도 2,917개에 그칩니다. 꼬리 아이템은 1개씩만 더할 뿐 머리가 총량을 지배하기 때문입니다. 반면 조회 수(`collectionFetches`)는 하이라이트 총량이 아니라 아이템 수 N에 정비례합니다. 이 대비가 6절의 핵심입니다. ### 4.4 왜 이렇게 구성했는가 (설계 의도) -- **하이라이트 Zipf-like 편중** → "매우 많은 하이라이트" 조건 + Top-N 필요성 재현(§4.3). +- **하이라이트 Zipf-like 편중** → "매우 많은 하이라이트" 조건 + Top-N 필요성 재현. - **User 공유 vs Page 전용** → 같은 즉시 로딩인데 조회 수가 갈리는 것을 데이터로 보입니다. User는 1차 캐시가 재조회를 걸러 distinct 유저 수(≤20)로 억제되고 Page는 아이템마다 달라 그대로 N번. 모두 유니크 유저였다면 이 대비가 사라집니다. "EAGER secondary SELECT 반복 횟수는 **fetch 방식 × distinct 연관 대상 수의 결합**으로 달라진다"는 핵심을 못 보입니다. -- **공개 범위 6:2:2** → 세 분기(public / mentioned / private)를 모두 충분히 포함하도록 설정한 **합성 비율**로, 이후 공개 범위 필터링·인덱싱 실험의 기반을 미리 심습니다. +- **공개 범위 6:2:2** → 세 분기(public / mentioned / private)를 모두 충분히 포함하도록 설정한 합성 비율로, 이후 공개 범위 필터링·인덱싱 실험의 기반을 미리 심습니다. - **시간 분산** → `first_highlighted_at` 정렬키를 만들어 시간순 페이징(keyset)·정렬 인덱스 실험 기반을 마련합니다. ### 4.5 측정 규율 — 캐시와 통계가 결과를 왜곡하지 않게 -- 같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹습니다. 그래서 지연 반복 루프는 **매 반복마다** 타이머를 켜기 전에 `em.clear()`를 호출합니다. 덕분에 (a) 매 호출이 실제로 DB를 때리고, (b) `clear()` 자체 비용은 측정 구간 밖에 놓입니다. 두 번째 반복부터 캐시가 조회량을 갉아먹어 값이 섞이는 오염이 없습니다. +- 같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹습니다. 지연 반복 루프는 **매 반복마다** 타이머를 켜기 전에 `em.clear()`를 호출합니다. 덕분에 (a) 매 호출이 실제로 DB를 때리고, (b) `clear()` 자체 비용은 측정 구간 밖에 놓입니다. 두 번째 반복부터 캐시가 조회량을 갉아먹어 값이 섞이는 오염이 없습니다. - 쿼리 수는 `stats.clear()` 직후 딱 1회 실행분으로만 읽어 "회당 정확값"을 얻습니다. -- 지연은 쿼리 수와 분리해 별도로 반복 측정하고, 앞의 몇 회는 JIT·커넥션 워밍업 구간으로 보고 버렸습니다. **그래도 이 값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값입니다.** GC·JIT 영향이 남아 있으므로 절대값보다 N에 따른 증가 방향만 확인했습니다. 그래서 §6.2에도 `p50`·`p99`가 아니라 "median/max of 5"로 적었습니다. +- 지연은 쿼리 수와 분리해 별도로 반복 측정하고 앞의 몇 회는 JIT·커넥션 워밍업 구간으로 보고 버렸습니다. 그래도 이 값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값입니다. GC·JIT 영향이 남아 있으므로 절대값보다 N에 따른 증가 방향만 확인했습니다. 그래서 6.2절에도 `p50`·`p99`가 아니라 "median/max of 5"로 적었습니다. -**한 데이터셋에 여러 변수가 섞여 있다는 한계.** 현재 데이터셋은 N을 키우면 반환 FeedItem 수·Highlight 총 행수·엔티티/DTO 생성량·DB 왕복이 **동시에** 늘어납니다. 따라서 지연의 원인을 어느 하나에만 돌릴 수 없습니다(§6.2). 이후에는 변수를 하나씩 격리한 데이터셋으로 다시 검증할 계획입니다. 아래 A/B/C는 **아직 실행하지 않았으며, 실행하기 전에는 수치를 채우지 않습니다**. +**한 데이터셋에 여러 변수가 섞여 있다는 한계.** 현재 데이터셋은 N을 키우면 반환 FeedItem 수·Highlight 총 행수·엔티티/DTO 생성량·DB 왕복이 동시에 늘어납니다. 따라서 지연의 원인을 어느 하나에만 돌릴 수 없습니다. 이후에는 변수를 하나씩 격리한 데이터셋으로 다시 검증할 계획입니다. 아래 A/B/C는 **아직 실행하지 않았으며 실행하기 전에는 수치를 채우지 않습니다**. | 격리 데이터셋 | 구성 | 격리하는 변수 | 상태 | |---|---|---|---| @@ -288,7 +288,7 @@ Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고, 고전적 ### 4.6 왜 DB 엔진마다 실행계획·인덱스가 다른가 -§4.1에서 "인메모리 H2를 쓰지 않는다"의 근거로 "실행계획·인덱스 동작이 엔진마다 다르다"를 들었습니다. 왜 다른지를 짚습니다. 비용 기반 옵티마이저는 가능한 여러 계획의 **비용을 추정해 가장 싼 것을 고릅니다.** 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다릅니다. 네 축이 갈립니다. +앞서 4.1절에서 "인메모리 H2를 쓰지 않는다"의 근거로 "실행계획·인덱스 동작이 엔진마다 다르다"를 들었습니다. 왜 다른지를 짚습니다. 비용 기반 옵티마이저는 가능한 여러 계획의 비용을 추정해 가장 싼 것을 고릅니다. 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다릅니다. 네 축이 갈립니다. | 계획을 가르는 축 | PostgreSQL 16 (운영) | H2 (인메모리) | MySQL / InnoDB (대조) | |---|---|---|---| @@ -297,31 +297,31 @@ Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고, 고전적 | **저장·가시성** | heap + MVCC. 인덱스 스캔도 **가시성 맵**을 봐야 하고, 그래서 커버링 인덱스라도 벌크 로드 직후엔 index-only scan이 heap을 재방문한다 | 인메모리 구조라 PostgreSQL식 가시성 맵·heap 재방문 비용 구조가 없다 | 클러스터드 인덱스(PK 자체가 데이터) + undo. 2차 인덱스는 PK 재조회 | | **인덱스 종류·기능** | B-tree/Hash/GiST/GIN/BRIN/SP-GiST, **부분 인덱스**·표현식 인덱스·`DESC`/`NULLS FIRST\|LAST` 정렬 인덱스 | 주로 B-tree/hash, 부분 인덱스 미지원 | B-tree 중심, 부분 인덱스 미지원·함수 인덱스 8.0+ | -계획은 이 네 축의 함수입니다. 그래서 **같은 쿼리·같은 데이터라도** 엔진이 바뀌면 (a) Seq Scan ↔ Index Scan 선택이 뒤집히고, (b) 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, (c) PostgreSQL 특유의 가시성 맵·index-only scan 미묘함이 재현되지 않습니다. 인메모리로 재서 나온 계획을 운영 PostgreSQL 계획으로 읽으면 이 세 지점에서 **체계적으로 틀린 결론**에 이릅니다. +계획은 이 네 축의 함수입니다. 그래서 같은 쿼리·같은 데이터라도 엔진이 바뀌면 (a) Seq Scan ↔ Index Scan 선택이 뒤집히고, (b) 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, (c) PostgreSQL 특유의 가시성 맵·index-only scan 미묘함이 재현되지 않습니다. 인메모리로 재서 나온 계획을 운영 PostgreSQL 계획으로 읽으면 이 세 지점에서 **체계적으로 틀린 결론**에 이릅니다. 이건 추상적 우려가 아니라 이 문서 안에서 이미 두 번 부딪히는 축입니다. -- **통계 의존** — §6.4의 Plan A는 추정 `rows=1`과 실제 `rows=500`이 500배 차이 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 담지 못했다는 가설을 세웠고, Plan B에서 검증합니다. 통계를 수집하고 사용하는 방식이 엔진마다 다르므로 이 현상은 **실제 엔진에서만** 정확하게 관찰할 수 있습니다. -- **선택도 의존** — §8은 "테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다"고 유보합니다. Seq↔Index 판정 자체가 비용 모델·선택도 추정의 산물이라, 다른 엔진이면 다른 임계에서 갈립니다. -- **인덱스 기능 의존** — 이후 랩의 공개 범위 인덱싱·keyset 정렬(§8, OD-01의 `NULLS LAST` 처리)은 부분 인덱스·정렬 인덱스 기능에 기댑니다. 이 기능이 없는 엔진에서 실험하면 접근 경로 자체가 달라 결과가 무의미합니다. +- **통계 의존** — 6.4절의 Plan A는 추정 `rows=1`과 실제 `rows=500`이 500배 차이 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 담지 못했다는 가설을 세웠고 Plan B에서 검증합니다. 통계를 수집하고 사용하는 방식이 엔진마다 다르므로 이 현상은 실제 엔진에서만 정확하게 관찰할 수 있습니다. +- **선택도 의존** — 8절은 "테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다"고 유보합니다. Seq↔Index 판정 자체가 비용 모델·선택도 추정의 산물이라 다른 엔진이면 다른 임계에서 갈립니다. +- **인덱스 기능 의존** — 이후 랩의 공개 범위 인덱싱·keyset 정렬(8절, OD-01의 `NULLS LAST` 처리)은 부분 인덱스·정렬 인덱스 기능에 기댑니다. 이 기능이 없는 엔진에서 실험하면 접근 경로 자체가 달라 결과가 무의미합니다. -정리하면 측정 대상이 **계획·인덱스 동작**인 이상 DB는 대체재가 아니라 측정 대상의 일부입니다. 그래서 운영과 같은 PostgreSQL을 사용했습니다(§4.1). +측정 대상이 **계획·인덱스 동작**인 이상 DB는 대체재가 아니라 측정 대상의 일부입니다. 그래서 운영과 같은 PostgreSQL을 사용했습니다. ### 4.7 왜 전용 측정 도구 대신 내장 3종인가 -§4.1에서 사용한 Hibernate `Statistics`·`System.nanoTime`·`EXPLAIN`은 모두 **이미 스택에 있는 도구**라 의존성을 더하지 않습니다. p6spy·datasource-proxy(정확한 SQL별 실행 수), JMH(엄밀한 지연 벤치), APM·프로파일러(종단 지연·플레임그래프) 같은 전용 도구도 후보였습니다. 다만 L1에서 확인하려던 것은 정밀한 지연이나 운영 처리량이 아니라 "쿼리 발생량이 N에 비례해 늘어나는가"라는 방향성이었습니다. 그래서 주장의 범위에 맞춰 내장 도구를 선택했습니다. +4.1절에서 사용한 Hibernate `Statistics`·`System.nanoTime`·`EXPLAIN`은 모두 **이미 스택에 있는 도구**라 의존성을 더하지 않습니다. p6spy·datasource-proxy(정확한 SQL별 실행 수), JMH(엄밀한 지연 벤치), APM·프로파일러(종단 지연·플레임그래프) 같은 전용 도구도 후보였습니다. 다만 기준선 단계에서 확인하려던 것은 정밀한 지연이나 운영 처리량이 아니라 "쿼리 발생량이 N에 비례해 늘어나는가"라는 방향성이었습니다. 주장의 범위에 맞춰 내장 도구를 선택했습니다. | 측정 대상 | 쓴 도구 (내장·무의존) | 주는 것 / 한계 | 전용 대안 | 왜 지금 이걸로 충분한가 | |---|---|---|---|---| -| **쿼리 발생 형태(N+1)** | Hibernate `Statistics` | 초기화 컬렉션 수·PreparedStatement 수. shape별 정확 SQL 수는 아님(§6.1) | p6spy · datasource-proxy · QuickPerf `@ExpectSelect` | 필요한 건 성장 **형태**(≈`N`)뿐 → 무의존 카운터로 충분. 정확한 per-shape SQL이 필요해지는 단계(Batch Fetch로 "컬렉션 수 = SQL 수" 등식이 깨지는 L5)에서 도입한다고 §6.1에 이미 예고 | -| **지연** | `System.nanoTime` | 단일 스레드·warm 근사(방향성만) | JMH | L1은 절대값·p99를 주장하지 않습니다. 게다가 지연 로딩을 재현하려면 **테스트 트랜잭션을 연 채 퍼시스턴스 슬라이스 안에서** 재야 하는데, 이는 격리 JVM·steady-state를 전제하는 JMH와 안 맞습니다. 도구 정밀도가 주장 강도를 넘으면 "이게 운영 수치"라는 오해를 부른다 | +| **쿼리 발생 형태(N+1)** | Hibernate `Statistics` | 초기화 컬렉션 수·PreparedStatement 수. shape별 정확 SQL 수는 아님 | p6spy · datasource-proxy · QuickPerf `@ExpectSelect` | 필요한 건 성장 **형태**(≈`N`)뿐 → 무의존 카운터로 충분. 정확한 per-shape SQL이 필요해지는 단계(Batch Fetch로 "컬렉션 수 = SQL 수" 등식이 깨지는 지점)에서 도입한다고 6.1절에 이미 예고 | +| **지연** | `System.nanoTime` | 단일 스레드·warm 근사(방향성만) | JMH | 기준선 단계는 절대값·p99를 주장하지 않습니다. 게다가 지연 로딩을 재현하려면 **테스트 트랜잭션을 연 채 퍼시스턴스 슬라이스 안에서** 재야 하는데, 이는 격리 JVM·steady-state를 전제하는 JMH와 안 맞습니다. 도구 정밀도가 주장 강도를 넘으면 "이게 운영 수치"라는 오해를 부른다 | | **실행계획** | `EXPLAIN (ANALYZE, BUFFERS)` | 운영 엔진이 실제로 고른 plan·buffers의 **원천** | APM · JFR · async-profiler | 엔진이 선택한 계획 자체가 필요하므로 native EXPLAIN을 사용했습니다. APM은 운영 관측에 더 적합합니다 | 세 선택을 관통하는 원리는 셋입니다. 1. **의존성 무추가** — 이 측정은 스켈레톤 모듈의 슬라이스 테스트 안에서 돕니다. 클래스패스에 이미 있는 것만으로 재현되면 "이 도구 깔고 이 설정 맞춰야 재현됨" 같은 장벽이 없습니다. -2. **정밀도 = 주장 강도.** 방향성만 확인하는 값에 JMH·APM의 엄밀도를 붙인다고 근거가 더 강해지지는 않습니다. 오히려 측정 데이터보다 정밀한 결론처럼 보일 수 있습니다. 같은 이유로 지연을 `p50`·`p99`가 아니라 "중앙값/최댓값(5회)"로 적었습니다(§6.2). -3. **측정 지점의 제약이 도구를 고릅니다.** N+1은 열린 트랜잭션·지연 로딩에서만 결정적으로 재현되므로(§4.1) 측정은 그 지점 안에 있어야 합니다. HTTP 종단·격리 JVM을 전제하는 도구는 이 지점을 못 잡습니다. +2. **정밀도 = 주장 강도.** 방향성만 확인하는 값에 JMH·APM의 엄밀도를 붙인다고 근거가 더 강해지지는 않습니다. 오히려 측정 데이터보다 정밀한 결론처럼 보일 수 있습니다. 같은 이유로 지연을 `p50`·`p99`가 아니라 "중앙값/최댓값(5회)"로 적었습니다. +3. **측정 지점의 제약이 도구를 고릅니다.** N+1은 열린 트랜잭션·지연 로딩에서만 결정적으로 재현되므로 측정은 그 지점 안에 있어야 합니다. HTTP 종단·격리 JVM을 전제하는 도구는 이 지점을 못 잡습니다. 측정 질문이 바뀌면 도구도 그에 맞게 바꿉니다. 다음 단계에 필요한 도구는 아래처럼 정리했습니다. @@ -360,10 +360,10 @@ public List loadFeed(int page, int size) { ### 5.2 조회 전략은 포트 뒤 어댑터의 책임 -조회 전략을 바꾸더라도 웹·애플리케이션 계층까지 함께 바꾸고 싶지는 않았습니다. 그래서 상위 -계층에는 조회 사용자·페이지 크기·반환할 `FeedSummary`만 드러내고, 구체적인 조회 방식은 +조회 전략을 바꾸더라도 웹·애플리케이션 계층까지 함께 바꾸고 싶지는 않았습니다. 상위 +계층에는 조회 사용자·페이지 크기·반환할 `FeedSummary`만 드러내고 구체적인 조회 방식은 퍼시스턴스 어댑터에 두었습니다. 조회 경로는 `GET /feed` → `FeedController` → -`GetFeedUseCase` → `FeedQueryPort`이며, `FeedQueryAdapter`가 이 포트를 구현해 PostgreSQL을 +`GetFeedUseCase` → `FeedQueryPort`이며 `FeedQueryAdapter`가 이 포트를 구현해 PostgreSQL을 조회합니다. @@ -381,7 +381,7 @@ public List loadFeed(int page, int size) { Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 중 무엇을 쓰는지는 `FeedQueryPort` -구현의 책임입니다. 그래서 조회 전략을 교체해도 상위 계층은 바뀌지 않습니다. +구현의 책임입니다. 조회 전략을 교체해도 상위 계층은 바뀌지 않습니다. ### 5.3 기준선이 의도한 범위에서는 정상이다 @@ -396,17 +396,17 @@ Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 중 무엇을 쓰는 - mentioned 사용자 관계 - 최종 커서(keyset) 페이징 -따라서 이 단계는 전체 기능 요구사항의 완료본이 아니라, **조회 문제를 발견하기 위한 기능적 기준선**입니다. "정상"은 이 기준선이 의도한 범위에 한정된 말이고, 다음 관심사는 NFR입니다. +이 단계는 전체 기능 요구사항의 완료본이 아니라 **조회 문제를 발견하기 위한 기능적 기준선**입니다. "정상"은 이 기준선이 의도한 범위에 한정된 말입니다. 다음 관심사는 NFR입니다. ### 5.4 왜 추가 쿼리가 나가나 — EAGER는 "로딩 시점" 계약이지 JOIN 보장이 아니다 -엔티티에는 fetch를 따로 명시하지 않았습니다. 따라서 `@ManyToOne`은 즉시 로딩(EAGER), +엔티티에는 fetch를 따로 명시하지 않았습니다. `@ManyToOne`은 즉시 로딩(EAGER), `@OneToMany`는 지연 로딩(LAZY)이라는 JPA 기본값을 사용합니다. 여기서 중요한 지점이 있습니다. `FetchType.EAGER`는 연관이 **반환 시점까지 로딩돼 있어야 한다**는 계약이지, 반드시 루트 SQL의 JOIN으로 가져오라는 의미가 아닙니다. -- `findAllBy(...)`는 파생 쿼리입니다. **현재 Hibernate 기준선에서는** 루트(feed_items)를 먼저 조회한 뒤, 쿼리에서 fetch join하지 않은 EAGER ToOne 연관을 JOIN이 아니라 별도의 2차 SELECT로 채웠습니다. 루트를 가져온 다음에 user·page를 행마다 조회합니다. -- 단건 조회(`entityManager.find(id)`)에서는 Hibernate가 JOIN으로 가져오는 경우가 있지만, 그건 provider·매핑·fetch profile에 달린 동작이지 일반적인 JPA 보장이 아닙니다. 리스트 파생 쿼리인 여기서는 2차 SELECT로 나갔습니다. "즉시 로딩이면 한 번에 가져오겠지"라는 착각이 깨지는 대목입니다. +- `findAllBy(...)`는 파생 쿼리입니다. **현재 Hibernate 기준선에서는** 루트(feed_items)를 먼저 조회한 뒤 EAGER ToOne 연관을 채웠습니다. 쿼리에서 fetch join하지 않은 연관이라 JOIN이 아니라 별도의 2차 SELECT였습니다. 루트를 가져온 다음에 user·page를 행마다 조회합니다. +- 단건 조회(`entityManager.find(id)`)에서는 Hibernate가 JOIN으로 가져오는 경우가 있지만 그건 provider·매핑·fetch profile에 달린 동작이지 일반적인 JPA 보장이 아닙니다. 리스트 파생 쿼리인 여기서는 2차 SELECT로 나갔습니다. "즉시 로딩이면 한 번에 가져오겠지"라는 착각이 깨지는 대목입니다. - `highlights`는 지연 로딩이라 루트 조회 시엔 나가지 않다가 매핑 루프에서 `getHighlights()`에 접근하는 순간 그 아이템의 컬렉션을 1쿼리로 가져옵니다. 아이템마다 한 번씩입니다. @@ -435,19 +435,19 @@ Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 중 무엇을 쓰는 안 됩니다. - `getCollectionFetchCount()` = **초기화된 컬렉션 수**. "실행된 SELECT SQL 수"가 아닙니다. -- `getPrepareStatementCount()` = **획득한 PreparedStatement 수**. 이 값도 SQL 실행 수와 항상 같지는 않습니다(§4.1). +- `getPrepareStatementCount()` = **획득한 PreparedStatement 수**. 이 값도 SQL 실행 수와 항상 같지는 않습니다. 현재 기준선에는 batch/subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나갑니다. 이 -조건에서만 "초기화된 컬렉션 수 N = highlights 자식 SELECT 수 N"이 성립합니다. L5에서 -Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨집니다. 그래서 두 지표의 -이름을 구분했습니다. ToOne(User·Page) 조회 수는 총 PreparedStatement에서 content 1건, +조건에서만 "초기화된 컬렉션 수 N = highlights 자식 SELECT 수 N"이 성립합니다. +Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨집니다. 두 지표의 이름을 +구분한 이유입니다. ToOne(User·Page) 조회 수는 총 PreparedStatement에서 content 1건, 페이지 count 1건, highlights 컬렉션 N건을 빼서 계산했습니다. ### 6.2 실측 — 조회량이 N에 정확히 비례한다 -먼저 N이 무엇을 뜻하는지 정리했습니다. **N은 전체 테이블 크기가 아니라 한 요청에서 반환한 +먼저 N이 무엇을 뜻하는지 정리했습니다. N은 전체 테이블 크기가 아니라 **한 요청에서 반환한 FeedItem 수**입니다. 이 랩에서는 `seed(N)` 뒤에 `loadFeed(0, N)`을 호출해 데이터셋 크기와 -page size를 모두 N으로 맞췄습니다. 따라서 아래 표의 N은 "한 페이지 요청이 조립하는 부모 엔티티 +page size를 모두 N으로 맞췄습니다. 아래 표의 N은 "한 페이지 요청이 조립하는 부모 엔티티 수"를 뜻합니다. **측정값(직접 측정).** 초기화 컬렉션 수·총 PreparedStatement는 Hibernate `Statistics`, 지연은 `System.nanoTime`, 시드 하이라이트는 시더 콘솔에서 그대로 읽은 값입니다. @@ -458,7 +458,7 @@ page size를 모두 N으로 맞췄습니다. 따라서 아래 표의 N은 "한 | 100 | **100** | 222 | 85.9 ms | 108.3 ms | 1,961 | | 1,000 | **1,000** | 2,022 | 193.7 ms | 238.4 ms | 2,917 | -**파생값(분해).** 총 PreparedStatement를 SQL shape별로 가른 값입니다. 직접 측정이 아니라 **시더 카디널리티 + 총계 + Spring Data count 생략 규칙**으로 역산했습니다. 측정값과 섞어 읽지 않도록 성격과 증거를 함께 표기합니다. +**파생값(분해).** 총 PreparedStatement를 SQL shape별로 가른 값입니다. 직접 측정이 아니라 시더 카디널리티 + 총계 + Spring Data count 생략 규칙으로 역산했습니다. 측정값과 섞어 읽지 않도록 성격과 증거를 함께 표기합니다. | 지표 | N=10 | N=100 | N=1,000 | 성격 | 증거 | |---|---:|---:|---:|---|---| @@ -482,13 +482,13 @@ page size를 모두 N으로 맞췄습니다. 따라서 아래 표의 N은 "한 **count 쿼리는 왜 나올까요?** `findAllBy(Pageable)`가 `Page`을 반환하기 때문입니다. Spring Data는 전체 페이지 수를 알려주려고 `select count(...)`를 한 번 더 실행합니다. 다만 `offset==0`이고 `pageSize > 반환 건수`이면 count를 건너뜁니다. 라운드트립 스모크는 1건을 -pageSize 10으로 조회해 이 조건에 들어갔고, count가 생략되어 총 4건이 나왔습니다. 반면 위 +pageSize 10으로 조회해 이 조건에 들어갔고 count가 생략되어 총 4건이 나왔습니다. 반면 위 측정은 `pageSize == 반환 건수(N)`라 count가 실제로 실행됩니다. 그래서 25 / 222 / 2,022에 각각 count 1건이 포함되어 있습니다. > 이 count는 이후 페이징 전략의 결정 포인트이기도 합니다. 최종 피드가 전체 페이지 수를 요구하지 않는다면 `Page` 대신 `Slice`나 커서 결과로 바꿔 count 쿼리를 없앨 수 있습니다. -지연은 `latencyMicros(n, 7, 2)`로 7회 반복하고 앞의 2회를 워밍업으로 버린 뒤, 남은 **5개 +지연은 `latencyMicros(n, 7, 2)`로 7회 반복하고 앞의 2회를 워밍업으로 버린 뒤 남은 **5개 표본의 중앙값과 최댓값**을 기록했습니다. 표본이 5개뿐이어서 `p50`·`p99`라고 부르지 않았습니다. 실제 코드의 p99 인덱스도 5개 중 최댓값을 가리킵니다. 안정적인 꼬리 지연을 말하려면 warm-up 후 100회 이상 측정한 독립 세트가 여러 개 필요합니다. 여기서는 꼬리 지연이 아니라 N에 따른 왕복 @@ -510,14 +510,14 @@ pageSize 10으로 조회해 이 조건에 들어갔고, count가 생략되어 [Editable source](assets/diagrams/nplus1-query-fanout/nplus1-query-fanout.drawio) · [Grounded VizSpec](.techviz/nplus1-query-fanout/spec.json) -**이 관찰은 서로 다른 두 위반을 동시에 드러냅니다.** "하이라이트 수와 무관한 조회량"이라는 요구가 깨지는데, 깨지는 방식이 하나가 아닙니다. +이 관찰은 서로 다른 두 위반을 동시에 드러냅니다. "하이라이트 수와 무관한 조회량"이라는 요구가 깨지는데 깨지는 방식이 하나가 아닙니다. -- **N+1(왕복).** `collectionFetches = N`은 한 요청에서 반환하는 **FeedItem(부모) 수**에 비례해 +- **N+1(왕복).** `collectionFetches = N`은 한 요청에서 반환하는 FeedItem(부모) 수에 비례해 늘었습니다. Highlight 수가 아니라 아이템마다 컬렉션을 한 번씩 초기화하기 때문에 부모 수만큼 DB를 왕복합니다. - **과조회(행수).** 한 번의 왕복에서는 해당 FeedItem의 Highlight를 **전부** 읽어 옵니다. 가장 - 많은 아이템은 최대 500행입니다. 따라서 반환 행수·전송량·엔티티 생성은 **자식 수**에 비례해 - 늘어납니다(§6.4). + 많은 아이템은 최대 500행입니다. 반환 행수·전송량·엔티티 생성은 자식 수에 비례해 + 늘어납니다. 부모 수에 따른 왕복 증가와 자식 수에 따른 과조회가 **같은 기준선에 동시에** 존재합니다. @@ -533,7 +533,7 @@ pageSize 10으로 조회해 이 조건에 들어갔고, count가 생략되어 이 측정으로 확인한 N+1의 증가 기준은 전체 테이블 크기가 아니라 **한 요청에서 조립하는 부모 엔티티 수**였습니다. 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않습니다. 대신 전체 테이블 크기는 OFFSET·정렬·가시성 필터 비용에 영향을 줍니다. 이 비용은 별도 축으로 분리해 -L15/L16에서 측정했습니다(§8). +keyset 페이징(14절)과 가시성 조건(15절)에서 측정했습니다. ### 6.3 조회 증가 폭은 fetch 방식과 연관 데이터 수가 함께 결정한다 @@ -548,7 +548,7 @@ L15/L16에서 측정했습니다(§8). | **Page** (EAGER ToOne) | 아이템당 1개(전부 다름) | 아니다 | 10 / 100 / 1,000 | | **highlights** (지연 로딩 컬렉션) | 아이템당 컬렉션 | — (아이템마다 1회) | 10 / 100 / 1,000 | -EAGER의 secondary SELECT **구조**가 추가 조회의 가능성을 만들고, 실제로 몇 번 실행되는지는 Persistence Context 안에서 **서로 다른 연관 대상(distinct target)이 몇 개인지**가 정합니다. 그래서 같은 `@ManyToOne(EAGER)`라도 User는 distinct 대상 ≤20개 → 약 20회, Page는 distinct 대상 N개 → N회로 갈립니다. "즉시 로딩 하나 붙였을 뿐인데 왜 어떤 건 터지고 어떤 건 안 터지나"의 답은 애너테이션 하나가 아니라 fetch 방식 × distinct 카디널리티의 곱에 있습니다. +EAGER의 secondary SELECT 구조가 추가 조회의 가능성을 만듭니다. 실제로 몇 번 실행되는지는 Persistence Context 안에서 **서로 다른 연관 대상(distinct target)이 몇 개인지**가 정합니다. 같은 `@ManyToOne(EAGER)`라도 User는 distinct 대상 ≤20개 → 약 20회, Page는 distinct 대상 N개 → N회로 갈립니다. "즉시 로딩 하나 붙였을 뿐인데 왜 어떤 건 터지고 어떤 건 안 터지나"의 답은 애너테이션 하나가 아니라 fetch 방식 × distinct 카디널리티의 곱에 있습니다. ### 6.4 각 조회는 "빠르다" — 그런데도 느리다 @@ -564,17 +564,17 @@ Planning Time: 0.086 ms Execution Time: 0.173 ms ``` -개별 하이라이트 조회는 `feed_item_id` 탐색을 인덱스로 처리하고(Index Scan) 0.173 ms로 빠릅니다. 그런데 이 빠른 쿼리가 N번 반복됩니다. N=1,000이면 피드 한 번 로딩이 194 ms로 커집니다. 즉 이 문제는 "쿼리가 느려서"가 아니라 "빠른 쿼리를 N번 왕복해서" 생깁니다. +개별 하이라이트 조회는 `feed_item_id` 탐색을 인덱스로 처리하고(Index Scan) 0.173 ms로 빠릅니다. 그런데 이 빠른 쿼리가 N번 반복됩니다. N=1,000이면 피드 한 번 로딩이 194 ms로 커집니다. 이 문제는 "쿼리가 느려서"가 아니라 "빠른 쿼리를 N번 왕복해서" 생깁니다. -다만 이 실행계획을 "이미 최적"이라고 결론지으면 안 됩니다. 최종 요구사항 관점에서 두 문제가 함께 있다(§6.2의 두 위반과 같은 짝입니다). +다만 이 실행계획을 "이미 최적"이라고 결론지으면 안 됩니다. 최종 요구사항 관점에서 두 문제가 함께 있습니다. 6.2절의 두 위반과 같은 짝입니다. - **반복 왕복**: 같은 자식 쿼리가 FeedItem마다 반복됩니다. 현재 ORM fetch plan의 문제이므로 인덱스로는 풀 수 없고 왕복 횟수 자체를 줄여야 합니다. - **컬렉션 과조회**: 이 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 한 번에 최대 500행을 읽어 옵니다. 응답에 필요한 건 최신 3개뿐인데 `ORDER BY created_at DESC LIMIT 3`가 없어 결과량을 제한하지 못합니다. 이건 SQL shape와 인덱스 설계까지 함께 풀어야 합니다. -따라서 정확히는 "**N회 반복의 원인은 fetch plan에 있지만, 최종 Top-3 조회 비용은 SQL shape·인덱스까지 함께 해결해야 한다**"가 맞습니다. +정확히는 "**N회 반복의 원인은 fetch plan에 있지만, 최종 Top-3 조회 비용은 SQL shape·인덱스까지 함께 해결해야 한다**"가 맞습니다. -**Plan A만으로 결론을 내리지는 않았습니다.** Plan A에서 추정한 `rows=1`과 실제 `rows=500`은 +Plan A만으로 결론을 내리지는 않았습니다. Plan A에서 추정한 `rows=1`과 실제 `rows=500`은 500배 차이가 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 반영하지 못했다는 가설을 세웠습니다. 이 가설은 `ANALYZE highlights` 뒤에 Plan B를 다시 측정해 검증할 예정입니다. 아직 실행하지 않았으므로 Plan B 열은 비워 두었습니다. @@ -589,8 +589,8 @@ Execution Time: 0.173 ms EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. -- **warm cache**: `Buffers: shared hit=14, read=0`은 **warm buffer cache** 결과라 디스크 I/O가 낀 cold 실행시간으로 읽으면 안 됩니다. -- **0.173 ms를 194 ms와 합산·비교 금지**: `Execution Time`은 PostgreSQL executor 내부 시간에 가깝고 ORM 엔티티 생성·JDBC 결과 전달·DTO 매핑·직렬화·HTTP를 포함하지 않습니다. 애플리케이션 지연(§6.2)과 같은 지표가 아닙니다. +- **warm cache**: `Buffers: shared hit=14, read=0`은 warm buffer cache 결과라 디스크 I/O가 낀 cold 실행시간으로 읽으면 안 됩니다. +- **0.173 ms를 194 ms와 합산·비교 금지**: `Execution Time`은 PostgreSQL executor 내부 시간에 가깝고 ORM 엔티티 생성·JDBC 결과 전달·DTO 매핑·직렬화·HTTP를 포함하지 않습니다. 애플리케이션 지연과 같은 지표가 아닙니다. ### 6.5 코드에 루프가 없는데 왜 N+1인가 @@ -603,8 +603,8 @@ EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. ## 7. User·Page 연관 숨은 추가 쿼리 정량화 -§6에서 highlights 조립에 해당하는 조회 수를 분리했지만, 총 PreparedStatement에는 여전히 User·Page -연관 조회가 남았습니다. §6.3에서는 시더 카디널리티로 13 / 120 / 1,020이라는 값을 역산했습니다. +앞 절에서 highlights 조립에 해당하는 조회 수를 분리했지만 총 PreparedStatement에는 여전히 User·Page +연관 조회가 남았습니다. 6.3절에서는 시더 카디널리티로 13 / 120 / 1,020이라는 값을 역산했습니다. 이번에는 같은 `loadFeed`를 두고 엔티티별 fetch 통계를 직접 읽어 이 예측을 확인했습니다. 코드를 새로 만든 것은 아니며 측정 지표만 바꿨습니다. @@ -616,17 +616,17 @@ EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. - `getEntityFetchCount()` = **2차 SELECT로 로드된 엔티티 인스턴스 수**(User + Page 합). - `getEntityStatistics(PageJpaEntity.class.getName()).getFetchCount()` / `…UserJpaEntity…` = **엔티티별** fetch 수. -§6.3의 User/Page 값은 "총계 − content − count − 컬렉션 N"으로 역산한 **파생값**이었습니다. +6.3절의 User/Page 값은 "총계 − content − count − 컬렉션 N"으로 역산한 **파생값**이었습니다. 이번에는 Hibernate 통계에서 직접 읽은 값과 같은지 확인했습니다. > 지표 이름을 정확히 읽어야 합니다. `getEntityFetchCount()`는 "실행된 SELECT SQL 수"가 아니라 > **2차 fetch로 초기화된 엔티티 수**입니다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 > 있어 회귀 가드는 시더 카디널리티와 무관하게 성립하는 **`pageFetch == N`(엔티티별)** 으로 -> 고정하고, 합계는 회계 항등식으로 교차 검증했습니다. +> 고정하고 합계는 회계 항등식으로 교차 검증했습니다. ### 7.2 실측 — 같은 `@ManyToOne(EAGER)`가 정반대 곡선을 그린다 -**측정값(직접 측정).** 아래는 `getEntityStatistics(...).getFetchCount()`와 `getEntityFetchCount()`가 낸 값입니다. §6.3에서 역산한 파생값과 **정확히 일치**합니다. +**측정값(직접 측정).** 아래는 `getEntityStatistics(...).getFetchCount()`와 `getEntityFetchCount()`가 낸 값입니다. 앞서 6.3절에서 역산한 파생값과 정확히 일치합니다. | N | Page fetch(★선형) | User fetch(평탄) | ToOne 합(`entityFetch`) | 초기화 컬렉션 | 총 PreparedStatement | |---:|---:|---:|---:|---:|---:| @@ -636,20 +636,20 @@ EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. 성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv). -검산(§6.3 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. **§6.3에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했다** — 파생 예측이 실측으로 확정됐습니다. +검산(6.3절 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. 앞서 6.3절에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했습니다 — 파생 예측이 실측으로 확정됐습니다. 같은 `@ManyToOne(EAGER)`인데도 Page fetch는 N을 따라 10 → 100 → 1,000으로 늘고 User -fetch는 20에서 멈췄습니다. Page는 아이템마다 달라 정확히 N번 조회되지만, User는 소수 풀을 +fetch는 20에서 멈췄습니다. Page는 아이템마다 달라 정확히 N번 조회되지만 User는 소수 풀을 재사용하고 한 번 로드한 대상이 1차 캐시에 남기 때문입니다. 즉 N+1이 생길 가능성은 EAGER라는 -코드에서 나오지만, 실제 증가 폭은 연관 데이터의 카디널리티에 따라 달라집니다. +코드에서 나오지만 실제 증가 폭은 연관 데이터의 카디널리티에 따라 달라집니다. -> 지연은 §6.2와 **같은 `loadFeed` 호출**을 잰 것이므로 별도 지연 축이 아닙니다. N2는 그 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐, 새로운 지연을 만들지 않습니다. +> 지연은 6.2절과 **같은 `loadFeed` 호출**을 잰 것이므로 별도 지연 축이 아닙니다. N2는 그 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐, 새로운 지연을 만들지 않습니다. ### 7.3 필드에 접근하지 않아도 ToOne 쿼리가 발생한다 -§6.5에서는 지연 로딩이 `stream()` 뒤에 반복을 감춘 모습을 확인했습니다. ToOne은 필드에 접근하지 +6.5절에서는 지연 로딩이 `stream()` 뒤에 반복을 감춘 모습을 확인했습니다. ToOne은 필드에 접근하지 않아도 조회된다는 점이 달랐습니다. 이를 확인하려고 `loadFeed` 대신 아무것도 매핑하지 않는 순수 -JPQL로 `feed_items`만 조회하고, `getUser()`·`getPage()`·`getHighlights()`는 **한 번도 +JPQL로 `feed_items`만 조회하고 `getUser()`·`getPage()`·`getHighlights()`는 **한 번도 호출하지 않았습니다**. **측정값(직접 측정).** 출처 `FeedPersistenceIT.l2EagerToOneFiresEvenWithZeroFieldAccess`(seed 100, 접근 0회). @@ -661,13 +661,13 @@ JPQL로 `feed_items`만 조회하고, `getUser()`·`getPage()`·`getHighlights() | 0회 | highlights | `@OneToMany` (LAZY) | **0** | 아무 필드도 읽지 않았는데 Page 2차 SELECT가 N번 나왔습니다. 제가 조회 코드를 작성하지 않았는데도 -EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY 컬렉션은 접근하지 않았으므로 0이 -나왔습니다. 이 테스트로 EAGER는 사용 여부와 관계없이 미리 로딩하고, LAZY는 접근할 때 로딩한다는 +EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY 컬렉션은 접근하지 않았으므로 0이었습니다. +이 테스트로 EAGER는 사용 여부와 관계없이 미리 로딩하고 LAZY는 접근할 때 로딩한다는 차이를 확인했습니다. ### 7.4 같은 실행계획, 정반대 비용 — 반복되는 ToOne 부모 쿼리 -§6.4에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리** +6.4절에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리** (`SELECT * FROM pages WHERE id = ?`, `… FROM users WHERE id = ?`)를 실행계획으로 확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). @@ -685,18 +685,18 @@ Index Scan using pk_users on users `WHERE id = ?`는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져옵니다. 개별 쿼리는 빨랐지만 Page 쿼리는 이 빠른 실행계획을 **N번 반복**했습니다. -**pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같습니다.** -그런데 §7.2의 증가 곡선은 정반대였습니다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였습니다. +pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같습니다. +그런데 7.2절의 증가 곡선은 정반대였습니다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였습니다. Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됩니다. 단건 계획은 이미 Index Scan이므로 -인덱스를 더하는 것으로는 해결되지 않습니다. §9부터 왕복 횟수를 줄이는 fetch 전략을 시도합니다. -warm cache와 executor 시간에 관한 한계는 §6.4와 같습니다. +인덱스를 더하는 것으로는 해결되지 않습니다. 9절부터 왕복 횟수를 줄이는 fetch 전략을 시도합니다. +warm cache와 executor 시간에 관한 한계는 6.4절과 같습니다. ### 7.5 루프와 필드 접근 없이 N+1이 생기는 이유 -`@ManyToOne`은 fetch를 명시하지 않으면 EAGER가 기본값입니다(§5.4). 파생 쿼리인 +`@ManyToOne`은 fetch를 명시하지 않으면 EAGER가 기본값입니다. 파생 쿼리인 `findAllBy`는 EAGER 연관을 루트 SQL의 JOIN으로 자동 병합하지 않고 **행마다 2차 SELECT**로 채웠습니다. 그래서 `getUser()`·`getPage()`를 읽기 전부터 조회가 나갔습니다. 코드에 루프나 -접근이 없어서 표면에 보이지 않았고, Page와 User의 카디널리티가 달라 증가 폭도 다르게 나타났습니다. +접근이 없어서 표면에 보이지 않았습니다. Page와 User는 카디널리티가 달라 증가 폭도 다르게 나타났습니다. fetch 계약(EAGER/LAZY)과 실제 사용(접근/미접근)을 교차하면 EAGER의 죄가 정확히 어디인지 드러납니다. @@ -705,28 +705,28 @@ fetch 계약(EAGER/LAZY)과 실제 사용(접근/미접근)을 교차하면 EAGE | **EAGER**(현재 User·Page) | 나간다 — **낭비**(안 짠 N+1) | 나간다 (즉시 로딩 N+1) | | **LAZY**(가정) | 안 나간다 | 나간다 (지연 로딩 N+1) — timing만 다름 | -`loadFeed`는 매핑 과정에서 user·page를 실제로 사용합니다. 따라서 EAGER를 LAZY로 바꿔도 조회 -시점만 달라질 뿐 N+1은 다시 생깁니다. 이 문제를 fetch **타입** 변경만으로 풀 수 없다고 판단했고, -Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸는 fetch **전략**을 +`loadFeed`는 매핑 과정에서 user·page를 실제로 사용합니다. EAGER를 LAZY로 바꿔도 조회 +시점만 달라질 뿐 N+1은 다시 생깁니다. 이 문제를 fetch **타입** 변경만으로 풀 수 없다고 판단했습니다. +대신 Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸는 fetch **전략**을 차례로 시도했습니다. --- ## 8. 확인된 문제와 이후 검증할 가설 -여기까지 측정하고 나니 문제를 두 축으로 나눌 필요가 있었습니다. 연관 조회 폭증은 수치로 확인했지만, +여기까지 측정하고 나니 문제를 두 축으로 나눌 필요가 있었습니다. 연관 조회 폭증은 수치로 확인했지만 기준 쿼리의 Seq Scan + Sort는 아직 병목이라고 단정할 수 없었습니다. 그래서 확인된 문제와 검증할 가설을 다음처럼 분리했습니다. | | 축 A — **연관 조회 폭증(N+1)** · 확인됨 | 축 B — **기준 쿼리 Seq Scan + Sort** · 가설 | |---|---|---| -| 관찰 | 쿼리 수가 `1 + count + distinct(user) + N + N` (§6.2에서 실측) | 목록 쿼리 한 방이 Seq Scan + Sort | +| 관찰 | 쿼리 수가 `1 + count + distinct(user) + N + N` (실측) | 목록 쿼리 한 방이 Seq Scan + Sort | | 원인 | **fetch 전략** (EAGER 2차 SELECT / 지연 컬렉션) | 정렬 인덱스가 이 쿼리에 안 걸림(아래) | | 해법 축 | fetch join / batch / DTO 프로젝션 | 정렬에 맞는 인덱스 / keyset | -피드는 시간순 정렬이 필요하므로 목록 쿼리에 `ORDER BY first_highlighted_at DESC, id`가 붙습니다. 스키마에 `ix_feed_items_visibility_sort (visibility, first_highlighted_at DESC, id)`가 있긴 하지만, 이 기준 쿼리에는 `visibility =` 필터가 없어 인덱스의 **선두 컬럼(visibility)이 맞물리지 않아** 정렬에 쓰이지 못합니다. 그래서 "인덱스 부재"가 아니라 "이 filterless 쿼리에 맞는 정렬 인덱스가 없음"이 정확한 진단입니다. +피드는 시간순 정렬이 필요하므로 목록 쿼리에 `ORDER BY first_highlighted_at DESC, id`가 붙습니다. 스키마에 `ix_feed_items_visibility_sort (visibility, first_highlighted_at DESC, id)`가 있긴 하지만 이 기준 쿼리에는 `visibility =` 필터가 없습니다. 인덱스의 **선두 컬럼(visibility)이 맞물리지 않으니** 정렬에도 쓰이지 못합니다. 그래서 "인덱스 부재"가 아니라 "이 filterless 쿼리에 맞는 정렬 인덱스가 없음"이 정확한 진단입니다. -다만 **Seq Scan 자체를 곧바로 문제로 판정하지는 않습니다.** 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있고, N=1,000은 인덱스 효과를 판단하기엔 작습니다. 이 계획이 실제 병목인지는 피드 규모(N=1k~1M)와 페이지 깊이(OFFSET)를 키우며 정렬 인덱스 유무에 따른 `rows`·`buffers`·sort spill·execution time을 대조해 이후 랩(L15)에서 검증합니다. +다만 Seq Scan 자체를 곧바로 문제로 판정하지는 않습니다. 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있습니다. N=1,000은 인덱스 효과를 판단하기엔 작습니다. 이 계획이 실제 병목인지는 이후 keyset 페이징 랩에서 검증합니다. 피드 규모(N=1k~1M)와 페이지 깊이(OFFSET)를 키우며 정렬 인덱스 유무에 따른 `rows`·`buffers`·sort spill·execution time을 대조하는 방식입니다. 두 축은 해결 방법도 다릅니다. 축 A(N+1)는 fetch 전략 문제라 인덱스로 풀리지 않고, 축 B(정렬)는 인덱스·쿼리 문제라 fetch join으로 풀리지 않습니다. 이후 단계에서는 두 축을 분리해 검증했습니다. @@ -738,7 +738,7 @@ Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸 컬렉션 N+1과 User·Page의 숨은 쿼리를 확인한 뒤에는 "나누어 가져오지 말고 한 번에 가져오면 되지 않을까"라고 생각했습니다. 그래서 user·page·highlights·mentions를 모두 `join fetch`로 루트 SQL에 합쳐 보았습니다. 결과는 두 가지 실패였습니다. 컬렉션 두 개를 동시에 fetch join하자 -`MultipleBagFetchException`이 발생했고, 하나만 합치자 부모와 자식의 곱만큼 전송 행이 +`MultipleBagFetchException`이 발생했습니다. 하나만 합치자 부모와 자식의 곱만큼 전송 행이 늘었습니다. 쿼리 수는 줄었지만 전송량이 커졌으므로 이 단계부터는 쿼리 수뿐 아니라 전송 행수도 함께 측정했습니다. @@ -750,27 +750,27 @@ Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸 `MultipleBagFetchException`을 재현하려면 컬렉션이 **둘 이상** 필요했습니다. 기준선 스키마에는 `highlights`만 있었으므로 목표 스키마의 `feed_item_mentions`를 이 단계에서 먼저 추가했습니다. -다만 지금 필요한 것은 fetch join할 두 번째 bag뿐이어서 범위를 **퍼시스턴스 계층까지**로 +다만 지금 필요한 것은 fetch join할 두 번째 bag뿐이어서 범위를 퍼시스턴스 계층까지로 제한했습니다. 추가한 코드는 마이그레이션(`V7__feed_mentions.sql`), 경량 자식 엔티티 `FeedItemMentionJpaEntity`, 부모의 `@OneToMany List<…> mentions`, 시더입니다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 공개 범위 단계까지 미뤘습니다. -> **기존 측정은 바뀌지 않았습니다.** `mentions`는 `@OneToMany` 기본 **LAZY**이고 `loadFeed`와 -> §7.3의 접근 0 테스트도 `getMentions()`를 호출하지 않습니다. §6·§7의 테스트를 다시 실행해 +> **기존 측정은 바뀌지 않았습니다.** `mentions`는 `@OneToMany` 기본 LAZY이고 `loadFeed`와 +> 7.3절의 접근 0 테스트도 `getMentions()`를 호출하지 않습니다. 6·7절의 테스트를 다시 실행해 > `collectionFetches == N`, 접근 0에서 `== 0`, `pageFetch == N`이 그대로 유지되는지 확인했습니다. -목표 스키마의 `feed_item_mentions`는 복합 PK `(feed_item_id, mentioned_user_id)`지만, 이 +목표 스키마의 `feed_item_mentions`는 복합 PK `(feed_item_id, mentioned_user_id)`지만 이 랩에서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 **대리키(id) + `UNIQUE(feed_item_id, mentioned_user_id)`**로 구현했습니다. 유일성은 그대로 보장됩니다. -시더는 `MENTIONED` 아이템에만 사용자를 연결하고, 사용자 풀보다 많이 넣어 UNIQUE 제약을 +시더는 `MENTIONED` 아이템에만 사용자를 연결합니다. 사용자 풀보다 많이 넣어 UNIQUE 제약을 어기지 않도록 `min(2+i%4, poolSize)`로 상한을 두었습니다. ### 9.2 실패 ① 두 컬렉션 동시 fetch join → `MultipleBagFetchException` -**bag은 순서 컬럼(`@OrderColumn`)이 없는 `List`입니다.** `highlights`와 `mentions`가 모두 +bag은 순서 컬럼(`@OrderColumn`)이 없는 `List`입니다. `highlights`와 `mentions`가 모두 bag인 상태에서 두 컬렉션을 fetch join하면 feed_item 한 행이 highlights h개 × mentions m개, 즉 **h×m 행**으로 늘어납니다. Hibernate는 이 곱집합을 안전하게 원래 컬렉션으로 되돌릴 수 없다고 -판단해 **쿼리 생성(createQuery) 시점에** 예외를 던집니다. 데이터가 0건이어도 발생하는 매핑 +판단해 쿼리 생성(createQuery) 시점에 예외를 던집니다. 데이터가 0건이어도 발생하는 매핑 단계의 거부입니다. ```java @@ -787,23 +787,23 @@ java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchExcep ``` 실제로 실행해 보니 `MultipleBagFetchException`은 **`IllegalArgumentException`으로 감싸져** -나왔습니다(FQN은 `org.hibernate.loader.MultipleBagFetchException`). 따라서 테스트를 +나왔습니다(FQN은 `org.hibernate.loader.MultipleBagFetchException`). 테스트를 `hasCauseInstanceOf(MultipleBagFetchException.class)`에만 맞추면 래핑 계층이나 버전 차이에 취약합니다. 이 테스트에서는 원인 체인을 클래스명 문자열로 펼친 뒤 `contains("MultipleBagFetchException")` 으로 확인했습니다(Hibernate ORM 7.1.8 기준). ### 9.3 실패 ② 컬렉션 하나만 fetch join → 카테시안으로 전송 행수 증가 -컬렉션을 **하나만**(`highlights`) fetch join하면 예외는 나지 않지만, -`feed_items ⋈ highlights`가 부모를 자식 수만큼 반복한 행을 만듭니다. 그래서 쿼리 수가 -아니라 DB가 애플리케이션에 전달한 **조인 행수**를 측정했습니다. +컬렉션을 하나만(`highlights`) fetch join하면 예외는 나지 않지만 +`feed_items ⋈ highlights`가 부모를 자식 수만큼 반복한 행을 만듭니다. 쿼리 수가 +아니라 DB가 애플리케이션에 전달한 **조인 행수**를 측정한 이유입니다. > **⚠ 측정 정정(Hibernate 6+/7)** — 처음에는 "`distinct` 없는 결과 리스트 크기 = Σ > highlights(전송 행수)"라고 예상했습니다. 하지만 결과 리스트 크기는 **N**(10/100/1000)이었습니다. > Hibernate 6+가 fetch join의 **루트 엔티티를 자동으로 중복 제거**하기 때문입니다. 카테시안은 > SQL과 전송 단계에 그대로 남아 있으므로 리스트 크기 대신 실제 조인 카디널리티 > `SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id`를 -> 측정했습니다. 이 문제는 EXPLAIN actual rows(§9.5)나 조인 count로 확인해야 합니다. +> 측정했습니다. 이 문제는 EXPLAIN actual rows나 조인 count로 확인해야 합니다. **측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv). @@ -813,19 +813,19 @@ java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchExcep | 100 | **1,961** | 100 | 100 | 1,961 | 19.6× | 121 | | 1,000 | **2,917** | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 | -전송 행수는 항상 아이템 수 N보다 많았고, §4.3의 시드 하이라이트 총량과 정확히 일치했습니다. +전송 행수는 항상 아이템 수 N보다 많았고 4.3절의 시드 하이라이트 총량과 정확히 일치했습니다. 조인이 모든 자식 행을 부모에 붙여 전송했기 때문입니다. Zipf 분포에서 뒤쪽 아이템은 highlight가 -한 개뿐이라 폭발 배수는 128.5× → 19.6× → 2.9×로 줄었지만, 절대 전송 행수는 계속 +한 개뿐이라 폭발 배수는 128.5× → 19.6× → 2.9×로 줄었지만 절대 전송 행수는 계속 Σ highlights였습니다. 제가 원한 것은 N개 아이템이었지만 DB가 전달한 것은 모든 highlight 행이었습니다. ### 9.4 쿼리 수만 보면 개선처럼 보인다 -같은 N=100 데이터에서 기준선 `loadFeed`는 PreparedStatement가 222개였고, highlights를 +같은 N=100 데이터에서 기준선 `loadFeed`는 PreparedStatement가 222개였고 highlights를 fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처럼 보였기 때문에 항목별로 다시 나눠 보았습니다. -| 구분 | 기준선 loadFeed(§6.2) | highlights fetch join(§9.3) | 결과 | +| 구분 | 기준선 loadFeed | highlights fetch join | 결과 | |---|---:|---:|---| | 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 | | Page count | 1 | 0 | 이 랩은 `Pageable`이 아닌 원시 JPQL이라 Spring Data count 없음 | @@ -835,13 +835,13 @@ fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처 222개가 121개로 줄어든 주된 이유는 highlights 컬렉션 N개가 루트 조인 하나로 합쳐졌기 때문입니다. 나머지 1개 차이는 원시 JPQL에는 Spring Data count가 없어서 생겼습니다. 하지만 -121개 중 **120개는 여전히 ToOne 2차 SELECT**였고, 조인 하나는 **1,961행**을 전달했습니다. +121개 중 **120개는 여전히 ToOne 2차 SELECT**였고 조인 하나는 1,961행을 전달했습니다. 비용이 사라진 것이 아니라 쿼리 수에서 전송 행수와 메모리로 옮겨 갔습니다. ### 9.5 조인이 행을 곱하는 것을 실행계획에서 -§6.4에서는 반복되는 자식 단건 쿼리를, §7.4에서는 부모 단건 쿼리를 확인했습니다. 이번에는 -fetch join이 만든 조인 하나를 확인했습니다. 아래는 seed(100) 직후 같은 형태의 쿼리를 +앞서 6.4절에서는 반복되는 자식 단건 쿼리를, 7.4절에서는 부모 단건 쿼리를 확인했습니다. 이번 +차례는 fetch join이 만든 조인 하나입니다. 아래는 seed(100) 직후 같은 형태의 쿼리를 EXPLAIN한 결과입니다(원문: [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). ```text @@ -853,13 +853,13 @@ 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 노드)인데, **Hash Join 노드의 actual rows는 1,961**(= Σ highlights)로 부풉니다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 것 — 리스트 크기(100, §9.3의 Hib6 dedup)로는 안 보이는 실체를 플랜이 드러냅니다. `rows=4202`(추정) vs `rows=1961`(실제)의 오차는 §6.4 Plan A와 같은 통계 이슈(대량 시드 직후 `ANALYZE` 미실행)이고, warm cache·executor 시간 caveat도 §6.4와 같습니다. +부모 `feed_items`는 100행(Hash 노드)인데 **Hash Join 노드의 actual rows는 1,961**(= Σ highlights)로 부풉니다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 것 — 리스트 크기(100, 9.3절의 Hib6 dedup)로는 안 보이는 실체를 플랜이 드러냅니다. `rows=4202`(추정) vs `rows=1961`(실제)의 오차는 6.4절 Plan A와 같은 통계 이슈입니다(대량 시드 직후 `ANALYZE` 미실행). warm cache·executor 시간 caveat도 마찬가지입니다. ### 9.6 두 bag이 거부되고 한 bag은 행이 늘어나는 이유 bag 두 개를 동시에 `join fetch`하면 Hibernate가 곱집합을 원래 컬렉션으로 되돌릴 수 없어 `MultipleBagFetchException`을 던집니다. 하나만 join하면 예외는 없지만 부모 행이 자식 수만큼 -늘어납니다. 쿼리 수는 1+N에서 1로 줄어도 전송 행수와 메모리는 커졌고, Hibernate 6+의 루트 +늘어납니다. 쿼리 수는 1+N에서 1로 줄어도 전송 행수와 메모리는 커졌습니다. Hibernate 6+의 루트 중복 제거 때문에 결과 리스트만 보면 이 증가가 보이지 않았습니다. 이 결과를 보고 fetch join은 ToOne에는 적합하지만 컬렉션에는 주의가 필요하다고 판단했습니다. 다음에는 컬렉션 하나만 fetch join한 상태에서 페이징을 적용해 보았습니다. @@ -870,9 +870,9 @@ join한 상태에서 페이징을 적용해 보았습니다. 컬렉션 하나만 fetch join하고 `setMaxResults(20)`을 적용하면 전송량도 한 페이지로 줄어들 것이라고 생각했습니다. 하지만 Hibernate는 컬렉션 fetch join에 페이징을 걸자 DB `LIMIT`을 사용하지 -않았습니다. 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 냈고, 경고도 함께 남겼습니다. +않았습니다. 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 냈고 경고도 함께 남겼습니다. -반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였습니다. 그래서 이번에는 +반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였습니다. 이번에는 `returned`뿐 아니라 **`feedItemLoaded`**, 즉 실제로 메모리에 올린 부모 엔티티 수를 측정했습니다. @@ -880,17 +880,17 @@ join한 상태에서 페이징을 적용해 보았습니다. > `@BatchSize`·엔티티 페이징·DTO Projection을 적용했을 때 전후 차이를 같은 기준으로 비교하기 > 위해서입니다. -### 10.1 무대 — 새 프로덕션 코드 0 (§9 무대 + 페이징 한 줄) +### 10.1 무대 — 새 프로덕션 코드 0 (9절 무대 + 페이징 한 줄) -§10에서는 §9의 데이터와 매핑을 그대로 두고 `highlights` fetch join에 페이징 한 줄만 +이번 절에서는 9절의 데이터와 매핑을 그대로 두고 `highlights` fetch join에 페이징 한 줄만 추가했습니다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았습니다. 이 쿼리는 `FeedQueryAdapter`가 아니라 통합 테스트 안의 원시 JPQL로만 실행했습니다. ```java -// IT 안에서 세우는 §10 무대 (프로덕션 아님): -"select f from FeedItemJpaEntity f join fetch f.highlights " // ← §9의 한 bag fetch join +// IT 안에서 세우는 10절 무대 (프로덕션 아님): +"select f from FeedItemJpaEntity f join fetch f.highlights " // ← 9절의 한 bag fetch join + "order by f.firstHighlightedAt desc, f.id asc" -// + .setFirstResult(0).setMaxResults(20) // ← §10의 방아쇠: 페이징 +// + .setFirstResult(0).setMaxResults(20) // ← 10절의 방아쇠: 페이징 ``` 기본 설정(`hibernate.query.fail_on_pagination_over_collection_fetch=false`)에서는 이 쿼리가 @@ -898,11 +898,11 @@ join한 상태에서 페이징을 적용해 보았습니다. 실패시킬 수 있습니다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 사용할 수 있습니다. -> **N1/N2/§9 회귀 없음**: §10은 프로덕션 코드를 안 건드리므로 §6·§7·§9의 단언(`collectionFetches == N`, `pageFetch == N`, `MultipleBagFetchException`, 조인 카디널리티 = Σ highlights)은 그대로 GREEN입니다. §10의 추가분은 IT 측정 메서드뿐입니다. +> **N1/N2/9절 회귀 없음**: 10절은 프로덕션 코드를 안 건드리므로 6·7·9절의 단언(`collectionFetches == N`, `pageFetch == N`, `MultipleBagFetchException`, 조인 카디널리티 = Σ highlights)은 그대로 GREEN입니다. 이번 절의 추가분은 IT 측정 메서드뿐입니다. ### 10.2 실측 — 응답은 한 페이지인데 부모는 전부 로드한다 -컬렉션 하나를 fetch join한 뒤 페이징하자 `returned`는 페이지 크기였지만, 부모 엔티티는 +컬렉션 하나를 fetch join한 뒤 페이징하자 `returned`는 페이지 크기였지만 부모 엔티티는 **N개 전부** 로드되었습니다. `EntityStatistics.getLoadCount()`로 FeedItem 로드 수를 따로 읽어 응답 크기와 실제 적재량을 비교했습니다. @@ -925,9 +925,9 @@ join한 상태에서 페이징을 적용해 보았습니다. > 반면 `getCollectionFetchCount()`에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 > 지표로 사용하지 않았습니다. -그리고 이 쿼리가 던지는 경고 자체가 §10의 얼굴입니다. +그리고 이 쿼리가 던지는 경고 자체가 이 절의 얼굴입니다. -> **⚠ 측정 정정(Hibernate 7)** — 널리 알려진 경고 코드는 `HHH000104`지만, 이 랩에서 사용한 +> **⚠ 측정 정정(Hibernate 7)** — 널리 알려진 경고 코드는 `HHH000104`지만 이 랩에서 사용한 > Hibernate ORM 7.1.8은 `HHH90003004`를 기록했습니다. > > ```text @@ -955,7 +955,7 @@ join한 상태에서 페이징을 적용해 보았습니다. 예상과 달리 이 fetch join의 지연은 기준선보다 낮았습니다. N=1,000에서 기준선 최댓값은 238.4 ms였고 fetch join은 83.526 ms였습니다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문입니다. 하지만 메모리 할당은 약 1.5 MB에서 10.0 MB로 늘었습니다. 지연만 보면 개선처럼 -보이지만, 페이지에 필요하지 않은 N개 부모와 모든 highlights를 하이드레이트하고 있었습니다. +보이지만 페이지에 필요하지 않은 N개 부모와 모든 highlights를 하이드레이트하고 있었습니다. > **왜 "힙 델타"가 아니라 스레드 누적 할당을 썼을까요?** 인메모리 페이징이 버린 부모는 곧 > GC 대상이 되어 `used heap`의 전후 차이에 잘 나타나지 않습니다. `getThreadAllocatedBytes` @@ -983,17 +983,17 @@ Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) ``` -(a)엔 `Limit` 노드가 없다 = **DB가 페이징을 안 했습니다.** 조인 결과 전체(actual rows = Σ highlights)를 `quicksort`로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 합니다. (b)엔 `Limit` 노드가 정렬 위에 얹혀 `top-N heapsort`로 상위 몇 행만 취합니다. **quicksort(전체 정렬) vs top-N heapsort(상위 몇 행)** — "인메모리 페이징 vs DB 페이징"의 비용 차이가 계획 레벨로 드러납니다. (a)에 `Limit`이 없다는 것 자체가 "DB가 페이징을 안 했으니 누군가 메모리에서 했다"의 증거입니다. (컬럼명·리터럴 하드코딩이라 인젝션 무관. warm cache·executor 시간 caveat는 §6.4와 같습니다.) +(a)엔 `Limit` 노드가 없다 = DB가 페이징을 안 했습니다. 조인 결과 전체(actual rows = Σ highlights)를 `quicksort`로 정렬한 뒤 그대로 반환하고 페이지로 자르는 일은 Hibernate가 메모리에서 합니다. (b)엔 `Limit` 노드가 정렬 위에 얹혀 `top-N heapsort`로 상위 몇 행만 취합니다. **quicksort(전체 정렬) vs top-N heapsort(상위 몇 행)** — "인메모리 페이징 vs DB 페이징"의 비용 차이가 계획 레벨로 드러납니다. (a)에 `Limit`이 없다는 것 자체가 "DB가 페이징을 안 했으니 누군가 메모리에서 했다"의 증거입니다. (컬럼명·리터럴 하드코딩이라 인젝션 무관. warm cache·executor 시간 caveat는 6.4절과 같습니다.) ### 10.5 컬렉션 fetch join과 페이징을 함께 쓰기 어려운 이유 컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어납니다. 여기에 DB `LIMIT`을 걸면 부모 20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 하이라이트가 누락될 수 있습니다. Hibernate는 이 손상을 피하려고 SQL에서 `LIMIT`을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 -페이지를 자릅니다. §10.4에서 SQL(a)에 `Limit` 노드가 없었던 이유입니다. 이 동작 때문에 +페이지를 자릅니다. 앞서 10.4절의 SQL(a)에 `Limit` 노드가 없었던 이유입니다. 이 동작 때문에 컬렉션 fetch join과 페이징을 함께 사용하지 않기로 했습니다. -다음 단계에서는 **fetch join을 버리고 엔티티만 페이징**했습니다. 그러면 §10.4의 SQL(b)처럼 +다음 단계에서는 **fetch join을 버리고 엔티티만 페이징**했습니다. 그러면 10.4절의 SQL(b)처럼 `LIMIT`이 정상적으로 발행됩니다. 다만 highlights가 다시 LAZY가 되어 컬렉션 N+1이 돌아옵니다. 그래서 페이지 부모 키를 모아 `IN`으로 조회하는 Batch Fetch를 함께 적용했습니다. @@ -1001,19 +1001,19 @@ Limit (... rows=20 ...) (actual ... rows=20 loops=1) ## 11. 배치 페치 — 엔티티 페이징과 IN 배치 적용 -Fetch Join을 빼고 엔티티만 페이징하니 DB `LIMIT`은 다시 동작했지만, LAZY 연관의 N+1이 +Fetch Join을 빼고 엔티티만 페이징하니 DB `LIMIT`은 다시 동작했지만 LAZY 연관의 N+1이 돌아왔습니다. 그래서 `hibernate.default_batch_fetch_size=100`을 적용해 부모 키를 `IN`으로 -묶었습니다. `loadFeed` 코드는 바꾸지 않았고, 세션 설정만 달리한 뒤 §6의 기준선과 같은 지표로 -전후를 비교했습니다. +묶었습니다. `loadFeed` 코드는 그대로 두고 세션 설정만 달리한 뒤 앞서 잡은 기준선과 같은 +지표로 전후를 비교했습니다. -> `default_batch_fetch_size`는 세션 전체에 영향을 줍니다. 기존 테스트에 바로 적용하면 §6~§10의 -> 기준선도 함께 바뀌므로, 새 IT 클래스인 `FeedBatchFetchIT`에만 설정했습니다. 기존 테스트를 +> `default_batch_fetch_size`는 세션 전체에 영향을 줍니다. 기존 테스트에 바로 적용하면 앞서 측정한 +> 기준선도 함께 바뀌므로 새 IT 클래스인 `FeedBatchFetchIT`에만 설정했습니다. 기존 테스트를 > 다시 실행해 기준선이 그대로 유지되는지도 확인했습니다. ### 11.1 fix는 세션 설정 한 줄 — 순진 loadFeed 코드는 그대로 배치 페치는 두 단계로 동작합니다. 먼저 fetch join 없이 **엔티티만** 페이징해 DB `LIMIT`이 -정상적으로 적용되게 합니다. 그다음 LAZY 연관은 부모 키를 모아 **`IN` 배치**로 채웁니다. +정상적으로 적용되게 합니다. 그다음 LAZY 연관은 부모 키를 모아 `IN` 배치로 채웁니다. 이렇게 하면 N+1이 `ceil(N/batch)`번으로 줄어듭니다. ```yaml @@ -1021,16 +1021,16 @@ Fetch Join을 빼고 엔티티만 페이징하니 DB `LIMIT`은 다시 동작했 spring.jpa.properties.hibernate.default_batch_fetch_size: 100 ``` -`loadFeed`(§5.1)는 그대로 두었습니다. `findAllBy(Pageable)`로 엔티티를 페이징하고, 매핑할 때 -LAZY 연관에 접근합니다. §6에서 N+1을 만들었던 코드가 이 설정 아래에서는 배치로 동작합니다. -특정 컬렉션에만 `@BatchSize(size=100)`를 붙일 수도 있지만, 그러면 기준선 매핑 자체가 바뀝니다. +`loadFeed`는 그대로 두었습니다. `findAllBy(Pageable)`로 엔티티를 페이징하고 매핑할 때 +LAZY 연관에 접근합니다. 앞서 N+1을 만들었던 그 코드가 이 설정 아래에서는 배치로 동작합니다. +특정 컬렉션에만 `@BatchSize(size=100)`를 붙일 수도 있지만 그러면 기준선 매핑 자체가 바뀝니다. 비교를 위해 이 랩에서는 세션 property로 격리했습니다. ### 11.2 실측 — 배치 적용 전후의 쿼리 수 -`loadFeed(0, n)`(§6.2와 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = §6.2, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv). +`loadFeed(0, n)`(기준선과 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = 기준선 실측, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv). -| N | before: 순진 총 PreparedStatement(§6.2) | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch(§6.2) | after: 컬렉션 fetch | +| N | before: 순진 총 PreparedStatement | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch | after: 컬렉션 fetch | |---:|---:|---:|---:|---:|---:| | 10 | 25 | **5** | — | 10 | **1** | | 100 | 222 | **5** | — | 100 | **1** | @@ -1041,31 +1041,31 @@ LAZY 연관에 접근합니다. §6에서 N+1을 만들었던 코드가 이 설 23개는 루트 1개, count 1개, highlights 배치 10개, page 배치 10개, user 배치 1개로 나뉩니다. 다만 컬렉션 fetch 지표는 제가 예상한 방식과 달라 아래처럼 설명을 정정했습니다. -> **★ 실측 정정** — 처음에는 `getCollectionFetchCount()`를 초기화된 컬렉션 수라고만 보고, +> **★ 실측 정정** — 처음에는 `getCollectionFetchCount()`를 초기화된 컬렉션 수라고만 보고 > 배치를 적용해도 N으로 유지될 것이라고 예상했습니다. 실제로는 10 / 100 / 1,000에서 > **1 / 1 / 10 = `ceil(N/batch)`**으로 줄었습니다. 이 결과에 맞춰 지표를 여러 컬렉션을 -> 채운 **fetch SELECT 연산 수**로 다시 해석했습니다. 배치 적용 여부는 `prepared`와 +> 채운 fetch SELECT 연산 수로 다시 해석했습니다. 배치 적용 여부는 `prepared`와 > `collectionFetch`를 함께 보고 판단했습니다. ### 11.3 DB 페이징으로 over-fetch가 사라진다 -§10은 fetch join 인메모리 페이징이라 응답이 한 페이지인데 부모 N개를 하이드레이트했다(`feedItemLoaded`=N). 배치는 **엔티티만 페이징**이라 DB `LIMIT`이 정상 작동해 페이지 크기만 로드합니다. `loadFeed(0, 20)`, `FeedBatchFetchIT.l5EntityPagingLoadsOnlyThePageNotWholeDataset`: +앞 절의 fetch join 인메모리 페이징은 응답이 한 페이지인데 부모 N개를 하이드레이트했다(`feedItemLoaded`=N). 배치는 **엔티티만 페이징**이라 DB `LIMIT`이 정상 작동해 페이지 크기만 로드합니다. `loadFeed(0, 20)`, `FeedBatchFetchIT.l5EntityPagingLoadsOnlyThePageNotWholeDataset`: -| N | returned | feedItemLoaded (§11 배치) | feedItemLoaded (§10 fetch join, 대조) | +| N | returned | feedItemLoaded (배치) | feedItemLoaded (fetch join, 대조) | |---:|---:|---:|---:| | 10 | 10 | **10** | 10 | | 100 | 20 | **20** | 100 | | 1,000 | 20 | **20** | 1,000 | -§10에서는 `feedItemLoaded`가 N까지 늘었지만, 배치 적용 뒤에는 페이지 크기인 20에서 +fetch join에서는 `feedItemLoaded`가 N까지 늘었지만 배치 적용 뒤에는 페이지 크기인 20에서 멈췄습니다. 인메모리가 아니라 DB에서 `LIMIT`으로 부모를 먼저 자른 결과입니다. -### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (§9·§10 둘 다 해소) +### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (카테시안·인메모리 페이징 둘 다 해소) -§10의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. §11은 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고, 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). +앞 절의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. 이번에는 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). ```text --- (a) 엔티티만 페이징 — Limit 노드 존재 (§10 (a) fetch join 조인엔 없었다) +-- (a) 엔티티만 페이징 — Limit 노드 존재 (fetch join 조인엔 없었다) Limit (... rows=20 ...) (actual ... rows=20 loops=1) -> Sort Sort Method: top-N heapsort Memory: 28kB -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) @@ -1077,20 +1077,20 @@ Hash Semi Join (... actual ... rows=1509 loops=1) ← 페이지 부모 20개 ``` SQL(a)에는 `Limit` 노드가 있어 DB가 페이지 크기만큼 부모를 골랐습니다. SQL(b)의 semi-join은 -부모와 자식을 곱하지 않고 자식 행만 반환했습니다. 실행계획에서도 §9의 카테시안과 §10의 +부모와 자식을 곱하지 않고 자식 행만 반환했습니다. 실행계획에서도 앞서 본 카테시안과 인메모리 페이징이 모두 사라졌음을 확인했습니다. warm cache·executor 시간에 관한 한계는 -§6.4와 같습니다. +6.4절과 같습니다. ### 11.5 배치가 N+1과 페이징을 함께 해결하는 이유 -fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고, 이 때문에 DB가 부모 기준 `LIMIT`을 -적용할 수 없었습니다. 배치에서는 부모만 먼저 페이징하고, 자식은 `WHERE fk IN (?,…)`으로 따로 +fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고 이 때문에 DB가 부모 기준 `LIMIT`을 +적용할 수 없었습니다. 배치에서는 부모만 먼저 페이징하고 자식은 `WHERE fk IN (?,…)`으로 따로 가져옵니다. `default_batch_fetch_size=B`는 초기화되지 않은 프록시를 최대 B개씩 모아 -`ceil(N/B)`번에 로드합니다. 결과적으로 PreparedStatement는 2,022개에서 23개로 줄었고, +`ceil(N/B)`번에 로드합니다. 결과적으로 PreparedStatement는 2,022개에서 23개로 줄었고 부모 로드 수도 N이 아니라 페이지 크기에 머물렀습니다. 이 결과를 바탕으로 컬렉션 조회에는 fetch join 대신 배치를 사용하기로 했습니다. -### 11.6 배치가 못 푸는 것 — 엔티티 과적재 (→ §12/L6) +### 11.6 배치가 못 푸는 것 — 엔티티 과적재 배치로 쿼리 수와 페이징 문제는 풀었지만 엔티티는 여전히 통째로 하이드레이트했습니다. `FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities`에서 seed 1,000의 첫 페이지 20건을 @@ -1102,21 +1102,21 @@ join 대신 배치를 사용하기로 했습니다. ## 12. DTO 프로젝션 — 필요한 값만 조회하기 -배치를 적용한 뒤에도 화면에 필요하지 않은 엔티티가 1,569개나 만들어졌습니다. 그래서 +배치를 적용한 뒤에도 화면에 필요하지 않은 엔티티가 1,569개나 만들어졌습니다. `SELECT new (...)`로 필요한 스칼라 값만 조회하는 `loadFeedProjection`을 추가했습니다. 같은 화면 결과를 만들면서 `getEntityLoadCount()`가 1,569에서 0으로 줄어드는지 확인했습니다. > 기존 `loadFeed`를 바로 교체하면 앞 절의 기준선을 다시 측정할 수 없습니다. 그래서 -> `loadFeedProjection`을 별도 메서드로 추가하고 같은 데이터로 비교했습니다. §6~§11의 테스트도 -> 다시 실행해 기존 결과가 유지되는지 확인했습니다. +> `loadFeedProjection`을 별도 메서드로 추가하고 같은 데이터로 비교했습니다. 기준선부터 배치까지의 +> 테스트도 다시 실행해 기존 결과가 유지되는지 확인했습니다. ### 12.1 fix는 두 개의 스칼라 프로젝션 — 엔티티 대신 필요 컬럼만 프로젝션은 두 부분입니다. **(A)** 부모의 필요 스칼라 컬럼만 페이징으로 프로젝션(컬렉션 조인 없음 → `LIMIT` 정상, 카테시안 없음). **(B)** 그 페이지 부모들의 자식을 필요 스칼라 컬럼만 `IN`으로 프로젝션 → 메모리 그룹핑. ```java -// FeedQueryAdapter.loadFeedProjection — loadFeed(순진, §6~§11)는 무변경. +// FeedQueryAdapter.loadFeedProjection — loadFeed(순진)는 무변경. // (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용(하이드레이션 아님), 페이징은 엔티티에. select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) from FeedItemJpaEntity f join f.user u join f.page p @@ -1128,16 +1128,16 @@ select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) `FeedSummary`의 마지막 인자는 `List`라 생성자 표현식 한 번으로 만들 수 없었습니다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했습니다. 이 랩에서는 -회귀 비교를 위해 sibling 메서드로 두었고, 프로덕션 경로에서는 이 프로젝션을 `FeedQueryPort`의 +회귀 비교를 위해 sibling 메서드로 두었고 프로덕션 경로에서는 이 프로젝션을 `FeedQueryPort`의 CQRS-lite 계약으로 노출합니다. ### 12.2 실측 — 엔티티 로드가 0으로 줄어든다 -seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 §11의 배치 조회와 비교했습니다. +seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 앞 절의 배치 조회와 비교했습니다. 프로젝션은 하이드레이트한 엔티티가 0개였습니다. 원본: [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv). -| 지표 | before: §11 배치 | after: §12 프로젝션 | +| 지표 | before: 배치 | after: 프로젝션 | |---|---:|---:| | entitiesLoaded (seed 1,000) | 1,569 | **0** | | prepared (N=1,000) | 23 | **2** | @@ -1146,26 +1146,26 @@ seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 §11의 배치 조 하이드레이트한 엔티티는 1,569개에서 0개로 줄었습니다. `SELECT new (...)`는 영속 엔티티 대신 스칼라 값으로 record를 만듭니다. `join f.user u`도 `u.name` 컬럼을 읽기 위한 경로일 뿐 User 엔티티를 만들지는 않습니다. 부모 스칼라 쿼리와 자식 IN 쿼리만 남아 prepared는 -2개로 고정되었고, 엔티티 컬렉션을 초기화하지 않아 collectionFetch도 0이었습니다. +2개로 고정되었고 엔티티 컬렉션을 초기화하지 않아 collectionFetch도 0이었습니다. ### 12.3 N이 늘어도 쿼리는 2개로 유지된다 N을 10, 100, 1,000으로 바꿔 다시 측정해도 prepared는 **항상 2개**였습니다. 기준선과 배치 결과를 같은 표에 놓고 증가 형태를 비교했습니다. -| N | §6 순진(1+N) | §11 배치(1+ceil(N/batch)·연관) | §12 프로젝션(상수) | +| N | 순진(1+N) | 배치(1+ceil(N/batch)·연관) | 프로젝션(상수) | |---:|---:|---:|---:| | 10 | 25 | 5 | **2** | | 100 | 222 | 5 | **2** | | 1,000 | 2,022 | 23 | **2** | -기준선의 쿼리 수는 N을 따라 늘었고, 배치는 배치 크기 단위로 늘었습니다. 프로젝션은 부모 스칼라 +기준선의 쿼리 수는 N을 따라 늘었고 배치는 배치 크기 단위로 늘었습니다. 프로젝션은 부모 스칼라 쿼리 1개와 자식 IN 쿼리 1개로 유지되었습니다. 페이지 부모가 최대 20개라 자식 IN 쿼리도 한 번만 -실행되었습니다. 엔티티 로드 수도 §11의 1,569개에서 §12의 0개로 줄었습니다. +실행되었습니다. 엔티티 로드 수도 배치의 1,569개에서 프로젝션의 0개로 줄었습니다. ### 12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정) -§11의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt)). +앞서 11절의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt)). ```text -- (a) 부모 스칼라 프로젝션 — Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) @@ -1175,13 +1175,13 @@ Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) -> Hash Join (fi.user_id = u.id) ← users 조인 -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 -- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) -Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라이트 합(§11 배치와 동일) +Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라이트 합(11절 배치와 동일) ``` -> **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만, -> 부모 프로젝션의 width는 **2088**로 엔티티 조회의 1194보다 컸습니다(원본: +> **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만 +> 부모 프로젝션의 width는 2088로 엔티티 조회의 1194보다 컸습니다(원본: > [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). -> `users`와 `pages` 조인의 행폭이 반영되고, PostgreSQL의 `width`가 실제 전송 바이트가 아니라 +> `users`와 `pages` 조인의 행폭이 반영되고 PostgreSQL의 `width`가 실제 전송 바이트가 아니라 > 컬럼 타입의 평균폭 추정치이기 때문입니다. 프로젝션의 효과는 SQL 플랜의 width가 아니라 > `Statistics.getEntityLoadCount()`에서 확인했습니다. @@ -1193,7 +1193,7 @@ Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차 엔티티보다 프로젝션이 맞다고 판단했습니다. 이 효과는 DB 실행계획보다 ORM/JVM 층의 엔티티 로드 수에서 확인할 수 있었습니다. -### 12.6 프로젝션이 못 푸는 것 — 페이지당 전량 (→ §13/L14) +### 12.6 프로젝션이 못 푸는 것 — 페이지당 전량 프로젝션은 엔티티 과적재를 없앴지만 자식 IN 쿼리는 페이지 부모의 하이라이트를 **전부** 가져왔습니다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였습니다(원본: @@ -1205,11 +1205,10 @@ Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차 ## 13. Top-N-per-group — 부모마다 최신 3개를 가져오는 세 가지 방법 -프로젝션으로 엔티티는 만들지 않게 되었지만, 부모 20개의 하이라이트 1,509행을 모두 가져오는 +프로젝션으로 엔티티는 만들지 않게 되었지만 부모 20개의 하이라이트 1,509행을 모두 가져오는 문제는 남았습니다. 화면에는 부모마다 최신 3개만 필요했습니다. 표준 JPQL만으로는 윈도우 함수와 -LATERAL을 표현할 수 없어서 native SQL로 내려갔고, 윈도우 함수·LATERAL·2단계 배치 세 방식을 -같은 데이터로 비교했습니다. 세 방식이 같은 top-3을 만드는지 먼저 확인한 뒤 실행계획과 buffers를 -비교했습니다. +LATERAL을 표현할 수 없어서 native SQL로 내려갔습니다. 윈도우 함수·LATERAL·2단계 배치 세 +방식이 같은 top-3을 만드는지 먼저 확인한 뒤 같은 데이터로 실행계획과 buffers를 비교했습니다. ### 13.1 단순한 `LIMIT`이 부모별로 적용되지 않는 이유 @@ -1233,12 +1232,12 @@ SELECT t.* FROM (SELECT h.*, row_number() OVER (PARTITION BY h.feed_item_id SELECT p.id, top3.* FROM () p CROSS JOIN LATERAL ( SELECT h.color, h.text, h.created_at FROM highlights h WHERE h.feed_item_id = p.id ORDER BY h.created_at DESC LIMIT 3) top3; --- ⓒ 2단계 배치: 자식을 한 방 IN 으로 가져와 앱에서 부모별 3컷 (§11 배치의 연장) +-- ⓒ 2단계 배치: 자식을 한 방 IN 으로 가져와 앱에서 부모별 3컷 (11절 배치의 연장) SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h WHERE h.feed_item_id IN () ORDER BY h.feed_item_id, h.created_at DESC; -- 앱컷 ``` -`PARTITION BY`(윈도우)·부모별 상관 서브쿼리(LATERAL)·앱 그룹핑(2단계)이 각각 `LIMIT`이 못 하는 "그룹당"을 만듭니다. 무대는 `FeedTopNIT`(신규 IT, native SQL을 `JdbcTemplate`으로) — L14는 `loadFeed`/`loadFeedProjection`을 건드리지 않는 **프로덕션 코드 0**(§10처럼 IT-only). 표준 JPQL엔 윈도우도 LATERAL도 없어(§13.6) native로 내려갑니다. +`PARTITION BY`(윈도우)·부모별 상관 서브쿼리(LATERAL)·앱 그룹핑(2단계)이 각각 `LIMIT`이 못 하는 "그룹당"을 만듭니다. 무대는 신규 IT인 `FeedTopNIT`이고 native SQL은 `JdbcTemplate`으로 실행합니다. 10절처럼 IT-only라 `loadFeed`와 `loadFeedProjection`은 건드리지 않았고 프로덕션 코드 변경은 0입니다. 표준 JPQL엔 윈도우도 LATERAL도 없어서 native로 내려갑니다. ### 13.2 실측 — 세 방법의 결과와 단순 LIMIT의 오작동 @@ -1284,7 +1283,7 @@ WindowAgg Run Condition: (row_number() OVER (?) <= 3) Buffers: shared hit=43 세 방식은 모두 같은 top-3 60행을 만들었지만 읽는 방식은 달랐습니다. LATERAL은 부모마다 `ix_highlights_feed_items_created`를 seek해 3개에서 멈췄고 buffers는 204였습니다. 윈도우와 2단계 방식은 같은 `Hash Semi Join`으로 1,509행을 모두 읽어 buffers가 430이었습니다. 윈도우는 -그 위에서 `WindowAgg`로 60행을 남겼고, 2단계는 1,509행을 애플리케이션에 전달했습니다. +그 위에서 `WindowAgg`로 60행을 남겼고 2단계는 1,509행을 애플리케이션에 전달했습니다. 쿼리 개수만으로는 이 차이를 볼 수 없었고 실행계획과 buffers를 함께 봐야 했습니다. ### 13.4 인덱스 유무 토글 — LATERAL의 빠름은 LATERAL이 아니라 인덱스 seek 덕 @@ -1302,8 +1301,8 @@ LATERAL의 buffers가 작은 이유가 복합 인덱스인지 확인했습니다 복합 인덱스를 제거하자 LATERAL은 부모마다 highlights를 Seq Scan하고 대부분을 필터로 버렸습니다. buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 늘었습니다. LATERAL 문법 자체가 빠른 것이 아니라 `(feed_item_id, created_at DESC)` 인덱스로 -부모별 상위 3개를 바로 찾을 수 있어서 빨랐습니다. 이 인덱스는 새로 추가한 것이 아니라 -`V6__feed.sql`부터 있었습니다. +부모별 상위 3개를 바로 찾을 수 있어서 빨랐습니다. 이 인덱스는 `V6__feed.sql`부터 있었습니다. +새로 추가한 것이 아닙니다. ### 13.5 그룹 크기가 승자를 가른다 — K 곡선 @@ -1317,40 +1316,40 @@ buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 | 500 | 1,509 | 269 | 1,509 | 171 | 반환 행수는 K에 따라 60 → 695 → 1,509로 늘었습니다. LATERAL의 buffers는 모든 K에서 -윈도우보다 작았지만, 차이는 K가 작을수록 컸습니다. 부모의 하이라이트 500개 중 K개만 인덱스로 +윈도우보다 작았지만 차이는 K가 작을수록 컸습니다. 부모의 하이라이트 500개 중 K개만 인덱스로 읽기 때문입니다. K가 그룹 크기인 500에 가까워지면 LATERAL도 대부분을 읽습니다. 현재 피드는 -그룹이 크고 K가 3으로 작아서 LATERAL을 선택했고, K가 그룹 크기에 가까운 조회라면 더 단순한 -윈도우 함수를 선택할 수 있습니다. +그룹이 크고 K가 3으로 작아서 LATERAL을 선택했습니다. K가 그룹 크기에 가까운 조회라면 더 +단순한 윈도우 함수를 고를 수 있습니다. ### 13.6 세 방법이 부모별 top-3을 만드는 방식 윈도우 함수는 `PARTITION BY feed_item_id`로 부모마다 순번을 매기고 `rn<=3`을 남깁니다. -DB에서 자르지만 순번을 만들기 위해 파티션 전체를 읽습니다. LATERAL은 부모마다 상관 서브쿼리를 +DB에서 자르지만 순번을 만들려고 파티션 전체를 읽습니다. LATERAL은 부모마다 상관 서브쿼리를 실행하고 복합 인덱스에서 3개를 읽으면 멈춥니다. 2단계 방식은 `IN`으로 자식을 모두 가져온 뒤 -애플리케이션에서 그룹핑합니다. 표준 JPQL에는 윈도우 함수와 LATERAL이 없고, Hibernate 6+ HQL도 -LATERAL은 지원하지 않습니다. 그래서 작은 K와 큰 그룹이라는 현재 조건에는 native LATERAL을 +애플리케이션에서 그룹핑합니다. 표준 JPQL에는 윈도우 함수와 LATERAL이 없고 Hibernate 6+ HQL도 +LATERAL은 지원하지 않습니다. 작은 K와 큰 그룹이라는 현재 조건에는 native LATERAL을 선택했습니다. ### 13.7 다음에 해결할 문제 — 부모 피드 페이징 아이템별 top-3은 60행으로 줄였지만 부모 피드 페이징은 여전히 `OFFSET`이었습니다. `OFFSET 900 LIMIT 20`을 측정하자 앞의 900행도 읽은 뒤 버렸습니다. 페이지가 깊어질수록 -비용이 늘어나는 문제를 해결하기 위해 다음 단계에서는 `(first_highlighted_at, id)` 커서를 -사용하는 keyset 페이징으로 바꿨습니다. +비용이 늘어나므로 다음 단계에서는 `(first_highlighted_at, id)`를 커서로 쓰는 keyset +페이징으로 바꿨습니다. --- ## 14. keyset vs OFFSET — 깊은 페이지의 조회량 비교 아이템별 top-3을 해결한 뒤 부모 피드의 페이징을 확인했습니다. 이 쿼리는 여전히 -`OFFSET :n LIMIT 20`을 사용하고 있어 페이지가 깊어질수록 앞의 행을 읽고 버렸습니다. 무한 +`OFFSET :n LIMIT 20`을 써서 페이지가 깊어질수록 앞의 행을 읽고 버렸습니다. 무한 스크롤에서는 이 비용이 계속 늘어납니다. 그래서 이전 페이지의 마지막 -`(first_highlighted_at, id)`를 커서로 넘기는 keyset 페이징으로 바꾸고, 페이지 깊이에 따른 +`(first_highlighted_at, id)`를 커서로 넘기는 keyset 페이징으로 바꾸고 페이지 깊이에 따른 스캔 행수를 비교했습니다. ### 14.1 왜 OFFSET은 깊은 페이지에서 죽나 — keyset의 shape -`OFFSET`은 정렬 순서에서 앞 `offset`행을 **생성한 뒤 버립니다**. 정렬키 인덱스가 있어도 그 튜플들을 훑어야 하고, 깊으면 아예 `Seq Scan`+`Sort`로 전량을 훑습니다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 **그 지점 이후만** 읽습니다. +`OFFSET`은 정렬 순서에서 앞 `offset`행을 **생성한 뒤 버립니다**. 정렬키 인덱스가 있어도 그 튜플들을 훑어야 하고 깊으면 아예 `Seq Scan`+`Sort`로 전량을 훑습니다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽습니다. ```sql -- ❌ 순진 OFFSET: 깊은 페이지에서 앞 n행을 읽어 버린다 (over-scan = offset+20) @@ -1365,7 +1364,7 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi 측정은 `FeedKeysetIT`의 native SQL로 격리했고 정렬키 인덱스는 테스트 안에서 CREATE/DROP 했습니다. V6의 `ix_feed_items_visibility_sort`는 선두 컬럼이 `visibility`라 가시성 필터가 -없는 keyset 쿼리에는 맞지 않았습니다. 따라서 `(first_highlighted_at DESC, id DESC)` 전용 +없는 keyset 쿼리에는 맞지 않았습니다. `(first_highlighted_at DESC, id DESC)` 전용 인덱스를 사용했습니다. 프로덕션에 반영할 때는 V8 마이그레이션으로 추가할 수 있습니다. ### 14.2 실측 — OFFSET은 깊이에 비례하고 keyset은 일정하다 @@ -1379,8 +1378,8 @@ actual rows로 계산했습니다(원본: [`evidence/metrics/l15-depth-curve.csv | 50 (980) | 1,000 | 20 | | 100 (1980) | **2,000** | **20** | -OFFSET이 훑은 행은 offset+20으로 20 → 1,000 → 2,000까지 늘었고, keyset은 계속 -20행이었습니다. 100번째 페이지에서 OFFSET은 결과 20행을 만들기 위해 2,000행을 읽었지만 +OFFSET이 훑은 행은 offset+20으로 20 → 1,000 → 2,000까지 늘었고 keyset은 계속 +20행이었습니다. 100번째 페이지에서 OFFSET은 결과 20행을 만들려고 2,000행을 읽었지만 keyset은 20행만 읽었습니다. 무한 스크롤의 뒤쪽 페이지가 느려지는 이유를 이 차이로 확인했습니다. ### 14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제 @@ -1411,16 +1410,16 @@ Limit (rows=20) Buffers: shared hit=1 read=2 ### 14.4 keyset의 조회량이 일정한 이유 -OFFSET은 건너뛸 행까지 읽지만, keyset은 커서 `(first_highlighted_at, id)` 이후를 인덱스에서 -range scan합니다. 같은 `first_highlighted_at`을 가진 행도 안정적으로 넘기려면 tie-break인 +OFFSET은 건너뛸 행까지 읽지만 keyset은 커서 `(first_highlighted_at, id)` 이후를 인덱스에서 +range scan합니다. `first_highlighted_at`이 같은 행도 안정적으로 넘기려면 tie-break인 `id`까지 커서에 포함해야 합니다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있습니다. `FeedKeysetIT.l15KeysetWalkMatchesOffsetPages`로 keyset의 두 번째 페이지가 OFFSET의 두 번째 페이지와 같은 20행, 같은 순서인지 확인했습니다. 정렬키·커서·인덱스의 컬럼과 방향이 모두 일치해야 합니다. -### 14.5 keyset이 못 푸는 것 — 가시성 OR (→ §15/L16) +### 14.5 keyset이 못 푸는 것 — 가시성 OR -keyset은 페이지 깊이를 풀었지만, 실서비스 피드는 **가시성**으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`), 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt). +keyset은 페이지 깊이를 풀었지만 실서비스 피드는 가시성으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`) 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt). ```text -- 가시성 OR 을 얹으면: 정렬키 Index Only Scan 이 사라지고 BitmapOr + 별도 Sort 로 @@ -1441,13 +1440,13 @@ bitmap으로 합치면서 인덱스의 정렬 순서를 잃었습니다. 그래 ## 15. 가시성 조건 — 단일 OR, UNION, 사전계산 비교 -keyset으로 페이지 깊이 문제를 풀었지만, `public + 내가 멘션된 것 + 내 비공개`라는 가시성 +keyset으로 페이지 깊이 문제를 풀었지만 `public + 내가 멘션된 것 + 내 비공개`라는 가시성 조건을 합치자 `BitmapOr`+`Sort`가 다시 나타났습니다. 단일 OR을 그대로 쓰는 방식, 세 분기를 UNION으로 나누는 방식, 뷰어별 가시성을 미리 계산하는 방식을 같은 결과 집합으로 비교했습니다. ### 15.1 단일 OR이 정렬 순서를 유지하지 못하는 이유 -하나의 인덱스는 하나의 선두 컬럼 순서만 줍니다. 가시성 3분기는 각각 다른 조건(visibility 값·user_id·mentions 조인)이라, 하나의 쿼리로 묶으면 플래너는 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 합니다. +하나의 인덱스는 하나의 선두 컬럼 순서만 줍니다. 가시성 3분기는 각각 다른 조건(visibility 값·user_id·mentions 조인)이라 하나의 쿼리로 묶으면 플래너는 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 합니다. ```sql -- ❌ 단일 OR: 3분기를 하나로 → BitmapOr + 전체 top-N Sort + 멘션 SubPlan (순서 인덱스 못 탐) @@ -1464,13 +1463,13 @@ SELECT fi.id, fi.first_highlighted_at FROM feed_items fi 측정은 `FeedVisibilityIT`에 격리했습니다. 신규 인덱스(`ix_mentions_user`, private partial)와 `feed_visible` 테이블도 테스트 안에서 생성하고 제거했습니다. V7의 `feed_item_mentions` 인덱스는 `(feed_item_id, …)` 순서라 "나를 멘션한 아이템"을 찾는 쿼리에 맞지 않았습니다. -따라서 `(mentioned_user_id, feed_item_id)` 인덱스를 추가해 비교했습니다. +`(mentioned_user_id, feed_item_id)` 인덱스를 추가해 비교했습니다. ### 15.2 실측 — 결과는 같고 실행계획은 다르다 seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방식이 같은 20개 feed_item을 -반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인했습니다. 그다음 -가시성 조건을 처리하는 실행계획을 비교했습니다(원본: +반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인한 뒤 가시성 조건을 +처리하는 실행계획을 비교했습니다(원본: [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). | 안 | 최상위/스캔 | Sort | 멘션 | 훑는 후보 | buffers | @@ -1479,7 +1478,7 @@ seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방 | ⓑ UNION 분해 | **`Merge Append`**(분기별 인덱스) | 분기별 병합 | `Hash Join` | ≤60 | 200 | | ⓒ **사전계산** | **`Index Only Scan`**(feed_visible) | **없음** | 사전 반영 | 20 | **1** | -**단일 OR**은 3분기를 `BitmapOr`로 합쳐 후보 **1,500**을 훑고 top-N `Sort`로 20을 낸다 — 순서를 인덱스로 못 내 재정렬한다(멘션 EXISTS는 hashed SubPlan). **UNION 분해**는 3분기를 각각 정렬 스트림으로 만들어 `Merge Append`로 병합(전체 재정렬 없음), EXISTS가 `Hash Join`으로 바뀐다(public은 고선택도라 bitmap+top-N, private는 partial 인덱스, mentioned는 조인 — **각 분기가 자기 최적 플랜**). **사전계산**은 `feed_visible` 커버링 인덱스의 단일 `Index Only Scan` — OR도 조인도 Sort도 없이 20행만(buffers **1**). +**단일 OR**은 3분기를 `BitmapOr`로 합쳐 후보 1,500을 훑고 top-N `Sort`로 20을 낸다 — 순서를 인덱스로 못 내 재정렬한다(멘션 EXISTS는 hashed SubPlan). **UNION 분해**는 3분기를 각각 정렬 스트림으로 만들어 `Merge Append`로 병합(전체 재정렬 없음), EXISTS가 `Hash Join`으로 바뀐다(public은 고선택도라 bitmap+top-N, private는 partial 인덱스, mentioned는 조인 — 각 분기가 자기 최적 플랜). **사전계산**은 `feed_visible` 커버링 인덱스의 단일 `Index Only Scan` — OR도 조인도 Sort도 없이 20행만(buffers 1). ### 15.3 세 플랜을 나란히 @@ -1501,34 +1500,34 @@ Limit -> Index Only Scan using ix_feed_visible (Index Cond: viewer_id=:me) Heap ### 15.4 UNION과 사전계산의 차이 -> **★ 실측 정정** — 처음에는 단일 OR이 Seq Scan을 하고, UNION이 buffers를 줄일 것으로 -> 예상했습니다. 실제 단일 OR은 `BitmapOr`+top-N `Sort`+hashed SubPlan을 사용했고, UNION의 +> **★ 실측 정정** — 처음에는 단일 OR이 Seq Scan을 하고 UNION이 buffers를 줄일 것으로 +> 예상했습니다. 실제 단일 OR은 `BitmapOr`+top-N `Sort`+hashed SubPlan을 사용했고 UNION의 > buffers는 200으로 단일 OR의 122보다 컸습니다. 각 분기가 따로 스캔하기 때문입니다. buffers가 > 1까지 줄어든 방식은 UNION이 아니라 사전계산이었습니다. 단일 OR은 세 분기를 bitmap으로 합치면서 정렬 순서를 잃습니다. UNION은 분기를 독립시켜 상관 술어를 `Hash Join`으로, 전체 병합을 `Merge Append`로 바꿨지만 요청할 때마다 세 분기를 스캔했습니다. 사전계산은 뷰어별 `feed_visible`을 미리 만들어 조회를 단일 `Index Only Scan`으로 -바꿨습니다. 대신 피드·멘션·가시성이 바뀔 때 읽기 모델을 갱신해야 하고, 뷰어 수만큼 저장 공간도 +바꿨습니다. 대신 피드·멘션·가시성이 바뀔 때 읽기 모델을 갱신해야 하고 뷰어 수만큼 저장 공간도 늘어납니다. ### 15.5 사전계산을 프로덕션에 적용할 때 필요한 것 `feed_visible`은 실험용 테이블이지만 프로덕션에서 상시 유지하려면 CQRS 읽기 모델이 됩니다. -쓰기 모델의 변경을 뷰어별 투영에 반영하고, 조회는 그 투영만 읽습니다. 여기까지 진행하면서 문제의 +쓰기 모델의 변경을 뷰어별 투영에 반영하고 조회는 그 투영만 읽습니다. 여기까지 진행하면서 문제의 범위가 N+1을 줄이는 SQL에서 화면에 맞는 읽기 모델을 설계하는 일로 넓어졌습니다. --- ## 16. Top-N·keyset·가시성을 한 쿼리로 통합하기 -Top-N·keyset·가시성을 각각 검증한 뒤 세 조건을 한 쿼리에 합쳤습니다. 실제 화면에서는 보이는 +세 기법을 각각 검증한 뒤 한 쿼리에 합쳤습니다. 실제 화면에서는 보이는 아이템만 골라 깊은 페이지를 넘기면서 각 아이템의 최신 하이라이트 3개를 함께 반환해야 합니다. `FeedCrownIT`에서 세 기법이 서로의 인덱스 사용을 방해하지 않는지 확인했습니다. ### 16.1 통합 쿼리의 shape — 부모선택 × LATERAL -통합 쿼리는 가시성 필터와 keyset으로 부모 20개를 고른 뒤, 각 부모에 LATERAL top-3을 +통합 쿼리는 가시성 필터와 keyset으로 부모 20개를 고른 뒤 각 부모에 LATERAL top-3을 적용합니다. 작은 K에서 유리했던 LATERAL을 자식 조회에 사용하고 keyset·가시성은 부모 선택 안에서 처리했습니다. @@ -1562,7 +1561,7 @@ Nested Loop (rows=60) ← LATERAL ### 16.3 간섭 시험 — 사전계산 위에선 겹치고, 단일 OR 위에선 매 페이지 재해소 -`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서, 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt). +`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt). | 부모선택 | 최상위 | 훑는 행 | feed_visible | 부모 buffers | |---|---|---:|---|---:| @@ -1572,7 +1571,7 @@ Nested Loop (rows=60) ← LATERAL > **★ 실측 정정** — 처음에는 사전계산 keyset에는 Sort가 없고 단일 OR에만 Sort가 생길 > 것으로 예상했습니다. 가장 깊은 커서에서는 두 방식 모두 남은 19행을 작은 quicksort로 > 정렬했습니다. 차이는 Sort 유무가 아니라 페이지에 도달하기까지 읽은 행수였습니다. 사전계산은 -> `ix_feed_visible`의 range에서 19행만 읽었고, 단일 OR은 가시성 세 분기와 멘션 조건을 다시 +> `ix_feed_visible`의 range에서 19행만 읽었고 단일 OR은 가시성 세 분기와 멘션 조건을 다시 > 계산하며 200행을 materialize했습니다. ### 16.4 조회 조건별 선택 기준 @@ -1581,29 +1580,29 @@ Nested Loop (rows=60) ← LATERAL | 축 | 문제 | 해법 | 언제 | 근거 | |---|---|---|---|---| -| Top-N-per-group | 아이템당 최신 top-3 | **LATERAL**(작은 K) / 윈도우(큰 K) | 항상 LATERAL, K가 그룹 크기에 근접하면 윈도우로 수렴 | §13 | -| 페이징 | 깊은 페이지 | **keyset**(커서+정렬키 인덱스) | 항상. OFFSET 은 깊이에 비례 붕괴 | §14 | -| 가시성 | 3분기 술어 | **UNION 분해** / **사전계산**(=CQRS) | 보통 UNION, 고트래픽 읽기 극단이면 사전계산 | §15 | -| 통합 | 셋을 한 쿼리로 | 부모선택(가시성+keyset) × LATERAL(Top-N) | 부모선택 사전계산/UNION 이면 매 페이지 재해소 없음 | §16 | +| Top-N-per-group | 아이템당 최신 top-3 | **LATERAL**(작은 K) / 윈도우(큰 K) | 항상 LATERAL, K가 그룹 크기에 근접하면 윈도우로 수렴 | 13절 | +| 페이징 | 깊은 페이지 | **keyset**(커서+정렬키 인덱스) | 항상. OFFSET 은 깊이에 비례 붕괴 | 14절 | +| 가시성 | 3분기 술어 | **UNION 분해** / **사전계산**(=CQRS) | 보통 UNION, 고트래픽 읽기 극단이면 사전계산 | 15절 | +| 통합 | 셋을 한 쿼리로 | 부모선택(가시성+keyset) × LATERAL(Top-N) | 부모선택 사전계산/UNION 이면 매 페이지 재해소 없음 | 16절 | 통합 쿼리에서 차이를 만든 부분은 부모 선택이었습니다. 사전계산이나 UNION 분해를 사용하면 -keyset과 Top-N을 그대로 합칠 수 있지만, 단일 OR은 페이지를 넘길 때마다 가시성 조건을 다시 +keyset과 Top-N을 그대로 합칠 수 있지만 단일 OR은 페이지를 넘길 때마다 가시성 조건을 다시 계산했습니다. ### 16.5 사전계산과 CQRS 읽기 모델의 경계 -세 조건을 가장 단순한 실행계획으로 합친 부모 선택은 사전계산(`feed_visible`)이었습니다. 하지만 -이를 상시 유지하려면 쓰기 모델의 변경을 뷰어별 투영에 동기화해야 합니다. 현재 범위에서 이 비용을 -바로 받아들일지는 별도 판단이 필요했습니다. +부모 선택 방식 가운데 사전계산(`feed_visible`)이 세 조건을 가장 단순한 실행계획으로 +합쳤습니다. 하지만 이를 상시 유지하려면 쓰기 모델의 변경을 뷰어별 투영에 동기화해야 합니다. +현재 범위에서 이 비용을 바로 받아들일지는 별도 판단이 필요했습니다. --- ## 17. CQRS-lite 읽기 모델 — 프로덕션 읽기 경로로 (주제 2 브릿지) -사전계산(`feed_visible`)의 실행계획이 가장 단순했지만, 이를 상시 유지되는 별도 저장소로 만들면 +사전계산(`feed_visible`)의 실행계획이 가장 단순했지만 이를 상시 유지되는 별도 저장소로 만들면 쓰기 모델의 이벤트로 읽기 저장소를 갱신하는 풀 CQRS가 필요합니다. 제가 정한 application-core 계약에서는 별도 물리 읽기 저장소를 에스컬레이션 대상으로 남겨 두었습니다. 이번 범위에서는 그 -계약을 유지하고, 같은 저장소 위에 읽기 전용 포트·DTO·쿼리를 분리하는 **CQRS-lite**를 +계약을 유지하고 같은 저장소 위에 읽기 전용 포트·DTO·쿼리를 분리하는 **CQRS-lite**를 구현했습니다. ### 17.1 CQRS-lite vs 풀 CQRS — 모델이냐, 저장소냐 @@ -1615,26 +1614,26 @@ keyset과 Top-N을 그대로 합칠 수 있지만, 단일 OR은 페이지를 넘 | 동기화 | 없음(요청 시 읽기최적 쿼리) | 쓰기→읽기(도메인 이벤트/아웃박스) | | 계약 | **지원**(query-bypass Projection) | **에스컬레이션 전용** | -핵심은 N+1을 "SQL로 푸느냐"에서 "**읽기 모델을 어떻게 설계하느냐**"로 넘어가는 것입니다. lite는 쓰기 애그리거트(`FeedItem`)와 분리된 읽기 경로를 같은 저장소 위에 세우고, full은 저장소까지 분리해 동기화 비용을 집니다. +핵심은 N+1을 "SQL로 푸느냐"에서 "**읽기 모델을 어떻게 설계하느냐**"로 넘어가는 것입니다. lite는 쓰기 애그리거트(`FeedItem`)와 분리된 읽기 경로를 같은 저장소 위에 세우고 full은 저장소까지 분리해 동기화 비용을 집니다. ### 17.2 무엇을 만들었나 + 실측 읽기 경로는 `FeedReadModelQueryPort` → `GetFeedReadModelUseCase` → `FeedReadModelQueryAdapter` -순서로 만들었습니다. 쿼리는 §12의 프로젝션과 §13의 window top-3을 합쳐 기존 `loadFeed`를 +순서로 만들었습니다. 쿼리는 12절의 프로젝션과 13절의 window top-3을 합쳐 기존 `loadFeed`를 건드리지 않고 화면에 필요한 형태를 바로 반환합니다. - 부모 페이지: JPQL `SELECT new`(엔티티 하이드레이션 0). - 자식 top-3: 네이티브 `row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3`. `FeedReadModelUseCaseIT`에서 N=10과 100을 측정한 결과 엔티티 로드는 **0**, 발행 쿼리는 -N과 관계없이 **2개**, `topHighlights`는 부모당 최대 3개였습니다. 아키텍처 게이트인 ArchUnit +N과 관계없이 2개, `topHighlights`는 부모당 최대 3개였습니다. 아키텍처 게이트인 ArchUnit `query_ports_do_not_leak…`, 의존 방향 검사, `./gradlew check`도 통과했습니다. window 쿼리는 Hibernate `Statistics`가 실제 발행 횟수를 셀 수 있도록 `JdbcTemplate` 대신 Hibernate `Session`으로 실행했습니다. ### 17.3 주제 2로 -여기서 N+1 주제가 아키텍처 주제로 넘어갑니다. lite가 읽기 모델을 **모델 수준**으로 분리했다면, 고트래픽 읽기·가시성 사전계산(§16의 `feed_visible`)이 실제로 필요해지는 순간 그것을 **저장소 수준**으로 올리는 게 풀 CQRS이고, 그때 계약·가드레일을 의도적으로 개정합니다. "N+1은 쓰기 모델로 읽기를 하려는 신호"라는 일반화가 여기서 헥사고날·CQRS 설계로 완결됩니다. +여기서 N+1 주제가 아키텍처 주제로 넘어갑니다. lite가 읽기 모델을 모델 수준으로 분리했다면, 고트래픽 읽기·가시성 사전계산(`feed_visible`)이 실제로 필요해지는 순간 그것을 저장소 수준으로 올리는 게 풀 CQRS입니다. 그때 계약·가드레일을 의도적으로 개정합니다. "N+1은 쓰기 모델로 읽기를 하려는 신호"라는 일반화가 여기서 헥사고날·CQRS 설계로 완결됩니다. --- @@ -1643,8 +1642,8 @@ Hibernate `Statistics`가 실제 발행 횟수를 셀 수 있도록 `JdbcTemplat 처음 만든 엔티티 조회에서 N+1을 확인한 뒤, 배치·프로젝션·Top-N·keyset·가시성 순서로 조회 구조를 바꿨습니다. 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, 하이라이트 전송은 1,509행에서 최대 60행으로 줄었습니다. 깊은 페이지는 2,000행 대신 20행을 -읽었고, 사전계산한 가시성 조회는 후보 1,500개 대신 20개에 접근했습니다. 이 결과를 같은 저장소 -위 CQRS-lite 읽기 경로에 반영했습니다. +읽었습니다. 사전계산한 가시성 조회는 후보 1,500개 대신 20개에 접근했습니다. 이 결과를 같은 +저장소 위 CQRS-lite 읽기 경로에 반영했습니다. - **풀 CQRS(주제 2, 에스컬레이션)**: 고트래픽 읽기에서 `feed_visible` 사전계산이 실제로 필요해지면 별도 물리 읽기 저장소와 쓰기→읽기 동기화(도메인 이벤트/아웃박스)를 추가합니다. @@ -1671,35 +1670,35 @@ cd src - 곡선(N2): `l2ToOneEagerHiddenNPlusOneCurve` (N=10/100/1000), `pageFetch == N`(선형)·`userFetch ≤ 20`(평탄)·`entityFetch == pageFetch + userFetch` 확인. - 접근 0 증명(N2): `l2EagerToOneFiresEvenWithZeroFieldAccess`, 접근 0인데 `pageFetch == 100`·`collectionFetch == 0`(EAGER는 나가고 LAZY는 안 나감). - 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). -- 다중 컬렉션 실패(§9): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인. -- 카테시안(§9): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)). -- 실행계획(§9): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). -- 인메모리 페이징(§10): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)). -- HHH000104 경고(§10): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭). -- EXPLAIN 대조(§10): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). -- 배치 해결(§11): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)). -- 페이징 정상(§11): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(§10 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). -- 잔여 비용(§11): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → L6)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). -- 프로젝션 해결(§12): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(§11의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). -- EXPLAIN·width 정정(§12): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). -- 잔여 비용(§12): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → L14)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). -- 정확성·전송(§13): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)). -- 플랜 대조(§13): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)). -- 인덱스 토글(§13): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)). -- 그룹 크기 곡선(§13): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). -- 잔여 비용(§13): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ L15 keyset). -- 깊이 곡선(§14): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). -- EXPLAIN·인덱스 유무(§14): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)). -- 정확성(§14): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서). -- 가시성 probe(§14 → L16): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt)). -- 정확성(§15): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). -- 3안 플랜 대조(§15): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). -- 분기별 인덱스(§15): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt)). -- 사전계산=CQRS(§15 → L12): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). -- 통합 정확성·shape(§16): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)). -- 한 플랜 세 기법(§16): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt)). -- 간섭 시험(§16): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt)). -- CQRS-lite 읽기 모델(§17): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(§12 프로젝션 + §13 window 결합, §12 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN. +- 다중 컬렉션 실패(9절): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인. +- 카테시안(9절): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)). +- 실행계획(9절): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). +- 인메모리 페이징(10절): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)). +- HHH000104 경고(10절): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭). +- EXPLAIN 대조(10절): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). +- 배치 해결(11절): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)). +- 페이징 정상(11절): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(10절 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). +- 잔여 비용(11절): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → 프로젝션 단계)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +- 프로젝션 해결(12절): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(11절의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- EXPLAIN·width 정정(12절): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +- 잔여 비용(12절): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → Top-N 단계)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- 정확성·전송(13절): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)). +- 플랜 대조(13절): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)). +- 인덱스 토글(13절): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)). +- 그룹 크기 곡선(13절): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). +- 잔여 비용(13절): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ keyset 페이징 단계). +- 깊이 곡선(14절): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). +- EXPLAIN·인덱스 유무(14절): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)). +- 정확성(14절): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서). +- 가시성 probe(14절 → 가시성 인덱싱 단계): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt)). +- 정확성(15절): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). +- 3안 플랜 대조(15절): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 분기별 인덱스(15절): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt)). +- 사전계산=CQRS(15절 → CQRS-lite 읽기 모델 단계): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 통합 정확성·shape(16절): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)). +- 한 플랜 세 기법(16절): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt)). +- 간섭 시험(16절): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt)). +- CQRS-lite 읽기 모델(17절): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(12절 프로젝션 + 13절 window 결합, 12절 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN. > 개별 테스트만 돌릴 때는 Gradle 와일드카드가 `*`임에 주의(`...`은 매칭 0). 예) `--tests '*FeedPersistenceIT.l2*'`. 초록불을 다시 돌리려면 `--rerun-tasks`(안 그러면 UP-TO-DATE로 건너뜀). 콘솔 측정 라인(`>>> LAB …`)은 `build/lab-results/feed-nplus1.md`에도 표로 적재됩니다. @@ -1710,42 +1709,42 @@ cd src - [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정). - [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문. - [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`evidence/explain/toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문. -- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(§9 카테시안). -- [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt) — §9 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). -- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(§10 인메모리 페이징, 결정적·hash-anchor). -- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). §측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). -- [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`evidence/explain/l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt) — §10 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. -- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(§11 배치 해결, 결정적·hash-anchor). -- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(§11 잔여 과적재 → L6). -- [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`evidence/explain/l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt) — §11 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. -- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(§11 배치)/after(§12 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(§12 프로젝션 해결, 결정적·hash-anchor). -- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(§12.4 실측 정정: 프로젝션이 오히려 넓습니다). -- [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`evidence/explain/l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) — §12 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. -- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(§13 정확성·전송, 결정적·hash-anchor). -- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(§13 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). -- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(§13 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). -- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(§13 인덱스 의존; 환경 의존 상대값 whitelist). -- [`evidence/explain/l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`evidence/explain/l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`evidence/explain/l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) — §13 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). -- [`evidence/explain/l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) — §13 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). -- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(§14 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). -- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(§14; buffers·exec는 환경 의존 whitelist). -- [`evidence/explain/l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`evidence/explain/l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`evidence/explain/l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) — §14 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. -- [`evidence/explain/l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt) — §14 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → L16). -- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(§15; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). -- [`evidence/explain/l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`evidence/explain/l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`evidence/explain/l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) — §15 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. -- [`evidence/explain/l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt) — §15 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. -- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(§16/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). -- [`evidence/explain/crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt) — §16 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). -- [`evidence/explain/crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`evidence/explain/crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt) — §16 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). +- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(9절 카테시안). +- [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt) — 9절 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). +- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(10절 인메모리 페이징, 결정적·hash-anchor). +- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). 측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). +- [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`evidence/explain/l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt) — 10절 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. +- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(11절 배치 해결, 결정적·hash-anchor). +- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(11절 잔여 과적재 → 프로젝션 단계). +- [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`evidence/explain/l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt) — 11절 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. +- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(11절 배치)/after(12절 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(12절 프로젝션 해결, 결정적·hash-anchor). +- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(12.4절 실측 정정: 프로젝션이 오히려 넓습니다). +- [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`evidence/explain/l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) — 12절 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. +- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(13절 정확성·전송, 결정적·hash-anchor). +- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(13절 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). +- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(13절 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). +- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(13절 인덱스 의존; 환경 의존 상대값 whitelist). +- [`evidence/explain/l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`evidence/explain/l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`evidence/explain/l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) — 13절 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). +- [`evidence/explain/l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) — 13절 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). +- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(14절 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). +- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(14절; buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`evidence/explain/l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`evidence/explain/l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) — 14절 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. +- [`evidence/explain/l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt) — 14절 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → 가시성 조건 인덱싱 단계). +- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(15절; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`evidence/explain/l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`evidence/explain/l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) — 15절 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. +- [`evidence/explain/l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt) — 15절 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. +- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(16절/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). +- [`evidence/explain/crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt) — 16절 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). +- [`evidence/explain/crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`evidence/explain/crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt) — 16절 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). ### B. 측정 환경·출처(provenance) -§6.2·§7 표의 수치는 아래 조건에서 나온 값입니다. 다른 환경에서는 지연 절대값·쿼리 플랜이 달라질 수 있으므로 절대값이 아니라 N에 따른 증가 형태로 읽습니다. +6.2절과 7절 표의 수치는 아래 조건에서 나온 값입니다. 다른 환경에서는 지연 절대값·쿼리 플랜이 달라질 수 있으므로 절대값이 아니라 N에 따른 증가 형태로 읽습니다. | 항목 | 값 | |---|---| -| 수치 출처 | N1: `FeedPersistenceIT.l1CollectionNPlusOneGrowsLinearlyWithN` 콘솔(`=== L1 N=… ===`) · N2: `l2ToOneEagerHiddenNPlusOneCurve`·`l2EagerToOneFiresEvenWithZeroFieldAccess`·`l2ExplainRepeatedPageToOneQuery` 콘솔(`>>> LAB L2 …`) · §9(Fetch Join): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`·`l3SingleCollectionFetchJoinExplodesTransferredRows`·`l3ExplainCollectionJoinRowMultiplication` 콘솔(`>>> LAB OBSERVE L3 …`) · §10(인메모리 페이징): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`·`l4EmitsHhh000104InMemoryPagingWarning`·`l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes` 콘솔(`>>> LAB OBSERVE L4 …`) · §11(배치 해결): **별도 클래스 `FeedBatchFetchIT`**(`default_batch_fetch_size=100` 격리)의 `l5BatchFetchCollapsesQueryCount`·`l5EntityPagingLoadsOnlyThePageNotWholeDataset`·`l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`·`l5ProbeBatchStillHydratesFullEntities` 콘솔(`>>> LAB OBSERVE L5 …`) · §12(프로젝션 해결): **별도 클래스 `FeedProjectionIT`**(배치 설정 없음, sibling 메서드 `loadFeedProjection`)의 `l6ProjectionHydratesZeroEntities`·`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`·`l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`·`l6ProbeProjectionStillFetchesAllHighlightsNotTopN` 콘솔(`>>> LAB OBSERVE L6 …`) · §13(Top-N-per-group): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL을 `JdbcTemplate`으로)의 `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14ExplainThreeWayPlanCompareIsTheCrownJewel`·`l14TransferAcrossStrategies`·`l14GroupSizeCurveWindowVsLateral`·`l14LateralDependsOnCompositeIndex`·`l14ProbeParentPagingStillUsesOffsetNotKeyset` 콘솔(`>>> LAB OBSERVE L14 …`) · §14(keyset vs OFFSET): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL·정렬키 인덱스 CREATE/DROP 토글)의 `l15DeepPageOffsetOverScansButKeysetStaysFlat`·`l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`·`l15KeysetWalkMatchesOffsetPages`·`l15ProbeVisibilityOrBreaksKeysetIndex` 콘솔(`>>> LAB OBSERVE L15 …`) · §15(가시성 술어 인덱싱): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `l16ThreeApproachesReturnSameVisibleSet`·`l16ExplainThreeWayPlanCompare`·`l16LowSelectivityBranchesRideTheirIndex`·`l16PrecomputeIsSingleIndexScanNoOrNoSort` 콘솔(`>>> LAB OBSERVE L16 …`) · §16(통합/Task 4): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `crownUnifiedReturnsSameShapeAcrossParentPaths`·`crownUnifiedPlanStacksVisibilityKeysetAndTopN`·`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`·`crownDecisionMatrixClaimsHoldInOneQuery` 콘솔(`>>> LAB OBSERVE crown …`) 및 리포트 `build/lab-results/feed-nplus1.md`·`feed-nplus1-l5.md`·`feed-nplus1-l6.md`·`feed-nplus1-l14.md`·`feed-nplus1-l15.md`·`feed-nplus1-l16.md`·`feed-nplus1-crown.md` | -| §9 측정 방식 주의 | 순진 조회(N1/N2)는 `loadFeed`(Spring Data `Pageable`)이지만, §9의 fetch join은 **원시 JPQL**(`Pageable` 없음)이라 count 쿼리가 없습니다. 전송 행수는 `resultList.size()`가 아니라 조인 count(`SELECT count(*) FROM feed_items JOIN highlights …`)로 측정한다 — Hibernate 6+ 루트 dedup 때문(§9.3). | +| 수치 출처 | N1: `FeedPersistenceIT.l1CollectionNPlusOneGrowsLinearlyWithN` 콘솔(`=== L1 N=… ===`) · N2: `l2ToOneEagerHiddenNPlusOneCurve`·`l2EagerToOneFiresEvenWithZeroFieldAccess`·`l2ExplainRepeatedPageToOneQuery` 콘솔(`>>> LAB L2 …`) · 9절(Fetch Join): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`·`l3SingleCollectionFetchJoinExplodesTransferredRows`·`l3ExplainCollectionJoinRowMultiplication` 콘솔(`>>> LAB OBSERVE L3 …`) · 10절(인메모리 페이징): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`·`l4EmitsHhh000104InMemoryPagingWarning`·`l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes` 콘솔(`>>> LAB OBSERVE L4 …`) · 11절(배치 해결): **별도 클래스 `FeedBatchFetchIT`**(`default_batch_fetch_size=100` 격리)의 `l5BatchFetchCollapsesQueryCount`·`l5EntityPagingLoadsOnlyThePageNotWholeDataset`·`l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`·`l5ProbeBatchStillHydratesFullEntities` 콘솔(`>>> LAB OBSERVE L5 …`) · 12절(프로젝션 해결): **별도 클래스 `FeedProjectionIT`**(배치 설정 없음, sibling 메서드 `loadFeedProjection`)의 `l6ProjectionHydratesZeroEntities`·`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`·`l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`·`l6ProbeProjectionStillFetchesAllHighlightsNotTopN` 콘솔(`>>> LAB OBSERVE L6 …`) · 13절(Top-N-per-group): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL을 `JdbcTemplate`으로)의 `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14ExplainThreeWayPlanCompareIsTheCrownJewel`·`l14TransferAcrossStrategies`·`l14GroupSizeCurveWindowVsLateral`·`l14LateralDependsOnCompositeIndex`·`l14ProbeParentPagingStillUsesOffsetNotKeyset` 콘솔(`>>> LAB OBSERVE L14 …`) · 14절(keyset vs OFFSET): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL·정렬키 인덱스 CREATE/DROP 토글)의 `l15DeepPageOffsetOverScansButKeysetStaysFlat`·`l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`·`l15KeysetWalkMatchesOffsetPages`·`l15ProbeVisibilityOrBreaksKeysetIndex` 콘솔(`>>> LAB OBSERVE L15 …`) · 15절(가시성 술어 인덱싱): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `l16ThreeApproachesReturnSameVisibleSet`·`l16ExplainThreeWayPlanCompare`·`l16LowSelectivityBranchesRideTheirIndex`·`l16PrecomputeIsSingleIndexScanNoOrNoSort` 콘솔(`>>> LAB OBSERVE L16 …`) · 16절(통합/Task 4): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `crownUnifiedReturnsSameShapeAcrossParentPaths`·`crownUnifiedPlanStacksVisibilityKeysetAndTopN`·`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`·`crownDecisionMatrixClaimsHoldInOneQuery` 콘솔(`>>> LAB OBSERVE crown …`) 및 리포트 `build/lab-results/feed-nplus1.md`·`feed-nplus1-l5.md`·`feed-nplus1-l6.md`·`feed-nplus1-l14.md`·`feed-nplus1-l15.md`·`feed-nplus1-l16.md`·`feed-nplus1-crown.md` | +| 9절 측정 방식 주의 | 순진 조회(N1/N2)는 `loadFeed`(Spring Data `Pageable`)이지만, 9절의 fetch join은 **원시 JPQL**(`Pageable` 없음)이라 count 쿼리가 없습니다. 전송 행수는 `resultList.size()`가 아니라 조인 count(`SELECT count(*) FROM feed_items JOIN highlights …`)로 측정한다 — Hibernate 6+ 루트 dedup 때문. | | 런타임 | Java 21 · Spring Boot 4.0.0 · Hibernate ORM 7.1.8.Final | | DB | PostgreSQL `postgres:16-alpine`(Testcontainers, 클래스당 1개 공유) | | 지연 표본 | 반복 7회 중 워밍업 2회 제외한 5회의 중앙값/최댓값 | @@ -1754,7 +1753,7 @@ cd src | 소스 모듈 | 어댑터 `adapter/outbound/persistence-jpa`, 테스트 `app-bootstrap` | | 원문 로그 | `app-bootstrap/build/test-results/test/TEST-*FeedPersistenceIT*.xml`의 system-out | -재현성을 더 높이려면 Docker 이미지를 digest로 고정하고(`postgres:16-alpine@sha256:…`), 측정 시작 시 `select version()`·`show server_version_num`·`show random_page_cost`·`show work_mem`를 함께 기록한다(쿼리 플랜은 버전·planner setting에 좌우됩니다). +재현성을 더 높이려면 Docker 이미지를 digest로 고정하고(`postgres:16-alpine@sha256:…`) 측정 시작 시 `select version()`·`show server_version_num`·`show random_page_cost`·`show work_mem`를 함께 기록한다(쿼리 플랜은 버전·planner setting에 좌우됩니다). ### C. 함정(테스트 설정) diff --git a/.run/n+1liner/final/document.pre-humanize.md b/.run/n+1liner/final/document.pre-humanize.md new file mode 100755 index 0000000..2fad312 --- /dev/null +++ b/.run/n+1liner/final/document.pre-humanize.md @@ -0,0 +1,1765 @@ +# 하이라이트 피드 조회 성능 — N+1 진단과 조회 전략의 진화 + +제가 만들려던 것은 페이지에 하이라이트가 아무리 많아도 조회량이 폭증하지 않는 피드 API였습니다. +처음에는 엔티티를 조회한 뒤 DTO로 바꾸는 구현으로도 충분해 보였습니다. 그런데 데이터를 늘려 보니 +화면에 필요한 행보다 훨씬 많은 엔티티와 쿼리가 생겼습니다. 그래서 실제 PostgreSQL에서 SQL과 +실행계획을 측정하고, 한 전략이 남긴 문제를 다음 전략으로 풀어 갔습니다. 이 문서는 그 과정과 +마지막에 남은 비용을 함께 기록한 글입니다. + +> **측정의 범위와 한계** — 아래 수치는 **단일 스레드 퍼시스턴스 통합 테스트**(`@DataJpaTest` + 실제 PostgreSQL)에서 SQL shape와 데이터 규모에 따른 **조회 횟수의 증가 형태**를 측정했습니다. 지연(latency) 값은 이미 열린 테스트 트랜잭션 안에서 어댑터 호출만 잰 **단일 스레드·warm-cache 로컬 비교값**이라 HTTP 종단 지연도 운영 p99도 아닙니다. 고트래픽 처리량·connection pool 안정성·동시성은 이 측정의 범위 밖이며, 별도 부하 테스트로 확인해야 합니다. + +--- + +## 1. 해결할 문제 + +제가 만든 하이라이트 피드 API에는 다음 요구사항이 있었습니다. + +- **공개 범위**(public / mentioned / private)를 사용자별로 정확히 적용합니다. +- **최초 하이라이트 시각**으로 정렬합니다. +- 피드 아이템별 **최신 하이라이트 최대 3개**를 포함합니다. +- **페이징**합니다. +- 페이지에 하이라이트가 아무리 많고 피드가 아무리 커도 **조회량이 비례해 폭증하지 않습니다**(고트래픽). + +기능 요구사항(FR)만 보면 평범한 조회입니다. 제가 해결해야 했던 부분은 고트래픽에서도 조회량이 +데이터 규모에 비례해 늘지 않게 만드는 비기능 요구사항(NFR)이었습니다. 다만 최초 구현에는 FR 전체를 +한꺼번에 넣지 않았습니다. 공개 범위 판정·최신 3개 제한·mentioned 관계·커서 페이징을 제외하고, +**조회 문제를 드러내기 위한 기능적 기준선**부터 만들었습니다(§5.3). + +--- + +## 2. 조회 전략의 전체 여정 + +최종 조회 구조를 먼저 정하고 구현하지는 않았습니다. 기준선을 측정하자 컬렉션 N+1(N1)과 User·Page +연관의 숨은 쿼리(N2)가 동시에 드러났습니다. 둘은 순서대로 생긴 문제가 아니라 같은 구현에서 갈라진 +문제였습니다. 저는 두 문제를 Fetch Join으로 한꺼번에 풀어 보려 했고, 그 시도가 다중 컬렉션과 +페이징 문제를 다시 만들었습니다. 이후 Batch Fetch → DTO Projection → 아이템별 Top-3 → Keyset +Pagination → 가시성 조건 인덱싱 순으로 전략을 바꿨습니다. + + + +![요구사항과 모델에서 기준선으로 진행한 뒤 N1과 N2로 분기하고 Fetch Join에서 합류해, 실패와 다섯 개선 단계를 거쳐 최종 피드 조회 구조에 이르는 흐름도.](assets/diagrams/strategy-journey/strategy-journey.svg) + +
+Diagram description + +왼쪽에서 과제 요구사항, 도메인·데이터 모델, 최초 피드 조회 기준선 순으로 시작합니다. 기준선에서 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 서로 앞뒤가 아닌 형제 문제로 동시에 갈라지고, 두 경로는 Fetch Join 시도에서 합류합니다. 이 시도는 다중 컬렉션·페이징 실패로 이어집니다. 마지막 노드는 Batch Fetch, DTO Projection, 아이템별 Top-3, Keyset Pagination, 가시성 조건 인덱싱을 거쳐 최종 피드 조회 구조에 도달하는 순서를 담습니다. + +
+ +[Editable source](assets/diagrams/strategy-journey/strategy-journey.drawio) · [Grounded VizSpec](.techviz/strategy-journey/spec.json) + + +--- + +## 3. 도메인·데이터 모델 + +### 3.1 관계와 스키마 + +- 한 **user**는 여러 **feed_item**을 가집니다. +- 한 **page**에는 여러 **feed_item**이 딸립니다. +- 한 **feed_item**에는 **highlights**가 여럿입니다. + + + +![users와 pages에서 feed_items로 모이고 highlights로 이어지는 기준선 관계도.](assets/diagrams/baseline-schema/baseline-schema.svg) + +
+Diagram description + +왼쪽의 users와 pages가 각각 중앙의 feed_items에 연결됩니다. feed_items는 오른쪽의 highlights로 이어집니다. 간선은 user와 page 각각에 여러 feed_item이 연결되고, 한 feed_item에 여러 highlight가 연결되는 관계를 나타냅니다. + +
+ +[Editable source](assets/diagrams/baseline-schema/baseline-schema.drawio) · [Grounded VizSpec](.techviz/baseline-schema/spec.json) + + +위 ERD는 제가 처음 만든 기준선(L1) 스키마입니다. `FeedItem`은 `(user, page)` 조합당 하나입니다. +같은 사용자가 같은 페이지에 하이라이트를 여러 개 만들어도 피드 아이템은 하나이며, 이 정의를 +`UNIQUE(user_id, page_id)` 제약으로 옮겼습니다. + +과제 완료 목표 모델에는 `feed_item_mentions`(피드 아이템 ↔ mentioned 사용자) 관계도 필요했습니다. +공개 범위가 핵심 요구사항이므로 최종 스키마에는 반드시 들어갑니다. 다만 퍼시스턴스 계층의 +테이블·엔티티·시더는 공개 범위 단계보다 앞선 §9에서 추가했습니다. `MultipleBagFetchException`을 +재현하려면 fetch join할 두 번째 bag이 필요했기 때문입니다. 도메인·응답 매핑·공개 범위 판정은 뒤 +단계에 남겨 두었습니다. 따라서 현재 기준선 그림과 최종 스키마는 구분해서 읽어야 합니다. + + + +![기존 users를 mentioned 사용자 역할로 재사용해 feed_item_mentions와 연결한 5노드 목표 관계도.](assets/diagrams/target-schema/target-schema.svg) + +
+Diagram description + +왼쪽의 users와 pages가 중앙의 feed_items에 연결됩니다. 오른쪽에는 highlights와 feed_item_mentions가 놓입니다. feed_items는 두 엔티티에 각각 연결되고, 기존 users도 mentioned 사용자 역할로 feed_item_mentions에 연결됩니다. + +
+ +[Editable source](assets/diagrams/target-schema/target-schema.drawio) · [Grounded VizSpec](.techviz/target-schema/spec.json) + + +> **Open Decision OD-01 — 하이라이트 없는 FeedItem 허용 여부** +> - **질문:** 하이라이트 없는 FeedItem이 존재할 수 있는가? +> - **현재 상태:** 미결정 · 현재 스키마: `first_highlighted_at timestamptz`(nullable, NOT NULL 아님). 시더는 하이라이트가 만든 FeedItem이므로 항상 값을 채웁니다. +> - **영향:** 정렬 / keyset cursor의 null 처리(`NULLS LAST`·커서 위치) / 부분 인덱스 predicate / FeedItem 생성 lifecycle. +> - **결정 시점:** keyset 페이징 단계(L15) 이전. NOT NULL로 좁힐지, null 정렬 위치를 정의할지를 그때 결론 냅니다. + +### 3.2 식별자는 `ResourceId` 값 객체로 생성한다 + +ID는 `String`이나 `UUID` 원시 타입으로 두지 않고 값 객체 +(`FeedItemId implements ResourceId`)로 만들었습니다. 이렇게 정한 이유는 네 가지입니다. + +**① 타입 안정성.** 인자 뒤바뀜을 컴파일 시점에 잡습니다. + +```java +// 원시 타입: 컴파일 통과, 런타임에 조용히 오작동 +void registerFeedLike(String userId, String feedItemId) { ... } +registerFeedLike(feedItemId, userId); // 뒤바뀜 — 컴파일러가 못 잡음 + +// 값 객체: 컴파일 에러 +void registerFeedLike(UserId userId, FeedItemId feedItemId) { ... } +registerFeedLike(feedItemId, userId); // 컴파일 실패 (타입 불일치) +``` + +**② 도메인 제약의 자가 검증.** 생성 경로가 곧 신뢰 경계입니다. `FeedItemId`가 존재한다는 것 자체가 "유효한 형식"을 보장합니다. 다만 이 정규식이 보장하는 것은 **8-4-4-4-12 hex의 UUID 문자열 형태**뿐입니다. UUID version이 7인지, variant가 RFC 규격인지는 검사하지 않습니다. "신규 ID가 UUIDv7 정책을 따른다"는 조건은 값 객체가 아니라 `IdFactory`가 보장합니다. version까지 강제하려면 값 객체에서 `UUID.fromString(value).version() == 7`을 검사해야 합니다. + +```java +@ValueObject +public record FeedItemId(String value) implements ResourceId { + private static final Pattern PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + public FeedItemId { + if (value == null || !PATTERN.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid feed item id format: " + value); + } + } +} +``` + +**③ 식별자 규격의 캡슐화.** ID 정책이 ULID → UUIDv7로 바뀌어도 비즈니스 로직은 타입만 보므로 **도메인 호출부의 변경을 줄입니다**. 그렇다고 값 객체 하나만 고치면 끝나는 것은 아닙니다. ID 생성 `IdFactory`, DB 컬럼 타입, 변환 매퍼, 커서 인코딩, 인덱스 크기·정렬 특성, 마이그레이션도 함께 영향을 받습니다. 값 객체는 이런 변경이 도메인 로직 전반으로 번지는 일을 줄여 줍니다. + +**④ 생성 정책 교체.** `IdFactory` 구현을 교체하면 다른 ID 정책으로 바꿀 수 있습니다. + +> **흔한 오해**: "`@ValueObject`가 모든 필드 final + setter 금지를 강제합니다." +> **실제**: 불변성은 `record`의 언어 특성입니다. `@ValueObject`에 걸리는 규칙은 **무인자 생성자 금지**(불변식을 우회하는 빈 생성자 뒷문 차단)이고 setter 금지는 애그리거트 루트(`@AggregateRoot`)의 별도 규칙입니다. + +> **흔한 오해**: "값 객체는 엔티티·서비스 필드로 못 씁니다." +> **실제**: 강제되는 규칙이 아니라 관례입니다. 퍼시스턴스 엔티티는 값 객체가 아니라 원시 `UUID`를 저장하고 매퍼 경계에서 변환합니다. 규칙으로 강제되는 것은 "도메인이 프레임워크에 의존하지 않는다"는 순수성입니다. + +### 3.3 퍼시스턴스 엔티티는 연관 게터를 좁게 연다 + +`FeedItemJpaEntity`의 연관 게터는 `public`으로 열지 않고 package-private로 좁혔습니다. + +```java +public class FeedItemJpaEntity extends AuditableEntity { // 클래스는 public + public UUID getId() { return id; } // 식별자는 public + UserJpaEntity getUser() { return user; } // 연관은 package-private + PageJpaEntity getPage() { return page; } + List getHighlights() { return highlights; } +} +``` + +연관 게터가 열려 있으면 상위 계층이 엔티티 객체 그래프를 타고 다니며 지연 로딩을 아무 데서나 촉발하거나 영속성 컨텍스트·DB 스펙에 의존하게 됩니다. package-private로 좁히면 같은 패키지의 어댑터·매퍼만 그래프를 순회할 수 있습니다. + +> **흔한 오해 ①**: "엔티티 클래스를 package-private로 강제합니다." +> **실제**: package-private인 것은 클래스가 아니라 연관 게터이며, 이는 규칙이 아니라 방어적 캡슐화 관례입니다. 엔티티가 계층 밖으로 새는 것은 "컨트롤러가 엔티티를 의존/반환하지 않는다", "쿼리 포트가 엔티티 타입을 노출하지 않는다"는 경계 규칙이 막습니다. + +> **흔한 오해 ②**: "JPA 엔티티 클래스는 반드시 public이어야 합니다." +> **실제**: Jakarta Persistence 규격은 엔티티에 top-level(또는 static inner)·non-final·무인자 생성자 등을 요구하지만, 클래스 자체가 public이길 요구하지는 않습니다. 이 프로젝트에서는 도구 호환성을 단순하게 유지하려고 엔티티 클래스를 public으로 두었습니다. 연관 게터를 package-private로 좁혀도 매핑되는 이유는 이 엔티티가 **field access**(`@Id`가 필드에 붙음)를 사용하기 때문입니다. property access였다면 영속 속성 게터는 public/protected여야 합니다. + +--- + +## 4. 측정 환경과 데이터셋 + +조회 전략을 비교하기 전에 측정 환경부터 고정했습니다. 어디서·무엇으로·어떤 데이터를 측정했는지 +남기지 않으면 숫자가 달라졌을 때 코드 때문인지 환경 때문인지 구분할 수 없기 때문입니다. + +### 4.1 측정 환경 — 실제 PostgreSQL을 퍼시스턴스 계층에서 직접 측정 + +```java +@DataJpaTest +@ContextConfiguration(classes = CaSkeletonApplication.class) +@AutoConfigureTestDatabase(replace = NONE) // 인메모리 대체 금지 → 실제 DB +@Testcontainers(disabledWithoutDocker = true) +@TestPropertySource(properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/postgresql", + "spring.jpa.hibernate.ddl-auto=validate", // 엔티티↔마이그레이션 일치 강제 + "spring.jpa.properties.hibernate.generate_statistics=true"}) +class FeedPersistenceIT { + @Container @ServiceConnection + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine"); +} +``` + +- **실제 PostgreSQL 16**(Testcontainers)을 사용했습니다. 컨테이너 필드가 `static`이므로 테스트 메서드마다 새로 띄우지 않고 **`FeedPersistenceIT` 실행 동안 하나를 공유**합니다. 첫 테스트 전에 한 번 기동하고 마지막 테스트가 끝나면 종료합니다. 각 테스트의 데이터는 `@DataJpaTest` 트랜잭션 롤백과 명시적인 `em.clear()`로 격리했습니다. H2 같은 인메모리 DB를 쓰지 않은 이유는 N+1의 쿼리 수뿐 아니라 EXPLAIN 실행계획(Index/Seq Scan)과 인덱스 동작도 DB 엔진마다 다르기 때문입니다. 인메모리 DB에서 재면 운영 환경인 PostgreSQL과 다른 계획이 나와 잘못된 결론에 이를 수 있습니다. 엔진마다 계획이 달라지는 이유는 §4.6에서 다시 설명합니다. 재현성을 더 높이려면 `postgres:16-alpine` 태그보다 patch 버전이나 digest(`@sha256:...`)를 고정하는 편이 낫습니다. 같은 태그가 시점에 따라 다른 patch를 가리킬 수 있기 때문입니다. +- 스키마는 운영 마이그레이션과 같게 맞췄습니다. Flyway `V6__feed.sql`을 그대로 적용하고 `ddl-auto=validate`로 엔티티가 기대하는 테이블·컬럼·타입의 기본 불일치를 조기에 잡았습니다. 다만 `validate`만으로 모든 드리프트를 막을 수는 없습니다. 인덱스 구성, 부분 인덱스 predicate, check 제약, FK 삭제 정책, 컬럼 순서 등은 검증 범위 밖이므로 마이그레이션 검증과 catalog 조회로 따로 확인합니다. +- 퍼시스턴스 어댑터(`FeedQueryAdapter`)를 JPA 슬라이스에서 직접 호출합니다. HTTP를 거치지 않습니다. 이유는 둘입니다. 하나, N+1은 조회 계층의 현상이므로 웹·보안·직렬화 노이즈를 배제하고 순수한 쿼리 행동만 관찰합니다. 둘, 슬라이스 트랜잭션이 열려 있어 지연 로딩이 결정적으로 재현됩니다. +- **측정 도구**는 추가 라이브러리 없이 세 가지를 사용했습니다. 전용 도구 대신 이 조합을 고른 이유는 §4.7에서 설명합니다. + - Hibernate `Statistics` — **획득한 PreparedStatement 수**(`getPrepareStatementCount`), **초기화된 컬렉션 수**(`getCollectionFetchCount`), 엔티티 로드 수를 줍니다. 이는 SQL **shape별 정확한 실행 횟수**가 아닙니다. shape별 실행 횟수를 원문 SQL 수준에서 확정하려면 SQL 로그·`StatementInspector`·datasource-proxy·p6spy·PostgreSQL statement logging 중 하나로 **별도로 수집**해야 합니다(§6.1). + - `System.nanoTime` — 지연. + - `EXPLAIN (ANALYZE, BUFFERS)` — 실행계획. + +### 4.2 데이터셋을 어떻게 만드는가 — 4종의 개수가 다른 이유 + +`FeedSeedFixture.seed(N)`은 피드 아이템 N개를 만들면서 각 엔티티를 서로 다른 규칙으로 생성합니다. 그래서 feed_item·user·page·highlight의 총 개수가 전부 달라집니다. + +```text +seed(N): + users = max(3, min(20, N/5 + 1)) 명 생성 # 소수 풀 + pages = N 개 생성 # feed_item과 1:1 + for i in 0 .. N-1: + feed_item[i] = { + user = users[i % users.size], # 라운드로빈: 소수 유저를 돌려 씀 (공유) + page = pages[i], # 1:1: 아이템 전용 페이지 + visibility = (i%10 <6 ? PUBLIC : i%10 <8 ? MENTIONED : PRIVATE) # 6:2:2 + } + highlightCount = max(1, round(500 / (i+1)^1.15)) # 순위가 낮을수록 많음 (§4.3) + highlight[i] = highlightCount 개 생성 +``` + +| 엔티티 | 개수 | 어떻게 그 개수가 되나 | +|---|---|---| +| **feed_item** | **N** | 루프를 N번 돈다 (`N ∈ {10, 100, 1000}`) | +| **page** | **N** | `pages[i]` — 아이템마다 전용 페이지(1:1) | +| **user** | **max(3, min(20, N/5+1))** | 소수만 만들고 `users[i % size]`로 **돌려 씁니다**. N=10→3명, N=100·1000→20명 | +| **highlight** | **Σ Zipf-like** | 아이템마다 순위 기반으로 개수가 다름(§4.3). N=10→**1,285** · N=100→**1,961** · N=1,000→**2,917** | + +핵심은 user와 page가 같은 `@ManyToOne`인데 개수가 정반대라는 데 있습니다. user는 소수를 공유하고 page는 아이템마다 하나씩 만들었습니다. 이 비대칭 덕분에 뒤에서 같은 즉시 로딩인데도 조회 수가 달라지는 현상을 확인할 수 있습니다(§6.3). + +### 4.3 하이라이트 개수는 왜 Zipf 형태의 편중 분포로 만드나 + +하이라이트 개수는 균일(모두 3개)도, 정규분포(평균 근처에 몰림)도 아닙니다. 소수의 인기 아이템이 압도적으로 많고 나머지는 긴 꼬리로 급격히 적어집니다. 이 편중을 Zipf의 순위-빈도 형태에서 차용한 합성(synthetic) 분포로 재현합니다. + +```java +// FeedSeedFixture.skewedHighlightCount(i) +highlightCount(i) = max(1, round(500 / (i+1)^1.15)) // 상한 500, 하한 1 +``` + +Zipf의 법칙은 "순위 `r`인 항목의 빈도 ∝ `1/r^s`"이고, 고전적 지프는 지수 `s=1`이라 1위가 2위의 두 배입니다. 저는 조금 더 가파르게 줄어들도록 `s=1.15`를 사용했습니다. 이때 1위는 2위의 `2^1.15≈2.2`배가 됩니다. 단어 빈도·도시 인구·웹페이지 조회 수 같은 heavy-tailed 편중이 이 계열입니다. 다만 이 분포가 실제 라이너 데이터와 같다고 주장하는 것은 아닙니다. "일부 페이지에 하이라이트가 매우 많을 수 있음"을 통제된 방식으로 재현하려고 만든 스트레스 분포입니다. `max(1, …)`로 바닥값을 두었으므로 전 구간이 순수한 멱법칙을 따르지는 않고, floor를 적용한 truncated Zipf-like 분포에 가깝습니다. + +공식을 대입한 순위별 실제 생성 개수(원본: [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv)): + +| 순위(rank) | 1 | 2 | 3 | 5 | 10 | 50 | 100 | 꼬리(≈150위~) | +|---|---|---|---|---|---|---|---|---| +| 하이라이트 수 | 500 | 225 | 141 | 79 | 35 | 6 | 3 | 1~2 | + + + +![균일분포, 정규분포, Zipf-like 합성 분포를 분포 형태와 극단적 소수, 스트레스 조건 재현 여부, 선택 결과로 나란히 비교한 도표.](assets/diagrams/skew-profile/skew-profile.svg) + +
+Diagram description + +왼쪽부터 균일분포, 정규분포, Zipf-like 합성 분포를 같은 네 기준으로 비교합니다. 균일분포는 모든 아이템이 3개이고, 정규분포는 평균 근처에 몰려 둘 다 극단적으로 많은 소수를 만들지 못하므로 제외됩니다. Zipf-like 분포는 소수의 인기 아이템이 압도적인 무거운 머리와 나머지의 긴 꼬리를 만들며, 지수 s=1.15와 상한 500·하한 1을 사용해 매우 많은 하이라이트 조건과 Top-N 필요성을 재현하는 합성 스트레스 분포로 선택됩니다. + +
+ +[Editable source](assets/diagrams/skew-profile/skew-profile.drawio) · [Grounded VizSpec](.techviz/skew-profile/spec.json) + + +왜 균일·정규분포가 아니라 편중 분포인가: +- 균일(모두 3개)이면 과제의 "페이지에 하이라이트가 아무리 많아도"라는 조건을 재현하지 못합니다. 머리(수백 개)가 만드는 전송량·메모리 압박도, 아이템별 최신 3개(Top-N)를 뽑아야 하는 필요성도 사라집니다. +- 정규분포는 평균 근처로 몰려 "극단적으로 많은 소수"가 없습니다. 역시 머리가 안 생깁니다. +- "무거운 머리 + 긴 꼬리"를 재현하는 방법은 여러 가지입니다(log-normal, negative binomial, Pareto, 경험적 히스토그램 등). 그중 순위 기반으로 파라미터 하나(`s`)만 바꾸면 편중 강도를 조절할 수 있는 Zipf-like 형태를 골랐습니다. + +이 분포 때문에 하이라이트 총량은 N에 정비례하지 않습니다. N=10에서 이미 1,285개인데(0번 아이템 혼자 500개), N을 100배(1,000)로 키워도 2,917개에 그칩니다. 꼬리 아이템은 1개씩만 더할 뿐 머리가 총량을 지배하기 때문입니다. 반면 조회 수(`collectionFetches`)는 하이라이트 총량이 아니라 아이템 수 N에 정비례합니다. 이 대비가 §6의 핵심입니다. + +### 4.4 왜 이렇게 구성했는가 (설계 의도) + +- **하이라이트 Zipf-like 편중** → "매우 많은 하이라이트" 조건 + Top-N 필요성 재현(§4.3). +- **User 공유 vs Page 전용** → 같은 즉시 로딩인데 조회 수가 갈리는 것을 데이터로 보입니다. User는 1차 캐시가 재조회를 걸러 distinct 유저 수(≤20)로 억제되고 Page는 아이템마다 달라 그대로 N번. 모두 유니크 유저였다면 이 대비가 사라집니다. "EAGER secondary SELECT 반복 횟수는 **fetch 방식 × distinct 연관 대상 수의 결합**으로 달라진다"는 핵심을 못 보입니다. +- **공개 범위 6:2:2** → 세 분기(public / mentioned / private)를 모두 충분히 포함하도록 설정한 **합성 비율**로, 이후 공개 범위 필터링·인덱싱 실험의 기반을 미리 심습니다. +- **시간 분산** → `first_highlighted_at` 정렬키를 만들어 시간순 페이징(keyset)·정렬 인덱스 실험 기반을 마련합니다. + +### 4.5 측정 규율 — 캐시와 통계가 결과를 왜곡하지 않게 + +- 같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 먹습니다. 그래서 지연 반복 루프는 **매 반복마다** 타이머를 켜기 전에 `em.clear()`를 호출합니다. 덕분에 (a) 매 호출이 실제로 DB를 때리고, (b) `clear()` 자체 비용은 측정 구간 밖에 놓입니다. 두 번째 반복부터 캐시가 조회량을 갉아먹어 값이 섞이는 오염이 없습니다. +- 쿼리 수는 `stats.clear()` 직후 딱 1회 실행분으로만 읽어 "회당 정확값"을 얻습니다. +- 지연은 쿼리 수와 분리해 별도로 반복 측정하고, 앞의 몇 회는 JIT·커넥션 워밍업 구간으로 보고 버렸습니다. **그래도 이 값은 warm DB 캐시·동일 JVM·단일 스레드에서 잰 근삿값입니다.** GC·JIT 영향이 남아 있으므로 절대값보다 N에 따른 증가 방향만 확인했습니다. 그래서 §6.2에도 `p50`·`p99`가 아니라 "median/max of 5"로 적었습니다. + +**한 데이터셋에 여러 변수가 섞여 있다는 한계.** 현재 데이터셋은 N을 키우면 반환 FeedItem 수·Highlight 총 행수·엔티티/DTO 생성량·DB 왕복이 **동시에** 늘어납니다. 따라서 지연의 원인을 어느 하나에만 돌릴 수 없습니다(§6.2). 이후에는 변수를 하나씩 격리한 데이터셋으로 다시 검증할 계획입니다. 아래 A/B/C는 **아직 실행하지 않았으며, 실행하기 전에는 수치를 채우지 않습니다**. + +| 격리 데이터셋 | 구성 | 격리하는 변수 | 상태 | +|---|---|---|---| +| **A** | FeedItem 10 / 100 / 1,000, Highlight는 FeedItem당 정확히 1개 | 왕복(부모 수)만 변화 → **N+1 왕복** 격리 | 예정 | +| **B** | FeedItem 20 고정, Highlight 1 / 10 / 100 / 500 | 행수(자식 수)만 변화 → **과조회** 격리 | 예정 | +| **C** | Zipf-like 편중 유지 | 머리(Top-N) 스트레스 재현 | 예정 | + +### 4.6 왜 DB 엔진마다 실행계획·인덱스가 다른가 + +§4.1에서 "인메모리 H2를 쓰지 않는다"의 근거로 "실행계획·인덱스 동작이 엔진마다 다르다"를 들었습니다. 왜 다른지를 짚습니다. 비용 기반 옵티마이저는 가능한 여러 계획의 **비용을 추정해 가장 싼 것을 고릅니다.** 그런데 그 추정값도, 애초에 고를 수 있는 선택지도 엔진마다 다릅니다. 네 축이 갈립니다. + +| 계획을 가르는 축 | PostgreSQL 16 (운영) | H2 (인메모리) | MySQL / InnoDB (대조) | +|---|---|---|---| +| **비용 모델** | 튜너블 상수로 I/O를 값매김 — `random_page_cost=4`·`seq_page_cost=1`이 랜덤 접근(인덱스)을 상대적으로 비싸게 잡고, `effective_cache_size`가 캐시 가정을 바꾼다 | 비용 기반이지만 훨씬 단순하고 상수 모델이 다르다 | 비용 기반이나 상수·추정 규칙이 또 다르다 | +| **통계** | `ANALYZE`가 MCV 목록·히스토그램·`n_distinct`·`correlation`을 수집해 선택도(selectivity)를 추정 | 수집 통계가 제한적 | 8.0+ 히스토그램·index dive | +| **저장·가시성** | heap + MVCC. 인덱스 스캔도 **가시성 맵**을 봐야 하고, 그래서 커버링 인덱스라도 벌크 로드 직후엔 index-only scan이 heap을 재방문한다 | 인메모리 구조라 PostgreSQL식 가시성 맵·heap 재방문 비용 구조가 없다 | 클러스터드 인덱스(PK 자체가 데이터) + undo. 2차 인덱스는 PK 재조회 | +| **인덱스 종류·기능** | B-tree/Hash/GiST/GIN/BRIN/SP-GiST, **부분 인덱스**·표현식 인덱스·`DESC`/`NULLS FIRST\|LAST` 정렬 인덱스 | 주로 B-tree/hash, 부분 인덱스 미지원 | B-tree 중심, 부분 인덱스 미지원·함수 인덱스 8.0+ | + +계획은 이 네 축의 함수입니다. 그래서 **같은 쿼리·같은 데이터라도** 엔진이 바뀌면 (a) Seq Scan ↔ Index Scan 선택이 뒤집히고, (b) 부분·표현식·정렬 인덱스처럼 한쪽에만 있는 접근 경로가 통째로 사라지며, (c) PostgreSQL 특유의 가시성 맵·index-only scan 미묘함이 재현되지 않습니다. 인메모리로 재서 나온 계획을 운영 PostgreSQL 계획으로 읽으면 이 세 지점에서 **체계적으로 틀린 결론**에 이릅니다. + +이건 추상적 우려가 아니라 이 문서 안에서 이미 두 번 부딪히는 축입니다. + +- **통계 의존** — §6.4의 Plan A는 추정 `rows=1`과 실제 `rows=500`이 500배 차이 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 담지 못했다는 가설을 세웠고, Plan B에서 검증합니다. 통계를 수집하고 사용하는 방식이 엔진마다 다르므로 이 현상은 **실제 엔진에서만** 정확하게 관찰할 수 있습니다. +- **선택도 의존** — §8은 "테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있다"고 유보합니다. Seq↔Index 판정 자체가 비용 모델·선택도 추정의 산물이라, 다른 엔진이면 다른 임계에서 갈립니다. +- **인덱스 기능 의존** — 이후 랩의 공개 범위 인덱싱·keyset 정렬(§8, OD-01의 `NULLS LAST` 처리)은 부분 인덱스·정렬 인덱스 기능에 기댑니다. 이 기능이 없는 엔진에서 실험하면 접근 경로 자체가 달라 결과가 무의미합니다. + +정리하면 측정 대상이 **계획·인덱스 동작**인 이상 DB는 대체재가 아니라 측정 대상의 일부입니다. 그래서 운영과 같은 PostgreSQL을 사용했습니다(§4.1). + +### 4.7 왜 전용 측정 도구 대신 내장 3종인가 + +§4.1에서 사용한 Hibernate `Statistics`·`System.nanoTime`·`EXPLAIN`은 모두 **이미 스택에 있는 도구**라 의존성을 더하지 않습니다. p6spy·datasource-proxy(정확한 SQL별 실행 수), JMH(엄밀한 지연 벤치), APM·프로파일러(종단 지연·플레임그래프) 같은 전용 도구도 후보였습니다. 다만 L1에서 확인하려던 것은 정밀한 지연이나 운영 처리량이 아니라 "쿼리 발생량이 N에 비례해 늘어나는가"라는 방향성이었습니다. 그래서 주장의 범위에 맞춰 내장 도구를 선택했습니다. + +| 측정 대상 | 쓴 도구 (내장·무의존) | 주는 것 / 한계 | 전용 대안 | 왜 지금 이걸로 충분한가 | +|---|---|---|---|---| +| **쿼리 발생 형태(N+1)** | Hibernate `Statistics` | 초기화 컬렉션 수·PreparedStatement 수. shape별 정확 SQL 수는 아님(§6.1) | p6spy · datasource-proxy · QuickPerf `@ExpectSelect` | 필요한 건 성장 **형태**(≈`N`)뿐 → 무의존 카운터로 충분. 정확한 per-shape SQL이 필요해지는 단계(Batch Fetch로 "컬렉션 수 = SQL 수" 등식이 깨지는 L5)에서 도입한다고 §6.1에 이미 예고 | +| **지연** | `System.nanoTime` | 단일 스레드·warm 근사(방향성만) | JMH | L1은 절대값·p99를 주장하지 않습니다. 게다가 지연 로딩을 재현하려면 **테스트 트랜잭션을 연 채 퍼시스턴스 슬라이스 안에서** 재야 하는데, 이는 격리 JVM·steady-state를 전제하는 JMH와 안 맞습니다. 도구 정밀도가 주장 강도를 넘으면 "이게 운영 수치"라는 오해를 부른다 | +| **실행계획** | `EXPLAIN (ANALYZE, BUFFERS)` | 운영 엔진이 실제로 고른 plan·buffers의 **원천** | APM · JFR · async-profiler | 엔진이 선택한 계획 자체가 필요하므로 native EXPLAIN을 사용했습니다. APM은 운영 관측에 더 적합합니다 | + +세 선택을 관통하는 원리는 셋입니다. + +1. **의존성 무추가** — 이 측정은 스켈레톤 모듈의 슬라이스 테스트 안에서 돕니다. 클래스패스에 이미 있는 것만으로 재현되면 "이 도구 깔고 이 설정 맞춰야 재현됨" 같은 장벽이 없습니다. +2. **정밀도 = 주장 강도.** 방향성만 확인하는 값에 JMH·APM의 엄밀도를 붙인다고 근거가 더 강해지지는 않습니다. 오히려 측정 데이터보다 정밀한 결론처럼 보일 수 있습니다. 같은 이유로 지연을 `p50`·`p99`가 아니라 "중앙값/최댓값(5회)"로 적었습니다(§6.2). +3. **측정 지점의 제약이 도구를 고릅니다.** N+1은 열린 트랜잭션·지연 로딩에서만 결정적으로 재현되므로(§4.1) 측정은 그 지점 안에 있어야 합니다. HTTP 종단·격리 JVM을 전제하는 도구는 이 지점을 못 잡습니다. + +측정 질문이 바뀌면 도구도 그에 맞게 바꿉니다. 다음 단계에 필요한 도구는 아래처럼 정리했습니다. + +| 질문이 이렇게 바뀌면 | 승급할 도구 | +|---|---| +| shape별 정확한 SQL 실행 수가 필요 | p6spy · datasource-proxy · `StatementInspector` · PostgreSQL statement logging | +| 안정적 꼬리 지연(p99)이 필요 | warm-up 후 100회+ 반복·독립 세트, 또는 JMH | +| 운영 종단 지연·처리량·connection pool이 필요 | 부하 테스트 + APM | + +이 표의 아래 두 행은 문서 첫머리에서 "이 측정의 범위 밖"이라고 밝힌 항목입니다. 질문이 그 범위까지 넓어지면 그때 맞는 도구로 바꿉니다. + +--- + +## 5. 최초 구현과 첫 관찰 + +### 5.1 전략 — 엔티티 그래프를 로드하고 메모리에서 DTO로 매핑 + +처음에는 피드 아이템 엔티티를 조회한 뒤 Java Stream으로 순회하며 응답 DTO(`FeedSummary`)로 +필드를 옮겼습니다. 구현하기 쉽고 결과도 바로 확인할 수 있어서 기능적 기준선으로 삼았습니다. + +```java +@Override +public List loadFeed(int page, int size) { + return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream() + .map(fi -> new FeedSummary( + fi.getId().toString(), + fi.getUser().getName(), fi.getUser().getUsername(), // ToOne (즉시 로딩) + fi.getPage().getUrl(), fi.getPage().getTitle(), // ToOne (즉시 로딩) + fi.getFirstHighlightedAt(), + fi.getHighlights().stream() // 컬렉션 (지연 로딩) + .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt())) + .toList())) + .toList(); +} +``` + +### 5.2 조회 전략은 포트 뒤 어댑터의 책임 + +조회 전략을 바꾸더라도 웹·애플리케이션 계층까지 함께 바꾸고 싶지는 않았습니다. 그래서 상위 +계층에는 조회 사용자·페이지 크기·반환할 `FeedSummary`만 드러내고, 구체적인 조회 방식은 +퍼시스턴스 어댑터에 두었습니다. 조회 경로는 `GET /feed` → `FeedController` → +`GetFeedUseCase` → `FeedQueryPort`이며, `FeedQueryAdapter`가 이 포트를 구현해 PostgreSQL을 +조회합니다. + + + +![GET /feed를 받는 FeedController에서 GetFeedUseCase와 FeedQueryPort로 이어지고 FeedQueryAdapter가 포트를 구현하는 포트·어댑터 구조.](assets/diagrams/query-port-boundary/query-port-boundary.svg) + +
+Diagram description + +왼쪽의 FeedController가 GET /feed 요청을 받아 중앙의 GetFeedUseCase에 조회를 위임합니다. 유스케이스는 오른쪽의 FeedQueryPort에 조회를 의존합니다. FeedQueryAdapter는 FeedQueryPort를 구현하는 아웃바운드 어댑터이며 PostgreSQL 조회를 수행합니다. Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 같은 구체 전략은 이 어댑터의 책임이므로 상위 계층은 전략 교체의 영향을 받지 않습니다. + +
+ +[Editable source](assets/diagrams/query-port-boundary/query-port-boundary.drawio) · [Grounded VizSpec](.techviz/query-port-boundary/spec.json) + + +Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 중 무엇을 쓰는지는 `FeedQueryPort` +구현의 책임입니다. 그래서 조회 전략을 교체해도 상위 계층은 바뀌지 않습니다. + +### 5.3 기준선이 의도한 범위에서는 정상이다 + +최초 구현에서는 FeedItem과 User·Page·Highlight를 응답 형태로 조립하는 **기본 조회 경로**만 +검증했습니다. 요청한 크기만큼 피드 아이템이 조회되고 각 아이템에 User·Page 정보와 Highlight +목록이 정확히 담기는지는 라운드트립 테스트로 확인했습니다. 이 범위에서는 의도한 대로 동작했습니다. + +하지만 이 단계는 아직 다음을 반영하지 않습니다. + +- 조회 사용자에 따른 공개 범위(public / mentioned / private) 판정 +- 피드 아이템별 최신 하이라이트 **최대 3개** 제한 +- mentioned 사용자 관계 +- 최종 커서(keyset) 페이징 + +따라서 이 단계는 전체 기능 요구사항의 완료본이 아니라, **조회 문제를 발견하기 위한 기능적 기준선**입니다. "정상"은 이 기준선이 의도한 범위에 한정된 말이고, 다음 관심사는 NFR입니다. + +### 5.4 왜 추가 쿼리가 나가나 — EAGER는 "로딩 시점" 계약이지 JOIN 보장이 아니다 + +엔티티에는 fetch를 따로 명시하지 않았습니다. 따라서 `@ManyToOne`은 즉시 로딩(EAGER), +`@OneToMany`는 지연 로딩(LAZY)이라는 JPA 기본값을 사용합니다. + +여기서 중요한 지점이 있습니다. `FetchType.EAGER`는 연관이 **반환 시점까지 로딩돼 있어야 한다**는 계약이지, 반드시 루트 SQL의 JOIN으로 가져오라는 의미가 아닙니다. + +- `findAllBy(...)`는 파생 쿼리입니다. **현재 Hibernate 기준선에서는** 루트(feed_items)를 먼저 조회한 뒤, 쿼리에서 fetch join하지 않은 EAGER ToOne 연관을 JOIN이 아니라 별도의 2차 SELECT로 채웠습니다. 루트를 가져온 다음에 user·page를 행마다 조회합니다. +- 단건 조회(`entityManager.find(id)`)에서는 Hibernate가 JOIN으로 가져오는 경우가 있지만, 그건 provider·매핑·fetch profile에 달린 동작이지 일반적인 JPA 보장이 아닙니다. 리스트 파생 쿼리인 여기서는 2차 SELECT로 나갔습니다. "즉시 로딩이면 한 번에 가져오겠지"라는 착각이 깨지는 대목입니다. +- `highlights`는 지연 로딩이라 루트 조회 시엔 나가지 않다가 매핑 루프에서 `getHighlights()`에 접근하는 순간 그 아이템의 컬렉션을 1쿼리로 가져옵니다. 아이템마다 한 번씩입니다. + + + +![loadFeed 매핑, Hibernate, PostgreSQL 사이에서 루트 SELECT, EAGER user·page 2차 SELECT, getHighlights 접근, LAZY highlights SELECT가 차례로 일어나는 시퀀스.](assets/diagrams/eager-lazy-query-sequence/eager-lazy-query-sequence.svg) + +
+Diagram description + +세 참가자를 왼쪽부터 loadFeed DTO 매핑, Hibernate, PostgreSQL 순으로 읽습니다. loadFeed가 findAllBy 파생 쿼리를 호출하면 Hibernate가 PostgreSQL에서 feed_items를 먼저 조회합니다. 이어 fetch join되지 않은 EAGER user와 page를 별도의 2차 SELECT로 채우고, 반환 시점까지 로딩된 FeedItem을 loadFeed에 돌려줍니다. 이후 DTO 매핑이 getHighlights()에 접근하면 Hibernate가 해당 아이템의 highlights 컬렉션 SELECT를 실행합니다. + +
+ +[Editable source](assets/diagrams/eager-lazy-query-sequence/eager-lazy-query-sequence.drawio) · [Grounded VizSpec](.techviz/eager-lazy-query-sequence/spec.json) + + +--- + +## 6. 컬렉션 N+1 정량화 + +### 6.1 하이라이트 조회 수만 분리해 측정하기 + +기준선을 측정하자 count·User·Page·Highlight 쿼리가 한꺼번에 나왔습니다. 총계만으로는 어느 +연관이 문제인지 알기 어려웠습니다. 그래서 먼저 Hibernate의 `getCollectionFetchCount()`로 +하이라이트 조립 과정에서 발생한 조회 수를 분리했습니다. 다만 이 지표를 SQL 실행 횟수로 읽으면 +안 됩니다. + +- `getCollectionFetchCount()` = **초기화된 컬렉션 수**. "실행된 SELECT SQL 수"가 아닙니다. +- `getPrepareStatementCount()` = **획득한 PreparedStatement 수**. 이 값도 SQL 실행 수와 항상 같지는 않습니다(§4.1). + +현재 기준선에는 batch/subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나갑니다. 이 +조건에서만 "초기화된 컬렉션 수 N = highlights 자식 SELECT 수 N"이 성립합니다. L5에서 +Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 이 등식이 깨집니다. 그래서 두 지표의 +이름을 구분했습니다. ToOne(User·Page) 조회 수는 총 PreparedStatement에서 content 1건, +페이지 count 1건, highlights 컬렉션 N건을 빼서 계산했습니다. + +### 6.2 실측 — 조회량이 N에 정확히 비례한다 + +먼저 N이 무엇을 뜻하는지 정리했습니다. **N은 전체 테이블 크기가 아니라 한 요청에서 반환한 +FeedItem 수**입니다. 이 랩에서는 `seed(N)` 뒤에 `loadFeed(0, N)`을 호출해 데이터셋 크기와 +page size를 모두 N으로 맞췄습니다. 따라서 아래 표의 N은 "한 페이지 요청이 조립하는 부모 엔티티 +수"를 뜻합니다. + +**측정값(직접 측정).** 초기화 컬렉션 수·총 PreparedStatement는 Hibernate `Statistics`, 지연은 `System.nanoTime`, 시드 하이라이트는 시더 콘솔에서 그대로 읽은 값입니다. + +| N | 초기화 Highlight 컬렉션 | 총 PreparedStatement | 지연 중앙값(5회) | 지연 최댓값(5회) | 시드 하이라이트 | +|---:|---:|---:|---:|---:|---:| +| 10 | **10** | 25 | 32.8 ms | 36.1 ms | 1,285 | +| 100 | **100** | 222 | 85.9 ms | 108.3 ms | 1,961 | +| 1,000 | **1,000** | 2,022 | 193.7 ms | 238.4 ms | 2,917 | + +**파생값(분해).** 총 PreparedStatement를 SQL shape별로 가른 값입니다. 직접 측정이 아니라 **시더 카디널리티 + 총계 + Spring Data count 생략 규칙**으로 역산했습니다. 측정값과 섞어 읽지 않도록 성격과 증거를 함께 표기합니다. + +| 지표 | N=10 | N=100 | N=1,000 | 성격 | 증거 | +|---|---:|---:|---:|---|---| +| content | 1 | 1 | 1 | 파생 | 목록 루트 쿼리 1건(구조상 고정) | +| count | 1 | 1 | 1 | 파생 | `Page` 반환 → Spring Data count 규칙(아래) | +| distinct User SELECT | 3 | 20 | 20 | 파생 | 시더 `users=max(3,min(20,N/5+1))` + 1차 캐시 중복 제거 | +| Page SELECT | 10 | 100 | 1,000 | 파생 | 시더 `pages=N`(1:1), 아이템마다 달라 N번 | +| **ToOne(User+Page) 조회 수** | **13** | **120** | **1,020** | 파생 | 총계 − content − count − 컬렉션 N | + +```text +총 PreparedStatement += content 1 ++ count 1 ← Spring Data Page 반환의 전체 건수 count ++ distinct User targets ← ToOne, 1차 캐시로 중복 제거되어 distinct 수만큼 ++ N Page ← ToOne, 아이템마다 달라 N번 ++ N Highlight 컬렉션 ← 지연 로딩 컬렉션 초기화 +``` + +검산: `1 + 1 + 3 + 10 + 10 = 25` · `1 + 1 + 20 + 100 + 100 = 222` · `1 + 1 + 20 + 1000 + 1000 = 2022` ✓ + +**count 쿼리는 왜 나올까요?** `findAllBy(Pageable)`가 `Page`을 반환하기 때문입니다. +Spring Data는 전체 페이지 수를 알려주려고 `select count(...)`를 한 번 더 실행합니다. 다만 +`offset==0`이고 `pageSize > 반환 건수`이면 count를 건너뜁니다. 라운드트립 스모크는 1건을 +pageSize 10으로 조회해 이 조건에 들어갔고, count가 생략되어 총 4건이 나왔습니다. 반면 위 +측정은 `pageSize == 반환 건수(N)`라 count가 실제로 실행됩니다. 그래서 25 / 222 / 2,022에 +각각 count 1건이 포함되어 있습니다. + +> 이 count는 이후 페이징 전략의 결정 포인트이기도 합니다. 최종 피드가 전체 페이지 수를 요구하지 않는다면 `Page` 대신 `Slice`나 커서 결과로 바꿔 count 쿼리를 없앨 수 있습니다. + +지연은 `latencyMicros(n, 7, 2)`로 7회 반복하고 앞의 2회를 워밍업으로 버린 뒤, 남은 **5개 +표본의 중앙값과 최댓값**을 기록했습니다. 표본이 5개뿐이어서 `p50`·`p99`라고 부르지 않았습니다. +실제 코드의 p99 인덱스도 5개 중 최댓값을 가리킵니다. 안정적인 꼬리 지연을 말하려면 warm-up 후 +100회 이상 측정한 독립 세트가 여러 개 필요합니다. 여기서는 꼬리 지연이 아니라 N에 따른 왕복 +증가를 확인하려는 목적에 맞춰 측정 범위를 제한했습니다. + +세 조회 지표 모두 N을 따라 직선으로 증가합니다. 특히 하이라이트 컬렉션 초기화는 기울기 1의 직선(`= N`)이라 "조회량이 N에 정비례"함이 한눈에 드러납니다. + + + +![FeedItem N개를 반환하는 loadFeed 요청이 컬렉션 초기화 N회와 Highlight SELECT N회로 이어지는 인과 흐름도.](assets/diagrams/nplus1-query-fanout/nplus1-query-fanout.svg) + +
+Diagram description + +왼쪽의 loadFeed 요청은 한 페이지에서 N개의 FeedItem을 반환합니다. 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 됩니다. 현재 기준선에서는 배치나 서브셀렉트가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되므로 추가 조회가 N회 발생합니다. 각 SELECT는 해당 부모의 Highlight 자식 행을 전부 읽습니다. + +
+ +[Editable source](assets/diagrams/nplus1-query-fanout/nplus1-query-fanout.drawio) · [Grounded VizSpec](.techviz/nplus1-query-fanout/spec.json) + + +**이 관찰은 서로 다른 두 위반을 동시에 드러냅니다.** "하이라이트 수와 무관한 조회량"이라는 요구가 깨지는데, 깨지는 방식이 하나가 아닙니다. + +- **N+1(왕복).** `collectionFetches = N`은 한 요청에서 반환하는 **FeedItem(부모) 수**에 비례해 + 늘었습니다. Highlight 수가 아니라 아이템마다 컬렉션을 한 번씩 초기화하기 때문에 부모 수만큼 + DB를 왕복합니다. +- **과조회(행수).** 한 번의 왕복에서는 해당 FeedItem의 Highlight를 **전부** 읽어 옵니다. 가장 + 많은 아이템은 최대 500행입니다. 따라서 반환 행수·전송량·엔티티 생성은 **자식 수**에 비례해 + 늘어납니다(§6.4). + +부모 수에 따른 왕복 증가와 자식 수에 따른 과조회가 **같은 기준선에 동시에** 존재합니다. + +**"page size를 20으로 고정하면 N+1도 20으로 고정 아닌가?"** 맞습니다. 한 요청의 왕복 수는 page size에 묶입니다. 그러나 그 요청당 20회 왕복이 트래픽에 곱해집니다. + +```text +추가 Highlight SELECT/초 ≈ page size × RPS +예) page size 20 × 1,000 RPS ≈ 초당 20,000 자식 SELECT +``` + +그래서 N+1의 비용은 "한 요청 안에서 얼마나 크냐"가 아니라 "요청마다 반복되는 왕복이 처리량에 곱해질 때" 드러납니다. + +이 측정으로 확인한 N+1의 증가 기준은 전체 테이블 크기가 아니라 **한 요청에서 조립하는 부모 +엔티티 수**였습니다. 피드 테이블이 100만 행이어도 이 왕복 수 자체는 늘지 않습니다. 대신 전체 +테이블 크기는 OFFSET·정렬·가시성 필터 비용에 영향을 줍니다. 이 비용은 별도 축으로 분리해 +L15/L16에서 측정했습니다(§8). + +### 6.3 조회 증가 폭은 fetch 방식과 연관 데이터 수가 함께 결정한다 + +총 PreparedStatement(25 / 222 / 2,022)에서 content 1건·count 1건·highlights 컬렉션 N건을 +빼자 ToOne(User+Page) 조회 수 **13 / 120 / 1,020**이 남았습니다. 이전에 적었던 14 / 121 / +1,021에는 페이지 count 1건이 섞여 있었습니다. 이 값을 User와 Page로 다시 나누자 두 연관이 +정반대로 늘어났습니다. + +| 연관 | 데이터 분포 | 1차 캐시로 걸러지나 | N=10 / 100 / 1,000 조회 수 | +|---|---|---|---| +| **User** (EAGER ToOne) | 소수 풀 재사용(≤20명) | 그렇다 (공유되니 걸러짐) | 3 / 20 / 20 | +| **Page** (EAGER ToOne) | 아이템당 1개(전부 다름) | 아니다 | 10 / 100 / 1,000 | +| **highlights** (지연 로딩 컬렉션) | 아이템당 컬렉션 | — (아이템마다 1회) | 10 / 100 / 1,000 | + +EAGER의 secondary SELECT **구조**가 추가 조회의 가능성을 만들고, 실제로 몇 번 실행되는지는 Persistence Context 안에서 **서로 다른 연관 대상(distinct target)이 몇 개인지**가 정합니다. 그래서 같은 `@ManyToOne(EAGER)`라도 User는 distinct 대상 ≤20개 → 약 20회, Page는 distinct 대상 N개 → N회로 갈립니다. "즉시 로딩 하나 붙였을 뿐인데 왜 어떤 건 터지고 어떤 건 안 터지나"의 답은 애너테이션 하나가 아니라 fetch 방식 × distinct 카디널리티의 곱에 있습니다. + +### 6.4 각 조회는 "빠르다" — 그런데도 느리다 + +반복되는 하이라이트 조회 하나를 실행계획으로 확인했습니다. 아래는 **Plan A — 대량 시드 직후, +`ANALYZE` 실행 전**의 계획입니다(원문: [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt)). + +```text +Index Scan using ix_highlights_feed_items_created on highlights + (cost=0.27..8.29 rows=1 width=710) (actual time=0.026..0.123 rows=500 loops=1) + Index Cond: (feed_item_id = '2b5b931f-...'::uuid) + Buffers: shared hit=14 +Planning Time: 0.086 ms +Execution Time: 0.173 ms +``` + +개별 하이라이트 조회는 `feed_item_id` 탐색을 인덱스로 처리하고(Index Scan) 0.173 ms로 빠릅니다. 그런데 이 빠른 쿼리가 N번 반복됩니다. N=1,000이면 피드 한 번 로딩이 194 ms로 커집니다. 즉 이 문제는 "쿼리가 느려서"가 아니라 "빠른 쿼리를 N번 왕복해서" 생깁니다. + +다만 이 실행계획을 "이미 최적"이라고 결론지으면 안 됩니다. 최종 요구사항 관점에서 두 문제가 함께 있다(§6.2의 두 위반과 같은 짝입니다). + +- **반복 왕복**: 같은 자식 쿼리가 FeedItem마다 반복됩니다. 현재 ORM fetch plan의 문제이므로 + 인덱스로는 풀 수 없고 왕복 횟수 자체를 줄여야 합니다. +- **컬렉션 과조회**: 이 쿼리는 `SELECT * FROM highlights WHERE feed_item_id = ?`라 한 번에 최대 500행을 읽어 옵니다. 응답에 필요한 건 최신 3개뿐인데 `ORDER BY created_at DESC LIMIT 3`가 없어 결과량을 제한하지 못합니다. 이건 SQL shape와 인덱스 설계까지 함께 풀어야 합니다. + +따라서 정확히는 "**N회 반복의 원인은 fetch plan에 있지만, 최종 Top-3 조회 비용은 SQL shape·인덱스까지 함께 해결해야 한다**"가 맞습니다. + +**Plan A만으로 결론을 내리지는 않았습니다.** Plan A에서 추정한 `rows=1`과 실제 `rows=500`은 +500배 차이가 납니다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id`별 편중을 +반영하지 못했다는 가설을 세웠습니다. 이 가설은 `ANALYZE highlights` 뒤에 Plan B를 다시 측정해 +검증할 예정입니다. 아직 실행하지 않았으므로 Plan B 열은 비워 두었습니다. + +| 항목 | Plan A (현재, `ANALYZE` 전) | Plan B (`ANALYZE highlights` 후) | +|---|---|---| +| 추정 rows | 1 | 예정 | +| 실제 rows | 500 | 예정 | +| 스캔 방식 | Index Scan (`ix_highlights_feed_items_created`) | 예정 | +| Buffers | `shared hit=14, read=0` (warm) | 예정 | +| Execution Time | 0.173 ms | 예정 | + +EXPLAIN 수치를 읽을 때 주의할 두 가지가 더 있습니다. + +- **warm cache**: `Buffers: shared hit=14, read=0`은 **warm buffer cache** 결과라 디스크 I/O가 낀 cold 실행시간으로 읽으면 안 됩니다. +- **0.173 ms를 194 ms와 합산·비교 금지**: `Execution Time`은 PostgreSQL executor 내부 시간에 가깝고 ORM 엔티티 생성·JDBC 결과 전달·DTO 매핑·직렬화·HTTP를 포함하지 않습니다. 애플리케이션 지연(§6.2)과 같은 지표가 아닙니다. + +### 6.5 코드에 루프가 없는데 왜 N+1인가 + +`loadFeed`에는 하이라이트를 위한 명시적인 `for`가 없고 `getHighlights().stream()`만 있습니다. +처음에는 이 코드만 보고 조회가 N번 나간다고 알아차리기 어려웠습니다. 하지만 지연 로딩 컬렉션은 +접근하는 순간 조회하므로 아이템이 N개면 접근과 조회도 N번 발생합니다. 반복문이 없어진 것이 아니라 +스트림 뒤에 숨은 셈입니다. + +--- + +## 7. User·Page 연관 숨은 추가 쿼리 정량화 + +§6에서 highlights 조립에 해당하는 조회 수를 분리했지만, 총 PreparedStatement에는 여전히 User·Page +연관 조회가 남았습니다. §6.3에서는 시더 카디널리티로 13 / 120 / 1,020이라는 값을 역산했습니다. +이번에는 같은 `loadFeed`를 두고 엔티티별 fetch 통계를 직접 읽어 이 예측을 확인했습니다. 코드를 +새로 만든 것은 아니며 측정 지표만 바꿨습니다. + +### 7.1 ToOne 조회 수를 엔티티 fetch 통계로 확인한다 + +컬렉션 조회는 `getCollectionFetchCount()`로 분리했습니다. ToOne 조회는 Hibernate가 제공하는 +다음 두 지표로 나누었습니다. + +- `getEntityFetchCount()` = **2차 SELECT로 로드된 엔티티 인스턴스 수**(User + Page 합). +- `getEntityStatistics(PageJpaEntity.class.getName()).getFetchCount()` / `…UserJpaEntity…` = **엔티티별** fetch 수. + +§6.3의 User/Page 값은 "총계 − content − count − 컬렉션 N"으로 역산한 **파생값**이었습니다. +이번에는 Hibernate 통계에서 직접 읽은 값과 같은지 확인했습니다. + +> 지표 이름을 정확히 읽어야 합니다. `getEntityFetchCount()`는 "실행된 SELECT SQL 수"가 아니라 +> **2차 fetch로 초기화된 엔티티 수**입니다. Hibernate 버전에 따라 합계의 집계 범위가 달라질 수 +> 있어 회귀 가드는 시더 카디널리티와 무관하게 성립하는 **`pageFetch == N`(엔티티별)** 으로 +> 고정하고, 합계는 회계 항등식으로 교차 검증했습니다. + +### 7.2 실측 — 같은 `@ManyToOne(EAGER)`가 정반대 곡선을 그린다 + +**측정값(직접 측정).** 아래는 `getEntityStatistics(...).getFetchCount()`와 `getEntityFetchCount()`가 낸 값입니다. §6.3에서 역산한 파생값과 **정확히 일치**합니다. + +| N | Page fetch(★선형) | User fetch(평탄) | ToOne 합(`entityFetch`) | 초기화 컬렉션 | 총 PreparedStatement | +|---:|---:|---:|---:|---:|---:| +| 10 | **10** | 3 | 13 | 10 | 25 | +| 100 | **100** | 20 | 120 | 100 | 222 | +| 1,000 | **1,000** | 20 | 1,020 | 1,000 | 2,022 | + +성격: 측정값(직접) — 출처 `FeedPersistenceIT.l2ToOneEagerHiddenNPlusOneCurve`(콘솔 `>>> LAB L2 [eager toOne curve …]`, 리포트 `app-bootstrap/build/lab-results/feed-nplus1.md`). 원본: [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv). + +검산(§6.3 파생과 일치): `entityFetch = pageFetch + userFetch` → `10+3=13` · `100+20=120` · `1000+20=1020` ✓. 회계 항등식으로도 `총 PreparedStatement − 컬렉션 N − content(1) − count(1) = entityFetch` → `25−10−2=13` · `222−100−2=120` · `2022−1000−2=1020` ✓. **§6.3에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했다** — 파생 예측이 실측으로 확정됐습니다. + +같은 `@ManyToOne(EAGER)`인데도 Page fetch는 N을 따라 10 → 100 → 1,000으로 늘고 User +fetch는 20에서 멈췄습니다. Page는 아이템마다 달라 정확히 N번 조회되지만, User는 소수 풀을 +재사용하고 한 번 로드한 대상이 1차 캐시에 남기 때문입니다. 즉 N+1이 생길 가능성은 EAGER라는 +코드에서 나오지만, 실제 증가 폭은 연관 데이터의 카디널리티에 따라 달라집니다. + +> 지연은 §6.2와 **같은 `loadFeed` 호출**을 잰 것이므로 별도 지연 축이 아닙니다. N2는 그 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐, 새로운 지연을 만들지 않습니다. + +### 7.3 필드에 접근하지 않아도 ToOne 쿼리가 발생한다 + +§6.5에서는 지연 로딩이 `stream()` 뒤에 반복을 감춘 모습을 확인했습니다. ToOne은 필드에 접근하지 +않아도 조회된다는 점이 달랐습니다. 이를 확인하려고 `loadFeed` 대신 아무것도 매핑하지 않는 순수 +JPQL로 `feed_items`만 조회하고, `getUser()`·`getPage()`·`getHighlights()`는 **한 번도 +호출하지 않았습니다**. + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l2EagerToOneFiresEvenWithZeroFieldAccess`(seed 100, 접근 0회). + +| 접근 | 연관 | fetch 계약 | 접근 0에서 fetch 수 | +|---|---|---|---:| +| 0회 | Page | `@ManyToOne` (EAGER) | **100** (= N) | +| 0회 | User | `@ManyToOne` (EAGER) | 20 (풀 dedup) | +| 0회 | highlights | `@OneToMany` (LAZY) | **0** | + +아무 필드도 읽지 않았는데 Page 2차 SELECT가 N번 나왔습니다. 제가 조회 코드를 작성하지 않았는데도 +EAGER 기본값 때문에 생긴 N+1이었습니다. 같은 조건에서 LAZY 컬렉션은 접근하지 않았으므로 0이 +나왔습니다. 이 테스트로 EAGER는 사용 여부와 관계없이 미리 로딩하고, LAZY는 접근할 때 로딩한다는 +차이를 확인했습니다. + +### 7.4 같은 실행계획, 정반대 비용 — 반복되는 ToOne 부모 쿼리 + +§6.4에서 자식 컬렉션 쿼리를 확인한 것처럼, 이번에는 N2를 만드는 **반복되는 ToOne 부모 쿼리** +(`SELECT * FROM pages WHERE id = ?`, `… FROM users WHERE id = ?`)를 실행계획으로 +확인했습니다. 아래는 seed(100) 직후의 계획입니다(원문: [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). + +```text +-- pages +Index Scan using pk_pages on pages + (cost=0.14..8.15 rows=1 width=2104) (actual time=0.009..0.009 rows=1 loops=1) + Buffers: shared hit=2 Execution Time: 0.021 ms +-- users +Index Scan using pk_users on users + (cost=0.14..8.15 rows=1 width=2104) (actual time=0.013..0.014 rows=1 loops=1) + Buffers: shared hit=2 Execution Time: 0.022 ms +``` + +`WHERE id = ?`는 PK 조회라 두 쿼리 모두 pk Index Scan으로 1건을 약 0.02 ms에 가져옵니다. +개별 쿼리는 빨랐지만 Page 쿼리는 이 빠른 실행계획을 **N번 반복**했습니다. + +**pages와 users의 실행계획은 둘 다 pk Index Scan이고 실행시간도 약 0.02 ms로 거의 같습니다.** +그런데 §7.2의 증가 곡선은 정반대였습니다. 비용을 가른 것은 실행계획이 아니라 반복 횟수였습니다. +Page는 N번, User는 서로 다른 대상 수인 최대 20번 반복됩니다. 단건 계획은 이미 Index Scan이므로 +인덱스를 더하는 것으로는 해결되지 않습니다. §9부터 왕복 횟수를 줄이는 fetch 전략을 시도합니다. +warm cache와 executor 시간에 관한 한계는 §6.4와 같습니다. + +### 7.5 루프와 필드 접근 없이 N+1이 생기는 이유 + +`@ManyToOne`은 fetch를 명시하지 않으면 EAGER가 기본값입니다(§5.4). 파생 쿼리인 +`findAllBy`는 EAGER 연관을 루트 SQL의 JOIN으로 자동 병합하지 않고 **행마다 2차 SELECT**로 +채웠습니다. 그래서 `getUser()`·`getPage()`를 읽기 전부터 조회가 나갔습니다. 코드에 루프나 +접근이 없어서 표면에 보이지 않았고, Page와 User의 카디널리티가 달라 증가 폭도 다르게 나타났습니다. + +fetch 계약(EAGER/LAZY)과 실제 사용(접근/미접근)을 교차하면 EAGER의 죄가 정확히 어디인지 드러납니다. + +| | 접근 안 함 | 접근함(`loadFeed`) | +|---|---|---| +| **EAGER**(현재 User·Page) | 나간다 — **낭비**(안 짠 N+1) | 나간다 (즉시 로딩 N+1) | +| **LAZY**(가정) | 안 나간다 | 나간다 (지연 로딩 N+1) — timing만 다름 | + +`loadFeed`는 매핑 과정에서 user·page를 실제로 사용합니다. 따라서 EAGER를 LAZY로 바꿔도 조회 +시점만 달라질 뿐 N+1은 다시 생깁니다. 이 문제를 fetch **타입** 변경만으로 풀 수 없다고 판단했고, +Fetch Join, Batch Fetch, DTO Projection처럼 왕복과 적재 방식을 바꾸는 fetch **전략**을 +차례로 시도했습니다. + +--- + +## 8. 확인된 문제와 이후 검증할 가설 + +여기까지 측정하고 나니 문제를 두 축으로 나눌 필요가 있었습니다. 연관 조회 폭증은 수치로 확인했지만, +기준 쿼리의 Seq Scan + Sort는 아직 병목이라고 단정할 수 없었습니다. 그래서 확인된 문제와 +검증할 가설을 다음처럼 분리했습니다. + +| | 축 A — **연관 조회 폭증(N+1)** · 확인됨 | 축 B — **기준 쿼리 Seq Scan + Sort** · 가설 | +|---|---|---| +| 관찰 | 쿼리 수가 `1 + count + distinct(user) + N + N` (§6.2에서 실측) | 목록 쿼리 한 방이 Seq Scan + Sort | +| 원인 | **fetch 전략** (EAGER 2차 SELECT / 지연 컬렉션) | 정렬 인덱스가 이 쿼리에 안 걸림(아래) | +| 해법 축 | fetch join / batch / DTO 프로젝션 | 정렬에 맞는 인덱스 / keyset | + +피드는 시간순 정렬이 필요하므로 목록 쿼리에 `ORDER BY first_highlighted_at DESC, id`가 붙습니다. 스키마에 `ix_feed_items_visibility_sort (visibility, first_highlighted_at DESC, id)`가 있긴 하지만, 이 기준 쿼리에는 `visibility =` 필터가 없어 인덱스의 **선두 컬럼(visibility)이 맞물리지 않아** 정렬에 쓰이지 못합니다. 그래서 "인덱스 부재"가 아니라 "이 filterless 쿼리에 맞는 정렬 인덱스가 없음"이 정확한 진단입니다. + +다만 **Seq Scan 자체를 곧바로 문제로 판정하지는 않습니다.** 테이블이 작거나 조회 비율이 높으면 PostgreSQL이 Seq Scan을 고르는 게 더 빠를 수 있고, N=1,000은 인덱스 효과를 판단하기엔 작습니다. 이 계획이 실제 병목인지는 피드 규모(N=1k~1M)와 페이지 깊이(OFFSET)를 키우며 정렬 인덱스 유무에 따른 `rows`·`buffers`·sort spill·execution time을 대조해 이후 랩(L15)에서 검증합니다. + +두 축은 해결 방법도 다릅니다. 축 A(N+1)는 fetch 전략 문제라 인덱스로 풀리지 않고, 축 B(정렬)는 +인덱스·쿼리 문제라 fetch join으로 풀리지 않습니다. 이후 단계에서는 두 축을 분리해 검증했습니다. + +--- + +## 9. Fetch Join을 적용하며 확인한 두 가지 문제 + +컬렉션 N+1과 User·Page의 숨은 쿼리를 확인한 뒤에는 "나누어 가져오지 말고 한 번에 가져오면 +되지 않을까"라고 생각했습니다. 그래서 user·page·highlights·mentions를 모두 `join fetch`로 +루트 SQL에 합쳐 보았습니다. 결과는 두 가지 실패였습니다. 컬렉션 두 개를 동시에 fetch join하자 +`MultipleBagFetchException`이 발생했고, 하나만 합치자 부모와 자식의 곱만큼 전송 행이 +늘었습니다. 쿼리 수는 줄었지만 전송량이 커졌으므로 이 단계부터는 쿼리 수뿐 아니라 전송 행수도 +함께 측정했습니다. + +> **이 절에는 제가 fetch join을 직접 적용했다가 실패한 과정이 담겨 있습니다.** `.distinct()`· +> `List→Set`·`@BatchSize`로 바로 우회하지 않고 실패를 별도 테스트에 남겼습니다. 그래야 +> fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문입니다. + +### 9.1 두 번째 컬렉션(mentions)을 퍼시스턴스에만 최소로 붙인다 + +`MultipleBagFetchException`을 재현하려면 컬렉션이 **둘 이상** 필요했습니다. 기준선 스키마에는 +`highlights`만 있었으므로 목표 스키마의 `feed_item_mentions`를 이 단계에서 먼저 추가했습니다. +다만 지금 필요한 것은 fetch join할 두 번째 bag뿐이어서 범위를 **퍼시스턴스 계층까지**로 +제한했습니다. 추가한 코드는 마이그레이션(`V7__feed_mentions.sql`), 경량 자식 엔티티 +`FeedItemMentionJpaEntity`, 부모의 `@OneToMany List<…> mentions`, 시더입니다. +도메인 애그리거트·응답 매핑·공개 범위 판정은 공개 범위 단계까지 미뤘습니다. + +> **기존 측정은 바뀌지 않았습니다.** `mentions`는 `@OneToMany` 기본 **LAZY**이고 `loadFeed`와 +> §7.3의 접근 0 테스트도 `getMentions()`를 호출하지 않습니다. §6·§7의 테스트를 다시 실행해 +> `collectionFetches == N`, 접근 0에서 `== 0`, `pageFetch == N`이 그대로 유지되는지 확인했습니다. + +목표 스키마의 `feed_item_mentions`는 복합 PK `(feed_item_id, mentioned_user_id)`지만, 이 +랩에서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 **대리키(id) + +`UNIQUE(feed_item_id, mentioned_user_id)`**로 구현했습니다. 유일성은 그대로 보장됩니다. +시더는 `MENTIONED` 아이템에만 사용자를 연결하고, 사용자 풀보다 많이 넣어 UNIQUE 제약을 +어기지 않도록 `min(2+i%4, poolSize)`로 상한을 두었습니다. + +### 9.2 실패 ① 두 컬렉션 동시 fetch join → `MultipleBagFetchException` + +**bag은 순서 컬럼(`@OrderColumn`)이 없는 `List`입니다.** `highlights`와 `mentions`가 모두 +bag인 상태에서 두 컬렉션을 fetch join하면 feed_item 한 행이 highlights h개 × mentions m개, +즉 **h×m 행**으로 늘어납니다. Hibernate는 이 곱집합을 안전하게 원래 컬렉션으로 되돌릴 수 없다고 +판단해 **쿼리 생성(createQuery) 시점에** 예외를 던집니다. 데이터가 0건이어도 발생하는 매핑 +단계의 거부입니다. + +```java +// 착상: "연관 전부 fetch join" — 컬렉션 둘을 동시에 +select distinct f from FeedItemJpaEntity f + join fetch f.highlights + join fetch f.mentions +``` + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3TwoBagFetchJoinThrowsMultipleBagFetchException`. 예외 원인 체인(콘솔 원문): + +```text +java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException +``` + +실제로 실행해 보니 `MultipleBagFetchException`은 **`IllegalArgumentException`으로 감싸져** +나왔습니다(FQN은 `org.hibernate.loader.MultipleBagFetchException`). 따라서 테스트를 +`hasCauseInstanceOf(MultipleBagFetchException.class)`에만 맞추면 래핑 계층이나 버전 차이에 +취약합니다. 이 테스트에서는 원인 체인을 클래스명 문자열로 펼친 뒤 `contains("MultipleBagFetchException")` +으로 확인했습니다(Hibernate ORM 7.1.8 기준). + +### 9.3 실패 ② 컬렉션 하나만 fetch join → 카테시안으로 전송 행수 증가 + +컬렉션을 **하나만**(`highlights`) fetch join하면 예외는 나지 않지만, +`feed_items ⋈ highlights`가 부모를 자식 수만큼 반복한 행을 만듭니다. 그래서 쿼리 수가 +아니라 DB가 애플리케이션에 전달한 **조인 행수**를 측정했습니다. + +> **⚠ 측정 정정(Hibernate 6+/7)** — 처음에는 "`distinct` 없는 결과 리스트 크기 = Σ +> highlights(전송 행수)"라고 예상했습니다. 하지만 결과 리스트 크기는 **N**(10/100/1000)이었습니다. +> Hibernate 6+가 fetch join의 **루트 엔티티를 자동으로 중복 제거**하기 때문입니다. 카테시안은 +> SQL과 전송 단계에 그대로 남아 있으므로 리스트 크기 대신 실제 조인 카디널리티 +> `SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id`를 +> 측정했습니다. 이 문제는 EXPLAIN actual rows(§9.5)나 조인 count로 확인해야 합니다. + +**측정값(직접 측정).** 출처 `FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows`(N=10/100/1000). 원본: [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv). + +| N | 전송 행수(★조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement | +|---:|---:|---:|---:|---:|---:|---:| +| 10 | **1,285** | 10 | 10 | 1,285 | 128.5× | 14 | +| 100 | **1,961** | 100 | 100 | 1,961 | 19.6× | 121 | +| 1,000 | **2,917** | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 | + +전송 행수는 항상 아이템 수 N보다 많았고, §4.3의 시드 하이라이트 총량과 정확히 일치했습니다. +조인이 모든 자식 행을 부모에 붙여 전송했기 때문입니다. Zipf 분포에서 뒤쪽 아이템은 highlight가 +한 개뿐이라 폭발 배수는 128.5× → 19.6× → 2.9×로 줄었지만, 절대 전송 행수는 계속 +Σ highlights였습니다. 제가 원한 것은 N개 아이템이었지만 DB가 전달한 것은 모든 highlight +행이었습니다. + +### 9.4 쿼리 수만 보면 개선처럼 보인다 + +같은 N=100 데이터에서 기준선 `loadFeed`는 PreparedStatement가 222개였고, highlights를 +fetch join한 쿼리는 **121개**였습니다. 쿼리 수만 보면 개선처럼 보였기 때문에 항목별로 +다시 나눠 보았습니다. + +| 구분 | 기준선 loadFeed(§6.2) | highlights fetch join(§9.3) | 결과 | +|---|---:|---:|---| +| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 | +| Page count | 1 | 0 | 이 랩은 `Pageable`이 아닌 원시 JPQL이라 Spring Data count 없음 | +| highlights 컬렉션 | **100** | **0** | ★ N개 컬렉션 SELECT가 조인으로 **접힘**(N1 사라짐) | +| ToOne(User+Page) | 120 | **120** | ★ 그대로 — highlights만 fetch join했으니 N2는 안 풀림 | +| **합** | **222** | **121** | | + +222개가 121개로 줄어든 주된 이유는 highlights 컬렉션 N개가 루트 조인 하나로 합쳐졌기 +때문입니다. 나머지 1개 차이는 원시 JPQL에는 Spring Data count가 없어서 생겼습니다. 하지만 +121개 중 **120개는 여전히 ToOne 2차 SELECT**였고, 조인 하나는 **1,961행**을 전달했습니다. +비용이 사라진 것이 아니라 쿼리 수에서 전송 행수와 메모리로 옮겨 갔습니다. + +### 9.5 조인이 행을 곱하는 것을 실행계획에서 + +§6.4에서는 반복되는 자식 단건 쿼리를, §7.4에서는 부모 단건 쿼리를 확인했습니다. 이번에는 +fetch join이 만든 조인 하나를 확인했습니다. 아래는 seed(100) 직후 같은 형태의 쿼리를 +EXPLAIN한 결과입니다(원문: [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). + +```text +Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) + Hash Cond: (h.feed_item_id = fi.id) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=100 loops=1) + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) +Execution Time: 0.959 ms +``` + +부모 `feed_items`는 100행(Hash 노드)인데, **Hash Join 노드의 actual rows는 1,961**(= Σ highlights)로 부풉니다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 것 — 리스트 크기(100, §9.3의 Hib6 dedup)로는 안 보이는 실체를 플랜이 드러냅니다. `rows=4202`(추정) vs `rows=1961`(실제)의 오차는 §6.4 Plan A와 같은 통계 이슈(대량 시드 직후 `ANALYZE` 미실행)이고, warm cache·executor 시간 caveat도 §6.4와 같습니다. + +### 9.6 두 bag이 거부되고 한 bag은 행이 늘어나는 이유 + +bag 두 개를 동시에 `join fetch`하면 Hibernate가 곱집합을 원래 컬렉션으로 되돌릴 수 없어 +`MultipleBagFetchException`을 던집니다. 하나만 join하면 예외는 없지만 부모 행이 자식 수만큼 +늘어납니다. 쿼리 수는 1+N에서 1로 줄어도 전송 행수와 메모리는 커졌고, Hibernate 6+의 루트 +중복 제거 때문에 결과 리스트만 보면 이 증가가 보이지 않았습니다. 이 결과를 보고 fetch join은 +ToOne에는 적합하지만 컬렉션에는 주의가 필요하다고 판단했습니다. 다음에는 컬렉션 하나만 fetch +join한 상태에서 페이징을 적용해 보았습니다. + +--- + +## 10. 컬렉션 fetch join + 페이징 — 페이지를 원했는데 데이터셋 전체를 올린다 + +컬렉션 하나만 fetch join하고 `setMaxResults(20)`을 적용하면 전송량도 한 페이지로 줄어들 것이라고 +생각했습니다. 하지만 Hibernate는 컬렉션 fetch join에 페이징을 걸자 DB `LIMIT`을 사용하지 +않았습니다. 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 냈고, 경고도 함께 남겼습니다. + +반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였습니다. 그래서 이번에는 +`returned`뿐 아니라 **`feedItemLoaded`**, 즉 실제로 메모리에 올린 부모 엔티티 수를 +측정했습니다. + +> 이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했습니다. 다음 단계에서 +> `@BatchSize`·엔티티 페이징·DTO Projection을 적용했을 때 전후 차이를 같은 기준으로 비교하기 +> 위해서입니다. + +### 10.1 무대 — 새 프로덕션 코드 0 (§9 무대 + 페이징 한 줄) + +§10에서는 §9의 데이터와 매핑을 그대로 두고 `highlights` fetch join에 페이징 한 줄만 +추가했습니다. 새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았습니다. 이 쿼리는 +`FeedQueryAdapter`가 아니라 통합 테스트 안의 원시 JPQL로만 실행했습니다. + +```java +// IT 안에서 세우는 §10 무대 (프로덕션 아님): +"select f from FeedItemJpaEntity f join fetch f.highlights " // ← §9의 한 bag fetch join + + "order by f.firstHighlightedAt desc, f.id asc" +// + .setFirstResult(0).setMaxResults(20) // ← §10의 방아쇠: 페이징 +``` + +기본 설정(`hibernate.query.fail_on_pagination_over_collection_fetch=false`)에서는 이 쿼리가 +예외 없이 **경고 + 인메모리 페이징**으로 진행됩니다. 플래그를 `true`로 바꾸면 같은 쿼리를 즉시 +실패시킬 수 있습니다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 사용할 +수 있습니다. + +> **N1/N2/§9 회귀 없음**: §10은 프로덕션 코드를 안 건드리므로 §6·§7·§9의 단언(`collectionFetches == N`, `pageFetch == N`, `MultipleBagFetchException`, 조인 카디널리티 = Σ highlights)은 그대로 GREEN입니다. §10의 추가분은 IT 측정 메서드뿐입니다. + +### 10.2 실측 — 응답은 한 페이지인데 부모는 전부 로드한다 + +컬렉션 하나를 fetch join한 뒤 페이징하자 `returned`는 페이지 크기였지만, 부모 엔티티는 +**N개 전부** 로드되었습니다. `EntityStatistics.getLoadCount()`로 FeedItem 로드 수를 따로 +읽어 응답 크기와 실제 적재량을 비교했습니다. + +**측정값(직접 측정·파생).** `returned`·`feedItemLoaded`는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 `feedItemLoaded / returned`로 파생합니다. 출처 `FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`. 원본: [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv). + +| N | returned(페이지) | feedItemLoaded(★ = N) | over-fetch 배수 | 시드 하이라이트 | +|---:|---:|---:|---:|---:| +| 10 | 10 | **10** | 1.0× (안 보임) | 1,285 | +| 100 | 20 | **100** | 5.0× | 1,961 | +| 1,000 | 20 | **1,000** | 50.0× | 2,917 | + +`returned`는 페이지 크기에 고정되었지만 `feedItemLoaded`는 N을 따라 늘었습니다. over-fetch +배수도 1.0× → 5.0× → 50.0×로 증가했습니다. N=10에서는 데이터셋이 한 페이지보다 작아 +두 값이 같았고 문제가 보이지 않았습니다. 데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 +나타났습니다. + +> **왜 `getLoadCount()`를 사용했을까요?** fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 +> 로드된 부모 수가 `EntityStatistics.getLoadCount()`에 잡힙니다. 인메모리 페이징은 전체를 +> 하이드레이트한 뒤 부모 목록을 자르므로 `returned`가 20이어도 `getLoadCount() == N`입니다. +> 반면 `getCollectionFetchCount()`에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 +> 지표로 사용하지 않았습니다. + +그리고 이 쿼리가 던지는 경고 자체가 §10의 얼굴입니다. + +> **⚠ 측정 정정(Hibernate 7)** — 널리 알려진 경고 코드는 `HHH000104`지만, 이 랩에서 사용한 +> Hibernate ORM 7.1.8은 `HHH90003004`를 기록했습니다. +> +> ```text +> HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory +> ``` +> +> 메시지 본문은 `firstResult/maxResults specified with collection fetch; applying in memory`로 +> 같았습니다. 그래서 회귀 가드는 코드 번호만 비교하지 않고 `contains("HHH000104") || +> contains("collection fetch")`처럼 문구도 함께 확인하도록 만들었습니다. + +### 10.3 비용은 페이지가 아니라 데이터셋에 비례한다 + +응답은 한 페이지인데 비용은 N에 비례하는지 측정했습니다. 아래 값은 문서 첫머리에서 밝힌 대로 +**단일 스레드·warm-cache 상대값**입니다. 절대값이 아니라 N에 따른 변화 방향만 비교했습니다 +(원본: [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv)). + +| N | 지연 중앙값(5회) | 지연 최댓값(5회) | 스레드 누적 할당 | +|---:|---:|---:|---:| +| 10 | 6.184 ms | 6.566 ms | ≈1.5 MB | +| 100 | 13.890 ms | 16.062 ms | ≈3.0 MB | +| 1,000 | 79.452 ms | 83.526 ms | ≈10.0 MB | + +`returned`가 페이지 크기로 고정인데도 지연·할당이 N을 따라 오른다 = "페이징이 데이터를 안 줄였다"의 시간·메모리 증거입니다. + +예상과 달리 이 fetch join의 지연은 기준선보다 낮았습니다. N=1,000에서 기준선 최댓값은 +238.4 ms였고 fetch join은 83.526 ms였습니다. 컬렉션 N번 왕복이 조인 하나로 줄었기 +때문입니다. 하지만 메모리 할당은 약 1.5 MB에서 10.0 MB로 늘었습니다. 지연만 보면 개선처럼 +보이지만, 페이지에 필요하지 않은 N개 부모와 모든 highlights를 하이드레이트하고 있었습니다. + +> **왜 "힙 델타"가 아니라 스레드 누적 할당을 썼을까요?** 인메모리 페이징이 버린 부모는 곧 +> GC 대상이 되어 `used heap`의 전후 차이에 잘 나타나지 않습니다. `getThreadAllocatedBytes` +> (HotSpot)는 GC와 관계없이 호출이 만든 전체 할당량을 누적하므로 버려지는 엔티티까지 측정할 수 +> 있습니다. + +### 10.4 발행 SQL엔 LIMIT이 없다 — 인메모리 페이징의 스모킹건 + +인메모리 페이징을 실행계획에서도 확인했습니다. fetch join이 발행한 SQL(a)과 엔티티만 페이징한 +SQL(b)을 seed(100)에서 EXPLAIN으로 비교했습니다(원문: [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). + +```text +-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음 +Sort (... rows=1782 ...) (actual ... rows=1961 loops=1) + Sort Method: quicksort Memory: 445kB + -> Hash Join (... actual ... rows=1961 loops=1) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=100 loops=1) + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) + +-- (b) 엔티티만 페이징 — Limit 노드 존재 +Limit (... rows=20 ...) (actual ... rows=20 loops=1) + -> Sort (actual ... rows=20 loops=1) + Sort Method: top-N heapsort Memory: 28kB + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) +``` + +(a)엔 `Limit` 노드가 없다 = **DB가 페이징을 안 했습니다.** 조인 결과 전체(actual rows = Σ highlights)를 `quicksort`로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 합니다. (b)엔 `Limit` 노드가 정렬 위에 얹혀 `top-N heapsort`로 상위 몇 행만 취합니다. **quicksort(전체 정렬) vs top-N heapsort(상위 몇 행)** — "인메모리 페이징 vs DB 페이징"의 비용 차이가 계획 레벨로 드러납니다. (a)에 `Limit`이 없다는 것 자체가 "DB가 페이징을 안 했으니 누군가 메모리에서 했다"의 증거입니다. (컬럼명·리터럴 하드코딩이라 인젝션 무관. warm cache·executor 시간 caveat는 §6.4와 같습니다.) + +### 10.5 컬렉션 fetch join과 페이징을 함께 쓰기 어려운 이유 + +컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어납니다. 여기에 DB `LIMIT`을 걸면 부모 +20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 하이라이트가 누락될 수 있습니다. Hibernate는 +이 손상을 피하려고 SQL에서 `LIMIT`을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 +페이지를 자릅니다. §10.4에서 SQL(a)에 `Limit` 노드가 없었던 이유입니다. 이 동작 때문에 +컬렉션 fetch join과 페이징을 함께 사용하지 않기로 했습니다. + +다음 단계에서는 **fetch join을 버리고 엔티티만 페이징**했습니다. 그러면 §10.4의 SQL(b)처럼 +`LIMIT`이 정상적으로 발행됩니다. 다만 highlights가 다시 LAZY가 되어 컬렉션 N+1이 돌아옵니다. +그래서 페이지 부모 키를 모아 `IN`으로 조회하는 Batch Fetch를 함께 적용했습니다. + +--- + +## 11. 배치 페치 — 엔티티 페이징과 IN 배치 적용 + +Fetch Join을 빼고 엔티티만 페이징하니 DB `LIMIT`은 다시 동작했지만, LAZY 연관의 N+1이 +돌아왔습니다. 그래서 `hibernate.default_batch_fetch_size=100`을 적용해 부모 키를 `IN`으로 +묶었습니다. `loadFeed` 코드는 바꾸지 않았고, 세션 설정만 달리한 뒤 §6의 기준선과 같은 지표로 +전후를 비교했습니다. + +> `default_batch_fetch_size`는 세션 전체에 영향을 줍니다. 기존 테스트에 바로 적용하면 §6~§10의 +> 기준선도 함께 바뀌므로, 새 IT 클래스인 `FeedBatchFetchIT`에만 설정했습니다. 기존 테스트를 +> 다시 실행해 기준선이 그대로 유지되는지도 확인했습니다. + +### 11.1 fix는 세션 설정 한 줄 — 순진 loadFeed 코드는 그대로 + +배치 페치는 두 단계로 동작합니다. 먼저 fetch join 없이 **엔티티만** 페이징해 DB `LIMIT`이 +정상적으로 적용되게 합니다. 그다음 LAZY 연관은 부모 키를 모아 **`IN` 배치**로 채웁니다. +이렇게 하면 N+1이 `ceil(N/batch)`번으로 줄어듭니다. + +```yaml +# application.yml (프로덕션) 또는 테스트 @TestPropertySource — 애플리케이션 코드 변경 0: +spring.jpa.properties.hibernate.default_batch_fetch_size: 100 +``` + +`loadFeed`(§5.1)는 그대로 두었습니다. `findAllBy(Pageable)`로 엔티티를 페이징하고, 매핑할 때 +LAZY 연관에 접근합니다. §6에서 N+1을 만들었던 코드가 이 설정 아래에서는 배치로 동작합니다. +특정 컬렉션에만 `@BatchSize(size=100)`를 붙일 수도 있지만, 그러면 기준선 매핑 자체가 바뀝니다. +비교를 위해 이 랩에서는 세션 property로 격리했습니다. + +### 11.2 실측 — 배치 적용 전후의 쿼리 수 + +`loadFeed(0, n)`(§6.2와 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 `1+N`에서 급감합니다. before = §6.2, after = `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount`. 원본: [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv). + +| N | before: 순진 총 PreparedStatement(§6.2) | after: 배치 총 PreparedStatement | 붕괴 | before: 컬렉션 fetch(§6.2) | after: 컬렉션 fetch | +|---:|---:|---:|---:|---:|---:| +| 10 | 25 | **5** | — | 10 | **1** | +| 100 | 222 | **5** | — | 100 | **1** | +| 1,000 | 2,022 | **23** | **87.9×** | 1,000 | **10** | + +총 PreparedStatement는 25 / 222 / 2,022에서 5 / 5 / 23으로 줄었습니다. N=1,000에서는 +87.9배 차이였습니다. highlights뿐 아니라 user·page EAGER 연관도 같은 배치에 묶였습니다. +23개는 루트 1개, count 1개, highlights 배치 10개, page 배치 10개, user 배치 1개로 +나뉩니다. 다만 컬렉션 fetch 지표는 제가 예상한 방식과 달라 아래처럼 설명을 정정했습니다. + +> **★ 실측 정정** — 처음에는 `getCollectionFetchCount()`를 초기화된 컬렉션 수라고만 보고, +> 배치를 적용해도 N으로 유지될 것이라고 예상했습니다. 실제로는 10 / 100 / 1,000에서 +> **1 / 1 / 10 = `ceil(N/batch)`**으로 줄었습니다. 이 결과에 맞춰 지표를 여러 컬렉션을 +> 채운 **fetch SELECT 연산 수**로 다시 해석했습니다. 배치 적용 여부는 `prepared`와 +> `collectionFetch`를 함께 보고 판단했습니다. + +### 11.3 DB 페이징으로 over-fetch가 사라진다 + +§10은 fetch join 인메모리 페이징이라 응답이 한 페이지인데 부모 N개를 하이드레이트했다(`feedItemLoaded`=N). 배치는 **엔티티만 페이징**이라 DB `LIMIT`이 정상 작동해 페이지 크기만 로드합니다. `loadFeed(0, 20)`, `FeedBatchFetchIT.l5EntityPagingLoadsOnlyThePageNotWholeDataset`: + +| N | returned | feedItemLoaded (§11 배치) | feedItemLoaded (§10 fetch join, 대조) | +|---:|---:|---:|---:| +| 10 | 10 | **10** | 10 | +| 100 | 20 | **20** | 100 | +| 1,000 | 20 | **20** | 1,000 | + +§10에서는 `feedItemLoaded`가 N까지 늘었지만, 배치 적용 뒤에는 페이지 크기인 20에서 +멈췄습니다. 인메모리가 아니라 DB에서 `LIMIT`으로 부모를 먼저 자른 결과입니다. + +### 11.4 EXPLAIN — 페이징엔 Limit 노드, 배치 IN엔 곱셈 없음 (§9·§10 둘 다 해소) + +§10의 스모킹건은 "(a) fetch join 조인 SQL엔 Limit 노드가 없다"였습니다. §11은 정반대 — 엔티티만 페이징하니 Limit 노드가 붙고, 자식은 `IN` 배치라 행을 안 곱한다(seed(100), 원문: [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). + +```text +-- (a) 엔티티만 페이징 — Limit 노드 존재 (§10 (a) fetch join 조인엔 없었다) +Limit (... rows=20 ...) (actual ... rows=20 loops=1) + -> Sort Sort Method: top-N heapsort Memory: 28kB + -> Seq Scan on feed_items fi (actual ... rows=100 loops=1) + +-- (b) 배치 IN — Hash Semi Join, 자식 행만 반환 (카테시안 없음) +Hash Semi Join (... actual ... rows=1509 loops=1) ← 페이지 부모 20개의 highlights (합, 곱 아님) + -> Seq Scan on highlights h (actual ... rows=1961 loops=1) + -> Hash (actual ... rows=20 loops=1) ← 페이지 20개 부모 id +``` + +SQL(a)에는 `Limit` 노드가 있어 DB가 페이지 크기만큼 부모를 골랐습니다. SQL(b)의 semi-join은 +부모와 자식을 곱하지 않고 자식 행만 반환했습니다. 실행계획에서도 §9의 카테시안과 §10의 +인메모리 페이징이 모두 사라졌음을 확인했습니다. warm cache·executor 시간에 관한 한계는 +§6.4와 같습니다. + +### 11.5 배치가 N+1과 페이징을 함께 해결하는 이유 + +fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했고, 이 때문에 DB가 부모 기준 `LIMIT`을 +적용할 수 없었습니다. 배치에서는 부모만 먼저 페이징하고, 자식은 `WHERE fk IN (?,…)`으로 따로 +가져옵니다. `default_batch_fetch_size=B`는 초기화되지 않은 프록시를 최대 B개씩 모아 +`ceil(N/B)`번에 로드합니다. 결과적으로 PreparedStatement는 2,022개에서 23개로 줄었고, +부모 로드 수도 N이 아니라 페이지 크기에 머물렀습니다. 이 결과를 바탕으로 컬렉션 조회에는 fetch +join 대신 배치를 사용하기로 했습니다. + +### 11.6 배치가 못 푸는 것 — 엔티티 과적재 (→ §12/L6) + +배치로 쿼리 수와 페이징 문제는 풀었지만 엔티티는 여전히 통째로 하이드레이트했습니다. +`FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities`에서 seed 1,000의 첫 페이지 20건을 +조회하자 FeedItem·User·Page·Highlight를 합해 **1,569개 엔티티**가 영속 객체로 올라왔습니다 +(원본: [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +화면에는 일부 컬럼만 필요했으므로 다음에는 DTO 프로젝션으로 적재 대상을 줄였습니다. + +--- + +## 12. DTO 프로젝션 — 필요한 값만 조회하기 + +배치를 적용한 뒤에도 화면에 필요하지 않은 엔티티가 1,569개나 만들어졌습니다. 그래서 +`SELECT new (...)`로 필요한 스칼라 값만 조회하는 `loadFeedProjection`을 +추가했습니다. 같은 화면 결과를 만들면서 `getEntityLoadCount()`가 1,569에서 0으로 +줄어드는지 확인했습니다. + +> 기존 `loadFeed`를 바로 교체하면 앞 절의 기준선을 다시 측정할 수 없습니다. 그래서 +> `loadFeedProjection`을 별도 메서드로 추가하고 같은 데이터로 비교했습니다. §6~§11의 테스트도 +> 다시 실행해 기존 결과가 유지되는지 확인했습니다. + +### 12.1 fix는 두 개의 스칼라 프로젝션 — 엔티티 대신 필요 컬럼만 + +프로젝션은 두 부분입니다. **(A)** 부모의 필요 스칼라 컬럼만 페이징으로 프로젝션(컬렉션 조인 없음 → `LIMIT` 정상, 카테시안 없음). **(B)** 그 페이지 부모들의 자식을 필요 스칼라 컬럼만 `IN`으로 프로젝션 → 메모리 그룹핑. + +```java +// FeedQueryAdapter.loadFeedProjection — loadFeed(순진, §6~§11)는 무변경. +// (A) 부모 스칼라 프로젝션 — 조인은 컬럼 접근용(하이드레이션 아님), 페이징은 엔티티에. +select new FeedItemProjectionRow(f.id, u.name, u.username, p.url, p.title, f.firstHighlightedAt) + from FeedItemJpaEntity f join f.user u join f.page p + order by f.firstHighlightedAt desc, f.id asc // + setMaxResults(20) → LIMIT +// (B) 그 20개 부모의 하이라이트를 필요 컬럼만 IN 한 방으로 → feedItemId 로 그룹핑해 FeedSummary 조립 +select new HighlightProjectionRow(h.feedItem.id, h.color, h.text, h.createdAt) + from HighlightJpaEntity h where h.feedItem.id in (:pageIds) +``` + +`FeedSummary`의 마지막 인자는 `List`라 생성자 표현식 한 번으로 만들 수 +없었습니다. 부모와 자식을 각각 스칼라 캐리어로 조회한 뒤 메모리에서 조립했습니다. 이 랩에서는 +회귀 비교를 위해 sibling 메서드로 두었고, 프로덕션 경로에서는 이 프로젝션을 `FeedQueryPort`의 +CQRS-lite 계약으로 노출합니다. + +### 12.2 실측 — 엔티티 로드가 0으로 줄어든다 + +seed 1,000에서 `loadFeedProjection(0, 20)`을 실행하고 §11의 배치 조회와 비교했습니다. +프로젝션은 하이드레이트한 엔티티가 0개였습니다. 원본: +[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv). + +| 지표 | before: §11 배치 | after: §12 프로젝션 | +|---|---:|---:| +| entitiesLoaded (seed 1,000) | 1,569 | **0** | +| prepared (N=1,000) | 23 | **2** | +| collectionFetch (N=1,000) | 10 | **0** | + +하이드레이트한 엔티티는 1,569개에서 0개로 줄었습니다. `SELECT new (...)`는 영속 +엔티티 대신 스칼라 값으로 record를 만듭니다. `join f.user u`도 `u.name` 컬럼을 읽기 위한 +경로일 뿐 User 엔티티를 만들지는 않습니다. 부모 스칼라 쿼리와 자식 IN 쿼리만 남아 prepared는 +2개로 고정되었고, 엔티티 컬렉션을 초기화하지 않아 collectionFetch도 0이었습니다. + +### 12.3 N이 늘어도 쿼리는 2개로 유지된다 + +N을 10, 100, 1,000으로 바꿔 다시 측정해도 prepared는 **항상 2개**였습니다. 기준선과 +배치 결과를 같은 표에 놓고 증가 형태를 비교했습니다. + +| N | §6 순진(1+N) | §11 배치(1+ceil(N/batch)·연관) | §12 프로젝션(상수) | +|---:|---:|---:|---:| +| 10 | 25 | 5 | **2** | +| 100 | 222 | 5 | **2** | +| 1,000 | 2,022 | 23 | **2** | + +기준선의 쿼리 수는 N을 따라 늘었고, 배치는 배치 크기 단위로 늘었습니다. 프로젝션은 부모 스칼라 +쿼리 1개와 자식 IN 쿼리 1개로 유지되었습니다. 페이지 부모가 최대 20개라 자식 IN 쿼리도 한 번만 +실행되었습니다. 엔티티 로드 수도 §11의 1,569개에서 §12의 0개로 줄었습니다. + +### 12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정) + +§11의 D2는 "엔티티 페이징엔 Limit 노드"였습니다. 프로젝션도 (a) 부모 페이징에 `Limit`이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt)). + +```text +-- (a) 부모 스칼라 프로젝션 — Limit 존재하나 width=2088 (users·pages 조인이 행폭에 흘러든다) +Limit (... rows=20 width=2088) (actual ... rows=20 loops=1) + -> Sort Sort Method: top-N heapsort Memory: 27kB + -> Hash Join (fi.page_id = p.id) ← pages 조인 + -> Hash Join (fi.user_id = u.id) ← users 조인 + -> Seq Scan on feed_items fi (width=56) ← feed_items 자체는 좁다 +-- (b) 자식 스칼라 IN — Hash Semi Join, 자식 행만 반환 (곱셈 없음) +Hash Semi Join (... rows=1509 loops=1) ← 페이지 20 부모의 하이라이트 합(§11 배치와 동일) +``` + +> **★ 실측 정정** — 필요한 컬럼만 선택하면 EXPLAIN의 `width`도 줄어들 것으로 예상했지만, +> 부모 프로젝션의 width는 **2088**로 엔티티 조회의 1194보다 컸습니다(원본: +> [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +> `users`와 `pages` 조인의 행폭이 반영되고, PostgreSQL의 `width`가 실제 전송 바이트가 아니라 +> 컬럼 타입의 평균폭 추정치이기 때문입니다. 프로젝션의 효과는 SQL 플랜의 width가 아니라 +> `Statistics.getEntityLoadCount()`에서 확인했습니다. + +### 12.5 프로젝션이 엔티티를 만들지 않는 이유 + +배치는 SQL 왕복 횟수를 줄이고, 프로젝션은 적재할 대상을 줄입니다. `SELECT new +Carrier(f.id, u.name, …)`는 영속 엔티티를 만들지 않으므로 1차 캐시·더티체킹·lazy 프록시도 +생기지 않습니다. 배치 설정 여부와 관계없이 성립하는 동작입니다. 이 결과를 보고 화면 조회에는 +엔티티보다 프로젝션이 맞다고 판단했습니다. 이 효과는 DB 실행계획보다 ORM/JVM 층의 엔티티 로드 +수에서 확인할 수 있었습니다. + +### 12.6 프로젝션이 못 푸는 것 — 페이지당 전량 (→ §13/L14) + +프로젝션은 엔티티 과적재를 없앴지만 자식 IN 쿼리는 페이지 부모의 하이라이트를 **전부** +가져왔습니다. seed 1,000의 첫 페이지 20건에서 자식 행은 1,509개였습니다(원본: +[`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +화면에는 부모당 최신 3개, 최대 60개만 필요했습니다. 단순한 `IN` 쿼리의 `LIMIT`은 부모별로 +적용되지 않으므로 다음 단계에서 Top-N-per-group을 SQL로 구현했습니다. + +--- + +## 13. Top-N-per-group — 부모마다 최신 3개를 가져오는 세 가지 방법 + +프로젝션으로 엔티티는 만들지 않게 되었지만, 부모 20개의 하이라이트 1,509행을 모두 가져오는 +문제는 남았습니다. 화면에는 부모마다 최신 3개만 필요했습니다. 표준 JPQL만으로는 윈도우 함수와 +LATERAL을 표현할 수 없어서 native SQL로 내려갔고, 윈도우 함수·LATERAL·2단계 배치 세 방식을 +같은 데이터로 비교했습니다. 세 방식이 같은 top-3을 만드는지 먼저 확인한 뒤 실행계획과 buffers를 +비교했습니다. + +### 13.1 단순한 `LIMIT`이 부모별로 적용되지 않는 이유 + +처음에는 자식 쿼리 끝에 `LIMIT 3`을 붙였습니다. 하지만 `LIMIT`은 부모별 그룹이 아니라 +**최종 결과 집합 전체**에 적용되어 부모 하나의 하이라이트 3개만 남았습니다. + +```sql +-- ❌ 전체 결과에 LIMIT 3 → 페이지 20개 부모인데 3행만 (가장 최신 하이라이트 부모 1개만 채워짐) +SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id IN () ORDER BY h.created_at DESC LIMIT 3; +``` + +"그룹당 top-N"은 세 가지로 표현할 수 있습니다. 셋 다 같은 페이지-20 부모 서브쿼리(`… ORDER BY first_highlighted_at DESC, id ASC LIMIT 20`)를 입력으로 받습니다. + +```sql +-- ⓐ 윈도우 함수: 부모별 순번 → rn<=3 컷 (컷은 DB, 전송은 60행으로 접힘) +SELECT t.* FROM (SELECT h.*, row_number() OVER (PARTITION BY h.feed_item_id + ORDER BY h.created_at DESC) AS rn FROM highlights h + WHERE h.feed_item_id IN ()) t WHERE t.rn <= 3; +-- ⓑ LATERAL: 부모마다 상관 서브쿼리로 상위 3개만 인덱스 seek (ix_highlights_feed_items_created) +SELECT p.id, top3.* FROM () p CROSS JOIN LATERAL ( + SELECT h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id = p.id ORDER BY h.created_at DESC LIMIT 3) top3; +-- ⓒ 2단계 배치: 자식을 한 방 IN 으로 가져와 앱에서 부모별 3컷 (§11 배치의 연장) +SELECT h.feed_item_id, h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id IN () ORDER BY h.feed_item_id, h.created_at DESC; -- 앱컷 +``` + +`PARTITION BY`(윈도우)·부모별 상관 서브쿼리(LATERAL)·앱 그룹핑(2단계)이 각각 `LIMIT`이 못 하는 "그룹당"을 만듭니다. 무대는 `FeedTopNIT`(신규 IT, native SQL을 `JdbcTemplate`으로) — L14는 `loadFeed`/`loadFeedProjection`을 건드리지 않는 **프로덕션 코드 0**(§10처럼 IT-only). 표준 JPQL엔 윈도우도 LATERAL도 없어(§13.6) native로 내려갑니다. + +### 13.2 실측 — 세 방법의 결과와 단순 LIMIT의 오작동 + +`FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`(seed 1,000, page 20). 원본: [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv). + +| 전략 | 반환 행 | 커버한 부모 | 부모당 최대 | +|---|---:|---:|---:| +| ⓐ 윈도우 | 60 | 20 | 3 | +| ⓑ LATERAL | 60 | 20 | 3 | +| ⓒ 2단계(앱컷 전 전량) | **1,509** | 20 | 전량 | +| ❌ 순진 `LIMIT 3` | 3 | **1** | — | + +윈도우와 LATERAL은 부모 20개에서 각각 3개씩, 모두 60행을 반환했습니다. 2단계 방식은 +애플리케이션에서 자르기 전에 1,509행을 모두 전송했습니다. 순진한 `LIMIT 3`은 전체 결과에서 +3행만 남겨 부모 하나만 채우고 나머지 부모에는 하이라이트를 넣지 못했습니다. + +### 13.3 결과는 같지만 I/O는 달랐다 + +세 SQL은 캐시 상태를 맞추기 위해 같은 테스트 실행에서 `EXPLAIN (ANALYZE, BUFFERS)`로 +측정했습니다. 원문: [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · +[`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · +[`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt). 요약: +[`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv). + +| 전략 | 최상위 노드 (스캔·조인) | 반환 행 | buffers shared hit | exec | +|---|---|---:|---:|---:| +| ⓐ 윈도우 | `WindowAgg` ← `Hash Semi Join`(전량) | 60 | 430 | 1.552 ms | +| ⓑ **LATERAL** | `Nested Loop` ← `Index Scan`+`Limit 3` | 60 | **204** | **0.323 ms** | +| ⓒ 2단계 | `Sort` ← `Hash Semi Join`(전량) | 1,509 | 430 | 1.686 ms | + +```text +-- ⓑ LATERAL — 부모마다 인덱스 range scan, Limit 3 에서 멈춤 (loops=20, 각 rows=3) +Nested Loop (... rows=60) (actual ... rows=60 loops=1) Buffers: shared hit=204 + -> Limit (... rows=20) ← 페이지 20 부모 + -> Limit (... rows=3 ... loops=20) Buffers: shared hit=63 + -> Index Scan using ix_highlights_feed_items_created on highlights h + Index Cond: (feed_item_id = fi.id) ← 부모당 3개만 읽고 멈춘다 +-- ⓐ 윈도우 — 파티션 전량(1509)을 읽어 순번을 매긴 뒤 rn<=3 컷 +WindowAgg Run Condition: (row_number() OVER (?) <= 3) Buffers: shared hit=430 + -> Sort (... rows=1509) -> Hash Semi Join (... rows=1509) ← two-step 과 같은 스캔 +``` + +세 방식은 모두 같은 top-3 60행을 만들었지만 읽는 방식은 달랐습니다. LATERAL은 부모마다 +`ix_highlights_feed_items_created`를 seek해 3개에서 멈췄고 buffers는 204였습니다. 윈도우와 +2단계 방식은 같은 `Hash Semi Join`으로 1,509행을 모두 읽어 buffers가 430이었습니다. 윈도우는 +그 위에서 `WindowAgg`로 60행을 남겼고, 2단계는 1,509행을 애플리케이션에 전달했습니다. +쿼리 개수만으로는 이 차이를 볼 수 없었고 실행계획과 buffers를 함께 봐야 했습니다. + +### 13.4 인덱스 유무 토글 — LATERAL의 빠름은 LATERAL이 아니라 인덱스 seek 덕 + +LATERAL의 buffers가 작은 이유가 복합 인덱스인지 확인했습니다. 같은 쿼리를 두고 인덱스를 +제거한 뒤 다시 만들면서 측정했습니다. 원본: +[`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · +[`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv). + +| variant | 자식 접근 | buffers shared hit | exec | +|---|---|---:|---:| +| 인덱스 있음 | `Index Scan … (Limit 3)` | 168 | 0.336 ms | +| 인덱스 없음 | `Seq Scan`(Rows Removed by Filter 2842/loop) | **4446** | **5.472 ms** | + +복합 인덱스를 제거하자 LATERAL은 부모마다 highlights를 Seq Scan하고 대부분을 필터로 버렸습니다. +buffers는 168에서 4,446으로 약 26배, 실행시간은 0.336 ms에서 5.472 ms로 약 16배 +늘었습니다. LATERAL 문법 자체가 빠른 것이 아니라 `(feed_item_id, created_at DESC)` 인덱스로 +부모별 상위 3개를 바로 찾을 수 있어서 빨랐습니다. 이 인덱스는 새로 추가한 것이 아니라 +`V6__feed.sql`부터 있었습니다. + +### 13.5 그룹 크기가 승자를 가른다 — K 곡선 + +세 방식의 차이가 그룹 크기에 따라 달라지는지도 확인했습니다. seed 1,000에서 top-K를 +3·50·500으로 바꿔 측정했습니다(원본: [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). + +| K | 윈도우 반환 | 윈도우 buffers | LATERAL 반환 | LATERAL buffers | +|---:|---:|---:|---:|---:| +| 3 | 60 | 162 | 60 | 114 | +| 50 | 695 | 216 | 695 | 155 | +| 500 | 1,509 | 269 | 1,509 | 171 | + +반환 행수는 K에 따라 60 → 695 → 1,509로 늘었습니다. LATERAL의 buffers는 모든 K에서 +윈도우보다 작았지만, 차이는 K가 작을수록 컸습니다. 부모의 하이라이트 500개 중 K개만 인덱스로 +읽기 때문입니다. K가 그룹 크기인 500에 가까워지면 LATERAL도 대부분을 읽습니다. 현재 피드는 +그룹이 크고 K가 3으로 작아서 LATERAL을 선택했고, K가 그룹 크기에 가까운 조회라면 더 단순한 +윈도우 함수를 선택할 수 있습니다. + +### 13.6 세 방법이 부모별 top-3을 만드는 방식 + +윈도우 함수는 `PARTITION BY feed_item_id`로 부모마다 순번을 매기고 `rn<=3`을 남깁니다. +DB에서 자르지만 순번을 만들기 위해 파티션 전체를 읽습니다. LATERAL은 부모마다 상관 서브쿼리를 +실행하고 복합 인덱스에서 3개를 읽으면 멈춥니다. 2단계 방식은 `IN`으로 자식을 모두 가져온 뒤 +애플리케이션에서 그룹핑합니다. 표준 JPQL에는 윈도우 함수와 LATERAL이 없고, Hibernate 6+ HQL도 +LATERAL은 지원하지 않습니다. 그래서 작은 K와 큰 그룹이라는 현재 조건에는 native LATERAL을 +선택했습니다. + +### 13.7 다음에 해결할 문제 — 부모 피드 페이징 + +아이템별 top-3은 60행으로 줄였지만 부모 피드 페이징은 여전히 `OFFSET`이었습니다. +`OFFSET 900 LIMIT 20`을 측정하자 앞의 900행도 읽은 뒤 버렸습니다. 페이지가 깊어질수록 +비용이 늘어나는 문제를 해결하기 위해 다음 단계에서는 `(first_highlighted_at, id)` 커서를 +사용하는 keyset 페이징으로 바꿨습니다. + +--- + +## 14. keyset vs OFFSET — 깊은 페이지의 조회량 비교 + +아이템별 top-3을 해결한 뒤 부모 피드의 페이징을 확인했습니다. 이 쿼리는 여전히 +`OFFSET :n LIMIT 20`을 사용하고 있어 페이지가 깊어질수록 앞의 행을 읽고 버렸습니다. 무한 +스크롤에서는 이 비용이 계속 늘어납니다. 그래서 이전 페이지의 마지막 +`(first_highlighted_at, id)`를 커서로 넘기는 keyset 페이징으로 바꾸고, 페이지 깊이에 따른 +스캔 행수를 비교했습니다. + +### 14.1 왜 OFFSET은 깊은 페이지에서 죽나 — keyset의 shape + +`OFFSET`은 정렬 순서에서 앞 `offset`행을 **생성한 뒤 버립니다**. 정렬키 인덱스가 있어도 그 튜플들을 훑어야 하고, 깊으면 아예 `Seq Scan`+`Sort`로 전량을 훑습니다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 **그 지점 이후만** 읽습니다. + +```sql +-- ❌ 순진 OFFSET: 깊은 페이지에서 앞 n행을 읽어 버린다 (over-scan = offset+20) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + ORDER BY fi.first_highlighted_at DESC, fi.id DESC OFFSET 1980 LIMIT 20; +-- ✅ keyset/seek: 커서로 인덱스에서 그 지점 이후만 (깊이 무관 상수) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + WHERE (fi.first_highlighted_at, fi.id) < (:lastTs, :lastId) -- 이전 페이지 마지막 행의 정렬키 + ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20; +-- 전제 인덱스: feed_items (first_highlighted_at DESC, id DESC) ← 정렬키 전용 +``` + +측정은 `FeedKeysetIT`의 native SQL로 격리했고 정렬키 인덱스는 테스트 안에서 CREATE/DROP +했습니다. V6의 `ix_feed_items_visibility_sort`는 선두 컬럼이 `visibility`라 가시성 필터가 +없는 keyset 쿼리에는 맞지 않았습니다. 따라서 `(first_highlighted_at DESC, id DESC)` 전용 +인덱스를 사용했습니다. 프로덕션에 반영할 때는 V8 마이그레이션으로 추가할 수 있습니다. + +### 14.2 실측 — OFFSET은 깊이에 비례하고 keyset은 일정하다 + +seed 2,000에서 두 방식에 같은 정렬키 인덱스를 사용했습니다. "훑은 행"은 `Limit` 하위의 +actual rows로 계산했습니다(원본: [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). + +| 페이지 (offset) | OFFSET 훑은 행 | keyset 훑은 행 | +|---:|---:|---:| +| 1 (0) | 20 | 20 | +| 50 (980) | 1,000 | 20 | +| 100 (1980) | **2,000** | **20** | + +OFFSET이 훑은 행은 offset+20으로 20 → 1,000 → 2,000까지 늘었고, keyset은 계속 +20행이었습니다. 100번째 페이지에서 OFFSET은 결과 20행을 만들기 위해 2,000행을 읽었지만 +keyset은 20행만 읽었습니다. 무한 스크롤의 뒤쪽 페이지가 느려지는 이유를 이 차이로 확인했습니다. + +### 14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제 + +`FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`(깊은 페이지 offset 1980, 한 실행). 원문: [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt). 요약: [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv). + +| 변형 | 플랜 | 훑은 행 | buffers | exec | +|---|---|---:|---:|---:| +| OFFSET | `Limit`←`Sort`←`Seq Scan`(2,000) | 2,000 | 141 | 0.996 ms | +| **keyset + 인덱스** | `Limit`←`Index Only Scan` | **20** | **1** | **0.076 ms** | +| keyset − 인덱스 | `Limit`←`Sort`←`Seq Scan`(filter) | 20 | 141 | 0.373 ms | + +```text +-- keyset + 인덱스: 커서 이후 20행만 seek (Index Only Scan, 순서 인덱스 보장 → Sort 없음) +Limit (rows=20) Buffers: shared hit=1 read=2 + -> Index Only Scan using ix_feed_items_keyset on feed_items fi (actual rows=20) + Index Cond: (ROW(first_highlighted_at, id) < ROW('...'::timestamptz, '...'::uuid)) + Heap Fetches: 20 +-- keyset − 인덱스: 결과는 20이지만 정렬키 인덱스가 없어 Seq Scan 으로 전량을 훑는다 + -> Seq Scan on feed_items fi Rows Removed by Filter: 1980 Buffers: shared hit=141 +``` + +깊은 페이지에서 OFFSET은 `Seq Scan`+`Sort`로 2,000행을 훑고 20행만 남겼습니다 +(buffers 141). keyset은 정렬키 인덱스가 있을 때 `Index Only Scan`으로 커서 이후 20행만 +읽었고 buffers는 1이었습니다. 인덱스를 제거하자 keyset도 `Seq Scan`으로 2,000행을 +확인했습니다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 +있어야 했습니다. + +### 14.4 keyset의 조회량이 일정한 이유 + +OFFSET은 건너뛸 행까지 읽지만, keyset은 커서 `(first_highlighted_at, id)` 이후를 인덱스에서 +range scan합니다. 같은 `first_highlighted_at`을 가진 행도 안정적으로 넘기려면 tie-break인 +`id`까지 커서에 포함해야 합니다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있습니다. +`FeedKeysetIT.l15KeysetWalkMatchesOffsetPages`로 keyset의 두 번째 페이지가 OFFSET의 두 번째 +페이지와 같은 20행, 같은 순서인지 확인했습니다. 정렬키·커서·인덱스의 컬럼과 방향이 모두 +일치해야 합니다. + +### 14.5 keyset이 못 푸는 것 — 가시성 OR (→ §15/L16) + +keyset은 페이지 깊이를 풀었지만, 실서비스 피드는 **가시성**으로 필터해야 한다(`public` + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(`FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex`), 플래너는 정렬키 인덱스 `ix_feed_items_keyset`를 **더 이상 쓰지 못하고** 가시성 3분기를 각각 인덱스로 스캔한 `BitmapOr`로 떨어집니다. 원문: [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt). + +```text +-- 가시성 OR 을 얹으면: 정렬키 Index Only Scan 이 사라지고 BitmapOr + 별도 Sort 로 +Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장! + -> Bitmap Heap Scan on feed_items + -> BitmapOr + -> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='PUBLIC' AND ROW(...) < cursor) + -> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='MENTIONED' AND ...) + -> BitmapAnd (visibility='PRIVATE' ∩ user_id = me) + SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS) +``` + +가시성 조건을 추가하자 사라졌던 `Sort` 노드가 다시 나타났습니다. `OR`+`EXISTS`는 각 분기를 +bitmap으로 합치면서 인덱스의 정렬 순서를 잃었습니다. 그래서 다음 단계에서는 가시성 분기를 +`UNION ALL`로 나누는 방식과 뷰어별 결과를 미리 계산하는 방식을 비교했습니다. + +--- + +## 15. 가시성 조건 — 단일 OR, UNION, 사전계산 비교 + +keyset으로 페이지 깊이 문제를 풀었지만, `public + 내가 멘션된 것 + 내 비공개`라는 가시성 +조건을 합치자 `BitmapOr`+`Sort`가 다시 나타났습니다. 단일 OR을 그대로 쓰는 방식, 세 분기를 +UNION으로 나누는 방식, 뷰어별 가시성을 미리 계산하는 방식을 같은 결과 집합으로 비교했습니다. + +### 15.1 단일 OR이 정렬 순서를 유지하지 못하는 이유 + +하나의 인덱스는 하나의 선두 컬럼 순서만 줍니다. 가시성 3분기는 각각 다른 조건(visibility 값·user_id·mentions 조인)이라, 하나의 쿼리로 묶으면 플래너는 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 합니다. + +```sql +-- ❌ 단일 OR: 3분기를 하나로 → BitmapOr + 전체 top-N Sort + 멘션 SubPlan (순서 인덱스 못 탐) +SELECT fi.id, fi.first_highlighted_at FROM feed_items fi + WHERE (fi.visibility='PUBLIC' + OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m + WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me)) + OR (fi.visibility='PRIVATE' AND fi.user_id=:me)) + ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20; +-- ✅ UNION 분해: 3분기를 각각 정렬 보장 인덱스 쿼리로 → UNION ALL → Merge Append +-- ✅ 사전계산: 가시성을 뷰어별 feed_visible 로 미리 펼쳐 → 단일 index range scan (= CQRS 읽기 모델) +``` + +측정은 `FeedVisibilityIT`에 격리했습니다. 신규 인덱스(`ix_mentions_user`, private partial)와 +`feed_visible` 테이블도 테스트 안에서 생성하고 제거했습니다. V7의 `feed_item_mentions` +인덱스는 `(feed_item_id, …)` 순서라 "나를 멘션한 아이템"을 찾는 쿼리에 맞지 않았습니다. +따라서 `(mentioned_user_id, feed_item_id)` 인덱스를 추가해 비교했습니다. + +### 15.2 실측 — 결과는 같고 실행계획은 다르다 + +seed 2,000에서 user008이 볼 수 있는 피드를 조회했습니다. 세 방식이 같은 20개 feed_item을 +반환하는지는 `l16ThreeApproachesReturnSameVisibleSet`으로 먼저 확인했습니다. 그다음 +가시성 조건을 처리하는 실행계획을 비교했습니다(원본: +[`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). + +| 안 | 최상위/스캔 | Sort | 멘션 | 훑는 후보 | buffers | +|---|---|---|---|---:|---:| +| ⓐ 단일 OR | `BitmapOr`+`Bitmap Heap Scan`+top-N `Sort` | 재정렬 | hashed SubPlan | **1,500** | 122 | +| ⓑ UNION 분해 | **`Merge Append`**(분기별 인덱스) | 분기별 병합 | `Hash Join` | ≤60 | 200 | +| ⓒ **사전계산** | **`Index Only Scan`**(feed_visible) | **없음** | 사전 반영 | 20 | **1** | + +**단일 OR**은 3분기를 `BitmapOr`로 합쳐 후보 **1,500**을 훑고 top-N `Sort`로 20을 낸다 — 순서를 인덱스로 못 내 재정렬한다(멘션 EXISTS는 hashed SubPlan). **UNION 분해**는 3분기를 각각 정렬 스트림으로 만들어 `Merge Append`로 병합(전체 재정렬 없음), EXISTS가 `Hash Join`으로 바뀐다(public은 고선택도라 bitmap+top-N, private는 partial 인덱스, mentioned는 조인 — **각 분기가 자기 최적 플랜**). **사전계산**은 `feed_visible` 커버링 인덱스의 단일 `Index Only Scan` — OR도 조인도 Sort도 없이 20행만(buffers **1**). + +### 15.3 세 플랜을 나란히 + +```text +-- ⓐ 단일 OR: BitmapOr 로 후보 1500 → top-N Sort (순서 손실) buffers=122 +Limit -> Sort (top-N) -> Bitmap Heap Scan on feed_items (rows=1500, Rows Removed by Filter: 200) + -> BitmapOr [visibility='PUBLIC' | 'MENTIONED' | ix_feed_items_private user_id=:me] + Filter: ... (visibility='MENTIONED' AND hashed SubPlan) ... +-- ⓑ UNION 분해: 분기별 정렬 스트림을 Merge Append (전체 Sort 없음) buffers=200 +Limit -> Merge Append + -> [public] Bitmap Heap Scan + top-N Sort + -> [mentioned] Hash Join (feed_items ⋈ ix_mentions_user) + -> [private] Index Only Scan using ix_feed_items_private + Incremental Sort +-- ⓒ 사전계산: 단일 커버링 인덱스, Sort 없음 buffers=1 +Limit -> Index Only Scan using ix_feed_visible (Index Cond: viewer_id=:me) Heap Fetches: 20 +``` + +원문: [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) · [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt). + +### 15.4 UNION과 사전계산의 차이 + +> **★ 실측 정정** — 처음에는 단일 OR이 Seq Scan을 하고, UNION이 buffers를 줄일 것으로 +> 예상했습니다. 실제 단일 OR은 `BitmapOr`+top-N `Sort`+hashed SubPlan을 사용했고, UNION의 +> buffers는 200으로 단일 OR의 122보다 컸습니다. 각 분기가 따로 스캔하기 때문입니다. buffers가 +> 1까지 줄어든 방식은 UNION이 아니라 사전계산이었습니다. + +단일 OR은 세 분기를 bitmap으로 합치면서 정렬 순서를 잃습니다. UNION은 분기를 독립시켜 상관 +술어를 `Hash Join`으로, 전체 병합을 `Merge Append`로 바꿨지만 요청할 때마다 세 분기를 +스캔했습니다. 사전계산은 뷰어별 `feed_visible`을 미리 만들어 조회를 단일 `Index Only Scan`으로 +바꿨습니다. 대신 피드·멘션·가시성이 바뀔 때 읽기 모델을 갱신해야 하고, 뷰어 수만큼 저장 공간도 +늘어납니다. + +### 15.5 사전계산을 프로덕션에 적용할 때 필요한 것 + +`feed_visible`은 실험용 테이블이지만 프로덕션에서 상시 유지하려면 CQRS 읽기 모델이 됩니다. +쓰기 모델의 변경을 뷰어별 투영에 반영하고, 조회는 그 투영만 읽습니다. 여기까지 진행하면서 문제의 +범위가 N+1을 줄이는 SQL에서 화면에 맞는 읽기 모델을 설계하는 일로 넓어졌습니다. + +--- + +## 16. Top-N·keyset·가시성을 한 쿼리로 통합하기 + +Top-N·keyset·가시성을 각각 검증한 뒤 세 조건을 한 쿼리에 합쳤습니다. 실제 화면에서는 보이는 +아이템만 골라 깊은 페이지를 넘기면서 각 아이템의 최신 하이라이트 3개를 함께 반환해야 합니다. +`FeedCrownIT`에서 세 기법이 서로의 인덱스 사용을 방해하지 않는지 확인했습니다. + +### 16.1 통합 쿼리의 shape — 부모선택 × LATERAL + +통합 쿼리는 가시성 필터와 keyset으로 부모 20개를 고른 뒤, 각 부모에 LATERAL top-3을 +적용합니다. 작은 K에서 유리했던 LATERAL을 자식 조회에 사용하고 keyset·가시성은 부모 선택 +안에서 처리했습니다. + +```sql +SELECT p.pid, top3.color, top3.text, top3.created_at + FROM ( <부모선택: 가시성 + keyset 로 고른 부모 20> ) p + CROSS JOIN LATERAL ( + SELECT h.color, h.text, h.created_at FROM highlights h + WHERE h.feed_item_id = p.pid ORDER BY h.created_at DESC LIMIT 3 ) top3; +``` + +부모 선택 부분은 단일 OR, UNION 분해, 사전계산(`feed_visible`) 세 방식으로 만들었습니다. +`crownUnifiedReturnsSameShapeAcrossParentPaths`에서 세 방식이 같은 부모 20개를 반환하는지 +확인한 뒤 실행계획만 비교했습니다. + +### 16.2 실측 — 세 기법을 합친 실행계획 + +`FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN`(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹칩니다. 원본: [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt). + +```text +Nested Loop (rows=60) ← LATERAL (상관 조인) + -> Limit -> Index Only Scan using ix_feed_visible (rows=20) ← 가시성 + keyset (사전계산) + Index Cond: viewer_id = :me Heap Fetches: 20 + -> Limit -> Index Scan using ix_highlights_feed_items_created (loops=20) ← Top-N (부모당 top-3 seek) +-- Sort 노드 없음. buffers 65. +``` + +- **가시성+keyset** = `feed_visible` 커버링 인덱스의 단일 `Index Only Scan`(가시성은 사전 반영, keyset 은 인덱스 순서 상위 20). +- **Top-N** = 부모 20 마다 `ix_highlights_feed_items_created` 로 top-3 index seek(`Nested Loop` = LATERAL). +- **Sort 노드 없음** — 두 순서(부모 keyset·자식 created_at)가 모두 인덱스에서 나옵니다. 세 기법이 깨끗하게 합쳐집니다. + +### 16.3 간섭 시험 — 사전계산 위에선 겹치고, 단일 OR 위에선 매 페이지 재해소 + +`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`(가장 깊은 페이지, 커서 = visible−20). user008에게 보이는 `1,500` 중 마지막 페이지에서, 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈립니다. 원본: [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt). + +| 부모선택 | 최상위 | 훑는 행 | feed_visible | 부모 buffers | +|---|---|---:|---|---:| +| 사전계산 | `Nested Loop` | **19** | ✅ | 3 | +| 단일 OR | `Nested Loop` | **200** | ❌(구조적) | 31 | + +> **★ 실측 정정** — 처음에는 사전계산 keyset에는 Sort가 없고 단일 OR에만 Sort가 생길 +> 것으로 예상했습니다. 가장 깊은 커서에서는 두 방식 모두 남은 19행을 작은 quicksort로 +> 정렬했습니다. 차이는 Sort 유무가 아니라 페이지에 도달하기까지 읽은 행수였습니다. 사전계산은 +> `ix_feed_visible`의 range에서 19행만 읽었고, 단일 OR은 가시성 세 분기와 멘션 조건을 다시 +> 계산하며 200행을 materialize했습니다. + +### 16.4 조회 조건별 선택 기준 + +세 기법을 한 쿼리에 적용한 결과를 다음처럼 정리했습니다. + +| 축 | 문제 | 해법 | 언제 | 근거 | +|---|---|---|---|---| +| Top-N-per-group | 아이템당 최신 top-3 | **LATERAL**(작은 K) / 윈도우(큰 K) | 항상 LATERAL, K가 그룹 크기에 근접하면 윈도우로 수렴 | §13 | +| 페이징 | 깊은 페이지 | **keyset**(커서+정렬키 인덱스) | 항상. OFFSET 은 깊이에 비례 붕괴 | §14 | +| 가시성 | 3분기 술어 | **UNION 분해** / **사전계산**(=CQRS) | 보통 UNION, 고트래픽 읽기 극단이면 사전계산 | §15 | +| 통합 | 셋을 한 쿼리로 | 부모선택(가시성+keyset) × LATERAL(Top-N) | 부모선택 사전계산/UNION 이면 매 페이지 재해소 없음 | §16 | + +통합 쿼리에서 차이를 만든 부분은 부모 선택이었습니다. 사전계산이나 UNION 분해를 사용하면 +keyset과 Top-N을 그대로 합칠 수 있지만, 단일 OR은 페이지를 넘길 때마다 가시성 조건을 다시 +계산했습니다. + +### 16.5 사전계산과 CQRS 읽기 모델의 경계 + +세 조건을 가장 단순한 실행계획으로 합친 부모 선택은 사전계산(`feed_visible`)이었습니다. 하지만 +이를 상시 유지하려면 쓰기 모델의 변경을 뷰어별 투영에 동기화해야 합니다. 현재 범위에서 이 비용을 +바로 받아들일지는 별도 판단이 필요했습니다. + +--- + +## 17. CQRS-lite 읽기 모델 — 프로덕션 읽기 경로로 (주제 2 브릿지) + +사전계산(`feed_visible`)의 실행계획이 가장 단순했지만, 이를 상시 유지되는 별도 저장소로 만들면 +쓰기 모델의 이벤트로 읽기 저장소를 갱신하는 풀 CQRS가 필요합니다. 제가 정한 application-core +계약에서는 별도 물리 읽기 저장소를 에스컬레이션 대상으로 남겨 두었습니다. 이번 범위에서는 그 +계약을 유지하고, 같은 저장소 위에 읽기 전용 포트·DTO·쿼리를 분리하는 **CQRS-lite**를 +구현했습니다. + +### 17.1 CQRS-lite vs 풀 CQRS — 모델이냐, 저장소냐 + +| | CQRS-lite (이번 구현) | 풀 CQRS (에스컬레이션, 주제 2) | +|---|---|---| +| 분리 대상 | 읽기 **모델**(전용 포트·DTO·읽기최적 쿼리) | 읽기 **저장소**(별도 물리 테이블) | +| 저장소 | 쓰기와 **같은** 저장소 | **별도** — `feed_visible` 유지 | +| 동기화 | 없음(요청 시 읽기최적 쿼리) | 쓰기→읽기(도메인 이벤트/아웃박스) | +| 계약 | **지원**(query-bypass Projection) | **에스컬레이션 전용** | + +핵심은 N+1을 "SQL로 푸느냐"에서 "**읽기 모델을 어떻게 설계하느냐**"로 넘어가는 것입니다. lite는 쓰기 애그리거트(`FeedItem`)와 분리된 읽기 경로를 같은 저장소 위에 세우고, full은 저장소까지 분리해 동기화 비용을 집니다. + +### 17.2 무엇을 만들었나 + 실측 + +읽기 경로는 `FeedReadModelQueryPort` → `GetFeedReadModelUseCase` → `FeedReadModelQueryAdapter` +순서로 만들었습니다. 쿼리는 §12의 프로젝션과 §13의 window top-3을 합쳐 기존 `loadFeed`를 +건드리지 않고 화면에 필요한 형태를 바로 반환합니다. + +- 부모 페이지: JPQL `SELECT new`(엔티티 하이드레이션 0). +- 자식 top-3: 네이티브 `row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3`. + +`FeedReadModelUseCaseIT`에서 N=10과 100을 측정한 결과 엔티티 로드는 **0**, 발행 쿼리는 +N과 관계없이 **2개**, `topHighlights`는 부모당 최대 3개였습니다. 아키텍처 게이트인 ArchUnit +`query_ports_do_not_leak…`, 의존 방향 검사, `./gradlew check`도 통과했습니다. window 쿼리는 +Hibernate `Statistics`가 실제 발행 횟수를 셀 수 있도록 `JdbcTemplate` 대신 Hibernate +`Session`으로 실행했습니다. + +### 17.3 주제 2로 + +여기서 N+1 주제가 아키텍처 주제로 넘어갑니다. lite가 읽기 모델을 **모델 수준**으로 분리했다면, 고트래픽 읽기·가시성 사전계산(§16의 `feed_visible`)이 실제로 필요해지는 순간 그것을 **저장소 수준**으로 올리는 게 풀 CQRS이고, 그때 계약·가드레일을 의도적으로 개정합니다. "N+1은 쓰기 모델로 읽기를 하려는 신호"라는 일반화가 여기서 헥사고날·CQRS 설계로 완결됩니다. + +--- + +## 18. 다음 단계 + +처음 만든 엔티티 조회에서 N+1을 확인한 뒤, 배치·프로젝션·Top-N·keyset·가시성 순서로 +조회 구조를 바꿨습니다. 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, +하이라이트 전송은 1,509행에서 최대 60행으로 줄었습니다. 깊은 페이지는 2,000행 대신 20행을 +읽었고, 사전계산한 가시성 조회는 후보 1,500개 대신 20개에 접근했습니다. 이 결과를 같은 저장소 +위 CQRS-lite 읽기 경로에 반영했습니다. + +- **풀 CQRS(주제 2, 에스컬레이션)**: 고트래픽 읽기에서 `feed_visible` 사전계산이 실제로 + 필요해지면 별도 물리 읽기 저장소와 쓰기→읽기 동기화(도메인 이벤트/아웃박스)를 추가합니다. + 이 변경은 현재 계약의 범위를 넘으므로 계약과 가드레일을 함께 개정해야 합니다. +- **운영·다른 패러다임**: OSIV·커넥션 풀·Little's Law, 쓰기 N+1, 리액티브, 탐지기, + NoSQL 임베드는 이번 조회 문제를 해결한 뒤 별도 주제로 검증할 수 있습니다. + +작업을 마치고 보니 처음의 문제는 N+1 하나를 없애는 데서 끝나지 않았습니다. 화면에 필요한 +읽기 모델을 어떤 SQL·인덱스·모델로 만들 것인지까지 정해야 했습니다. + +--- + +## 부록. 측정 재현과 provenance, 함정 + +### A. 재현 + +```bash +cd src +./gradlew :app-bootstrap:test --tests '*FeedPersistenceIT*' # Docker 필요(Testcontainers) +``` + +- 곡선(N1): `l1CollectionNPlusOneGrowsLinearlyWithN` (N=10/100/1000), `collectionFetches == N` 확인. +- 실행계획(N1): `l1ExplainRepeatedHighlightChildQuery`, 반복되는 하이라이트 조회의 Index Scan 확인(→ [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt)). +- 곡선(N2): `l2ToOneEagerHiddenNPlusOneCurve` (N=10/100/1000), `pageFetch == N`(선형)·`userFetch ≤ 20`(평탄)·`entityFetch == pageFetch + userFetch` 확인. +- 접근 0 증명(N2): `l2EagerToOneFiresEvenWithZeroFieldAccess`, 접근 0인데 `pageFetch == 100`·`collectionFetch == 0`(EAGER는 나가고 LAZY는 안 나감). +- 실행계획(N2): `l2ExplainRepeatedPageToOneQuery`, pages·users의 pk Index Scan 확인(→ [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt)). +- 다중 컬렉션 실패(§9): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`, 두 bag 동시 fetch join이 `MultipleBagFetchException`(`IllegalArgumentException`으로 래핑)을 던지는 것 확인. +- 카테시안(§9): `l3SingleCollectionFetchJoinExplodesTransferredRows` (N=10/100/1000), 리스트 크기 = N(Hibernate 6+ dedup)인데 조인 카디널리티 = Σ highlights로 폭발하는 것 확인(→ [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv)). +- 실행계획(§9): `l3ExplainCollectionJoinRowMultiplication`, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt)). +- 인메모리 페이징(§10): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory` (N=10/100/1000), `returned == min(20, N)`인데 `feedItemLoaded == N`(전체 로드)임을 확인(→ [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv)). +- HHH000104 경고(§10): `l4EmitsHhh000104InMemoryPagingWarning`, `HHH90003004: ... collection fetch; applying in memory` WARN을 ListAppender로 캡처(코드 번호가 아니라 문구로 매칭). +- EXPLAIN 대조(§10): `l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes`, (a) 조인 SQL엔 Limit 노드 없음 / (b) 엔티티 페이징엔 있음 확인(→ [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt)). +- 배치 해결(§11): `FeedBatchFetchIT`(신규, 격리 클래스 `default_batch_fetch_size=100`) `l5BatchFetchCollapsesQueryCount` (N=10/100/1000), `prepared < N`(순진 `1+N`에서 붕괴)·`collectionFetch == ceil(N/batch)` 확인(→ [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv)). +- 페이징 정상(§11): `l5EntityPagingLoadsOnlyThePageNotWholeDataset`, `feedItemLoaded == min(20, N)`(§10 over-fetch 소멸). EXPLAIN `l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt)). +- 잔여 비용(§11): `l5ProbeBatchStillHydratesFullEntities`, 페이지 20건인데 `entitiesLoaded == 1,569`(엔티티 과적재 → L6)(→ [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv)). +- 프로젝션 해결(§12): `FeedProjectionIT`(신규, 격리 클래스, 배치 설정 없음) `l6ProjectionHydratesZeroEntities` (N=10/100/1000), `entitiesLoaded == 0`(§11의 1,569 소멸)·`prepared == 2`(N 무관 상수)·`collectionFetch == 0` 확인. 형태 동치 `l6ProjectionReturnsSameShapeAsNaiveLoadFeed`(프로젝션 vs 순진 loadFeed 같은 결과)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- EXPLAIN·width 정정(§12): `l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) · [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv)). +- 잔여 비용(§12): `l6ProbeProjectionStillFetchesAllHighlightsNotTopN`, 페이지 20건인데 자식 행 `1,509`(부모당 전량, top-3 아님 → L14)(→ [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv)). +- 정확성·전송(§13): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL) `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14TransferAcrossStrategies`, 윈도우·LATERAL은 부모당 3개(반환 60·부모 20), 2단계는 앱컷 전 전량 `1,509`, 순진 `LIMIT 3`은 전체 3행(부모 1개만 = 오작동) 확인(→ [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv)). +- 플랜 대조(§13): `l14ExplainThreeWayPlanCompareIsTheCrownJewel`, 세 방법 `EXPLAIN (ANALYZE, BUFFERS)` — LATERAL은 `Index Scan`(buffers 204)·윈도우/2단계는 같은 `Hash Semi Join`(buffers 430, 전량 1,509) 확인(→ [`l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) · [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv)). +- 인덱스 토글(§13): `l14LateralDependsOnCompositeIndex`, 같은 LATERAL을 `ix_highlights_feed_items_created` DROP 후 측정→`finally` 복구 — 인덱스 없으면 `Seq Scan`(Rows Removed by Filter 2842/loop)으로 buffers 168→4446(약 26배) 확인(→ [`l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) · [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv)). +- 그룹 크기 곡선(§13): `l14GroupSizeCurveWindowVsLateral`(K=3/50/500), 반환 60/695/1,509이고 LATERAL buffers가 모든 K에서 윈도우보다 작음(작은 K일수록 격차↑) 확인(→ [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv)). +- 잔여 비용(§13): `l14ProbeParentPagingStillUsesOffsetNotKeyset`, 부모 페이징이 아직 `OFFSET 900`이라 앞 900행 scan-then-discard(→ L15 keyset). +- 깊이 곡선(§14): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL) `l15DeepPageOffsetOverScansButKeysetStaysFlat`(offset 0/980/1980), OFFSET 훑은 행 = offset+20(20/`1,000`/`2,000`)인데 keyset은 20으로 일정함(page 100에서 100× over-scan) 확인(→ [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv)). +- EXPLAIN·인덱스 유무(§14): `l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`, OFFSET `Seq Scan`+`Sort`(2,000, buffers 141) vs keyset `Index Only Scan`(20, buffers 1); 인덱스 없으면 keyset도 `Seq Scan`(buffers 141) 확인(→ [`l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) · [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv)). +- 정확성(§14): `l15KeysetWalkMatchesOffsetPages`, keyset 커서로 넘긴 page 2 == OFFSET page 2(같은 20 id·같은 순서). +- 가시성 probe(§14 → L16): `l15ProbeVisibilityOrBreaksKeysetIndex`, keyset에 가시성 `OR`+`EXISTS`를 얹으면 정렬키 인덱스 미사용·`BitmapOr`+`Sort` 재등장(순서 seek 이점 소멸) 확인(→ [`l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt)). +- 정확성(§15): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `l16ThreeApproachesReturnSameVisibleSet`, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv)). +- 3안 플랜 대조(§15): `l16ExplainThreeWayPlanCompare`, 단일 OR(`BitmapOr`+top-N `Sort`+hashed SubPlan, 후보 `1,500`, buffers 122) vs UNION(`Merge Append`+`Hash Join`, buffers 200) vs 사전계산(`Index Only Scan` on feed_visible, Sort 없음, buffers 1) 확인(→ [`l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 분기별 인덱스(§15): `l16LowSelectivityBranchesRideTheirIndex`, mentioned 분기=`ix_mentions_user` 조인·private 분기=`ix_feed_items_private` partial의 `Index Only Scan` 확인(→ [`l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt)). +- 사전계산=CQRS(§15 → L12): `l16PrecomputeIsSingleIndexScanNoOrNoSort`, `feed_visible` 단일 `Index Only Scan`·Sort 없음·buffers 1 확인(→ [`l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt)). +- 통합 정확성·shape(§16): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글) `crownUnifiedReturnsSameShapeAcrossParentPaths`, 세 부모선택(단일 OR/UNION 분해/사전계산)이 같은 20 부모(unionEq·precomputeEq 참)·통합 결과 부모 20·총 60행·부모당 top-3 확인(→ [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv)). +- 한 플랜 세 기법(§16): `crownUnifiedPlanStacksVisibilityKeysetAndTopN`, 사전계산 부모선택 통합 쿼리가 `Index Only Scan`(ix_feed_visible) + `Nested Loop` LATERAL `Index Scan`(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ [`crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt)). +- 간섭 시험(§16): `crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`, 가장 깊은 페이지(보이는 `1,500` 중 마지막)에서 사전계산 부모선택은 `ix_feed_visible` 인덱스 range 로 19 행만, 단일 OR 부모선택은 feed_visible 미사용·`BitmapOr`+멘션 hashed SubPlan 으로 200 행 훑음(★ 실측정정: 깊은 커서에선 둘 다 남은 19 행 작은 Sort) 확인(→ [`crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt)). +- CQRS-lite 읽기 모델(§17): **프로덕션 경로**(시리즈 첫 프로덕션 코드, IT-only 아님) `GetFeedReadModelUseCase` → `FeedReadModelQueryPort` → `FeedReadModelQueryAdapter`(신규). `FeedReadModelUseCaseIT`(seed N∈{10, 100})가 유스케이스 경로에서 엔티티 로드 0·발행 쿼리 상수 2(N 무관)·부모당 top-3(§12 프로젝션 + §13 window 결합, §12 잔여 `1,509` → ≤60 해소) 반환 확인. ArchUnit `query_ports_do_not_leak…`·의존 방향·`./gradlew check` GREEN. + +> 개별 테스트만 돌릴 때는 Gradle 와일드카드가 `*`임에 주의(`...`은 매칭 0). 예) `--tests '*FeedPersistenceIT.l2*'`. 초록불을 다시 돌리려면 `--rerun-tasks`(안 그러면 UP-TO-DATE로 건너뜀). 콘솔 측정 라인(`>>> LAB …`)은 `build/lab-results/feed-nplus1.md`에도 표로 적재됩니다. + +원시 데이터 자산: + +- [`evidence/metrics/l1-query-growth.csv`](./evidence/metrics/l1-query-growth.csv) — N, 초기화 컬렉션, 총 PreparedStatement, ToOne 몫. +- [`evidence/metrics/l1-skew-distribution.csv`](./evidence/metrics/l1-skew-distribution.csv) — 순위별 하이라이트 수. +- [`evidence/metrics/l2-toone-split.csv`](./evidence/metrics/l2-toone-split.csv) — N, Page·User·entity fetch, 초기화 컬렉션, 총 PreparedStatement(N2 직접 측정). +- [`evidence/explain/highlights-child-plan-A.txt`](./evidence/explain/highlights-child-plan-A.txt) — N1 Plan A EXPLAIN 원문. +- [`evidence/explain/toone-pages-plan.txt`](./evidence/explain/toone-pages-plan.txt) · [`evidence/explain/toone-users-plan.txt`](./evidence/explain/toone-users-plan.txt) — N2 반복 ToOne 부모 쿼리 EXPLAIN 원문. +- [`evidence/metrics/l3-cartesian.csv`](./evidence/metrics/l3-cartesian.csv) — N, 전송 행수(조인 카디널리티), 리스트 크기(Hib6 dedup), distinct, 시드 하이라이트, 폭발 배수, 총 PreparedStatement(§9 카테시안). +- [`evidence/explain/l3-cartesian-join-plan.txt`](./evidence/explain/l3-cartesian-join-plan.txt) — §9 컬렉션 fetch join 조인의 EXPLAIN 원문(Hash Join actual rows = Σ highlights). +- [`evidence/metrics/l4-inmemory-paging.csv`](./evidence/metrics/l4-inmemory-paging.csv) — N, returned(페이지), feedItemLoaded(=N), over-fetch 배수, 시드 하이라이트(§10 인메모리 페이징, 결정적·hash-anchor). +- [`evidence/metrics/l4-cost-curve.csv`](./evidence/metrics/l4-cost-curve.csv) — N, 지연 p50/p99(ms), 스레드 누적 할당(KB). §측정 범위상 환경 의존 상대값이라 anchor가 아니라 whitelist(N에 따른 방향만 읽음). +- [`evidence/explain/l4-collection-join-no-limit.txt`](./evidence/explain/l4-collection-join-no-limit.txt) · [`evidence/explain/l4-entity-paging-limit.txt`](./evidence/explain/l4-entity-paging-limit.txt) — §10 (a) 조인 SQL(Limit 노드 부재) / (b) 엔티티 페이징(Limit 노드 존재) EXPLAIN 원문. +- [`evidence/metrics/l5-batch-resolution.csv`](./evidence/metrics/l5-batch-resolution.csv) — N, before/after PreparedStatement·컬렉션 fetch, feedItemLoaded(페이지), 붕괴 배수(§11 배치 해결, 결정적·hash-anchor). +- [`evidence/metrics/l5-hydration-probe.csv`](./evidence/metrics/l5-hydration-probe.csv) — 페이지 20건 조회의 엔티티 하이드레이트 총수(§11 잔여 과적재 → L6). +- [`evidence/explain/l5-entity-paging-limit.txt`](./evidence/explain/l5-entity-paging-limit.txt) · [`evidence/explain/l5-batch-in-semijoin.txt`](./evidence/explain/l5-batch-in-semijoin.txt) — §11 (a) 엔티티 페이징(Limit 노드 존재) / (b) 배치 IN(semi-join, 곱셈 없음) EXPLAIN 원문. +- [`evidence/metrics/l6-projection-resolution.csv`](./evidence/metrics/l6-projection-resolution.csv) — before(§11 배치)/after(§12 프로젝션) 엔티티 로드·PreparedStatement·컬렉션 fetch·자식 행수(§12 프로젝션 해결, 결정적·hash-anchor). +- [`evidence/metrics/l6-explain-width.csv`](./evidence/metrics/l6-explain-width.csv) — 부모 프로젝션 width vs 엔티티 페이징 width(§12.4 실측 정정: 프로젝션이 오히려 넓습니다). +- [`evidence/explain/l6-parent-projection.txt`](./evidence/explain/l6-parent-projection.txt) · [`evidence/explain/l6-child-projection.txt`](./evidence/explain/l6-child-projection.txt) — §12 (a) 부모 스칼라 프로젝션(Limit 존재, width 2088) / (b) 자식 스칼라 IN(semi-join, 행 안 곱함) EXPLAIN 원문. +- [`evidence/metrics/l14-topn-resolution.csv`](./evidence/metrics/l14-topn-resolution.csv) — 전략별(윈도우/LATERAL/2단계/순진) 반환 행·커버 부모·부모당 최대(§13 정확성·전송, 결정적·hash-anchor). +- [`evidence/metrics/l14-plan-compare.csv`](./evidence/metrics/l14-plan-compare.csv) — 3안 최상위 노드·반환 행·buffers(shared hit)·exec(§13 플랜 대조). buffers·exec는 워밍 캐시 상대값이라 anchor가 아니라 whitelist(같은 실행 내 상대 대조로만). +- [`evidence/metrics/l14-group-size.csv`](./evidence/metrics/l14-group-size.csv) — K∈{3, 50, 500}별 윈도우/LATERAL 반환 행·buffers(§13 그룹 크기 곡선; 반환은 결정적, buffers는 whitelist). +- [`evidence/metrics/l14-index-toggle.csv`](./evidence/metrics/l14-index-toggle.csv) — LATERAL 인덱스 유무 buffers·exec(§13 인덱스 의존; 환경 의존 상대값 whitelist). +- [`evidence/explain/l14-lateral-plan.txt`](./evidence/explain/l14-lateral-plan.txt) · [`evidence/explain/l14-window-plan.txt`](./evidence/explain/l14-window-plan.txt) · [`evidence/explain/l14-twostep-plan.txt`](./evidence/explain/l14-twostep-plan.txt) — §13 세 해법 EXPLAIN 원문(LATERAL Index Scan / 윈도우 WindowAgg / 2단계 Hash Semi Join). +- [`evidence/explain/l14-lateral-no-index.txt`](./evidence/explain/l14-lateral-no-index.txt) — §13 인덱스 DROP 후 같은 LATERAL EXPLAIN 원문(부모별 Seq Scan, buffers 폭증). +- [`evidence/metrics/l15-depth-curve.csv`](./evidence/metrics/l15-depth-curve.csv) — 페이지 깊이(offset)별 OFFSET/keyset 훑은 행·buffers(§14 깊이 곡선; OFFSET=offset+20 결정적·hash-anchor, buffers는 whitelist). +- [`evidence/metrics/l15-deep-page-compare.csv`](./evidence/metrics/l15-deep-page-compare.csv) — 깊은 페이지(offset 1980) OFFSET/keyset(+인덱스)/keyset(−인덱스) 최상위 노드·훑은 행·buffers·exec(§14; buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l15-offset-deep-page.txt`](./evidence/explain/l15-offset-deep-page.txt) · [`evidence/explain/l15-keyset-index-seek.txt`](./evidence/explain/l15-keyset-index-seek.txt) · [`evidence/explain/l15-keyset-no-index.txt`](./evidence/explain/l15-keyset-no-index.txt) — §14 OFFSET(Seq Scan+Sort) / keyset(Index Only Scan) / keyset 인덱스 없음(Seq Scan) EXPLAIN 원문. +- [`evidence/explain/l15-visibility-or-probe.txt`](./evidence/explain/l15-visibility-or-probe.txt) — §14 keyset + 가시성 OR/EXISTS EXPLAIN 원문(BitmapOr + Sort, 정렬키 인덱스 미사용 → L16). +- [`evidence/metrics/l16-plan-compare.csv`](./evidence/metrics/l16-plan-compare.csv) — 가시성 3안(단일 OR/UNION 분해/사전계산) 최상위 노드·Sort·멘션 처리·훑는 후보·buffers·exec(§15; 훑는 후보 1500은 결정적·hash-anchor, buffers·exec는 환경 의존 whitelist). +- [`evidence/explain/l16-single-or-plan.txt`](./evidence/explain/l16-single-or-plan.txt) · [`evidence/explain/l16-union-decompose-plan.txt`](./evidence/explain/l16-union-decompose-plan.txt) · [`evidence/explain/l16-precompute-plan.txt`](./evidence/explain/l16-precompute-plan.txt) — §15 단일 OR(BitmapOr+Sort+hashed SubPlan) / UNION 분해(Merge Append+Hash Join) / 사전계산(단일 Index Only Scan) EXPLAIN 원문. +- [`evidence/explain/l16-union-branches.txt`](./evidence/explain/l16-union-branches.txt) — §15 UNION 각 분기(mentioned=ix_mentions_user 조인 / private=partial 인덱스 / public=고선택도 bitmap) EXPLAIN 원문. +- [`evidence/metrics/crown-unified-plan.csv`](./evidence/metrics/crown-unified-plan.csv) — 통합(§16/Task 4) 부모선택별(사전계산/단일 OR) page 1·깊은 페이지 부모 수·행수·훑는 행·buffers·뷰어 가시 집합(부모/행/훑는 행은 결정적, buffers 는 환경 의존 whitelist). +- [`evidence/explain/crown-unified-precompute-plan.txt`](./evidence/explain/crown-unified-precompute-plan.txt) — §16 사전계산 부모선택 통합 쿼리 EXPLAIN 원문(Index Only Scan feed_visible + Nested Loop LATERAL, Sort 없음 — 한 플랜 세 기법). +- [`evidence/explain/crown-deep-keyset-precompute.txt`](./evidence/explain/crown-deep-keyset-precompute.txt) · [`evidence/explain/crown-deep-keyset-single-or.txt`](./evidence/explain/crown-deep-keyset-single-or.txt) — §16 깊은 페이지 keyset 간섭 시험 EXPLAIN 원문(사전계산 인덱스 range 19행 vs 단일 OR BitmapOr+멘션 SubPlan 200행). + +### B. 측정 환경·출처(provenance) + +§6.2·§7 표의 수치는 아래 조건에서 나온 값입니다. 다른 환경에서는 지연 절대값·쿼리 플랜이 달라질 수 있으므로 절대값이 아니라 N에 따른 증가 형태로 읽습니다. + +| 항목 | 값 | +|---|---| +| 수치 출처 | N1: `FeedPersistenceIT.l1CollectionNPlusOneGrowsLinearlyWithN` 콘솔(`=== L1 N=… ===`) · N2: `l2ToOneEagerHiddenNPlusOneCurve`·`l2EagerToOneFiresEvenWithZeroFieldAccess`·`l2ExplainRepeatedPageToOneQuery` 콘솔(`>>> LAB L2 …`) · §9(Fetch Join): `l3TwoBagFetchJoinThrowsMultipleBagFetchException`·`l3SingleCollectionFetchJoinExplodesTransferredRows`·`l3ExplainCollectionJoinRowMultiplication` 콘솔(`>>> LAB OBSERVE L3 …`) · §10(인메모리 페이징): `l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory`·`l4EmitsHhh000104InMemoryPagingWarning`·`l4ExplainCollectionJoinHasNoLimitButEntityPagingDoes` 콘솔(`>>> LAB OBSERVE L4 …`) · §11(배치 해결): **별도 클래스 `FeedBatchFetchIT`**(`default_batch_fetch_size=100` 격리)의 `l5BatchFetchCollapsesQueryCount`·`l5EntityPagingLoadsOnlyThePageNotWholeDataset`·`l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication`·`l5ProbeBatchStillHydratesFullEntities` 콘솔(`>>> LAB OBSERVE L5 …`) · §12(프로젝션 해결): **별도 클래스 `FeedProjectionIT`**(배치 설정 없음, sibling 메서드 `loadFeedProjection`)의 `l6ProjectionHydratesZeroEntities`·`l6ProjectionReturnsSameShapeAsNaiveLoadFeed`·`l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth`·`l6ProbeProjectionStillFetchesAllHighlightsNotTopN` 콘솔(`>>> LAB OBSERVE L6 …`) · §13(Top-N-per-group): **별도 클래스 `FeedTopNIT`**(IT-only, native SQL을 `JdbcTemplate`으로)의 `l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong`·`l14ExplainThreeWayPlanCompareIsTheCrownJewel`·`l14TransferAcrossStrategies`·`l14GroupSizeCurveWindowVsLateral`·`l14LateralDependsOnCompositeIndex`·`l14ProbeParentPagingStillUsesOffsetNotKeyset` 콘솔(`>>> LAB OBSERVE L14 …`) · §14(keyset vs OFFSET): **별도 클래스 `FeedKeysetIT`**(IT-only, native SQL·정렬키 인덱스 CREATE/DROP 토글)의 `l15DeepPageOffsetOverScansButKeysetStaysFlat`·`l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex`·`l15KeysetWalkMatchesOffsetPages`·`l15ProbeVisibilityOrBreaksKeysetIndex` 콘솔(`>>> LAB OBSERVE L15 …`) · §15(가시성 술어 인덱싱): **별도 클래스 `FeedVisibilityIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `l16ThreeApproachesReturnSameVisibleSet`·`l16ExplainThreeWayPlanCompare`·`l16LowSelectivityBranchesRideTheirIndex`·`l16PrecomputeIsSingleIndexScanNoOrNoSort` 콘솔(`>>> LAB OBSERVE L16 …`) · §16(통합/Task 4): **별도 클래스 `FeedCrownIT`**(IT-only, native SQL·신규 인덱스/feed_visible 토글)의 `crownUnifiedReturnsSameShapeAcrossParentPaths`·`crownUnifiedPlanStacksVisibilityKeysetAndTopN`·`crownDeepPageKeysetSeeksFewerRowsWithPrecomputeThanSingleOr`·`crownDecisionMatrixClaimsHoldInOneQuery` 콘솔(`>>> LAB OBSERVE crown …`) 및 리포트 `build/lab-results/feed-nplus1.md`·`feed-nplus1-l5.md`·`feed-nplus1-l6.md`·`feed-nplus1-l14.md`·`feed-nplus1-l15.md`·`feed-nplus1-l16.md`·`feed-nplus1-crown.md` | +| §9 측정 방식 주의 | 순진 조회(N1/N2)는 `loadFeed`(Spring Data `Pageable`)이지만, §9의 fetch join은 **원시 JPQL**(`Pageable` 없음)이라 count 쿼리가 없습니다. 전송 행수는 `resultList.size()`가 아니라 조인 count(`SELECT count(*) FROM feed_items JOIN highlights …`)로 측정한다 — Hibernate 6+ 루트 dedup 때문(§9.3). | +| 런타임 | Java 21 · Spring Boot 4.0.0 · Hibernate ORM 7.1.8.Final | +| DB | PostgreSQL `postgres:16-alpine`(Testcontainers, 클래스당 1개 공유) | +| 지연 표본 | 반복 7회 중 워밍업 2회 제외한 5회의 중앙값/최댓값 | +| Persistence Context | 지연 반복마다 `em.clear()`(측정 구간 밖) | +| DB 캐시 | warm(`shared read=0`) | +| 소스 모듈 | 어댑터 `adapter/outbound/persistence-jpa`, 테스트 `app-bootstrap` | +| 원문 로그 | `app-bootstrap/build/test-results/test/TEST-*FeedPersistenceIT*.xml`의 system-out | + +재현성을 더 높이려면 Docker 이미지를 digest로 고정하고(`postgres:16-alpine@sha256:…`), 측정 시작 시 `select version()`·`show server_version_num`·`show random_page_cost`·`show work_mem`를 함께 기록한다(쿼리 플랜은 버전·planner setting에 좌우됩니다). + +### C. 함정(테스트 설정) + +`@DataJpaTest`는 테스트 클래스 패키지에서 위로 올라가며 `@SpringBootConfiguration`을 찾습니다. 측정 테스트가 부트 앱(`CaSkeletonApplication`)의 조상 패키지가 아니라 형제 패키지에 있으면 "Unable to find a @SpringBootConfiguration"으로 실패합니다. `@ContextConfiguration(classes = CaSkeletonApplication.class)`로 설정 클래스를 명시하면 해결됩니다. + +### D. 슬라이드용 캡처 + +발표 슬라이드에서 화면 캡처로 보여줄 스크린샷은 [`assets/`](./assets/README.md)에 둔다(콘솔·SQL 로그·EXPLAIN 캡처). `assets/`은 슬라이드 캡처, `evidence/`는 원시 데이터·그림으로 역할을 구분합니다. diff --git a/src/claridoc/__pycache__/lint.cpython-312.pyc b/src/claridoc/__pycache__/lint.cpython-312.pyc index 23f7bb3e1e4d682b7d91ea23dfdb28084ca73807..9932c8ad66eb4c2ec08e516284189d92081bfc9e 100644 GIT binary patch delta 6887 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 delta 5551 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 E0T6$3DgXcg diff --git a/src/claridoc/__pycache__/report.cpython-312.pyc b/src/claridoc/__pycache__/report.cpython-312.pyc index f1c632db34be97cced5010685e13ec318408e90f..84e2143364591cfab113fe30c6bac042a7330270 100644 GIT binary patch delta 1802 zcmZ`(O>7fK6!zHof4zxg{1Z&#g%m}ak6K6Nft_g7* zg#%UFL)D@=6ogQvss~UiQRGWGHT{uF)yq=1#d0H+LodBqf)iD%&e*u5Bwfkhe)`^f z-@G?7>tFUQ_3=NrTn+<${(c~h|K`2M|7_XqG8o=*-}}RB0d6v2_7k7g2i!T)Is?ep z7GG08OaJdP=hOzOO4d0(NG>`(;x^c}V63PMpz@yMvK(xi=r9=YH`TT02i*N~`Ax$uBYbUTZo{7c$?dGRzp1O*G&aI+ z8Nj_|uh&<4ZQ8e*7u>%xv+2O9nLSw-Y`D1w9=v5u*vPNWCh!LN($)%iHo65K%JSpG zqz!mYy;+`w>_PKks%&R_Od@uli60*!6ZY2N5!g2#tZvGSH_aU(_v}Zh-CuUmeH6Z# zFjhwc2I}~})lVXhI&hY3Fs`eetI>L^#aY|CGn&*qWs?DTOG_J@Rj>Wa&8d;H1F^Dct zAuJ&|j-o1xYmy?Fs7vEWw=Ku`lnbh%7W2KkS>;{4Y6yU35dcW39Fh&J5m){n2g~G z%4l8v7}KK_JzCK#m6@<89GgNiT}MEw7Q2v=)Fh%yE;phx&OaZLngx_fel!_Vqsxtp zrWvFbjk1zy7lpy}1!^a2(j_E3-xyVxmbByw>lePCtrU! z?>X|WtzdI~z`e&6IKIGbX2=Jx_v^vd+if~07Wl27?YKRCclOS#-Z`Y7Je3a*=UY$f zTSs*6Oktki{Hc1o<8J4jPQ4?d51q)jp3FC&(l-z5-01?}y*T~d>{qk;;N(xe@_nRh zm-X%$oy$C~?OpQtKAiq&_S&r8bZ{}2_p?^?y1oY+mIb=MxS03v&wCE&bv=vQ7n2Xv z#dG@LP|0j`p9Ce3%jd8@HMs1~Qe$r8`T_v$xI%)B0+2{oqYQSDg~n#b5Zy7% z;3`>aY?+rR>u+)3b4)ffh%&G+I8Pztrq@$D6QOq=Mv$6LX`?vCbbDE+XFFQ7V5Ft7 zjEAY-vIes=DswDX8C4sqe3|MjlB=Pf7Iu)>&7j;|J7cUb?4|I`)-|cXbPDaon3O`H z-ZTK~3(ne-iIK}BdOSnN-nj6Rt%0r2Wm8vvbU^M3$c C(FYI! delta 642 zcmYL@OK1~O6o&7~I5WxInMu=_G)+>Qm_QxVTBEi;8cVTYZPeIAp+$*koERJOa*}{b zHiF{Hg`B0h=~fWJfIAm1#kCKDh;-MLk&2)jy*FB%#Wx@SJ@?%IQ~Lda{#Mfz!oJ32 zp*%bMM1Lopri47wyIVd9bj*q)=$HMV+f(vgz?>X_gVe#52%XuTgxdreyUgG2kmDT3d5*mdO@0|C!u#kd6DLnrMf-C2 zjbIzm4*&*2Y#gc>jYVJ$PsM7mAG@&$t?%BoVN=2BR;-V00@7iLw$kE7d6-yx%4HX?di({brN@=?;5B=B-b|O Qx}6pPCcirY<_63E0BgIJ+5i9m diff --git a/src/claridoc/__pycache__/style_contracts.cpython-312.pyc b/src/claridoc/__pycache__/style_contracts.cpython-312.pyc index f08df0dde4b518b89bedeb762002fb9568f75035..445785744c8e5c88881194b5eae8c3be39f006b8 100644 GIT binary patch delta 1694 zcmZ`(UrbY17(eI!>!tTnilx$0uq~}XYegL{OMr-DPTd4uL=%UyuIs&EYiaS^3;xON zS{Jhz$HUk)vIk>ImPHr01k=~O?Zsp-)NX0hkd5qVG9QYtWP95=#fzG_lji%~e&6r= zbH4Ap_x8|_XDmP0*P8)8=0~yk&GtEq1I8bA_CRXXHdl4(3Y^pbfXPnq`x)J~D8t8qR%l>uqmi9DvXLT`~UlrUG z?8}=is}K(6|88i7a5%rMagl~2`Jb8&Q81k6o6{zEKL7cSRT?hn7u)Z`ySmkP+7W$z zsamZ(mNWoxy#n>&Mn`WNs`&5bk>GWp!9&2fmC=nfrLqP`gO?#{hd|}@YtA+fN|X}- z{dbG~fWbB}O~*i*A@RmW0Z6kN8yAfzFE9&dtr`<=&>uG2Xw1+rx@`5d(O)AEWc3wG z(@7(=0%wOLMq^$dvL8pEJ;wl*G_rOy#_&vYFRqKz+VpE)+gDCgX3K8UIK*mLUKIdH zLWxcD8l}-1r5U!Fn-?h$ufHTV6=dONfJT||1ZJyTFZ=#A`@~zKp*0=Dzq{^Sz$g&r4nc& zLlm4MM^N;9t)0k5G-gFsC3!qDolrL?z|BMw)3Q=w5iVW|)S+XThK7iU6LEofH5kR# zszgd{CW!0>nj|8Lg;Ixw$2cfMMpbEg`TE9`&wxxhOHA7a4?O-dZ+*=7-5M*|0|k5Fe%Io@?{<}fy@gm#MsP@y&SSlnF|aI_5z@c42AMRyNQwcHW7zo!&D zSO^|01_z5B?<_ICH4cdWD&DQGeM`Z?$Nb<*y|rortfwl1j)AhjpZs>czuXyG+I{qq zx7azfCU7EG1su!iAN!mEG==+u-;SLa5cy)EOGGqmYHPD0&*+l=jW75%v0bf8#1!>x zEUAuRzn2)q)+WWG>RClaArB^aG|zY<6(LBLWGv98|K|GwPUy1#Ae_+`{B7n>u@`x{ sm2>(dzht$xd`Zol=gdp4Jw@x@Y`wl~=ZbYb0U2oDu(%)5CdtXAj1%kA*rJ4)7*bhML{mi9 zh^=M<1OyQyuAF9#FEVXykb8fpL6qirVP%_x;%1Bj8>a{ z_+BwGI&L->kYQx>*&HIcftfLAGpncz6Jy+FFY#J-#`Mi6tXr)Z{)xOXDC5MrPQM5=BC<*{Q C&RE<4 diff --git a/src/claridoc/providers/__pycache__/mock.cpython-312.pyc b/src/claridoc/providers/__pycache__/mock.cpython-312.pyc index 5d54b5a724ce90a07847e74fec205f2c6a31ccb4..7d491c5b37b2c191c338cef414d0ed202f05489d 100644 GIT binary patch delta 6173 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 delta 1999 zcmY*XZBUd|6n^i!yDT5>g2?yhD<1-j2qqPoCW?s#s01jIWw>`;77^GlKwD!+QBkDO z3%Uv~E`*wdh3qn;6O;AFKaS1vM~boOr?va;M2=03O-<)sz-VXAbM8IoInO!w>^t=w zb^n0W4^*mP4t~cShWZNaZS`LPD9N=!saFNumvh`DjyP*$iL!Q<3`CYi+W3ABFB5sJ z((_o2Ljl8VWLg#(0rBOc42SD^J%?8eBOHMv$xvjhI!crQD;mcD#xia&aN{6~a6D;> zibe^P$0xE}Og|^eagt32oMf9EP!JVRxpE1ofN@e7CzWy17$@DP0RAeboB`^YI14H@ zI2&*^+;gZ#?i!ZQg?k>@TCkS}LXIhI$fj)JObQ#EVqqN>oPopZa6aq%GMlXm)L&UL z*{foZ9ub5K@M~-@>!~u_U&kAm--zE}eiJUloAo?ow%{Tb*^1v}ej6@k${JAKzC`&g zQOWXe;}WJ^x}aQ!chGq>JK3vu!K-P%a{LZsRV=V7aTSa2#(NfbimMlLdvOiRy^HsO zzZVmg56jWQ5-^w!>>!vPtPacoRu6UvtO4vWSR+^y9b!c@mBdCc(?IAobS%&oNN$Hm zkkGiqXfqqgLI?bur(YLcXqJ@}#ntR;hKn?KUJ~tscXG_{=n$k1r~iZ~O*nj4rv#tN zEjjGIZinC-yCXRVyyJF3>KcKQ;1_#**E=M!L-6&E`@|_oNrOEkFFqkyy430G8d)P) zhe>mMOpw>*lPQ3|JZsIg+pn6%c1aeyHzkFU?thfdrh% z(}ay^fRrVU1h8->`8zR@BWMl7j~H1s63mA~R9+O+dQZ2Byjwdg{684OUKeirbj^g6(jRgX~~$;@yo1@FWO zXiKCF^NM+?wD|`}*G1?_Oqqp2Qpbqb)d91R2bm8S8tTY!R&5aNO~;xc;To#$)NBu5 zT$>Seq>EqC<;b(EBSbeNOM@pMGT+rs8nO$Jn@nbZyP&#<*jHze(bXL!KW8*-ex6Er zvUg(KJ9&%znNzG@Z29B-CXTLo1br)W>s{&%PNjNwS&PnOIB3w;S`1B%E?3b|1UgNs zs?&A`nR1}QWCWNEurxIrv}V&6H1Z{jG@EoLopDxe(P`@%p}b-LfrcjiY;d!wNvp$F zQ*FbnQm<4uS@`jpl6fkEm0%%fDCWjw@#JwoK=3@#GeHiYh#ndp#93o|*>F;TBIrE3n#7 zGnG{t`8kCwE_g1O3ybQkgGh9tyqzXC%jbAb)hdT{3{9&n3L2}h;``oL(6;+Qf1NCabu0{^ z9S_ouAOan=$fK>B(ORdqR?mSJptqt*R#l}SBQ>e6f_+~{gxsM2!&wSv2r^EEb-?ir z8NCw@Ak%4j9j9;`AWQk**JhgdKhs58+S1T>uKu=CIr@hDY%5x>MQ8{AtW;KxP=IM2 G(EkNi*2OUZ diff --git a/tests/__pycache__/test_cli.cpython-312.pyc b/tests/__pycache__/test_cli.cpython-312.pyc index e58af482d496982326af04e05fd69ff8c96ca115..dcfa781271cf781aac4f11f5208cd9d9cde3ca1d 100644 GIT binary patch delta 1845 zcmb7FU2NM_6u$m<9NSH^G)=mWIq3#T*R-U7_(xP-)@jfpF{J&lDavfOxO3xh?W}ER zDM*{>0}|aVv5LJ^JPmCRLwo5Pnzl-4yfo`XBWn+cXN0PyQc<-B?sbzg?FG1zzH`oZ zj?caK_#Ef=jwdPk7fBKTTH1YM{J8X^oFcjpAoD8F!D&JRr%8>`h>P@T3P}=48c7OC zW|~g3w@(t6$mlkg$()0RK3Wlc(K~Ki#qHa5>MxoIxqt98(QQ7HvC2-V>_8GFhpiaG z?&pE>ClRc+VW2TE7&r{=7@`>VV(7%M4?_$=nuYt3@MvgR4pgwpHa1B3yjE}uHj7ln zUod(5W8piZT_q6JToO_!0EqgwdxJgo@(iJol_07FUgU7tjP}PjlNDayKUU6iYSPvE3ZAoP#UnFHQVEIW+01 z%xq*gHP_T;dG{Xo0>x>vd!I{CJd%HM=|k)|F7`7^Yq=R}KG&49fh$y0mi~^d#L#Y$%FKN`MV$hZgh~=VI|;f# z7TCljvQGg5{ieZAM7nJR9z+>DUY{yVluX_BSijfoN<=ja(9kS31NDM7q5Itj(Ul&!$CFg6IBJZQ;f_nZphIKSPp(+?f2f5QS#LW&5 zVd%k-!SFA`h4+7Ey3#>^uwXB8d5m9Tzm{G|pDLkEbuGWMqwP-}?89*qs-S@;PS2~B zrhCo%`WAc}M{vypqnT=qB$~@{MS)4|PC2^mv8sF7XC4w}YC zDA?}(uaCTlHn2Z)e-e^|wNPZCQVk`Rj#fiGt9`j@C^yg4!qLT!YPf63sD}Gi`-iLH z;d$;c8R!&N`PgO@$kBBvRh3d}QuhXkyBorDbFvhEq#UTV?O%%D9K6NcIJO+Tcl_t} zyJuD-AJ!={5nhlsS)d&FEtaUo5|3hg7jxHyTC59eL)XMwEQz%v*Mb`@d|R|m2}*cD z+zOzy8w!wG*Tv4N*tvN2`oKf6wT+MoM1bYy9Ap?xv7)|z|}I#LTMn+zq% zTbX!3Mpq2TTQoijK=9;oH)tIqw)&c`ZMz=~0dOat%w?%NZw`cV3UfC&@EWpKC^6R~ zthDf0SD0KnuyRnudX~w(!HuLz+`dj37Cm~zD(Y$0<;8;oUFh||EC!So`~d0*f!`G{ zfHe$wWb814i=a-5j7iix!;HL{N-;7VaW(OI*8hu$xC`RR)G_?jFoqEfxWBgfw3ry){73b$wZT`*tgN2cA@)wR8u__K--Tcy$g3^*A zCZM53{2)SLavNtUn-GX8JeiS8I7Af06af*EAVLa6NP`G5Afd@tBnzZ&u@vR!mlVl? zm~2oZ<-y`9i6x1}Y(S<07!)ahMAawH;!@w-&y~k0?5D|ei>)ZNq_imS7K>kgUaBVR zWHDh?b48%KB0V6nlHoIuWB_X{;smik1TPR5gNPP}&rCe5IgDRqgjo5COefa}tFeLI zDKvS7aD;{-NP`K8Fa;3`K;jpNO>TZlX-=wLksXlB2*kx+lQl$?gcTY2Ix4R7D_rDP T_`(3>eDGkL94{it0@4ZqCL38Q diff --git a/tests/__pycache__/test_pipeline.cpython-312.pyc b/tests/__pycache__/test_pipeline.cpython-312.pyc index a5b93fc84a699d0e2c0a36f399376d1aa1a863a1..e25b5f8bbaa42c3a243714f6419d7fc3717ff3ec 100644 GIT binary patch delta 6061 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 T?AvW`w!L*|&PdoY?VA4=gyNq; delta 1104 zcmb7?-)j?D6vywKOp-}z+9VyDHo+2Is~y{>w)IEVP(;yIx>e8$yK!+$GdFeGNhaKx z_!CjVWqngQhzjDeFFxs0VA;RGmleT%Q7?*51%0y6KKZowBz6}BAG|Q1bMD-G&-a{j z@7?*|_q6{Vi$#eXwZXZCWc*HhxAL6a>7UC|Vi7CsbX4MncqLItP@<4YVre&s6=A`J z&G1L86Jk_LbTZwFF7^|`lM0b5+vG|b_R}zJgDIMY3v|4P3ccc%7R{PnV~$;AX34FV z?Ri6isFImP`L&_69J(?noqL3l0<}m%u|gKTp%wztD$=Ub2BZy2t4bTX7&O9-wn#S} z(BwHDLIfp5q|DulY1t)TXhqLs+&@w^0z&neQ-;ZCFT7NzVKG{vad;Cwp{$TbuI+wE zyA-VZ<)Lu{UL<&|O9t{Vv22#7U?$0EKQxm2XaYVZpA3XiD}um2HdT2NJ->dw!Vmf`x&;dQ2CBmay+~6a^eZ&g_-Xy0D$PJgx=;B?VOM(M>Q;;+TT8gQ z7Lk8TPqek94i_C+W-spFAt3?|tMX}_8i)Z2Bgl31_agQo)+6!~9(FB@gQtiT6+0oH z*$IQ0Lv$Q?=5yn(fh8)Fd^FVG)h@S+2xsSPcAjysCGsP@9eSMFi19XKytTgJk4^9( z_cJ{SE4loc??1hAG5NFmm(p+h=e54a<#@@lp{m~ZXDWucj7 zrD#`8)94aW(=6Ae5Hn34z_Ys%IRw@$w0hO{eJ1M(*NXmG$DV6muIMN^MQ&SeDOYhV z=6E~^6Qik6^C^en;b`}%=7WmiQ7?CvIW_G0e-BS+vvT2JQQmzswuT-m&!lC9>^3Iq;5*y(goqp-D>fx2Im|eJ}OCRf4nSS%s+UZeIE) Y(DPbNT$}oB=K9R9$Cd*`O*eb-FW7|u2LJ#7 diff --git a/tests/__pycache__/test_repository_contracts.cpython-312.pyc b/tests/__pycache__/test_repository_contracts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c95f1cabc2ad148e0a2ee79c3ee5be9d275e14c7 GIT binary patch literal 2552 zcma)8O-vg{6rTMtUfXMoNgzOpL8TFa);NUz1WHo11ckH#QXo=dsakEkV_37^b!OKP zEU2iOs#J-p{=E zz2`S?{AV!eN3b6Kp{5>q5PCwH(gL0?W_t_hB2tjTXeiCZ8HVmzjg7PL^ z2=myp%D9J6Dp>d)q^m_HRen%f*eiT3Y~lhI6i+UIkW%JSFQsuGi>@F=xQ7%UW>Y6$ zKE28nWbptN7615Igh(aeVN-QS*cL?$nqsHemcUcWw=hmX)^)>@E!EJ?0pR)TvNiUU zrjzrw$)i}eOsC8oS2fMd)mk_)rmG2Aiz-GUn+9c6&RSyzNff=iAGW|S+Z6I5!YIxt z2&%>@%soENL3G|ZHH0&Usagh^>M?YS$O&r%o0gd<7SeU$1qvxL+dqTLMPwEH0#z7V zpaVSXmW?}7${&&*D0GsaVWyeO=(9nDX4q+zDi(Av5NxK|RIw!vO0BXISNiE6dP}=^ z5QD&%uVk_R1z&*s@LDYjO6T21gJxST6%yV5ls}lQoBdI(rU(yxB`-6a;)l#)kQskW zX8glI$-0O+bQl}t&rfsvNTBuqkF1?XikJ!?agIx!cv)J?HC1)Iu7qjE1GtYsqy(ou z`-qOQDSri<&@0$f9JHB2O{g82%p}X8TktD0Xr8&>aF@G$SU&6q`}oJ%FL zHv*+n514J4DW{p*K=9(Ml?o$PDqw1Z64~`k4ZLjEn=4uYY#GOU|iZx4Jws__r z_;%Z3^IGdgTfDf>fvlTy7i_U2ySF~M_N6(<)%3!!EH2k|@c`|^#7*b5HCd8*5E;9y5Ur<#`I zc6DsCs>`nG`t{0MRo8mewK;#D^+x>9kg%_O)2WGSp$Xrt-7(7^Zb79 zL~n)X5$EX*`W{tydTV_HEv!>Uu$3jyn*)Z9;WZQ_Nte@Dk{rJzr41#k(Y+{1^nMgH zsymPu%4gwAe97=e>LaH|SH;Litgcp8kn zNo`yV6z8tcUC8JXc^6pvzMDFbJjXE1WAyqH6nczmo|Pd%oa_7Y^G)w*+k1N5+py_v zw!O_OjO}fm<+nm7=2bh?uzc1IwXQ|`>`>pVxKmcooL?H-jKu6nYzG08zl3;kF83E# WyTylR`@Zh~qW`PGJcsx?7teoPV;R{<0uN^#ma=3!3o6P)o$+itsfk9NNqNu4}K%&daY@h<8`bmr% zSsD>_lTxdSi6xt=Df+28h;UVbvOmT3MWtkmnrz=NK@(Sl4wIUYxyY+crQD*YwiLaR zl^uWA57GZ!?<^gEi3&(?&k`zMcE@L9T0&07+cYI6&nfMyo}7pG7P-fPNB?wQ(Gtql zc0GPgnTr`(jwvK+0@icpxnz7M$KWQ5vJ&t>H{a{m{6rCP&&4@qJ{3(ZEhstqdTe%4 zfv?UWsC**;U*Tspa$7mx7!j3YsaVcg=pBW({0UD^C@6*DI*XHQYa!1k&^NebJ?5#Z zTOV5++ZUayV;Qmfxi=#Q(n0uT^#fQ}=sZJt4}sunhDxFSxHrRjpL*77)@ss$zFl8` z);F5*jb?ol8Q(;Da(a)uu+K?Zt~tXszYuo0P?j6Wa0BU+kv(p5vsXwX)CHU^($R%C zp>>mObGi~wC6e>HMjlmw))*pn5+LW-CJCH|N46)Z2U_hq3U994^T@8X5rJH;7Q_IX z&c1RjM8cy4dI``3I-ypow)7Ks5CcRcFFFSa=>ZynAJPXDhIg((X^7}k1g0^(q_i;b z?nbGCzKp(iN%SDfw+QqU!t|hf7=_@D`=Rq9&ct6XmYCO~q%sV39v450UGfj;3ozh0 y)iaFH6k2V`N(~vQAx|0M!5I@}7Va8U4WFM9D_XihyjleJi=2j}qSz4_kvzG0Ymm5oV#Q`2OQ zuDP#{`(FK^*M(Gd2(AgQL}6LsI9r64cp6Oclqe1KA*yfx;WDeCG`y5TCC@_GicDza zPd2PX?Z-8gC|g-eB@A&ZoIAYVlkjMSW*nOv+UszS9`v}Q2hVKv|B|6C%jQ2bVLMaM z|As7a6FQ`nvv5PPVN1!QEc{YtpsSWjBXm3wDa67P;zXFl(jo~SJ2oa0JSnn*44>2s ze8^J{isqm;^t3Ac)SkhrUR>YNHxa79=j_dTg}F65==MD9NAuCrB95gHcOxh8L&6@5 zMhbKz(+mcquQGNC8oAeK3clw;R9kNueRMn)2=3S(CRu7&&fO;teozj(53$!NyJ6r% zyHJN2bxI`cUSt~H6h{>6%ci6nN*KvGumvz<{oS?8P to%aH}w}3Aahf4NiLLW8`Uop?2S+qL-%^2G<#`+vn+bak%M~2g^`8PuZnh^j1