feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,89 @@
---
id: 635fcedd-d402-4297-bcf3-9fcdf4200d28
kind: REFERENCE
slug: feed-visibility-query-pattern
title: Feed Visibility Query Pattern
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/635fcedd-d402-4297-bcf3-9fcdf4200d28/edit"
---
# Feed Visibility Query Pattern
조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기를 만든다. 하나의 OR로 묶는 방식, 분기를 UNION으로 나누는 방식, 사용자별로 미리 계산하는 방식이 같은 결과를 다른 비용으로 만든다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
단일 OR이 정렬 인덱스를 못 쓰는 것을 확인한 기록이다.
- **feed_visible을 Production CQRS로 승격할 것인가**
사전계산 방식이 남긴 판단이다.
- **Keyset Pagination 설계 기준**
이 조건과 함께 서야 하는 페이징 기준이다.
## 목적
세 분기는 조건의 성격이 다르다. 값 비교, 다른 테이블과의 관계 확인, 소유자 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 한 접근 경로로 만족시킬 수 없다.
정렬과 페이징을 함께 요구하면 이 차이가 실행계획에서 드러난다.
## 규칙
### 1. 단일 OR은 정렬 순서를 잃는다
세 분기를 하나의 조건으로 묶으면 플래너가 분기별로 스캔한 뒤 bitmap으로 합친다. 이 과정에서 인덱스가 주던 순서가 사라져 상위 몇 행을 만들기 위한 정렬이 다시 필요해진다.
관계 확인 조건은 hashed SubPlan으로 처리될 수 있다.
### 2. UNION 분해는 분기마다 자기 인덱스를 태운다
세 분기를 각각 정렬이 보장되는 쿼리로 만들고 병합하면 전체 재정렬이 사라진다. 관계 확인 조건도 조인으로 바뀐다.
대신 요청할 때마다 세 분기를 각각 스캔한다. 분기 수만큼 접근이 늘어 buffers가 단일 OR보다 클 수 있다.
### 3. 사전계산은 조회를 단일 인덱스 스캔으로 바꾼다
사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 읽는다. OR도 조인도 정렬도 없다.
대신 원본이 바뀔 때 이 투영을 갱신해야 하고 사용자 수만큼 저장 공간이 늘어난다.
### 4. 세 방식이 같은 결과를 내는지 먼저 확인한다
실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다.
### 5. 분기별 선택도에 맞는 인덱스를 따로 둔다
선택도가 낮은 분기는 전용 인덱스나 부분 인덱스가 유리하다. 관계 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다.
부모를 찾는 인덱스와 조회자를 찾는 인덱스는 컬럼 순서가 다르다.
### 6. buffers만으로 우열을 정하지 않는다
UNION은 분기별 스캔 때문에 buffers가 클 수 있지만 전체 정렬을 없앤다. 무엇을 줄이려는지에 따라 선택이 달라진다.
훑는 후보 수, 정렬 유무, buffers를 함께 본다.
### 7. 사전계산을 상시 유지하면 읽기 모델이 된다
미리 계산한 테이블을 계속 유지하려면 원본 변경을 투영에 반영해야 한다. 이 시점에 조회 최적화가 아니라 읽기 모델 설계 문제가 된다.
## 적용 조건
- 조회 사용자에 따라 보이는 항목이 달라지는 목록을 만들 때
- 가시성 조건과 정렬·페이징을 함께 요구할 때
- 고트래픽 읽기에서 조회 비용을 줄여야 할 때
## 예외
- 분기가 하나뿐이면 단일 조건이 가장 단순하다. 이 기준은 분기가 셋 이상일 때 적용한다.
- 쓰기가 잦고 읽기가 드물면 사전계산의 갱신 비용이 이득을 넘는다.
- 조회 사용자 수가 매우 많으면 사용자별 투영의 저장 공간을 먼저 계산한다.
## 예시
- 단일 OR : 분기별 스캔을 bitmap으로 합침, 정렬 재수행, 관계 조건은 hashed SubPlan
- UNION 분해 : 분기별 정렬 스트림을 병합, 전체 재정렬 없음, 관계 조건은 조인
- 사전계산 : 커버링 인덱스 하나, OR·조인·정렬 없음
- 갱신 비용 : 사전계산만 있음
- 저장 공간 : 사전계산은 조회 사용자 수에 비례
@@ -0,0 +1,95 @@
---
id: db99cbc5-9123-4599-b368-39ff3170e81d
kind: REFERENCE
slug: fetch-strategy-selection
title: Fetch Join · Batch · Projection 선택 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit"
---
# Fetch Join · Batch · Projection 선택 기준
세 전략은 서로 다른 비용을 줄인다. fetch join은 왕복을 접지만 행을 곱하고, batch는 왕복을 묶지만 엔티티를 그대로 만들고, 프로젝션은 적재를 없애지만 행수를 줄이지 않는다.
## 관계
- **Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증**
fetch join의 한계를 확인한 기록이다.
- **Collection Fetch Join Pagination의 In-memory Paging**
fetch join과 페이징이 함께 서지 못하는 것을 확인한 기록이다.
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
프로젝션이 남기는 비용을 확인한 기록이다.
## 목적
쿼리 수만 보고 전략을 고르면 비용이 다른 축으로 옮겨 간 것을 놓친다. 컬렉션 fetch join은 쿼리 수를 크게 줄이면서 전송 행수와 메모리를 키운다.
무엇을 줄이려는지 먼저 정하고 그 축을 재는 지표로 전후를 비교한다.
## 규칙
### 1. 컬렉션 fetch join은 두 개 이상 쓰지 않는다
순서 컬럼이 없는 List 두 개를 동시에 fetch join하면 곱집합을 원래 컬렉션으로 되돌릴 수 없어 쿼리 생성 시점에 거부된다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
### 2. 컬렉션 fetch join은 행을 곱한다
컬렉션 하나만 fetch join해도 부모 한 행이 자식 수만큼 반복된다. 전송 행수는 자식 총합이 된다.
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기로는 이 증가가 보이지 않는다. 조인 카디널리티나 실행계획의 actual rows로 확인한다.
### 3. 컬렉션 fetch join과 페이징을 같이 쓰지 않는다
부모 기준 LIMIT을 걸면 조인 행에서 잘려 일부 부모의 자식이 누락된다. Hibernate는 이를 피하려고 SQL에서 LIMIT을 빼고 전체를 읽은 뒤 메모리에서 자른다.
응답은 한 페이지지만 로드한 부모는 전체다. 발행 SQL에 Limit 노드가 없는 것이 이 동작의 증거다.
### 4. fetch join은 ToOne에 쓴다
ToOne은 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join이 적합하다.
### 5. 컬렉션에는 batch fetch를 쓴다
엔티티만 페이징해 DB LIMIT이 정상 작동하게 한 뒤, 지연 연관은 부모 키를 모아 IN으로 채운다. 배치 크기가 B면 왕복은 부모 수를 B로 나눈 올림값이 된다.
배치는 부모와 자식을 곱하지 않는다. 실행계획에서 semi-join으로 나타난다.
### 6. 화면 조회에는 프로젝션을 쓴다
필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않는다. 1차 캐시, 더티체킹, 지연 프록시도 생기지 않는다.
join이 있어도 컬럼을 읽기 위한 경로일 뿐 엔티티를 만들지 않는다. 이 동작은 배치 설정 여부와 관계없이 성립한다.
### 7. 프로젝션의 효과는 실행계획이 아니라 ORM 층에서 확인한다
필요한 컬럼만 골라도 EXPLAIN의 width가 줄지 않을 수 있다. 조인 대상의 행폭이 반영되고 width가 실제 전송 바이트가 아니라 타입의 평균폭 추정치이기 때문이다.
프로젝션의 이득은 엔티티 로드 수로 확인한다.
### 8. 세 전략이 남기는 비용을 적는다
fetch join은 행 폭증과 페이징 불가를 남긴다. batch는 엔티티 과적재를 남긴다. 프로젝션은 부모당 자식 전량 조회를 남긴다.
남은 비용을 적어야 다음 단계가 무엇을 풀어야 하는지 이어진다.
## 적용 조건
- 연관을 포함한 목록 조회를 설계할 때
- N+1을 확인하고 fetch 전략을 고를 때
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 남았는지 정리할 때
## 예외
- 컬렉션이 하나이고 페이징이 없으며 자식 수가 작다면 컬렉션 fetch join이 단순하다. 자식 수가 커질 수 있는 구조에는 쓰지 않는다.
- 배치 크기 설정은 세션 전체에 영향을 준다. 기존 측정을 유지하려면 별도 설정 범위로 격리한다.
## 예시
- 컬렉션 두 개 fetch join : 쿼리 생성 시점 거부
- 컬렉션 한 개 fetch join : 전송 행수 = 자식 총합
- 컬렉션 fetch join + 페이징 : DB LIMIT 없음, 부모 전체 로드
- ToOne fetch join : 행 곱하지 않음, 적합
- batch fetch : 왕복 = 부모 수 / 배치 크기 올림
- 프로젝션 : 엔티티 로드 0, 쿼리 상수, 자식 행수는 그대로
@@ -0,0 +1,90 @@
---
id: 51095f6e-2cc8-439c-8648-065033614215
kind: REFERENCE
slug: fetch-type-vs-fetch-strategy
title: Fetch Type과 Fetch Strategy 구분
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/51095f6e-2cc8-439c-8648-065033614215/edit"
---
# Fetch Type과 Fetch Strategy 구분
EAGER와 LAZY는 연관이 언제 로딩돼야 하는지를 정하는 계약이다. 어떤 SQL로 가져올지는 정하지 않는다. N+1은 fetch 타입을 바꿔서 풀리지 않고 왕복과 적재 방식을 바꾸는 전략으로 푼다.
## 관계
- **필드 접근 없이 발생한 EAGER ToOne N+1**
이 구분을 실제 측정으로 확인한 기록이다.
- **Fetch Join · Batch · Projection 선택 기준**
전략을 고르는 기준이다.
- **JPA N+1 정량 진단 기준**
두 축을 나눠 측정하는 방법이다.
## 목적
즉시 로딩이면 한 번에 가져올 것이라고 읽기 쉽다. 실제로는 파생 쿼리에서 루트를 먼저 조회한 뒤 연관을 행마다 2차 SELECT로 채우는 경우가 있다.
이 구분을 세워야 애너테이션을 바꾸는 것과 조회 방식을 바꾸는 것이 서로 다른 작업이라는 점이 드러난다.
## 규칙
### 1. EAGER는 로딩 시점 계약이지 JOIN 보장이 아니다
FetchType.EAGER는 연관이 반환 시점까지 로딩돼 있어야 한다는 계약이다. 루트 SQL의 JOIN으로 가져오라는 의미가 아니다.
파생 쿼리에서는 루트를 먼저 조회한 뒤 fetch join하지 않은 EAGER 연관을 별도의 2차 SELECT로 채울 수 있다.
단건 조회에서 JOIN으로 가져오는 경우가 있지만 그것은 provider, 매핑, fetch profile에 달린 동작이지 일반적인 JPA 보장이 아니다.
### 2. 기본값을 명시적으로 확인한다
@ManyToOne과 @OneToOne의 기본값은 EAGER다. @OneToMany와 @ManyToMany의 기본값은 LAZY다.
fetch를 적지 않은 코드에도 기본값이 적용된다. 코드에 조회가 보이지 않는다는 것이 조회가 나가지 않는다는 뜻은 아니다.
### 3. 접근 여부와 fetch 계약을 교차해서 본다
EAGER는 접근하지 않아도 나간다. 사용하지 않는 연관까지 조회하는 낭비가 된다.
LAZY는 접근할 때 나간다. 접근하면 같은 N+1이 시점만 달라져 다시 생긴다.
매핑 루프에서 연관을 실제로 사용한다면 EAGER를 LAZY로 바꿔도 N+1은 남는다.
### 4. 실제 증가 폭은 서로 다른 연관 대상 수가 정한다
같은 EAGER ToOne이라도 증가 곡선이 갈린다. 소수를 재사용하는 연관은 1차 캐시가 재조회를 걸러 서로 다른 대상 수만큼만 조회된다. 부모마다 다른 연관은 부모 수만큼 조회된다.
N+1이 생길 가능성은 fetch 계약이 만들고, 실제 실행 횟수는 Persistence Context 안에서 서로 다른 대상이 몇 개인지가 정한다.
### 5. 컬렉션 접근은 반복문 없이도 반복된다
지연 로딩 컬렉션은 접근하는 순간 조회한다. 부모가 N개면 접근과 조회도 N번이다.
스트림이나 매핑 함수 뒤에 있으면 명시적인 반복문이 보이지 않는다. 반복이 사라진 것이 아니라 표현이 바뀐 것이다.
### 6. 타입이 아니라 전략을 바꾼다
fetch 타입 변경은 조회 시점을 옮길 뿐이다. 왕복 수를 줄이려면 fetch join, batch fetch, 프로젝션처럼 조회 방식 자체를 바꾼다.
## 적용 조건
- 연관 매핑을 정하거나 바꿀 때
- N+1의 원인을 애너테이션에서 찾으려 할 때
- EAGER를 LAZY로 바꾸는 것으로 문제가 풀린다고 판단하기 전에
## 예외
- 단건 조회에서 provider가 JOIN을 선택하는 구현이 있다. 그 동작에 의존하려면 사용하는 provider와 버전에서 확인한 뒤 적는다.
- 연관을 전혀 사용하지 않는다면 LAZY로 바꾸는 것만으로 낭비가 사라진다. 이때는 전략 변경이 아니라 타입 변경이 맞는 해법이다.
## 예시
- EAGER : 반환 시점까지 로딩. SQL 형태는 보장하지 않음
- LAZY : 접근 시점에 로딩
- @ManyToOne 기본값 : EAGER
- @OneToMany 기본값 : LAZY
- 접근 0회 EAGER : 조회 나감 (낭비)
- 접근 0회 LAZY : 조회 안 나감
- 접근함 EAGER / LAZY : 둘 다 N+1, 시점만 다름
@@ -0,0 +1,90 @@
---
id: 06788903-3dfa-4f70-b159-f1224384fd0b
kind: REFERENCE
slug: keyset-pagination-design
title: Keyset Pagination 설계 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit"
---
# Keyset Pagination 설계 기준
OFFSET은 건너뛸 행까지 만든 뒤 버린다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽는다. 다만 커서와 같은 순서의 정렬키 인덱스가 있어야 이 이점이 생긴다.
## 관계
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
이 기준의 전제가 깨지는 조건을 확인한 기록이다.
- **Feed Pagination은 Keyset을 사용한다**
이 기준에서 나온 결정이다.
- **PostgreSQL Query Plan 측정 기준**
깊이별 비용을 실행계획으로 확인하는 기준이다.
## 목적
무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊으면 전량 스캔과 정렬로 떨어진다.
페이지 깊이와 무관하게 읽는 행수를 일정하게 유지하려면 커서 방식이 필요하다.
## 규칙
### 1. 커서에 정렬키를 모두 담는다
정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 빠지거나 중복된다.
### 2. tie-break 컬럼을 정렬과 커서에 넣는다
정렬키에 중복이 있을 수 있으면 유일한 컬럼을 마지막 정렬키로 더한다. 커서에도 같이 담는다.
### 3. 정렬키, 커서, 인덱스의 컬럼과 방향을 일치시킨다
셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort가 다시 생긴다. 방향까지 같아야 한다.
### 4. 정렬키 전용 인덱스를 확인한다
선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 필터가 없는 정렬 쿼리라면 정렬키만으로 된 인덱스가 필요하다.
인덱스가 없으면 keyset도 전량을 스캔한다. keyset 문법이 아니라 인덱스가 비용을 줄인다.
### 5. 깊이별로 훑은 행을 측정한다
OFFSET은 offset에 페이지 크기를 더한 만큼 훑는다. keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다.
한 페이지만 재면 차이가 보이지 않는다. 깊이를 바꿔 가며 곡선으로 확인한다.
### 6. 결과가 OFFSET과 같은지 검증한다
커서로 넘긴 페이지가 같은 순서의 같은 행을 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다.
### 7. 필터를 얹으면 전제가 깨질 수 있다
선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 합치면서 정렬 순서를 잃는다. 이때 Sort가 다시 나타난다.
필터가 있는 keyset은 필터를 포함한 인덱스 설계나 쿼리 분해가 함께 필요하다.
### 8. 정렬키에 null이 있을 수 있는지 먼저 정한다
정렬키가 nullable이면 null의 정렬 위치와 커서 표현을 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다.
## 적용 조건
- 무한 스크롤이나 깊은 페이지를 지원할 때
- 정렬 순서가 고정돼 있고 인덱스를 만들 수 있을 때
- 전체 페이지 수가 필요하지 않을 때
## 예외
- 임의 페이지 점프가 필요하면 커서만으로는 부족하다. OFFSET을 함께 두거나 다른 탐색을 설계한다.
- 전체 건수를 화면에 표시해야 하면 count를 별도로 다룬다. 커서 결과에는 전체 건수가 없다.
- 정렬 기준이 자주 바뀌면 기준마다 인덱스가 필요하다. 인덱스 수와 쓰기 비용을 함께 본다.
## 예시
- OFFSET 훑은 행 : offset + 페이지 크기
- keyset 훑은 행 : 페이지 크기 (깊이 무관)
- 커서 : (정렬키, tie-break) 조합
- 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향
- 인덱스 없는 keyset : 전량 스캔, 이점 없음
- 필터 추가 : 분기가 갈리면 Sort 재등장
@@ -0,0 +1,104 @@
---
id: b0b55ac9-c0a3-4c01-ba84-0aa478923ace
kind: REFERENCE
slug: nplus1-quantitative-diagnosis
title: JPA N+1 정량 진단 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b0b55ac9-c0a3-4c01-ba84-0aa478923ace/edit"
---
# JPA N+1 정량 진단 기준
N+1을 쿼리 로그의 인상이 아니라 지표로 확인한다. Hibernate Statistics의 지표는 이름이 뜻하는 것이 서로 달라서, SQL 실행 횟수로 바꿔 읽으면 배치를 적용한 뒤 결론이 어긋난다.
## 관계
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
엔티티별 fetch 통계로 ToOne 쪽을 확인한 기록이다.
- **PostgreSQL Query Plan 측정 기준**
같은 측정에서 실행계획을 다루는 기준이다.
## 목적
쿼리가 몇 개 나갔는지만 세면 어느 연관이 문제인지 알 수 없다. 총계에는 목록 루트, 페이지 count, ToOne 2차 SELECT, 컬렉션 초기화가 섞여 있다.
지표를 나눠 읽고 총계를 항등식으로 검산하면 어느 연관이 몇 번 조회되는지 확정할 수 있다. 그래야 fetch 전략을 바꿨을 때 무엇이 줄었는지 말할 수 있다.
## 규칙
### 1. 지표 이름이 뜻하는 것을 그대로 읽는다
getCollectionFetchCount()는 초기화된 컬렉션 수다. 실행된 SELECT SQL 수가 아니다.
getPrepareStatementCount()는 획득한 PreparedStatement 수다. 이 값도 SQL 실행 수와 항상 같지는 않다.
getEntityFetchCount()는 2차 fetch로 초기화된 엔티티 수다. 실행된 SELECT SQL 수가 아니다.
### 2. 등식이 성립하는 조건을 함께 적는다
batch나 subselect가 없을 때만 초기화 컬렉션 수와 자식 SELECT 수가 같다. 이 조건에서만 컬렉션 수를 SQL 수로 바꿔 읽을 수 있다.
Batch Fetch를 적용하면 여러 컬렉션을 한 SQL로 채우므로 등식이 깨진다. 배치 적용 여부는 prepared와 collectionFetch를 함께 보고 판단한다.
### 3. 총계를 형태별로 가르고 검산한다
총 PreparedStatement를 다음처럼 나눈다.
content 1
count 1
distinct ToOne 대상 수
N ToOne (아이템마다 다른 연관)
N 컬렉션 초기화
파생값과 직접 측정값이 일치하는지 교차 검증한다. 회계 항등식은 총 PreparedStatement에서 컬렉션 N, content 1, count 1을 뺀 값이 entityFetch와 같은지 보는 것이다.
### 4. 회귀 가드는 시더 카디널리티와 무관한 값으로 고정한다
합계 지표는 Hibernate 버전에 따라 집계 범위가 달라질 수 있다. 엔티티별 지표로 고정하는 편이 안정적이다. 예를 들어 아이템마다 다른 연관은 pageFetch == N이 성립한다.
합계는 회귀 가드가 아니라 교차 검증에 쓴다.
### 5. count 쿼리가 언제 나오는지 안다
Page를 반환하면 Spring Data가 전체 건수 count를 한 번 더 실행한다. offset이 0이고 pageSize가 반환 건수보다 크면 count를 건너뛴다.
같은 코드라도 pageSize와 반환 건수의 관계에 따라 총계가 달라진다. 측정값을 비교할 때 이 조건을 맞춘다.
### 6. 캐시가 결과를 먹지 않게 한다
같은 트랜잭션에서 조회를 반복하면 1차 캐시가 쿼리를 흡수한다. 지연 반복 루프는 매 반복마다 타이머를 켜기 전에 em.clear()를 호출한다. clear 비용은 측정 구간 밖에 둔다.
쿼리 수는 stats.clear() 직후 1회 실행분으로만 읽어 회당 정확값을 얻는다.
### 7. 증가 기준이 무엇인지 명시한다
N+1의 N은 전체 테이블 크기가 아니라 한 요청에서 조립하는 부모 엔티티 수다. 테이블이 100만 행이어도 이 왕복 수는 늘지 않는다.
전체 테이블 크기는 OFFSET, 정렬, 가시성 필터 비용에 영향을 준다. 이 비용은 별도 축으로 분리해 측정한다.
### 8. 왕복과 행수를 다른 축으로 센다
한 조회에 두 위반이 함께 있을 수 있다. 부모 수에 비례하는 왕복과, 한 번의 왕복에서 자식을 전부 읽는 과조회다.
왕복은 fetch 전략으로, 행수는 SQL 형태와 인덱스로 푼다. 한쪽을 고쳐 놓고 다른 쪽이 해결됐다고 적지 않는다.
## 적용 조건
- ORM 조회에서 쿼리 발생량이 데이터 규모를 따라 늘어나는지 확인할 때
- fetch 전략을 바꾸고 전후를 같은 지표로 비교할 때
- N+1 회귀를 테스트로 고정할 때
## 예외
- SQL 형태별 정확한 실행 횟수가 필요하면 이 지표만으로 부족하다. SQL 로그, StatementInspector, datasource-proxy, p6spy, PostgreSQL statement logging 중 하나로 따로 수집한다.
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
## 예시
- 초기화 컬렉션 수 : 실행된 SELECT 수가 아니라 초기화된 컬렉션 수
- 총 PreparedStatement : 획득한 statement 수, SQL 실행 수와 다를 수 있음
- 회계 항등식 : 총계 − 컬렉션 N content 1 count 1 = entityFetch
- 회귀 가드 : pageFetch == N (엔티티별, 시더 카디널리티 무관)
- 측정 규율 : 매 반복 전 em.clear, stats.clear 직후 1회만 읽기
@@ -0,0 +1,103 @@
---
id: e8c2e9ea-cd87-46f8-9469-849dbd433d86
kind: REFERENCE
slug: postgresql-query-plan-measurement
title: PostgreSQL Query Plan 측정 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e8c2e9ea-cd87-46f8-9469-849dbd433d86/edit"
---
# PostgreSQL Query Plan 측정 기준
실행계획과 인덱스 동작을 측정하려면 운영과 같은 DB 엔진에서 재야 한다. 비용 모델, 통계, 저장 구조, 인덱스 기능이 엔진마다 달라서 다른 엔진의 계획을 그대로 옮겨 읽으면 체계적으로 틀린 결론에 이른다.
## 관계
- **Query Plan은 실제 PostgreSQL에서 측정한다**
이 기준에서 나온 결정이다.
- **JPA N+1 정량 진단 기준**
같은 측정에서 쿼리 수를 다루는 기준이다.
- **ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가**
통계 축에서 남은 질문이다.
## 목적
쿼리 수만으로는 보이지 않는 것이 있다. 한 쿼리가 실어 나르는 행수, 정렬 방식, 인덱스 사용 여부, 읽은 블록 수다.
이 값을 확인하려면 엔진이 실제로 고른 계획을 봐야 한다.
## 규칙
### 1. 운영과 같은 엔진에서 측정한다
비용 기반 옵티마이저는 가능한 계획의 비용을 추정해 고른다. 추정값도, 고를 수 있는 선택지도 엔진마다 다르다.
네 축이 갈린다. 비용 상수로 표현되는 비용 모델, 수집하는 통계의 종류, 저장 구조와 가시성 처리, 사용할 수 있는 인덱스 종류와 기능이다.
인메모리 대체 DB에서 재면 스캔 방식 선택이 뒤집히고, 한쪽에만 있는 접근 경로가 통째로 사라지며, 그 엔진 특유의 동작이 재현되지 않는다.
### 2. 스키마를 운영 마이그레이션과 같게 맞춘다
같은 마이그레이션을 적용하고 엔티티와 스키마의 불일치를 조기에 잡는다.
다만 스키마 검증만으로 모든 드리프트를 막을 수 없다. 인덱스 구성, 부분 인덱스 조건, check 제약, 외래키 삭제 정책, 컬럼 순서는 검증 범위 밖이므로 따로 확인한다.
### 3. EXPLAIN은 ANALYZE와 BUFFERS를 함께 쓴다
추정만으로는 실제 행수를 알 수 없다. 실제 실행 결과와 읽은 블록 수를 함께 본다.
### 4. 추정 행수와 실제 행수의 차이를 기록한다
둘이 크게 벌어지면 통계가 데이터 분포를 담지 못한 것일 수 있다. 대량 데이터를 넣은 직후에 특히 그렇다.
이 차이를 발견하면 통계를 갱신한 뒤 다시 측정하고 전후를 비교한다.
### 5. warm cache 결과를 cold 실행시간으로 읽지 않는다
읽은 블록이 모두 캐시에서 왔다면 디스크 접근이 없는 값이다. 캐시 상태를 함께 기록한다.
### 6. Execution Time을 애플리케이션 지연과 합산하지 않는다
Execution Time은 엔진 내부 시간에 가깝다. ORM 엔티티 생성, 결과 전달, DTO 매핑, 직렬화, HTTP를 포함하지 않는다. 같은 지표가 아니다.
### 7. 여러 방식을 비교할 때는 같은 실행에서 잰다
캐시 상태를 맞추려면 같은 테스트 실행 안에서 연속으로 측정한다. 실행을 나누면 캐시 차이가 비교에 섞인다.
### 8. 인덱스 의존을 확인하려면 토글한다
어떤 방식이 빠른 이유가 문법인지 인덱스인지 가르려면 인덱스를 제거한 뒤 같은 쿼리를 다시 잰다. 측정이 끝나면 복구한다.
### 9. 측정 도구의 정밀도를 주장 강도에 맞춘다
방향성만 확인하는 값에 더 엄밀한 도구를 붙인다고 근거가 강해지지 않는다. 오히려 측정보다 정밀한 결론처럼 보인다.
표본이 적으면 백분위수로 부르지 않고 중앙값과 최댓값으로 적는다.
### 10. 재현 조건을 함께 남긴다
이미지 태그보다 patch 버전이나 digest를 고정하는 편이 낫다. 측정 시작 시 엔진 버전과 주요 플래너 설정을 함께 기록한다.
## 적용 조건
- 인덱스 설계나 쿼리 형태를 바꾸고 효과를 확인할 때
- 스캔 방식이나 정렬 방식이 바뀌었는지 확인할 때
- 여러 SQL 표현의 비용을 비교할 때
## 예외
- 쿼리 발생 횟수만 확인하면 되는 단계에서는 실행계획까지 필요하지 않다.
- 운영 종단 지연이나 처리량이 필요하면 이 측정의 범위 밖이다. 부하 테스트와 APM으로 확인한다.
- 안정적인 꼬리 지연이 필요하면 반복 횟수를 크게 늘린 독립 세트가 필요하다.
## 예시
- 엔진 : 운영과 같은 것. 인메모리 대체 금지
- 명령 : EXPLAIN (ANALYZE, BUFFERS)
- 캐시 : warm인지 cold인지 기록
- 추정 vs 실제 : 차이가 크면 통계 갱신 후 재측정
- 비교 : 같은 실행 안에서 연속 측정
- 인덱스 의존 : DROP 후 재측정, 끝나면 복구
- Execution Time : 애플리케이션 지연과 다른 지표
@@ -0,0 +1,93 @@
---
id: bf5f2462-0e94-4723-bdc8-f7dd709b2dbb
kind: REFERENCE
slug: top-n-per-group-selection
title: Top-N-per-group 선택 기준
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/bf5f2462-0e94-4723-bdc8-f7dd709b2dbb/edit"
---
# Top-N-per-group 선택 기준
부모마다 상위 N개를 뽑는 일은 LIMIT으로 표현되지 않는다. 윈도우 함수, LATERAL, 애플리케이션 그룹핑 세 가지가 같은 결과를 만들지만 읽는 행수가 다르다.
## 관계
- **Projection 이후에도 1,509행을 읽은 Row Over-fetch**
이 기준이 풀려던 문제다.
- **PostgreSQL Query Plan 측정 기준**
세 방식을 실행계획으로 비교한 기준이다.
- **Fetch Join · Batch · Projection 선택 기준**
앞 단계에서 왕복과 적재를 푼 기준이다.
## 목적
자식 조회에 LIMIT을 붙이면 최종 결과 집합 전체에 적용되어 부모 하나만 채워진다. 그룹당 상한은 다른 표현이 필요하다.
세 방식은 결과가 같으므로 정확성만으로 고를 수 없다. 읽는 행수와 buffers로 갈린다.
## 규칙
### 1. 단순 LIMIT은 그룹당 상한이 아니다
LIMIT은 최종 결과 집합에 적용된다. 부모 20개를 조회하면서 LIMIT 3을 붙이면 3행만 남아 부모 하나만 채워진다.
이 오작동은 결과 행수가 적어 정상처럼 보일 수 있다. 커버한 부모 수를 함께 확인한다.
### 2. 세 가지 표현을 구분한다
윈도우 함수는 부모별로 순번을 매기고 상위 몇 개를 남긴다. 순번을 만들려고 파티션 전체를 읽는다.
LATERAL은 부모마다 상관 서브쿼리를 실행하고 인덱스에서 필요한 개수만 읽고 멈춘다.
애플리케이션 그룹핑은 자식을 한 번에 가져온 뒤 코드에서 자른다. 자르기 전에 전량이 전송된다.
### 3. 작은 K에는 LATERAL이 유리하다
부모별 정렬 인덱스가 있으면 LATERAL은 부모마다 K개만 읽고 멈춘다. 그룹이 크고 K가 작을수록 읽지 않는 행이 많아진다.
### 4. K가 그룹 크기에 가까우면 윈도우로 수렴한다
K가 그룹 크기에 가까워지면 LATERAL도 대부분을 읽는다. 이때는 더 단순한 윈도우 함수를 고를 수 있다.
K를 바꿔 가며 buffers를 재면 어느 지점에서 뒤집히는지 볼 수 있다.
### 5. LATERAL의 이점은 인덱스에서 나온다
LATERAL 문법 자체가 빠른 것이 아니다. 부모별 정렬 인덱스가 있어야 상위 K개를 바로 찾는다.
인덱스가 없으면 부모마다 자식 테이블을 스캔하고 대부분을 필터로 버린다. 인덱스 유무를 토글해 확인한다.
### 6. 애플리케이션 그룹핑은 전송량을 줄이지 않는다
코드에서 자르면 결과는 맞지만 DB가 전달한 행은 전량이다. 전송량이 문제인 상황에서는 해법이 아니다.
### 7. 표준 JPQL로 표현되지 않는다
윈도우 함수와 LATERAL은 표준 JPQL에 없다. native SQL로 내려가야 한다. 이 결정을 기록에 남긴다.
### 8. 반환 행수와 커버한 부모를 함께 검증한다
세 방식이 같은 결과를 만드는지 먼저 확인한 뒤 실행계획을 비교한다. 반환 행수, 커버한 부모 수, 부모당 최대 개수를 함께 본다.
## 적용 조건
- 목록 응답에 부모별 자식 상위 몇 개를 포함해야 할 때
- 자식 전량 조회가 전송량 문제를 만들 때
- 그룹 크기가 크고 필요한 개수가 작을 때
## 예외
- 그룹 크기가 작아 전량을 읽어도 부담이 없으면 애플리케이션 그룹핑이 단순하다.
- 부모별 정렬 인덱스를 만들 수 없으면 LATERAL의 이점이 사라진다. 이때는 윈도우 함수와 buffers를 비교해 고른다.
## 예시
- 순진 LIMIT 3 : 전체에 적용, 부모 1개만 채워짐
- 윈도우 : 부모별 순번 뒤 상위 K, 파티션 전량 읽음
- LATERAL : 부모마다 인덱스에서 K개 읽고 멈춤
- 2단계 : 자식 전량 전송 뒤 코드에서 그룹핑
- 인덱스 없는 LATERAL : 부모마다 Seq Scan, buffers 급증
- 선택 : 작은 K는 LATERAL, K가 그룹 크기에 근접하면 윈도우