Files
document-haness/examples/golden/n+1liner/n+1liner.md
T

162 KiB
Executable File
Raw Blame History

하이라이트 피드 조회 성능 — N+1 진단과 조회 전략의 진화

높은 트래픽에서 페이지에 하이라이트가 아무리 많아도 조회량이 폭증하지 않는 하이라이트 피드 API를 만든다. 가장 단순한 구현에서 출발해 실제 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)는 순차 문제가 아니라 같은 기준선에서 동시에 나타난 형제 문제다. 전체 여정은 과제 요구사항 → 도메인·데이터 모델 → 최초 피드 조회(기준선)로 시작하고, 기준선에서 N1·N2가 갈라진 뒤 Fetch Join 시도로 합류한다. 이어 다중 컬렉션·페이징 실패 → Batch Fetch → DTO Projection → 아이템별 Top-3 → Keyset Pagination → 가시성 조건 인덱싱 → 최종 피드 조회 구조 순으로 발전한다.

요구사항과 모델에서 기준선으로 진행한 뒤 N1과 N2로 분기하고 Fetch Join에서 합류해, 실패와 다섯 개선 단계를 거쳐 최종 피드 조회 구조에 이르는 흐름도.

Diagram description

왼쪽에서 과제 요구사항, 도메인·데이터 모델, 최초 피드 조회 기준선 순으로 시작한다. 기준선에서 컬렉션 N+1(N1)과 User·Page 연관의 숨은 쿼리(N2)가 서로 앞뒤가 아닌 형제 문제로 동시에 갈라지고, 두 경로는 Fetch Join 시도에서 합류한다. 이 시도는 다중 컬렉션·페이징 실패로 이어진다. 마지막 노드는 Batch Fetch, DTO Projection, 아이템별 Top-3, Keyset Pagination, 가시성 조건 인덱싱을 거쳐 최종 피드 조회 구조에 도달하는 순서를 담는다.

Editable source · Grounded VizSpec


3. 도메인·데이터 모델

3.1 관계와 스키마

  • user는 여러 feed_item을 가진다.
  • page에는 여러 feed_item이 딸린다.
  • feed_item에는 highlights가 여럿이다.

users와 pages에서 feed_items로 모이고 highlights로 이어지는 기준선 관계도.

Diagram description

왼쪽의 users와 pages가 각각 중앙의 feed_items에 연결된다. feed_items는 오른쪽의 highlights로 이어진다. 간선은 user와 page 각각에 여러 feed_item이 연결되고, 한 feed_item에 여러 highlight가 연결되는 관계를 나타낸다.

Editable source · Grounded VizSpec

위 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노드 목표 관계도.

Diagram description

왼쪽의 users와 pages가 중앙의 feed_items에 연결된다. 오른쪽에는 highlights와 feed_item_mentions가 놓인다. feed_items는 두 엔티티에 각각 연결되고, 기존 users도 mentioned 사용자 역할로 feed_item_mentions에 연결된다.

Editable source · Grounded VizSpec

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<FeedItemId>)로 만든다. 이유는 네 가지다.

① 타입 안정성. 인자 뒤바뀜을 컴파일 시점에 잡는다.

// 원시 타입: 컴파일 통과, 런타임에 조용히 오작동
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을 값 객체에서 검사해야 한다).

@ValueObject
public record FeedItemId(String value) implements ResourceId<FeedItemId> {
  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다.

public class FeedItemJpaEntity extends AuditableEntity {   // 클래스는 public
  public UUID getId() { return id; }                       // 식별자는 public
  UserJpaEntity getUser() { return user; }                 // 연관은 package-private
  PageJpaEntity getPage() { return page; }
  List<HighlightJpaEntity> 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을 퍼시스턴스 계층에서 직접 측정

@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 실행 동안 하나를 공유한다(첫 테스트 전 1회 기동, 마지막 테스트 후 종료). 각 테스트의 데이터 격리는 @DataJpaTest 트랜잭션 롤백과 명시적 em.clear()가 맡는다. H2 같은 인메모리 DB를 쓰지 않는 이유는, N+1의 쿼리 수는 물론 EXPLAIN 실행계획(Index/Seq Scan)·인덱스 동작이 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의 총 개수가 전부 달라진다.

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는 아이템마다 전용이다(1:1). 이 비대칭이 뒤에서 "같은 즉시 로딩인데 조회 수가 갈리는" 현상을 만든다(§6.3).

4.3 하이라이트 개수는 왜 Zipf 형태의 편중 분포로 만드나

하이라이트 개수는 균일(모두 3개)도, 정규분포(평균 근처에 몰림)도 아니다. 소수의 인기 아이템이 압도적으로 많고 나머지는 긴 꼬리로 급격히 적어진다. 이 편중을 Zipf의 순위-빈도 형태에서 차용한 합성(synthetic) 분포로 재현한다.

// FeedSeedFixture.skewedHighlightCount(i)
highlightCount(i) = max(1, round(500 / (i+1)^1.15))     // 상한 500, 하한 1

Zipf의 법칙은 "순위 r인 항목의 빈도 ∝ 1/r^s"이고, 고전적 지프는 지수 s=1(1위가 2위의 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):

순위(rank) 1 2 3 5 10 50 100 꼬리(≈150위~)
하이라이트 수 500 225 141 79 35 6 3 1~2

균일분포, 정규분포, Zipf-like 합성 분포를 분포 형태와 극단적 소수, 스트레스 조건 재현 여부, 선택 결과로 나란히 비교한 도표.

Diagram description

왼쪽부터 균일분포, 정규분포, Zipf-like 합성 분포를 같은 네 기준으로 비교한다. 균일분포는 모든 아이템이 3개이고, 정규분포는 평균 근처에 몰려 둘 다 극단적으로 많은 소수를 만들지 못하므로 제외된다. Zipf-like 분포는 소수의 인기 아이템이 압도적인 무거운 머리와 나머지의 긴 꼬리를 만들며, 지수 s=1.15와 상한 500·하한 1을 사용해 매우 많은 하이라이트 조건과 Top-N 필요성을 재현하는 합성 스트레스 분포로 선택된다.

Editable source · Grounded VizSpec

왜 균일·정규분포가 아니라 편중 분포인가:

  • 균일(모두 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의 표본 수·표기는 그래서 "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 vs 실제 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이 ground truth다. 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)로 필드를 복사한다.

@Override
public List<FeedSummary> loadFeed(int page, int size) {
  return feedItem.findAllBy(PageRequest.of(Math.max(0, page), size <= 0 ? 20 : size)).stream()
      .map(fi -> new FeedSummary(
          fi.getId().toString(),
          fi.getUser().getName(), fi.getUser().getUsername(),   // ToOne (즉시 로딩)
          fi.getPage().getUrl(),  fi.getPage().getTitle(),      // ToOne (즉시 로딩)
          fi.getFirstHighlightedAt(),
          fi.getHighlights().stream()                           // 컬렉션 (지연 로딩)
              .map(h -> new FeedSummary.HighlightSummary(h.getColor(), h.getText(), h.getCreatedAt()))
              .toList()))
      .toList();
}

5.2 조회 전략은 포트 뒤 어댑터의 책임

이 구현이 낳는 문제도, 앞으로의 모든 해법도 퍼시스턴스 어댑터 안에서 일어난다. 웹·애플리케이션 계층은 조회 사용자·페이지 크기·반환할 FeedSummary만 안다. 구체적인 조회 경로는 GET /feedFeedControllerGetFeedUseCaseFeedQueryPort이며, FeedQueryAdapter가 이 포트를 구현해 PostgreSQL을 조회한다.

GET /feed를 받는 FeedController에서 GetFeedUseCase와 FeedQueryPort로 이어지고 FeedQueryAdapter가 포트를 구현하는 포트·어댑터 구조.

Diagram description

왼쪽의 FeedController가 GET /feed 요청을 받아 중앙의 GetFeedUseCase에 조회를 위임한다. 유스케이스는 오른쪽의 FeedQueryPort에 조회를 의존한다. FeedQueryAdapter는 FeedQueryPort를 구현하는 아웃바운드 어댑터이며 PostgreSQL 조회를 수행한다. Fetch Join, Batch Fetch, DTO Projection, 윈도우 함수 같은 구체 전략은 이 어댑터의 책임이므로 상위 계층은 전략 교체의 영향을 받지 않는다.

Editable source · Grounded VizSpec

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를 명시하지 않았으므로 JPA 기본값 그대로다: @ManyToOne은 즉시 로딩(EAGER), @OneToMany는 지연 로딩(LAZY).

여기서 중요한 지점이 있다. 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가 차례로 일어나는 시퀀스.

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 · Grounded VizSpec


6. 컬렉션 N+1 정량화

6.1 하이라이트 조립 몫만 떼어내기 — 지표의 정확한 의미

순진한 조회는 여러 종류의 추가 쿼리(count·User·Page·Highlight)가 섞여 나가므로 총 쿼리 수만 보면 원인이 흐려진다. 하이라이트 조립의 몫만 격리하는 지표가 Hibernate의 getCollectionFetchCount()인데, 이름을 정확히 새겨야 한다.

  • getCollectionFetchCount() = 초기화된 컬렉션 수. "실행된 SELECT SQL 수"가 아니다.
  • getPrepareStatementCount() = 획득한 PreparedStatement 수. 역시 SQL 실행 수와 항상 같지는 않다(§4.1에서 이 도구의 의미 범위를 짚었다).

현재 기준선에서는 batch/subselect가 없어 컬렉션 하나를 초기화할 때 SQL 하나가 나가므로 우연히 "초기화된 컬렉션 수 N = highlights 자식 SELECT 수 N"이 성립한다. L5에서 Batch Fetch를 켜면 초기화된 컬렉션은 N개여도 실제 SQL은 ceil(N/batchSize)개라 이 등식이 깨진다. 그래서 지금부터 두 이름을 분리해 쓴다. ToOne(User·Page) 연관 몫을 격리하려면 총 PreparedStatement에서 content 1건, 페이지 count 1건(§6.2), highlights 컬렉션 N건을 빼야 한다.

6.2 실측 — 조회량이 N에 정확히 비례한다

먼저 N의 의미를 못박는다. N은 전체 테이블 크기가 아니라 한 요청에서 반환한 FeedItem 수다. 이 랩에서는 데이터셋 크기와 page size를 모두 N으로 설정했다(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
총 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<FeedItem>을 반환하기 때문이다. 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로 부르지 않고 "중앙값/최댓값(5회)"로 표기한다(실제 코드의 p99 인덱스도 5개 중 최댓값을 가리킨다). 안정적 꼬리 지연을 주장하려면 warm-up 후 100회 이상·독립 세트 여러 개가 필요하지만, L1의 관심사는 꼬리 지연이 아니라 N에 따른 왕복 증가이므로 여기서는 이 정도로 둔다.

세 조회 지표 모두 N을 따라 직선으로 증가한다. 특히 하이라이트 컬렉션 초기화는 기울기 1의 직선(= N)이라 "조회량이 N에 정비례"함이 한눈에 드러난다.

FeedItem N개를 반환하는 loadFeed 요청이 컬렉션 초기화 N회와 Highlight SELECT N회로 이어지는 인과 흐름도.

Diagram description

왼쪽의 loadFeed 요청은 한 페이지에서 N개의 FeedItem을 반환한다. 매핑이 각 부모의 Highlight 컬렉션에 접근하면 컬렉션 초기화가 부모마다 한 번씩 일어나 총 N회가 된다. 현재 기준선에서는 배치나 서브셀렉트가 없어 초기화 한 번마다 Highlight SELECT도 한 번 실행되므로 추가 조회가 N회 발생한다. 각 SELECT는 해당 부모의 Highlight 자식 행을 전부 읽는다.

Editable source · Grounded VizSpec

이 관찰은 서로 다른 두 위반을 동시에 드러낸다. "하이라이트 수와 무관한 조회량"이라는 요구가 깨지는데, 깨지는 방식이 하나가 아니다.

  • N+1(왕복). collectionFetches = N은 한 요청에서 반환하는 FeedItem(부모) 수에 비례해 DB 왕복이 는다. Highlight 수에 비례하는 게 아니다 — 아이템마다 컬렉션 초기화 1회씩이라 부모 수만큼 왕복한다.
  • 과조회(행수). 그 한 번의 왕복이 해당 FeedItem의 Highlight를 전부(머리는 최대 500행) 읽어 온다. 반환 행수·전송량·엔티티 생성이 자식 수에 비례해 는다(SQL shape로 §6.4에서 확인).

부모 수에 따른 왕복 증가와 자식 수에 따른 과조회가 같은 기준선에 동시에 존재한다.

"page size를 20으로 고정하면 N+1도 20으로 고정 아닌가?" 맞다. 한 요청의 왕복 수는 page size에 묶인다. 그러나 그 요청당 20회 왕복이 트래픽에 곱해진다.

추가 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 방식과 distinct 연관 수의 결합으로 정해진다

총 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).

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 B는 예정이다. Plan A의 rows=1 vs 실제 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=0warm 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번, 조회도 N번. 지연 로딩이 스트림 뒤에 반복을 감췄다. 편의를 주는 대신 조회 시점을 코드에서 감추는 새는 추상화다.


7. User·Page 연관 숨은 추가 쿼리 정량화

§6은 자식 컬렉션(highlights) 조립 몫을 격리했다. 그런데 총 PreparedStatement에서 그 몫을 빼도 User·Page 연관 몫이 남는다 — §6.3에서 시더 카디널리티로 역산해 파생값(13 / 120 / 1,020)으로 미리 갈라 둔 그 값이다. 이 절은 같은 분해를 엔티티별 fetch 통계로 직접 측정해 파생 예측을 확정하고, 컬렉션 N+1(N1)과 다른 N2만의 성격 — 같은 즉시 로딩인데 정반대 곡선 — 을 드러낸다. N2는 새로 짓는 코드가 없다. 같은 순진 조회(loadFeed)를 재는 지표만 바꾼다.

7.1 ToOne 몫만 직접 격리한다 — 총계 역산이 아니라 엔티티 fetch 통계로

§6.1이 컬렉션 몫을 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로 초기화된 엔티티 수다(§6.1의 컬렉션 지표와 같은 성격). 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.

검산(§6.3 파생과 일치): entityFetch = pageFetch + userFetch10+3=13 · 100+20=120 · 1000+20=1020 ✓. 회계 항등식으로도 총 PreparedStatement 컬렉션 N content(1) count(1) = entityFetch25102=13 · 2221002=120 · 202210002=1020 ✓. §6.3에서 역산했던 13 / 120 / 1,020을 직접 측정이 그대로 재현했다 — 파생 예측이 실측으로 확정됐다.

같은 @ManyToOne(EAGER)인데 Page fetch는 N을 따라 선형(10 → 100 → 1,000)으로 서고 User fetch는 20에서 평탄해진다. 이유는 §6.3에서 이미 갈랐다 — Page는 아이템당 고유(dedup 없음)라 정확히 N번, User는 소수 풀(시더 users=max(3,min(20,N/5+1)))을 재사용하고 한 번 로드된 대상이 1차 캐시에 남아 distinct 수만큼만 나간다. N+1의 유무는 코드(EAGER)가 정하고, 곡선의 기울기는 데이터(카디널리티)가 정한다.

지연은 §6.2와 같은 loadFeed 호출을 잰 것이므로 별도 지연 축이 아니다. N2는 그 한 번의 조회가 만드는 왕복을 fetch 종류별로 분해했을 뿐, 새로운 지연을 만들지 않는다.

7.3 접근하지 않아도 나간다 — "안 짠 N+1"의 스모킹건

§6.5는 "코드에 루프가 없는데 N+1"을 컬렉션 관점에서 봤다(지연 로딩이 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번 나갔다 = 내가 안 짠 N+1. 같은 조건에서 지연 로딩 컬렉션은 접근이 없으니 0이다. 이 한 테스트가 EAGER와 LAZY의 결정적 차이를 보여준다 — EAGER는 안 써도 로딩하고, LAZY는 접근할 때만 로딩한다. §5.4에서 명제로 둔 "EAGER는 로딩 시점 계약"의 실측 증명이다: EAGER의 죄는 "필요와 무관하게 미리 로딩한다"는 것이다.

7.4 같은 실행계획, 정반대 비용 — 반복되는 ToOne 부모 쿼리

§6.4가 반복되는 자식 컬렉션 쿼리를 실행계획으로 뜯었듯, 여기서는 N2를 만드는 반복되는 ToOne 부모 쿼리(SELECT * FROM pages WHERE id = ?, … FROM users WHERE id = ?)를 본다. 아래는 seed(100) 직후의 계획이다(원문: evidence/explain/toone-pages-plan.txt · toone-users-plan.txt).

-- 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에 가져온다. §6.4의 자식 쿼리와 같은 반전이다 — 개별 쿼리는 빠른데 그게 Page는 N번 반복된다.

여기서 N2만의 요점이 드러난다. pages와 users의 실행계획은 사실상 동일하다(둘 다 pk Index Scan, ~0.02 ms). 그런데 §7.2에서 곡선은 정반대였다. 즉 비용을 가르는 것은 실행계획이 아니라 그 계획이 몇 번 반복되는지다 — Page는 N번, User는 distinct ≤20번. EXPLAIN만 보면 둘이 똑같아 보이는 것이 바로 '숨은' N+1의 얼굴이다. 단건 계획이 이미 최적(Index Scan)이라 인덱스로는 안 풀리고, 왕복 횟수 자체를 줄이는 fetch 전략으로만 풀린다(§9). warm cache·executor 시간 caveat는 §6.4와 같다.

7.5 왜 루프도 접근도 없는데 N+1인가 — 기전

@ManyToOne은 fetch를 명시하지 않으면 기본 EAGER다(§5.4). 그리고 파생 쿼리(findAllBy)는 EAGER 연관을 루트 SQL의 JOIN으로 자동 병합하지 않고 행마다 2차 SELECT로 채운다. 그래서 getUser()·getPage()읽기도 전에 이미 나가 있다 — 코드엔 루프도 접근도 없는데 N+1이다. '숨은' 이유는 둘이다: (1) 애너테이션 기본값이라 코드 표면에 안 보이고, (2) 심각도는 카디널리티가 정한다(Page 고유 → N, User 풀 → 평탄). 같은 EAGER, 정반대 곡선.

fetch 계약(EAGER/LAZY)과 실제 사용(접근/미접근)을 교차하면 EAGER의 죄가 정확히 어디인지 드러난다.

접근 안 함 접근함(loadFeed)
EAGER(현재 User·Page) 나간다 — 낭비(안 짠 N+1) 나간다 (즉시 로딩 N+1)
LAZY(가정) 안 나간다 나간다 (지연 로딩 N+1) — timing만 다름

loadFeed는 매핑에서 user·page를 실제로 쓰므로, 즉시 로딩을 지연 로딩으로 바꿔도 이 조회에선 N+1이 (타이밍만 바뀐 채) 그대로 재현된다. 그래서 진짜 해법은 fetch 타입 토글이 아니라 fetch 전략이다 — 한 번에 끌어오거나(Fetch Join), 배치로 묶거나(Batch Fetch), 필요한 컬럼만 뽑는(DTO Projection) 것. 그 시도가 낳는 문제 연쇄가 §9다.


8. 확인된 문제와 이후 검증할 가설

지금까지 드러난 것은 서로 다른 두 축이고, 이후 진단에서 둘을 섞으면 안 된다. 한쪽은 이미 정량화한 문제이고, 다른 한쪽은 아직 병목인지 확정하지 못한 가설이다.

축 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(N1, §6)과 User·Page 연관의 숨은 쿼리(N2, §7)를 둘 다 정량화했다 — 같은 순진 조회가 낳은 형제 문제다. 자연스러운 첫 해법 착상은 "N번 나눠 갈 걸 한 번에 가져오자" — 연관(user·page·highlights·mentions)을 전부 join fetch로 루트 SQL에 병합하는 것이다. 그런데 이 착상은 컬렉션에서 두 번 터진다: ① 컬렉션을 동시에 fetch join하면 곱집합이라 Hibernate가 아예 거부하고(MultipleBagFetchException), ② 하나만 fetch join해도 부모⋈자식이 카테시안 곱으로 전송 행을 부풀린다. N1/N2가 "쿼리 수가 N에 비례해 는다"였다면, 여기서는 정반대로 쿼리 수는 오히려 줄어드는데(1+N→1) 전송 행수가 곱으로 폭발한다 — 지표를 쿼리 수에서 전송 행수로 갈아 끼워야 보이는 함정이다.

이 절은 "재현·측정"이 아니라 "시도 → 실패"다. §6·§7이 순진 조회를 그대로 두고 잰 것과 달리, 여기서는 fetch join을 직접 써서 터뜨린다. .distinct()·List→Set·@BatchSize로 "고치는" 것은 이 실패가 낳는 다음 문제(페이징 무력화 → Batch Fetch)로 이어지는 사슬을 지우므로, 이 절에서는 실패를 격리해 남기기만 한다(해법은 §11 이후).

9.1 두 번째 컬렉션(mentions)을 퍼시스턴스에만 최소로 붙인다

MultipleBagFetchException은 컬렉션이 둘 이상이어야 재현된다. 기준선 스키마(§3.1)에는 highlights 하나뿐이라, §3.1의 목표 스키마에 있던 feed_item_mentions여기서 앞당겨 붙인다. 단, 이 랩이 필요로 하는 것은 "fetch join할 두 번째 컬렉션(bag)"뿐이므로 퍼시스턴스 계층까지만 추가한다 — 마이그레이션(V7__feed_mentions.sql) + 자식 엔티티(FeedItemMentionJpaEntity, HighlightJpaEntity와 같은 경량 자식·감사 컬럼 없음) + 부모의 @OneToMany List<…> mentions 한 줄 + 시더. 도메인 애그리거트·응답 매핑·공개 범위 판정은 이 랩 밖이다(그건 §3.1이 말한 "공개 범위 단계").

핵심 — N1/N2 측정 불변: mentions@OneToMany 기본 LAZY이고 loadFeed도 §7.3의 "접근 0" 테스트도 getMentions()를 부르지 않는다. 그래서 §6·§7의 단언(collectionFetches == N, 접근 0에서 == 0, pageFetch == N)은 그대로 GREEN이다 — 재실행으로 확인했다. 이 컬렉션은 오직 아래 fetch join 착상이 끌어올 두 번째 bag으로만 존재한다.

한 가지 구현 정직성: 목표 스키마(§3.1)의 feed_item_mentions는 복합 PK (feed_item_id, mentioned_user_id)지만, 이 랩의 엔티티는 highlights와 같은 대리키(id) + UNIQUE(feed_item_id, mentioned_user_id) 스타일로 붙였다(@OneToMany List bag 매핑이 복합키보다 단순하고, 유일성은 UNIQUE로 동일하게 보장). 시더는 MENTIONED 아이템에만 유저 풀 크기 안에서 몇 명씩 심는다(풀보다 많이 심으면 UNIQUE 위반이라 min(2+i%4, poolSize)로 상한).

9.2 실패 ① 두 컬렉션 동시 fetch join → MultipleBagFetchException

bag = 순서 컬럼(@OrderColumn)이 없는 List. highlightsmentions도 bag이다. 둘을 동시에 fetch join하면 feed_item 한 행이 (highlights h개) × (mentions m개) = h×m 행으로 부푼다. Hibernate는 이 곱집합을 안전히 중복 제거로 되돌릴 수 없다고 판단해 쿼리 생성(createQuery) 시점에 예외를 던진다 — 데이터가 0건이어도 던지는 매핑 레벨 거부다.

// 착상: "연관 전부 fetch join" — 컬렉션 둘을 동시에
select distinct f from FeedItemJpaEntity f
  join fetch f.highlights
  join fetch f.mentions

측정값(직접 측정). 출처 FeedPersistenceIT.l3TwoBagFetchJoinThrowsMultipleBagFetchException. 예외 원인 체인(콘솔 원문):

java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException

여기서 실측이 알려준 실무 포인트 하나: MultipleBagFetchExceptionIllegalArgumentException으로 래핑돼 나온다(FQN도 org.hibernate.loader.MultipleBagFetchException). 그래서 테스트를 hasCauseInstanceOf(MultipleBagFetchException.class)로 잡으면 래핑 계층·버전 차이에 취약하다 — 원인 체인을 클래스명 문자열로 펼쳐(causeChain) contains("MultipleBagFetchException")로 확인하는 편이 견고하다. (Hibernate ORM 7.1.8 기준.)

9.3 실패 ② 컬렉션 하나만 fetch join → 카테시안 (전송 행수 폭발)

컬렉션을 하나만(highlights) fetch join하면 예외는 안 나지만, feed_items ⋈ highlights부모를 자식 수만큼 곱한 행을 만든다. L3의 스타 지표는 그래서 쿼리 수가 아니라 전송 행수 — DB가 실제로 만들어 앱으로 실어 나른 조인 행수다.

⚠ 측정 정정(Hibernate 6+/7) — 낡은(H5) 멘탈모델은 "distinct 없는 결과 리스트 크기 = Σ highlights(전송 행수)"였다. 실측은 이를 반증한다. select f from FeedItemJpaEntity f join fetch f.highlights의 결과 리스트 크기는 N(10/100/1000)으로 나온다 — Hibernate 6+가 fetch join의 루트 엔티티를 자동 dedup하기 때문이다. 카테시안은 SQL/전송 레벨에 그대로 있으므로, 전송 행수는 리스트 크기가 아니라 실제 조인 카디널리티로 잰다: SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id. 이게 더 정직한 L3다 — "쿼리 수도 줄고(§9.4) 리스트 크기마저 N으로 dedup되어 카테시안이 이중으로 숨는다. EXPLAIN actual rows(§9.5)나 조인 count로만 드러난다."

측정값(직접 측정). 출처 FeedPersistenceIT.l3SingleCollectionFetchJoinExplodesTransferredRows(N=10/100/1000). 원본: 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의 시드 하이라이트 총량(Σ)과 정확히 일치하는 것에 주목한다: 조인이 highlights 테이블의 모든 자식 행을 부모에 곱해 실어 나른 것이다. 폭발 배수(128.5× → 19.6× → 2.9×)는 N이 커질수록 줄지만(§4.3의 Zipf 편중 때문 — 꼬리 아이템은 highlight 1개라 곱이 작다), 절대 전송 행수는 언제나 Σ highlights ≫ N이다. "원한 건 N개 아이템인데 나른 건 Σ highlights 행"이 한 줄로 드러난다.

9.4 쿼리 수는 오히려 줄어든다 — L3의 함정

같은 데이터에서 순진 loadFeed(§6.2)는 총 222 PreparedStatement였는데(N=100), 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개가 base 조인으로 접힌 것(N1이 사라짐)이다(나머지 1건 차이는 원시 JPQL이라 count가 없는 측정 차이). "N+1 없앴다"고 쿼리 수만 보면 개선처럼 보인다. 둘째, 그런데 121 중 120은 여전히 ToOne 2차 SELECT(N2) 다 — highlights 하나만 fetch join했으니 User·Page의 숨은 N+1은 그대로다. 그리고 그 접힌 조인 한 방이 §9.3에서 본 대로 1,961행을 실어 나른다. 비용은 사라진 게 아니라 쿼리 수에서 전송 행수·메모리로 옮겨갔을 뿐이고, 애초에 두 컬렉션을 합치려던 원래 착상은 §9.2에서 아예 거부당했다.

9.5 조인이 행을 곱하는 것을 실행계획에서

§6.4는 반복되는 자식 단건 쿼리를, §7.4는 반복되는 부모 단건 쿼리를 봤다. 여기서는 조인 한 방을 본다. 아래는 seed(100) 직후, fetch join이 발행하는 조인과 같은 shape를 EXPLAIN한 것이다(원문: evidence/explain/l3-cartesian-join-plan.txt).

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(순서 없는 List) 둘을 동시에 join fetch하면 feed_item 한 행이 highlights h개 × mentions m개로 곱해진다. Hibernate는 이 곱집합을 안전히 원래 컬렉션들로 되돌릴 수 없어 쿼리 생성 시점에 MultipleBagFetchException을 던진다(§9.2). 하나만 join해도 부모⋈자식이 자식 수만큼 행을 곱한다(카테시안, §9.3). 쿼리 수는 1+N→1로 줄지만(§9.4의 함정) 전송 행수·메모리가 그만큼 늘고, Hibernate 6+의 루트 dedup이 리스트 크기마저 N으로 만들어 그 폭발을 숨긴다. fetch join은 ToOne엔 이상적이고(단건 조인으로 N2를 접을 수 있다) 컬렉션엔 함정이라는 게 이 절의 결론이다 — 그리고 이 결론이 다음 문제(하나만 fetch join하되 페이징을 걸면?)로 이어진다(§10).


10. 컬렉션 fetch join + 페이징 — 페이지를 원했는데 데이터셋 전체를 올린다

§9.6의 결론은 "fetch join은 컬렉션엔 함정"이었다. 그래도 남는 미련은 "그럼 컬렉션은 하나만(highlights) fetch join하되, 응답은 어차피 한 페이지니 페이징을 걸면 되지 않나"다 — §9.3에서 카테시안이 무서웠던 이유가 "전부 실어 나른다"였으니, setMaxResults(20)로 앞부분만 받으면 해결처럼 보인다. 그런데 이 후퇴는 세 번째로 터진다: 컬렉션 fetch join에 페이징을 걸면 Hibernate가 HHH000104 경고를 찍고 DB LIMIT 없이 결과셋 전체를 메모리로 올려 부모 기준으로 잘라낸다(인메모리 페이징).

여기서 지표가 또 갈아 끼워진다. §6·§7은 쿼리 수, §9는 전송 행수였다. §10의 함정은 그 다음 층 — returned(반환한 페이지 크기)만 보면 "페이징 정상"으로 착각한다. 실제로 하이드레이트한 부모는 데이터셋 전체이므로, 스타 지표는 returned가 아니라 **feedItemLoaded(실제로 메모리에 올린 부모 엔티티 수)**다.

이 절도 "시도 → 실패"다. §9와 마찬가지로 fetch join을 직접 써서 터뜨린다. 여기서 @BatchSize·엔티티만 페이징·DTO Projection·fail_on_pagination_over_collection_fetch=true로 "고치는" 것은 이 실패가 낳는 다음 고리(Batch Fetch, §11)를 지우므로, 이 절에서는 실패를 격리해 남기기만 한다.

10.1 무대 — 새 프로덕션 코드 0 (§9 무대 + 페이징 한 줄)

§9가 두 번째 컬렉션(mentions)을 퍼시스턴스에 붙였다면, §10은 그 무대를 그대로 두고 highlights 하나짜리 fetch join에 페이징 한 줄만 더한다 — 새 엔티티·마이그레이션·시더·프로덕션 코드가 전혀 없다. 그 fetch join은 프로덕션(FeedQueryAdapter)에 없고, §7.3의 "접근 0" 테스트나 §9의 fetch join 테스트처럼 IT 안에서 원시 JPQL로 세운다.

// 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로 켜면 같은 쿼리가 예외로 즉시 실패하는데, 그건 "조용한 함정"을 "시끄러운 실패"로 바꿀 뿐 근본 해결(§11 Batch Fetch)은 아니다 — 다만 운영에선 안전밸브로 켜 둘 값어치가 있다.

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)로 정확히 격리한다 — 응답 크기(resultList.size())가 아니라 "메모리에 올린 부모 수"가 스타다.

측정값(직접 측정·파생). returned·feedItemLoaded는 결정적(리스트 크기·Hibernate 통계로 확정), over-fetch 배수는 feedItemLoaded / returned로 파생한다. 출처 FeedPersistenceIT.l4CollectionFetchJoinPagingLoadsWholeDatasetInMemory. 원본: 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 배수 = N / 페이지 크기로 선형 증가(1.0× → 5.0× → 50.0×)한다. 셋째, N=10에선 배수가 1.0×라 함정이 안 보인다 — 데이터셋이 페이지보다 작으면(N ≤ 페이지) feedItemLoaded == returned라 정상처럼 통과하고, 운영 데이터(큰 N)에서만 힙·지연이 터진다. "개발/테스트 시드를 통과하고 운영에서만 폭발한다"의 수치적 정체다.

getLoadCount()인가 (지표 이름 정확히): fetch join 쿼리는 부모(FeedItem)를 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 리스트에서 first/max를 자르므로, returned가 페이지 크기여도 getLoadCount() == N이다 — "페이지를 원했는데 전체를 로드"의 정확한 통계 증거다. (getCollectionFetchCount()는 join으로 로드된 컬렉션엔 안 잡힐 수 있어 §10 신호가 아니다. §6.1의 컬렉션 지표, §7.1의 엔티티 지표와 같은 성격의 이름 구분이다.)

그리고 이 쿼리가 던지는 경고 자체가 §10의 얼굴이다.

⚠ 측정 정정(Hibernate 7) — 경고 코드는 HHH000104가 아니라 HHH90003004다. 널리 알려진 코드는 HHH000104지만, 이 랩의 Hibernate ORM 7.1.8이 실제로 찍은 WARN(Logback ListAppender로 캡처)은 코드 번호만 재부여됐다:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

메시지 본문은 그대로다(firstResult/maxResults specified with collection fetch; applying in memory) — Hibernate 6→7에서 메시지 코드가 재번호됐을 뿐이다(§9.3의 "Hibernate 6+ 루트 dedup" 정정과 같은 결의 버전 드리프트). 그래서 회귀가드는 코드 번호에 매달리지 말고 contains("HHH000104") || contains("collection fetch")처럼 문구로도 매칭해 버전 차이에 견고하게 둔다.

10.3 비용은 페이지가 아니라 데이터셋에 비례한다

응답은 한 페이지인데 비용은 N에 비례함을 잰다. 다만 여기서 정직해야 한다 — 이 값들은 문서 최상단 한계 선언대로 단일 스레드·warm-cache 상대값이라 절대값이 아니라 N에 따른 방향으로만 읽는다(그래서 hash-anchor하지 않고 whitelist로 둔다; 원본: 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을 따라 오른다 = "페이징이 데이터를 안 줄였다"의 시간·메모리 증거다.

여기서 §10만의 정직한 반전이 하나 있다. 이 fetch join 지연은 순진 조회(§6.2)보다 오히려 낮다 — N=1,000에서 순진 조회 최댓값 238.4 ms vs 이 fetch join 83.526 ms. 컬렉션 N개 왕복이 조인 한 방으로 접혔으니 지연만 보면 "빨라졌다"고 착각한다. 그래서 더 위험하다. §10의 진짜 비용은 벽시계 지연이 아니라 메모리 과적재다 — 페이지엔 몇 건만 필요한데 N개 부모(그리고 그들에 매달린 Σ highlights 행)를 전부 하이드레이트하느라 할당이 데이터셋을 따라 오른다(≈1.5 → ≈10.0 MB). 지연으로는 안 보이고 힙 압박·GC로 드러나는 함정이다.

왜 "힙 델타"가 아니라 스레드 누적 할당인가: 반환 직후 인메모리 페이징이 버린 부모(N − 페이지 크기 개)는 곧 GC돼 used heap before/after 델타를 0에 가깝게 만든다 — §10의 위험을 오히려 숨긴다. getThreadAllocatedBytes(HotSpot)는 GC와 무관하게 이 호출이 만든 할당 전량을 누적하므로 버려지는 과적재까지 잡는다.

10.4 발행 SQL엔 LIMIT이 없다 — 인메모리 페이징의 스모킹건

§9.5가 조인 한 방이 행을 곱하는 것을 봤다면, §10은 그 조인에 페이징을 걸어도 SQL엔 LIMIT이 안 붙는다를 본다. fetch join이 발행하는 조인 SQL(a)과, 엔티티만 페이징한 SQL(b)을 대조 EXPLAIN한다(seed(100), 원문: evidence/explain/l4-collection-join-no-limit.txt · l4-entity-paging-limit.txt).

-- (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은 부모⋈자식이라 부모 한 행이 자식 수만큼 곱해진 행으로 나온다(§9.3의 카테시안). 여기에 DB LIMIT을 걸면 "20개 부모"가 아니라 "20개 조인 행"을 자르게 되어, 어떤 부모는 하이라이트가 잘린 **반쪽(손상)**으로 로드된다. Hibernate는 이 손상을 피하려고 LIMIT을 SQL에서 빼고 조인 결과 전체를 읽어 메모리에서 부모 기준으로 first/max를 적용한다(HHH90003004, §10.2). 그래서 응답은 페이지 크기처럼 보여도 실제론 N개 부모 전부를 하이드레이트한다 — (a)에 Limit 노드가 없고 전체 행을 정렬하는 §10.4가 그 계획 레벨 증거다. 컬렉션 fetch join은 페이징과 공존 불가이고, 이게 fetch join이 ToOne엔 이상적이지만(단건 조인으로 N2를 접는다) 컬렉션엔 (§9의 카테시안 + §10의 페이징 불가) 이중 함정인 이유다.

그리고 이 결론이 다음 수를 정한다. fetch join을 버리고 엔티티만 페이징하면 §10.4의 (b)처럼 LIMIT이 정상 발행된다. 다만 그러면 highlights가 다시 LAZY라 §6의 컬렉션 N+1이 페이지 크기만큼 돌아온다 — 그 나머지 절반(부모 키를 모아 IN으로 접기)이 §11의 Batch Fetch다.


11. 배치 페치 — 엔티티 페이징 + IN 배치로 처음 제대로 푼다 (착상 → 해결)

§6~§10은 전부 "문제"였다 — 컬렉션 N+1(§6), ToOne 숨은 N+1(§7), fetch join 카테시안(§9), fetch join 페이징 불가(§10). §10의 마지막 착상은 "fetch join을 버리고 엔티티만 페이징 + 연관은 IN 배치"였다. §11은 그 착상을 실행해 처음으로 제대로 푸는 절이다. 세션 설정 한 줄(hibernate.default_batch_fetch_size=100)이면 순진 loadFeed 코드를 한 글자도 안 고치고 N+1이 배치로 접히고, fetch join이 없으니 페이징이 DB LIMIT으로 정상 발행된다. 지표가 이 문서에서 처음으로 before → after를 가진다.

이 절은 "재현·측정"도 "시도→실패"도 아니다 — "착상 → 해결"이다. §6~§10과 달리 fix가 있다. 그리고 그 fix는 격리해서 측정한다: default_batch_fetch_size는 세션 전역이라 §6~§10을 재는 어댑터 테스트에 넣으면 그 단언들이 깨진다. 그래서 새 IT 클래스(FeedBatchFetchIT)에 이 설정만 얹어 잰다 — §6~§10 측정은 byte 단위로 그대로 GREEN(회귀 0, 실측 확인).

11.1 fix는 세션 설정 한 줄 — 순진 loadFeed 코드는 그대로

배치 페치는 두 부분이다. (A) 페이징을 fetch join이 아니라 엔티티만에 건다(→ DB LIMIT 정상, 카테시안 없음). (B) LAZY 연관은 부모 키를 모아 IN 배치로 채운다(→ N+1이 ceil(N/batch)로 접힘).

# application.yml (프로덕션) 또는 테스트 @TestPropertySource — 애플리케이션 코드 변경 0:
spring.jpa.properties.hibernate.default_batch_fetch_size: 100

loadFeed(§5.1)는 그대로다 — findAllBy(Pageable)(엔티티 페이징 → LIMIT) + map에서 LAZY 연관 접근. §6에서 N+1이던 바로 그 코드가, 이 설정 한 줄로 배치가 된다. (프로덕션 권장 = 전역 안전 기본값 이 한 줄, 또는 특정 컬렉션만 @BatchSize(size=100). 후자는 정적이라 순진 조회까지 바꿔 §6 측정을 깨므로 랩은 세션 property로 격리한다.)

11.2 실측 — 쿼리 수가 접힌다 (before/after 스타)

loadFeed(0, n)(§6.2와 정확히 같은 호출)을 배치 세션에서 재면 SQL 총량이 순진의 1+N에서 급감한다. before = §6.2, after = FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount. 원본: 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가 순진의 선형(1+N: 25 / 222 / 2,022)에서 준평탄(1+ceil(N/batch)·연관: 5 / 5 / 23)으로 접힌다 — N=1,000에서 87.9× 붕괴. 둘째, ToOne(user/page EAGER)도 같은 배치에 걸려 §7의 page 선형 N+1이 함께 사라진다(after 23 = 1 루트 + 1 count + 10 highlights 배치 + 10 page 배치 + 1 user 배치). 셋째, §6.1이 예고한 "컬렉션 수 = SQL 수" 등식 깨짐이 실측된다 — 단, 방향이 예상과 달랐다(아래 정정).

★ 실측 정정 — getCollectionFetchCount()는 배치에서 N이 아니라 ceil(N/batch)로 떨어진다: §6.1은 "getCollectionFetchCount() = 초기화된 컬렉션 수라 배치를 켜도 그대로 N, 변하는 건 SQL 수(prepared)뿐"이라 적었다. 실측(batch=100)은 이를 반증한다 — 컬렉션 fetch가 §6의 N(10 / 100 / 1,000)에서 배치의 **1 / 1 / 10 = ceil(N/batch)**로 떨어진다. 즉 이 지표는 "초기화 수"가 아니라 컬렉션을 채운 fetch SELECT 연산 수다 — 배치가 여러 컬렉션을 한 SELECT로 채우면 그만큼 준다. 그래서 배치 해결의 증인은 prepared(SQL 총량)와 collectionFetch(컬렉션 fetch 연산 수) 둘 다다. (§9.3의 "Hibernate 6+ 루트 dedup", §10의 "HHH000104HHH90003004"와 같은 결의 지표 정정 — ORM 지표 이름을 실측으로 재확인.)

11.3 페이징이 DB로 내려간다 — over-fetch 소멸 (§10 정면 대조)

§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의 over-fetch(feedItemLoaded=N)가 소멸한다 — 인메모리 페이징이 아니라 DB LIMIT이라 정확히 페이지 크기만 자른다. §10 표(N을 따라 오르는 곡선)와 이 표(페이지 크기에 평탄한 곡선)를 겹치면 그 간격이 배치+엔티티페이징의 이득이다.

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 · l5-batch-in-semijoin.txt).

-- (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

(a)에 Limit 노드 존재 = §10의 인메모리 페이징 해소(DB가 페이징을 한다). (b) semi-join이 자식 행만 반환(부모 M + 자식 K, M×K 아님) = §9의 카테시안 소멸. 한 계획 대조가 §9·§10 두 실패를 동시에 해소했음을 계획 레벨로 보인다. (컬럼명·리터럴 하드코딩이라 인젝션 무관. warm cache·executor 시간 caveat는 §6.4와 같다.)

11.5 왜 배치는 N+1과 페이징을 동시에 푸나 — 기전

fetch join(§9·§10)은 부모⋈자식 조인이라 행을 곱했다 — 그래서 카테시안(전송 폭발, §9)이고, DB LIMIT은 "N개 부모"가 아니라 "N개 조인 행"을 잘라 페이징이 무너졌다(§10). 배치는 두 부분으로 정반대를 한다. (A) 페이징을 엔티티만에 건다 — 루트 쿼리에 컬렉션 조인이 없으니 행이 안 곱해지고 DB LIMIT이 정확히 페이지 부모를 자른다(§11.4 (a)에 Limit 노드). (B) 자식은 부모 키를 모아 WHERE fk IN (?,…) 한 방으로 채운다 — default_batch_fetch_size=B가 미초기화 프록시를 최대 B개씩 모아 ceil(N/B) 번에 로드한다. 조인이 아니라 별도 IN이라 부모 M행 + 자식 K행 = M+K(합)이지 M×K(곱)가 아니다(§11.4 (b) semi-join). 그래서 §6(컬렉션 N+1)·§7(ToOne N+1)·§9(카테시안)·§10(페이징 불가)를 한 착상으로 동시에 푼다 — PreparedStatement 1+N → 1+ceil(N/batch)·연관(2,022→23), 페이징 정상, over-fetch 소멸(feedItemLoaded N→페이지 크기). 컬렉션엔 fetch join이 아니라 배치가 답이다.

11.6 배치가 못 푸는 것 — 엔티티 과적재 (→ §12/L6)

배치는 쿼리·페이징을 풀었지만 엔티티를 통째로 하이드레이트한다. FeedBatchFetchIT.l5ProbeBatchStillHydratesFullEntities(원본: evidence/metrics/l5-hydration-probe.csv): 페이지 20건 조회(seed 1,000)에도 1,569 엔티티(FeedItem+User+Page+Highlight)를 영속 객체로 올린다 — 전 컬럼 SELECT·영속성 컨텍스트 적재·더티체킹 후보. (페이지 20건인데 1,569인 이유: 정렬키 상 상위 아이템이 §4.3 편중 시드의 highlight-heavy 머리라 Σhighlights가 크다.) 화면(FeedSummary)엔 몇 컬럼만 필요하므로, 이 과적재가 DTO 프로젝션(§12)의 동기다.


12. DTO 프로젝션 — 엔티티를 안 만들어 과적재를 없앤다 (착상 → 해결)

§11(배치)은 "몇 번의 SQL로 가져오나"(왕복 축)를 풀었지만, 화면 조회가 엔티티를 통째로 하이드레이트하는 잔여 비용을 남겼다(§11.6의 1,569 엔티티). §12는 그 다음 고리 — 필요한 컬럼만 프로젝션하면 엔티티가 아예 안 만들어진다. SELECT new <carrier>(...)는 스칼라 값만 뽑으므로 Hibernate가 영속 엔티티를 인스턴스화하지 않는다 → getEntityLoadCount()1,569에서 0으로, 영속성 컨텍스트 미적재, 더티체킹 0. 이 문서의 두 번째 before/after이자, §11(왕복 축)과 **직교하는 "적재 형태 축"**의 해법이다.

이 절도 "착상 → 해결"이다. §11처럼 fix가 있다. 다만 §11의 fix는 설정 한 줄이었고 §12의 fix는 실제 쿼리다. 그래서 순진 loadFeed(§6~§11이 재는 대상)를 고치면 그 랩들이 깨진다 — §11이 sibling IT 클래스로 격리했듯, §12는 순진 loadFeed를 그대로 두고 어댑터에 **sibling 메서드 loadFeedProjection**를 더해 격리한다. §6~§11 측정은 byte 단위 그대로 GREEN(회귀 0, 실측 확인).

12.1 fix는 두 개의 스칼라 프로젝션 — 엔티티 대신 필요 컬럼만

프로젝션은 두 부분이다. (A) 부모의 필요 스칼라 컬럼만 페이징으로 프로젝션(컬렉션 조인 없음 → LIMIT 정상, 카테시안 없음). (B) 그 페이지 부모들의 자식을 필요 스칼라 컬럼만 IN으로 프로젝션 → 메모리 그룹핑.

// 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<HighlightSummary>SELECT new FeedSummary(...) 한 방으론 못 만든다(생성자 표현식은 컬렉션을 못 채운다) — 그래서 부모/자식 스칼라 캐리어 둘로 나눠 프로젝션한 뒤 메모리에서 조립한다. (프로덕션-정직한 진화는 이 메서드를 FeedQueryPort의 CQRS-lite 프로젝션 계약으로 노출하고 loadFeed를 대체하는 것 — 랩은 회귀 격리를 위해 sibling 메서드로 둔다.)

12.2 실측 — 엔티티가 0으로 (before/after 스타)

loadFeedProjection(0, 20)(페이지 20, seed 1,000)을 §11 배치와 대조하면 하이드레이트한 엔티티가 소멸한다. before = §11(FeedBatchFetchIT), after = FeedProjectionIT.l6ProjectionHydratesZeroEntities. 원본: 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 <carrier>(...)는 스칼라 컬럼만 뽑아 캐리어 record를 만들 뿐 FeedItemJpaEntity/UserJpaEntity/PageJpaEntity/HighlightJpaEntity 영속 엔티티를 인스턴스화하지 않는다. 조인(join f.user u)은 u.name 컬럼에 닿기 위한 것이지 User를 하이드레이트하는 게 아니다. 그래서 영속성 컨텍스트에 아무것도 안 붙고 더티체킹 후보 0. 둘째, prepared가 상수 2(부모 스칼라 + 자식 IN)로 N과 완전 무관해진다(아래 §12.3). 셋째, collectionFetch가 0 — 엔티티 컬렉션을 초기화하지 않는다(자식은 별도 스칼라 프로젝션이라 §11의 컬렉션 fetch 연산조차 없다).

12.3 쿼리가 N에 평탄해진다 — 상수 2 (§6·§11 삼중 대조)

prepared를 N∈{10, 100, 1000}에서 재면 상수 2다. §6 순진(1+N 선형)·§11 배치(1+ceil(N/batch) 준평탄)와 겹치면 세 곡선의 성격이 드러난다.

N §6 순진(1+N) §11 배치(1+ceil(N/batch)·연관) §12 프로젝션(상수)
10 25 5 2
100 222 5 2
1,000 2,022 23 2

§6은 선형(N을 따라 오른다), §11은 준평탄(배치 크기로 접힌다), §12는 평탄(부모 스칼라 1 + 자식 IN 1 = 2, N 무관 — 페이지 부모가 ≤20이라 자식 IN은 항상 한 방). 엔티티 로드도 §11 ≈Σ(page)(seed1000=1,569) vs §12 0으로 평탄해진다. "무엇을 적재하나" 축의 절감이다.

12.4 EXPLAIN — Limit·semi-join은 있으나 width는 좁아지지 않는다 (★ 실측 정정)

§11의 D2는 "엔티티 페이징엔 Limit 노드"였다. 프로젝션도 (a) 부모 페이징에 Limit이 있고 (b) 자식 IN은 semi-join이라 행을 안 곱한다(원문: evidence/explain/l6-parent-projection.txt · l6-child-projection.txt).

-- (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는 좁아지지 않는다(오히려 넓다): 초안 착상은 "프로젝션은 필요 6컬럼만 읽어 width가 엔티티 SELECT fi.*(§11 (a) width 1194)보다 좁다" 였다. 실측은 정반대다 — 부모 프로젝션 width = 2088 > 1194(원본: evidence/metrics/l6-explain-width.csv). 이유: (1) 프로젝션이 users·pages조인해 그 행폭이 흘러들고(Hash Join 2개), (2) PG의 width는 실제 바이트가 아니라 컬럼 타입 평균폭 추정치(unbounded varchar는 크게 잡힘)라 "선택한 컬럼 수"가 아니라 "조인된 행폭"을 반영한다. 결론: 프로젝션의 이득은 SQL 플랜에 안 보인다 — 플랜은 배치와 비슷하거나 더 복잡하고 width는 오히려 넓다. 진짜 이득은 ORM/JVM 층(엔티티 0·영속성 컨텍스트 미적재·더티체킹 0·힙 할당 급감)이라 Statistics.getEntityLoadCount()로만 보인다. (§9.3 "Hibernate 6+ 루트 dedup", §10 "HHH000104HHH90003004", §11.2 "collectionFetch=ceil(N/batch)"에 이은 네 번째 실측 정정 — 직관 지표를 실측으로 재확인.)

12.5 왜 프로젝션은 엔티티를 0으로 만드나 — 기전 (배치와 직교)

배치(§11)와 프로젝션(§12)은 서로 다른 축의 해법이다. 배치는 "몇 번의 SQL로 가져오나"(왕복 축)를 풀고, 프로젝션은 "무엇을 가져오나"(적재 형태 축)를 푼다. SELECT new Carrier(f.id, u.name, …)는 스칼라 컬럼만 선택해 캐리어 record를 만든다 — Hibernate는 영속 엔티티를 인스턴스화하지 않으므로 영속성 컨텍스트에 아무것도 안 붙고(1차 캐시 미적재), 더티체킹 대상도 0, lazy 프록시도 0이다. 조인은 컬럼에 닿기 위한 경로일 뿐 하이드레이션이 아니다. 그래서 배치를 켜든 안 켜든 무관하다(프로젝션은 프록시/컬렉션 자체를 안 만든다 — §12는 배치 설정 없이 성립). 배치를 켜도 엔티티는 통째로 올라오고(§11 잔여), 프로젝션은 엔티티를 아예 안 만든다. 화면 조회엔 엔티티가 아니라 프로젝션이라는 결론이 여기서 실측된다(query-bypass CQRS-lite). 흥미롭게도 이 이득은 EXPLAIN엔 안 보인다(§12.4) — 이득이 DB가 아니라 애플리케이션(ORM/JVM) 층에 있기 때문이다.

12.6 프로젝션이 못 푸는 것 — 페이지당 전량 (→ §13/L14)

프로젝션은 엔티티 과적재를 없앴지만, 자식 IN 프로젝션 (B)는 페이지 부모들의 하이라이트 전량을 가져온다. FeedProjectionIT.l6ProbeProjectionStillFetchesAllHighlightsNotTopN(원본: evidence/metrics/l6-projection-resolution.csv): 페이지 20건(seed 1,000)의 자식 행이 1,509다 — 화면엔 부모당 최신 3개(≤60)면 충분한데도. 그룹(부모)당 LIMIT은 단순 IN 프로젝션으로 못 건다(그룹이 아닌 행에 LIMIT). 이 잔여가 **Top-N-per-group(L14)**의 동기다.


13. Top-N-per-group — 그룹당 최신 3개를 SQL로 (세 해법 대결)

§12(프로젝션)는 엔티티 과적재를 없앴지만, 자식 IN 프로젝션이 페이지 부모들의 하이라이트 전량(§12.6의 1,509)을 가져오는 잔여를 남겼다. 화면엔 부모당 최신 3개(≤60)면 충분한데도. 이 절(L14)은 그 "페이지당 3"을 SQL로 푼다 — 그런데 §6~§12와 성격이 다르다. 앞의 랩들은 JPA 설정·매핑(fetch/batch/SELECT new)으로 풀렸지만, 여기선 표준 JPQL로 표현조차 안 되는(윈도우 함수·LATERAL) SQL·인덱스 문제이고, 해법이 하나가 아니라 셋이다. 그래서 이 절의 주인공은 "before/after 숫자 하나"가 아니라 세 해법의 쿼리플랜을 나란히 놓은 대조표다 — 셋 다 같은 top-3을 내지만, DB가 만드는 방식(스캔·조인·버퍼)이 다르기 때문이다.

13.1 왜 순진 LIMIT은 그룹에 안 걸리나 — 세 해법의 shape

문제의 뿌리는 LIMIT최종 결과 집합에 걸린다는 것이다 — "그룹당"이라는 개념이 없다. 그래서 순진한 시도는 실패한다.

-- ❌ 전체 결과에 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 (<page-20 부모 ids>) ORDER BY h.created_at DESC LIMIT 3;

"그룹당 top-N"은 세 가지로 표현할 수 있다. 셋 다 같은 페이지-20 부모 서브쿼리(… ORDER BY first_highlighted_at DESC, id ASC LIMIT 20)를 입력으로 받는다.

-- ⓐ 윈도우 함수: 부모별 순번 → 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 (<ids>)) t WHERE t.rn <= 3;
-- ⓑ LATERAL: 부모마다 상관 서브쿼리로 상위 3개만 인덱스 seek (ix_highlights_feed_items_created)
SELECT p.id, top3.* FROM (<page-20 부모>) 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 (<ids>) 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 실측 — 세 해법은 같은 top-3, 순진 LIMIT은 오작동

FeedTopNIT.l14ThreeStrategiesReturnTopThreePerParentAndNaiveLimitIsWrong·l14TransferAcrossStrategies(seed 1,000, page 20). 원본: evidence/metrics/l14-topn-resolution.csv.

전략 반환 행 커버한 부모 부모당 최대
ⓐ 윈도우 60 20 3
ⓑ LATERAL 60 20 3
ⓒ 2단계(앱컷 전 전량) 1,509 20 전량
순진 LIMIT 3 3 1

윈도우·LATERAL은 부모당 정확히 3개(20개 부모 × 3 = 60행)를 낸다. 2단계는 앱컷 전 페이지 부모들의 하이라이트 전량 1,509행을 전송한다 — 이게 바로 §12.6이 남긴 잔여의 정체이고, top-3(60)로 접으면 전송이 25분의 1로 준다. 순진 LIMIT 3은 전체 결과에서 3행만 남겨 가장 최신 하이라이트를 가진 부모 하나만 채우고 나머지는 0이 되는 오작동을 낸다(LIMIT엔 "그룹당"이 없다).

13.3 세 해법의 쿼리플랜 대조 — 같은 답, 다른 I/O (★ 스타)

이 절의 핵심. FeedTopNIT.l14ExplainThreeWayPlanCompareIsTheCrownJewel이 세 SQL을 같은 실행에서 EXPLAIN (ANALYZE, BUFFERS)로 잰다(같은 캐시 상태 = apples-to-apples). 원문: l14-window-plan.txt · l14-lateral-plan.txt · l14-twostep-plan.txt. 요약: evidence/metrics/l14-plan-compare.csv.

전략 최상위 노드 (스캔·조인) 반환 행 buffers shared hit exec
ⓐ 윈도우 WindowAggHash Semi Join(전량) 60 430 1.552 ms
LATERAL Nested LoopIndex Scan+Limit 3 60 204 0.323 ms
ⓒ 2단계 SortHash Semi Join(전량) 1,509 430 1.686 ms
-- ⓑ 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개만 읽고 멈춘다 — top 부모(하이라이트 500장)여도 3개만 읽어 buffers가 204로 최소, 셋 중 유일하게 인덱스 스캔이다. ⓐ 윈도우ⓒ 2단계는 buffers가 430으로 똑같다 — 둘 다 같은 Hash Semi Join으로 페이지 부모들의 하이라이트 전량(1,509)을 읽기 때문이다. 차이는 그 위다: 윈도우는 WindowAgg로 DB에서 60으로 컷(PG 15+는 rn<=3Run Condition으로 밀어넣어 조기 종료)하고, 2단계는 컷이 없어 1,509행을 그대로 앱에 넘긴다. 즉 윈도우 = 2단계 + DB측 컷이고, LATERAL만 구조적으로 다른(인덱스 seek) 해법이다. "쿼리 개수"로는 셋을 구분할 수 없다 — 플랜 shape과 buffers로만 갈린다.

13.4 인덱스 유무 토글 — LATERAL의 빠름은 LATERAL이 아니라 인덱스 seek 덕

LATERAL이 buffers 최소인 이유를 인과로 못 박는다. FeedTopNIT.l14LateralDependsOnCompositeIndex같은 LATERAL 쿼리를 인덱스를 뺐다(DROP INDEX) 다시 만들며(finally 복구) 잰다. 원본: l14-lateral-no-index.txt · 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하고 필터로 버린 뒤(Rows Removed by Filter: 2842) top-N 정렬로 3개를 고른다 — buffers가 168에서 4446으로(약 26배), 실행 시간이 0.336에서 5.472 ms로(약 16배) 폭증한다. 인덱스가 없으면 LATERAL도 무너진다. 대부분의 글은 "LATERAL 쓰면 빠르다"에서 멈추지만, 빠름의 정체는 LATERAL 문법이 아니라 (feed_item_id, created_at DESC) 복합 인덱스를 seek할 수 있다는 데 있다. 그리고 이 인덱스는 새로 만든 게 아니다 — 스키마 최초의 V6__feed.sql이 이미 깔아 둔 것(윈도우는 파티션 전량을 읽어 이 토글에 덜 민감하다). L14의 이득은 "인덱스를 신설해서"가 아니라 "이미 있는 인덱스를 타게 SQL을 쓰느냐"에서 갈린다.

13.5 그룹 크기가 승자를 가른다 — K 곡선

세 해법의 우열은 그룹 크기에 달렸다. FeedTopNIT.l14GroupSizeCurveWindowVsLateral이 top-K를 3/50/500으로 바꾸며 잰다(seed 1,000). 원본: 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로 오른다(K가 그룹 크기에 이르면 전량). LATERAL buffers가 모든 K에서 윈도우보다 작지만(114<162, 155<216, 171<269), 격차는 K가 작을수록 크다 — top 부모의 하이라이트 500장 중 K만 인덱스로 읽기 때문이다. K가 그룹 크기(500)에 근접하면 LATERAL도 사실상 전량을 읽어 윈도우로 수렴한다. 의사결정: 그룹이 크고 top-K가 작으면(피드의 top-3이 정확히 이 경우) LATERAL, top-K가 그룹 크기에 근접하면 윈도우가 더 단순하다.

13.6 왜 세 해법이 각각 top-3을 만드나 — 기전 (그리고 왜 native인가)

LIMIT은 최종 결과 집합에 걸려 "그룹당"을 모른다. 세 해법은 각각 다른 자리에서 컷을 만든다. 윈도우PARTITION BY feed_item_id로 파티션(그룹)마다 순번을 매겨 rn<=3으로 자른다 — 컷은 DB에서 일어나지만 순번을 매기려면 파티션 전체를 읽어야 해 스캔은 전량이다. LATERAL은 부모 행마다 상관 서브쿼리(WHERE h.feed_item_id = p.id)를 돌리고 그 안에 ORDER BY created_at DESC LIMIT 3이 있어, 복합 인덱스가 있으면 부모별로 상위 3개만 읽고 멈춘다(그래서 큰 그룹에서 압도적). 2단계는 자식을 한 방 IN으로 가져와 애플리케이션 메모리에서 그룹핑·컷한다(결과는 맞지만 전량 전송). 왜 native로 내려가야 하나 — 표준 JPQL(Jakarta Persistence)에는 윈도우 함수도 LATERAL도 없다. Hibernate 6+ HQL은 윈도우 함수를 확장으로 지원하지만 LATERAL은 없다. 2단계만이 표준 JPQL(IN)+앱컷으로 표현되는 유일한 안이다. 앞 절들(§6~§12)이 ORM 설정 계층에서 풀렸다면, 이 절은 그 아래 SQL·인덱스 계층으로 내려가야 풀린다는 것 자체가 왕관 문제의 성격이다.

13.7 이 해법이 남기는 것 — 부모 피드 페이징 (→ L15)

아이템별 top-3은 풀렸다(60행). 그러나 페이지-20 부모 서브쿼리가 보여주듯 부모 피드 자체를 페이징해야 하고, 그 페이징이 아직 OFFSET 기반이다. FeedTopNIT.l14ProbeParentPagingStillUsesOffsetNotKeyset: OFFSET 900 LIMIT 20Limit 노드 아래 Seq Scan feed_items(rows=1000)를 두어 앞 900행을 읽어 버린다(scan-then-discard) — 깊은 페이지일수록 선형으로 악화한다. 다음 고리는 keyset(seek) 페이징(WHERE (first_highlighted_at, id) < (:lastTs, :lastId))이다(L15). 그리고 keyset이 인덱스를 타려면 공개 범위 술어까지 같은 쿼리에 들어와야 하는데, 그것이 OR+EXISTS라 인덱스를 못 타는 다음 문제(가시성 술어 인덱싱, L16)로 이어진다. 각 해법이 다음 문제를 낳는다는 것이 이 여정의 성격이다(§2).


14. keyset vs OFFSET — 깊은 페이지에서 무너지지 않는 페이징 (착상 → 해결)

§13(Top-N-per-group)은 아이템별 top-3을 풀었지만, 그 페이지-20 부모 서브쿼리는 사실 부모 피드 페이징이고 아직 ORDER BY first_highlighted_at DESC, id DESC OFFSET :n LIMIT 20이다(§13.7). page 1은 빠르지만, 무한 스크롤로 깊은 페이지에 가면 OFFSET은 앞 n행을 읽어서 버린다(scan-then-discard) — 비용이 페이지 깊이에 비례해 붕괴한다. §14는 그 다음 고리 — keyset(seek) 페이징이다. 커서 (first_highlighted_at, id)로 정렬키 인덱스에서 그 지점 이후만 seek하면 페이지 깊이와 무관하게 ~20행만 읽는다. 이 문서의 세 번째 before/after이고, "부모를 어떻게 넘기나"(페이지 깊이) 축의 해법이다.

14.1 왜 OFFSET은 깊은 페이지에서 죽나 — keyset의 shape

OFFSET은 정렬 순서에서 앞 offset행을 생성한 뒤 버린다. 정렬키 인덱스가 있어도 그 튜플들을 훑어야 하고, 깊으면 아예 Seq Scan+Sort로 전량을 훑는다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽는다.

-- ❌ 순진 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(신규 IT, native SQL을 JdbcTemplate으로) — IT-only(프로덕션 코드 0). 정렬키 인덱스는 IT 안에서 CREATE/DROP 토글한다. 왜 새 인덱스인가: V6의 ix_feed_items_visibility_sort는 **선두 컬럼이 visibility**라(§3.1), 가시성 필터 없는 피드 keyset은 못 받친다. 그래서 (first_highlighted_at DESC, id DESC) 전용 인덱스가 필요하다(프로덕션 진화는 마이그레이션 V8).

14.2 실측 — OFFSET은 깊이에 비례, keyset은 평탄 (before/after 스타)

FeedKeysetIT.l15DeepPageOffsetOverScansButKeysetStaysFlat(seed 2,000, 같은 정렬키 인덱스). "훑은 행"은 Limit 하위의 실제 actual rows다. 원본: 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으로 평탄하다. page 100에서 OFFSET은 결과 20행을 위해 2,000행을 훑는다(100× over-scan) — keyset은 여전히 20행이다. 두 곡선은 page 1에서 같이 출발해(둘 다 20) 깊이에 따라 교차 없이 발산한다. 이것이 "무한 스크롤이 뒤로 갈수록 느려지는" 현상의 정체이자, keyset이 그것을 없애는 이유다.

14.3 EXPLAIN — scan-then-discard vs index seek, 그리고 정렬키 인덱스가 전제

FeedKeysetIT.l15ExplainOffsetScansThenDiscardsKeysetSeeksAndNeedsIndex(깊은 페이지 offset 1980, 한 실행). 원문: l15-offset-deep-page.txt · l15-keyset-index-seek.txt · l15-keyset-no-index.txt. 요약: evidence/metrics/l15-deep-page-compare.csv.

변형 플랜 훑은 행 buffers exec
OFFSET LimitSortSeq Scan(2,000) 2,000 141 0.996 ms
keyset + 인덱스 LimitIndex Only Scan 20 1 0.076 ms
keyset 인덱스 LimitSortSeq Scan(filter) 20 141 0.373 ms
-- 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행만 seek하고 순서가 인덱스로 보장돼 Sort 노드조차 없다(buffers 1). 셋째, keyset 인덱스는 결과 행(20)은 필터로 같지만 정렬키 인덱스가 없어 Seq Scan으로 전량을 훑는다(Rows Removed by Filter: 1980, buffers 141) — OFFSET과 같은 buffers다. 즉 keyset이 평탄한 것은 keyset 문법이 아니라 정렬키 인덱스 덕이다(§13.4의 LATERAL 교훈과 같은 결). 인덱스가 없으면 keyset도 무너진다.

14.4 왜 keyset은 상수인가 — 기전 (커서 = 정렬키 전체)

OFFSET의 비용은 "건너뛴 행도 읽는다"에서 온다. keyset은 커서 (first_highlighted_at, id)가 정렬 순서의 한 점을 가리키고, row-value 비교 (a,b) < (:ts,:id)가 그 점 이후를 인덱스에서 range scan하므로 앞부분을 훑지 않는다. 커서가 **정렬키 전체(tie-break id 포함)**여야 하는 이유는 같은 first_highlighted_at을 가진 행들에서 경계가 유일해지기 때문이다 — first_highlighted_at만으로 커서를 잡으면 같은 시각 경계에서 행을 빠뜨리거나 중복한다(FeedKeysetIT.l15KeysetWalkMatchesOffsetPages는 keyset로 넘긴 페이지가 OFFSET 같은 페이지와 동일한 20행·동일 순서임을 확인한다). 그래서 정렬키·커서·인덱스가 셋 다 (first_highlighted_at, id)로 일치해야 하고, 정렬 방향(DESC)과 row-value 방향, 인덱스 방향이 어긋나면 인덱스를 못 탄다. 이것이 keyset을 상수로 만드는 기전이다.

14.5 keyset이 못 푸는 것 — 가시성 OR (→ §15/L16)

keyset은 페이지 깊이를 풀었지만, 실서비스 피드는 가시성으로 필터해야 한다(public + 내가 멘션된 것 + 내 비공개). 그 필터를 keyset과 같은 쿼리에 얹으면(FeedKeysetIT.l15ProbeVisibilityOrBreaksKeysetIndex), 플래너는 정렬키 인덱스 ix_feed_items_keyset더 이상 쓰지 못하고 가시성 3분기를 각각 인덱스로 스캔한 BitmapOr로 떨어진다. 원문: l15-visibility-or-probe.txt.

-- 가시성 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 노드의 재등장이다 — keyset의 "순서가 인덱스로 보장돼 Sort가 없다"는 이점이 OR+EXISTS 때문에 소멸한다(bitmap은 순서를 안 준다). 즉 가시성 OR은 keyset을 다시 "훑고 정렬"로 되돌린다. 이 잔여가 **가시성 술어 인덱싱(L16)**의 동기다 — 각 가시성 분기를 정렬 보장 인덱스 스캔으로 만들어 UNION ALL로 병합하거나, 부분·복합 인덱스, 극단적으로는 사전계산(비정규화)으로.


15. 가시성 술어 인덱싱 — OR/EXISTS를 인덱스로, 그리고 모델로 (세 해법 대결, 왕관 닫힘)

§14(keyset)는 페이지 깊이를 풀었지만, 실서비스 피드는 가시성으로 필터해야 한다(§14.5) — public + 내가 멘션된 것 + 내 비공개. 그 필터를 keyset과 같은 쿼리에 얹으면 OR+EXISTS가 정렬키 인덱스를 못 타고 BitmapOr+Sort로 무너졌다. §15는 그 가시성 술어를 인덱스로 다시 태운다 — §13(Top-N)처럼 해법이 셋(단일 OR / UNION 분해 / 사전계산)이고, 스타는 세 플랜의 대조다. 그리고 그 대조의 결론이 왕관을 닫고 아키텍처(CQRS)로 넘어가는 다리가 된다.

15.1 왜 단일 OR은 순서 인덱스를 못 타나 — 세 해법의 shape

하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 가시성 3분기는 각각 다른 조건(visibility 값·user_id·mentions 조인)이라, 하나의 쿼리로 묶으면 플래너는 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.

-- ❌ 단일 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(신규 IT, IT-only). 신규 인덱스(ix_mentions_user, private partial)와 feed_visible 테이블은 IT 안에서 토글한다. feed_item_mentions의 V7 인덱스는 (feed_item_id, …)라 "나를 멘션한 아이템" 조회를 못 타므로 (mentioned_user_id, feed_item_id) 신규 인덱스가 필요하다(부분·복합 인덱스 세트의 일부).

15.2 실측 — 셋 다 같은 피드, 세 개의 다른 플랜 (스타)

FeedVisibilityIT.l16ExplainThreeWayPlanCompare(seed 2,000, 뷰어 user008). 세 해법 모두 같은 20 feed_item을 낸다(l16ThreeApproachesReturnSameVisibleSet로 확인) — 다른 건 DB가 3분기 가시성을 어떻게 소화하나다. 원본: 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 세 플랜을 나란히

-- ⓐ 단일 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 · l16-union-decompose-plan.txt · l16-precompute-plan.txt · l16-union-branches.txt.

15.4 UNION은 구조를 고치고, 사전계산은 자릿수를 바꾼다 — 기전 (★ 실측 정정)

★ 실측 정정: 초안 예측은 "단일 OR = seq scan / 인덱스 미사용", "UNION이 buffers를 줄인다"였다. 실측은 둘 다 정정한다. (1) 단일 OR은 seq scan이 아니라 BitmapOr+top-N Sort+hashed SubPlan이다(V6·partial 인덱스가 있어 bitmap을 탄다). (2) UNION 분해는 buffers를 줄이지 않는다 — 오히려 200(> 단일 OR 122)이다. 각 분기가 자기 스캔을 하기 때문이다. **진짜 order-of-magnitude 이득은 UNION이 아니라 사전계산(buffers 1)**이다.

정리하면 세 해법은 서로 다른 층을 고친다. 단일 OR은 3분기를 하나의 bitmap으로 묶어 순서를 잃고(재정렬) 분기별 최적화를 못 한다. UNION 분해는 각 분기를 독립 쿼리로 만들어 구조를 고친다 — 상관 술어가 Hash Join으로, 전체 정렬이 Merge Append로, 각 분기가 자기 인덱스로. 그러나 여전히 요청 시점에 3분기를 스캔·병합하므로 비용의 자릿수는 그대로다. 사전계산은 가시성 판정을 뷰어별 feed_visible로 미리 펼쳐 조회를 단일 Index Only Scan으로 바꾼다 — 모델을 바꿔 자릿수를 바꾼다(buffers 1). 그 대가는 쓰기 시 갱신(피드·멘션·가시성 변경 시 재계산)과 뷰어 수만큼의 저장 팽창이다. "쿼리를 다시 쓰면 구조가 좋아지고, 모델을 바꾸면 규모가 달라진다"가 이 절의 결론이다.

15.5 왕관 닫힘 — 사전계산 = CQRS 읽기 모델 (→ §16 통합, §17/L12)

feed_visible은 실험용 테이블이지만 그 프로덕션 형태는 CQRS 읽기 모델이다 — 쓰기 모델(FeedItem 애그리거트·도메인 이벤트)이 읽기 모델(뷰어별 투영)을 갱신하고, 조회는 그 투영을 단순히 읽는다. 여기서 왕관이 닫힌다: Top-N(§13) + keyset(§14) + 가시성(§15)을 한 피드 조회로 만족시키는 최종 형태가, 결국 "N+1을 SQL로 푸는" 문제에서 "읽기 모델을 어떻게 설계하는가"의 문제로 넘어간다. N+1은 애초에 쓰기 모델로 읽기를 하려 해서 생긴 신호였고, 그 신호가 우리를 통합(§16)과 CQRS(§17, 주제 2 아키텍처)로 데려간다.


16. 왕관 통합 — 세 기법을 한 쿼리로, 그리고 의사결정 매트릭스 (왕관 완결)

§13(Top-N)·§14(keyset)·§15(가시성)은 피드 조회의 세 축을 따로 풀었다. 실서비스 피드 화면은 셋을 동시에 요구한다 — 나에게 보이는 것만(가시성), 깊은 페이지도 안 무너지게(keyset), 아이템당 최신 top-3(Top-N). §16은 셋을 한 개의 피드 조회로 합류시키고, 세 기법이 서로 간섭하는지를 실측한다. 무대는 FeedCrownIT(신규 IT, IT-only).

16.1 통합 쿼리의 shape — 부모선택 × LATERAL

통합 쿼리는 (가시성 필터 + keyset 로 고른 부모) 를 LATERAL top-3 으로 감싼다. LATERAL 은 §13의 Top-N 승자(작은 K), keyset·가시성은 부모선택 안에서 합쳐진다.

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) 세 방식으로 만들 수 있고, 셋 다 같은 20 부모를 낸다(crownUnifiedReturnsSameShapeAcrossParentPaths: unionEq·precomputeEq 참). 답은 같고 플랜만 다르다.

16.2 실측 — 한 플랜에 세 기법 (스타)

FeedCrownIT.crownUnifiedPlanStacksVisibilityKeysetAndTopN(seed 2,000, 뷰어 user008, page 1). 사전계산 부모선택 위의 통합 쿼리는 세 기법을 재정렬 없이 한 플랜에 겹친다. 원본: crown-unified-precompute-plan.txt.

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(가장 깊은 페이지, 커서 = visible20). user008에게 보이는 1,500 중 마지막 페이지에서, 부모선택을 사전계산으로 두느냐 단일 OR로 두느냐가 갈린다. 원본: crown-deep-keyset-precompute.txt · crown-deep-keyset-single-or.txt.

부모선택 최상위 훑는 행 feed_visible 부모 buffers
사전계산 Nested Loop 19 3
단일 OR Nested Loop 200 (구조적) 31

★ 실측 정정: 초안은 "사전계산 위 keyset 은 Sort 없이 seek, 단일 OR 은 Sort 로 깨진다"였다. 실측은 정정한다 — 가장 깊은 커서에선 둘 다 작은 Sort(남은 19 행 quicksort)가 붙는다(Bitmap 스캔은 정렬 출력을 안 한다). 차이는 "Sort 유무"가 아니라 "페이지에 닿는 비용"이다: 사전계산은 ix_feed_visible 인덱스 range 로 19 행만 훑지만, 단일 OR 은 사전계산 읽기 모델을 못 써(구조적) 매 페이지 가시성 3분기를 BitmapOr 로 다시 풀고 멘션 EXISTS 를 hashed SubPlan 으로 200 행 materialize 한다. page 1 에선 사전계산이 순수 Index Only Scan(Sort 전무)이고, 깊어질수록 단일 OR 의 "매 페이지 전체 재해소" 비용이 벌어진다.

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 로 두면 §15의 발견이 통합 쿼리에서 재현된다 — 매 페이지 가시성을 다시 푼다.

16.5 왕관 완결 — 사전계산 = CQRS 읽기 모델 (→ §17/L12)

세 기법을 재정렬 없이 겹치는 유일한 부모선택은 사전계산(feed_visible)이다. 그 프로덕션 형태가 CQRS 읽기 모델 — 쓰기 모델(FeedItem 애그리거트·도메인 이벤트)이 뷰어별 투영을 갱신하고, 조회는 그 투영을 keyset+LATERAL 로 읽는다. 여기서 왕관이 완결된다: Top-N + keyset + 가시성을 한 피드 조회로 만족시키는 최종 형태가 곧 읽기 모델 설계 문제이고, 그게 §17(CQRS-lite 읽기 모델)이 실현하며 거기서 주제 2(아키텍처)로 넘어간다.


17. CQRS-lite 읽기 모델 — 프로덕션 읽기 경로로 (주제 2 브릿지)

§16은 세 기법을 재정렬 없이 겹치는 유일한 부모선택이 사전계산(feed_visible)임을 보였다. 그런데 feed_visible상시 유지되는 별도 저장소로 두는 것은 곧 풀 CQRS(쓰기 모델이 도메인 이벤트로 읽기 저장소를 갱신)다. 참조 구현(ca-tmpl)의 application-core 계약은 그 **"별도 물리 읽기 저장소를 갖는 풀 CQRS"를 "범위 밖 — 에스컬레이션 전용"**으로 못박아 뒀다(주제 2에서 계약을 의도적으로 개정한 뒤에야 연다). 그래서 프로덕션 읽기 경로는 계약이 지원하는 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(프로젝션 DTO만 반환) → GetFeedReadModelUseCase(QueryUseCase, tx.inRead) → FeedReadModelQueryAdapter. 읽기 쿼리는 **§12(프로젝션, 엔티티 0) + §13(window top-3)**을 합쳐, naive loadFeed를 건드리지 않고 화면 shape 그대로 반환한다:

  • 부모 페이지: JPQL SELECT new(엔티티 하이드레이션 0).
  • 자식 top-3: 네이티브 row_number() OVER (PARTITION BY feed_item_id ORDER BY created_at DESC) <= 3.

FeedReadModelUseCaseIT(seed N∈{10, 100}) 실측: 엔티티 로드 0 · 발행 쿼리 상수 2(N 무관) · topHighlights 부모당 top-3(§12의 잔여 1,509 → ≤60 해소). 아키텍처 게이트(ArchUnit query_ports_do_not_leak…·의존 방향·./gradlew check) 전부 GREEN. (측정 판단: window 쿼리를 JdbcTemplate이 아니라 Hibernate Session으로 발행해 Statistics가 실제 발행 쿼리를 관측하게 했다 — 아니면 "상수 2"가 공허하게 참이 된다.)

17.3 주제 2로

여기서 N+1 주제가 아키텍처 주제로 넘어간다. lite가 읽기 모델을 모델 수준으로 분리했다면, 고트래픽 읽기·가시성 사전계산(§16의 feed_visible)이 실제로 필요해지는 순간 그것을 저장소 수준으로 올리는 게 풀 CQRS이고, 그때 계약·가드레일을 의도적으로 개정한다. "N+1은 쓰기 모델로 읽기를 하려는 신호"라는 일반화가 여기서 헥사고날·CQRS 설계로 완결된다.


18. 다음 단계

§6~§17은 하이라이트 피드 조회 하나를 놓고 N+1을 진단(§6~§10)하고, 배치(§11)·프로젝션(§12)·Top-N(§13)·keyset(§14)·가시성(§15)으로 차례로 푼 뒤 셋을 한 쿼리로 통합(§16)하고, 그 읽기를 CQRS-lite 프로덕션 경로(§17)로 승격했다. 각 해법이 다음 문제(또는 잔여 비용)를 낳는 연쇄였고 — 배치는 왕복 수(1+N → 상수 2), 프로젝션은 적재 형태(엔티티 1,569 → 0), Top-N은 그룹당 전량(1,509 → 60), keyset은 페이지 깊이(OFFSET 2,000 → keyset 20), 가시성은 술어 인덱싱(단일 OR 1,500 후보 → 사전계산 20) — 그 마지막이 읽기 모델(§17)에 닿았다.

  • 풀 CQRS(주제 2, 에스컬레이션): §17의 lite는 같은 저장소 위 읽기 모델이었다. 고트래픽 읽기·가시성 사전계산(§16 feed_visible)이 실제로 필요해지면 그것을 별도 물리 읽기 저장소로 올리고 쓰기→읽기 동기화(도메인 이벤트/아웃박스)를 배선하는 게 풀 CQRS다 — 참조 구현 계약이 "에스컬레이션 전용"으로 둔 지점이라, 계약·가드레일을 의도적으로 개정한 뒤 주제 2(헥사고날·CQRS)에서 연다.
  • 운영·크로스패러다임(나머지 축): OSIV·커넥션풀·Little's Law, 쓰기 N+1, 리액티브, 자작 탐지기, NoSQL 임베드 등은 N+1을 다른 축으로 넓히는 upside다(핵심 문제 해결엔 필수 아님).

결국 이 문제는 N+1 하나를 없애는 문제가 아니라 화면에 필요한 읽기 모델을 어떤 SQL·인덱스·모델로 만들 것인가의 문제다(§2).


부록. 측정 재현과 provenance, 함정

A. 재현

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).
  • 곡선(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 · 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).
  • 실행계획(§9): l3ExplainCollectionJoinRowMultiplication, 조인(Hash Join) 노드 actual rows = Σ highlights 확인(→ 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).
  • 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 · 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).
  • 페이징 정상(§11): l5EntityPagingLoadsOnlyThePageNotWholeDataset, feedItemLoaded == min(20, N)(§10 over-fetch 소멸). EXPLAIN l5ExplainEntityPagingHasLimitAndBatchInHasNoRowMultiplication, (a) 엔티티 페이징엔 Limit 노드 존재 / (b) 배치 IN은 semi-join(행 안 곱함)(→ evidence/explain/l5-entity-paging-limit.txt · l5-batch-in-semijoin.txt).
  • 잔여 비용(§11): l5ProbeBatchStillHydratesFullEntities, 페이지 20건인데 entitiesLoaded == 1,569(엔티티 과적재 → L6)(→ 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).
  • EXPLAIN·width 정정(§12): l6ExplainProjectionHasLimitAndSemiJoinNotNarrowerWidth, (a) 부모 프로젝션 Limit 노드 존재하나 width 안 좁아짐(2088 > 엔티티 1194) / (b) 자식 IN semi-join(행 안 곱함). 프로젝션 이득은 EXPLAIN 아니라 ORM 층(→ evidence/explain/l6-parent-projection.txt · l6-child-projection.txt · evidence/metrics/l6-explain-width.csv).
  • 잔여 비용(§12): l6ProbeProjectionStillFetchesAllHighlightsNotTopN, 페이지 20건인데 자식 행 1,509(부모당 전량, top-3 아님 → L14)(→ 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).
  • 플랜 대조(§13, 스타): l14ExplainThreeWayPlanCompareIsTheCrownJewel, 세 해법 EXPLAIN (ANALYZE, BUFFERS) — LATERAL은 Index Scan(buffers 204)·윈도우/2단계는 같은 Hash Semi Join(buffers 430, 전량 1,509) 확인(→ l14-lateral-plan.txt · l14-window-plan.txt · l14-twostep-plan.txt · 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/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).
  • 잔여 비용(§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).
  • 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 · l15-keyset-index-seek.txt · l15-keyset-no-index.txt · 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).
  • 정확성(§15): 별도 클래스 FeedVisibilityIT(IT-only, native SQL·신규 인덱스/feed_visible 토글) l16ThreeApproachesReturnSameVisibleSet, 단일 OR == UNION 분해 == 사전계산이 같은 20 feed_item(답 동일, 플랜만 다름) 확인(→ 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 · l16-union-decompose-plan.txt · l16-precompute-plan.txt).
  • 분기별 인덱스(§15): l16LowSelectivityBranchesRideTheirIndex, mentioned 분기=ix_mentions_user 조인·private 분기=ix_feed_items_private partial의 Index Only Scan 확인(→ l16-union-branches.txt).
  • 사전계산=CQRS(§15 → L12): l16PrecomputeIsSingleIndexScanNoOrNoSort, feed_visible 단일 Index Only Scan·Sort 없음·buffers 1 확인(→ 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).
  • 한 플랜 세 기법(§16, 스타): crownUnifiedPlanStacksVisibilityKeysetAndTopN, 사전계산 부모선택 통합 쿼리가 Index Only Scan(ix_feed_visible) + Nested Loop LATERAL Index Scan(ix_highlights_feed_items_created)로 세 기법을 재정렬(Sort) 없이 한 플랜에 겹침 확인(→ 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 · crown-deep-keyset-single-or.txt).
  • CQRS-lite 읽기 모델(§17): 프로덕션 경로(시리즈 첫 프로덕션 코드, IT-only 아님) GetFeedReadModelUseCaseFeedReadModelQueryPortFeedReadModelQueryAdapter(신규). 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에도 표로 적재된다.

원시 데이터 자산:

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/에 둔다(콘솔·SQL 로그·EXPLAIN 캡처). assets/은 슬라이드 캡처, evidence/는 원시 데이터·그림으로 역할을 구분한다.