feat: 문서 구조 변경 및 tech-visual 스킬 추가
This commit is contained in:
+89
@@ -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·조인·정렬 없음
|
||||
- 갱신 비용 : 사전계산만 있음
|
||||
- 저장 공간 : 사전계산은 조회 사용자 수에 비례
|
||||
+95
@@ -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, 쿼리 상수, 자식 행수는 그대로
|
||||
+90
@@ -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, 시점만 다름
|
||||
+90
@@ -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 재등장
|
||||
+104
@@ -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회만 읽기
|
||||
+103
@@ -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 : 애플리케이션 지연과 다른 지표
|
||||
+93
@@ -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가 그룹 크기에 근접하면 윈도우
|
||||
Reference in New Issue
Block a user