docs(n+1liner): adopt the decomposition contract and strip evaluative prose

- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 ·
  KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d
  (문서가 인용한 IT 7종이 그 커밋에 있다)
- 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를
  통과하지 못해 Case 로 빼지 않았다
- 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision
- 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은
  절반이 있는 문장은 평가만 뺐다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent b25357c48a
commit 62520a4dce
28 changed files with 1818 additions and 838 deletions
@@ -4,84 +4,86 @@ kind: QUESTION
slug: cardinality-estimate-after-analyze
title: ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#6-4
- final/document.md#4-6
---
# ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
반복되는 하이라이트 조회의 실행계획에서 추정 행수 1이고 실제 행수는 500이었다. 대량 데이터를 넣은 직후 통계를 갱신하지 않아 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
플래너가 계획을 고르기 전에 조건에 걸리는 행이 몇 개일지 어림한 값이 추정 행수(cardinality estimate)이고, PostgreSQL은 `ANALYZE`로 컬럼 분포 통계를 모아 이 값을 낸다. 반복되는 하이라이트 조회의 실행계획에서 추정 행수 1이었는데 실제로는 500행이 나왔다. 대량 시드 직후 `ANALYZE`를 실행하지 않아 통계가 `feed_item_id` 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
추정과 실제의 차이를 기록하는 기준이다.
추정 행수와 실제 행수가 벌어지면 통계를 갱신한 뒤 다시 재고 전후를 비교하라고 적어 두었다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 실행계획이 나온 기록이다.
이 실행계획`EXPLAIN`으로 확인하고 500배 차이를 처음 적었다.
## 사실
- 대량 시드 직후 측정한 계획에서 추정 rows는 1, 실제 rows는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 인덱스로 처리했고 실행시간은 0.173 ms였다.
- 읽은 블록 모두 캐시에서 왔다. 디스크 읽기는 0이었다.
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id 자식 수가 크게 다르다. 상한 500, 하한 1이다.
- fetch join 조인 계획에서도 추정 4,202 실제 1,961의 차이가 있었다.
- 시드 직후 통계 갱신 명령을 실행하지 않았다.
- 대량 시드 직후 계획에서 추정 행수는 1인데 실제 행수는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 ix_highlights_feed_items_created 인덱스로 처리했고(Index Scan) 실행시간은 0.173 ms였다.
- 읽은 블록 14개가 모두 캐시에 있었고 디스크 읽기는 0이었다(shared hit=14, read=0).
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id마다 자식 행이 500개에서 1개까지 벌어진다.
- fetch join 계획의 Hash Join 노드에서도 추정 rows=4202 실제 1,961로 어긋났다.
- 대량 시드 직후 ANALYZE를 실행하지 않았다.
## 가정
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정 실제에 가까워진다.
- 추정 달라지면 플래너가 다른 계획을 고를 수 있다.
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정 행수가 실제에 가까워진다.
- 추정 행수가 달라지면 플래너가 다른 계획을 고를 수 있다.
- 편중이 큰 컬럼은 기본 통계 대상 수로 부족할 수 있다.
## 미지수
- 통계를 갱신한 뒤 추정 행수가 실제에 얼마나 가까워지는가.
- 추정 바뀌면 스캔 방식이 바뀌는가. 인덱스에서 순차 스캔으로, 또는 그 반대로 뒤집히는가.
- 편중이 큰 컬럼에 통계 대상 수를 늘리면 추정이 더 좋아지는가.
- 실행시간 읽은 블록 가 달라지는가.
- 이 차이가 지금까지의 결론는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 후 통계 갱신을 절차에 넣을 것인가.
- ANALYZE highlights를 실행하고 같은 조회를 다시 EXPLAIN하면 추정 행수가 1에서 얼마로 바뀌는가.
- 추정 행수가 바뀌면 Index Scan이 Seq Scan으로, 또는 그 반대로 뒤집히는가.
- highlights.feed_item_id의 통계 대상 수를 늘리면 추정 행수가 실제 500에 더 가까워지는가.
- 실행시간 0.173 ms와 읽은 블록 14개가 달라지는가.
- 통계를 갱신하면 지금까지의 결론 중 무엇이는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 뒤 ANALYZE를 절차에 넣을 것인가.
## 제약
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 비교가 흐려진다.
- 지금까지 기록한 실행계획은 모두 갱신 전 값이다. 갱신 후 값과 섞어 읽지 않도록 표기를 구분해야 한다.
- 실행하기 전에는 수치를 채우지 않는다.
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 읽은 블록 수를 나란히 놓을 수 없다.
- 지금까지 기록한 실행계획은 모두 ANALYZE 전 값이다. 갱신 후 값과 섞지 않도록 ANALYZE 전을 Plan A, 후를 Plan B로 나눠 적는다.
## 선택지
### 1. 통계를 갱신하고 전후를 비교한다
같은 데이터에서 갱신 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
측정이 한 번 더 필요하지만 가설을 닫을 수 있다.
같은 데이터에서 ANALYZE highlights 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
### 2. 통계 대상 수까지 조절해 본다
편중이 큰 컬럼의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
highlights.feed_item_id의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
변수가 하나 더 늘어 비교가 복잡해진다.
변수가 하나 더 늘어 갱신 전후 비교와 통계 대상 수 비교가 섞인다.
### 3. 갱신 전 값만 두고 넘어간다
왕복 수와 전송 행수에 관한 결론은 통계와 무관하다. 추정 차이를 한계로만 적고 진행한다.
플래너가 다른 계획을 고를 가능성을 확인하지 못한 채 남는다.
플래너가 다른 계획을 고르는지는 확인하지 못한다.
## 다음 검증
1. 대량 시드 직후 현재 계획을 다시 기록한다. 갱신 전 값임을 명시한다.
1. 대량 시드 직후 계획을 다시 기록한다. ANALYZE 전 값이므로 Plan A로 적는다.
2. 통계를 갱신한다.
2. ANALYZE highlights를 실행한다.
3. 같은 쿼리를 같은 실행 안에서 다시 EXPLAIN다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
3. 같은 쿼리를 같은 테스트 실행 안에서 EXPLAIN (ANALYZE, BUFFERS)로 다시 잰다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
4. 두 계획을 나란히 두고 무엇이 달라졌는지 적는다.
4. Plan A와 Plan B를 나란히 두고 무엇이 달라졌는지 적는다.
5. 계획이 바뀌었다면 지금까지의 결론 중 영향을 받는 항목이 있는지 확인한다.
6. 운영 절차에 대량 적재 후 통계 갱신을 넣을지 판단한다.
6. 운영 절차에 대량 적재 뒤 ANALYZE를 넣을지 판단한다.
@@ -4,85 +4,88 @@ kind: QUESTION
slug: concurrency-stability
title: 실제 동시 트래픽에서도 이 구조가 안정적인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/6cbe963f-86f8-4df6-be5a-900712970d01/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-5
- final/document.md#18
---
# 실제 동시 트래픽에서도 이 구조가 안정적인가
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 조회 횟수의 증가 형태를 확인한 것이다. 처리량, 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라 조회 구조가 실제 부하를 견디는지 아직 모른다.
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 아이템 수 N을 키울 때 쿼리 수와 읽는 행수가 얼마나 늘어나는지만 확인했다. 여러 요청이 겹칠 때의 처리량 커넥션 풀 안정성은 이 테스트가 재지 않았으므로, 조회 구조가 실제 부하를 견디는지 아직 모른다.
한 요청만 놓고 보면 왕복 수는 페이지 크기에 묶여 있다. 그 요청당 왕복이 트래픽에 곱해진 값은 요청을 동시에 보내야 나오고, 그 측정은 하지 않았다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
측정 범위와 도구 선택을 정한 기준이다.
무엇을 어디까지 재고 어떤 도구로 잴지 그 기록에서 정했다.
- **feed_visible을 Production CQRS로 승격할 것인가**
부하 결과가 필요한 다른 판단이다.
고트래픽 읽기에서 사전계산이 실제로 필요한지는 부하 결과를 봐야 정할 수 있다.
## 사실
- 측정은 단일 스레드에서 이미 열린 테스트 트랜잭션 안의 어댑터 호출만 쟀다. HTTP 종단 거치지 않았다.
- 지연 값은 warm cache 상태의 로컬 비교값이다. 표본은 7회 중 앞 2회를 버린 5개다.
- 표본이 적어 백분위수 대신 중앙값과 최댓값으로 기록했다.
- 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, 자식 전송은 1,509행에서 최대 60행으로 줄었다.
- 깊은 페이지 조회는 2,000행 대신 20행 읽었다.
- 문서 첫머리에 고트래픽 처리량, 커넥션 풀 안정성, 동시성이 범위 밖임을 명시했다.
- 측정은 단일 스레드에서 이미 열린 테스트 트랜잭션 안의 어댑터 호출만 쟀고, HTTP 종단 거치지 않았다.
- 지연 값은 DB 캐시가 이미 채워진 상태에서 잰 로컬 비교값이고, 7회 반복 중 앞 2회를 워밍업으로 버린 5개가 표본이다.
- 표본이 5개뿐이어서 p50·p99 대신 중앙값과 최댓값으로 적었다.
- N=1,000에서 왕복 수는 2,022개에서 23개로, 엔티티 로드는 1,569개에서 0개로, 자식 전송은 1,509행에서 최대 60행으로 줄었다.
- 100번째 페이지에서 OFFSET은 20행을 만들려고 2,000행을 읽었고 keyset은 20행 읽었다.
- 고트래픽 처리량 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라고 문서 첫머리에 적어 두었다.
- 커넥션 풀과 Little's Law는 이번 조회 문제를 해결한 뒤 별도 주제로 검증할 수 있다고 다음 단계에 함께 적어 두었다.
## 가정
- 왕복 수가 줄면 같은 트래픽에서 DB 부하도 줄어든다.
- 요청 왕복이 처리량에 곱해지므로 왕복 감소는 처리량 한계다.
- 단일 스레드에서 확인한 조회 형태는 동시 실행에서도 유지된다.
- 요청마다 반복되는 왕복이 처리량에 곱해지므로, 왕복이 줄면 처리량 한계라간다.
- 단일 스레드에서 잰 요청당 쿼리 수와 읽는 행수는 여러 요청이 동시에 돌아도 달라지지 않는다.
## 미지수
- 목표 처리량에서 커넥션 풀이 포화되는가. 풀 크기와 대기 시간은 어떻게 되는가.
- 동시 실행에서 지연 분포가 어떻게 되는가. 꼬리 지연 어디까지 늘어나는가.
- 배치 크기 설정이 동시 실행에서 어떻게 작동하는가. 세션마다 독립인가.
- 깊은 페이지 요청이 섞이면 전체 지연에 어떤 영향을 주는가.
- 캐시가 차갑거나 통계가 갱신되지 않은 상태에서 계획이 달라지는가.
- 어느 지표를 운영 알람 기준으로 삼을 것인가.
- 목표 처리량에서 커넥션 풀이 포화되는가. 포화되지 않으려면 풀을 몇 개로 잡아야 하고 요청은 얼마나 기다리는가.
- 요청이 동시에 몰릴 때 지연이 어느 구간에 몰리는가. 꼬리 지연 어디까지 벌어지는가.
- hibernate.default_batch_fetch_size=100은 세션 전체에 걸리는 설정인데, 여러 세션이 동시에 도는 동안에도 세션마다 따로 적용되는가.
- 깊은 페이지 요청이 섞이면 전체 지연이 얼마나 밀리는가.
- DB 캐시가 비어 있거나 ANALYZE를 돌리지 않아 통계가 낡은 상태에서 실행계획이 달라지는가.
- 운영 알람 기준으로 어느 지표를 삼을 것인가.
## 제약
- 단일 스레드 값으로는 동시성 질문에 답할 수 없다. 도구를 바꿔야 한다.
- 안정적인 꼬리 지연을 말하려면 워밍업 후 반복 횟수를 크게 늘린 독립 세트가 여러 개 필요하다.
- 부하 테스트 환경이 운영과 다르면 결과를 그대로 옮길 수 없다. 데이터 규모와 하드웨어를 맞춰야 한다.
- 로컬에서 확인한 것을 운영에서 확인한 것으로 승격하지 않는다.
- 단일 스레드에서 잰 값으로는 동시 실행을 말할 수 없어서, 부하 테스트와 APM(Application Performance Monitoring, 애플리케이션 성능 모니터링)으로 도구를 바꿔야 한다.
- 안정적인 꼬리 지연(p99)을 말하려면 워밍업 뒤 100회 넘게 반복한 독립 세트가 여러 개 있어야 한다.
- 부하 테스트 환경의 데이터 규모와 하드웨어를 운영에 맞추지 않으면 거기서 나온 결과를 운영으로 그대로 옮길 수 없다.
## 선택지
### 1. 부하 테스트와 APM으로 확인한다
목표 처리량을 정하고 종단 지연, 처리량, 커넥션 풀 지표를 함께 잰다. 조회 구조의 개선이 부하에서도 나타나는지 본다.
환경 구성과 데이터 준비에 시간이 든다.
목표 처리량을 먼저 정하고 HTTP 종단 지연 처리량, 커넥션 풀 사용률을 함께 잰다. 왕복이 줄어든 만큼 처리량이 올라가는지 부하에서 확인한다.
### 2. 반복 횟수를 늘린 지연 측정부터 한다
같은 단일 스레드 조건에서 표본을 크게 늘려 꼬리 지연을 먼저 안정화한다. 동시성은 아직 다루지 않는다.
동시성 질문에는 여전히 답하지 못한다.
같은 단일 스레드 조건에서 반복을 100회 넘게 늘려 꼬리 지연부터 안정시킨다. 동시성은 아직 다루지 않는다.
### 3. 현재 범위를 명시하고 운영 판단은 미룬다
조회 형태 개선까지만 주장하고 처리량은 다루지 않는다. 필요해때 부하 테스트를 연다.
쿼리 수와 읽는 행수가 줄었다는 데까지만 주장하고 처리량은 다루지 않는다. 필요해지면 그때 부하 테스트를 연다.
운영에 올린 뒤 문제를 발견할 위험이 남는다.
운영에 올린 뒤에야 문제를 발견할 수 있다.
## 다음 검증
1. 목표 처리량과 허용 지연을 먼저 정한다. 기준이 없으면 결과를 판정할 수 없다.
1. 목표 처리량과 허용 지연을 먼저 정한다. 이 두 값이 없으면 부하 결과가 통과인지 실패인지 판정할 수 없다.
2. 운영과 비슷한 데이터 규모를 준비한다. 편중 분포를 유지한다.
2. 운영과 비슷한 데이터 규모를 준비하되 하이라이트 개수의 Zipf 형태 편중은 그대로 둔다.
3. 부하 테스트로 종단 지연과 처리량을 측정한다. 커넥션 풀 사용률과 대기 시간을 함께 본다.
3. 부하 테스트로 HTTP 종단 지연과 처리량을 측정하고, 커넥션 풀 사용률과 대기 시간을 함께 본다.
4. 개선 전후 구조를 같은 조건에서 비교한다. 왕복 감소가 처리량으로 이어지는지 확인한다.
4. 개선 전후 구조를 같은 조건에서 비교해 왕복이 줄어든 것이 처리량으로 이어지는지 확인한다.
5. 깊은 페이지 얕은 페이지 요청을 섞어 지연 분포를 본다.
5. 깊은 페이지 요청과 얕은 페이지 요청을 섞어 지연이 어디까지 벌어지는지 본다.
6. 운영 알람으로 쓸 지표를 정한다.
@@ -4,81 +4,86 @@ kind: QUESTION
slug: isolate-round-trip-and-row-volume
title: Round Trip과 Row Volume을 독립 측정할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5159c415-232d-424a-970a-b0db52746767/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#4-5
- final/document.md#6-2
---
# Round Trip과 Row Volume을 독립 측정할 것인가
현재 데이터셋은 N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, DB 왕복이 동시에 늘어난다. 지연이 늘어난 원인을 어느 하나에 돌릴 수 없다. 변수를 하나씩 격리한 데이터셋을 만들지 정하지 않았다.
현재 데이터셋은 N을 키우면 반환 FeedItem 수, Highlight 총 행수, 엔티티 생성량, DB 왕복이 한꺼번에 늘어나서 지연이 늘어난 원인을 넷 중 어느 하나에 돌릴 수 없다. 변수를 하나씩 격리한 데이터셋을 만들지는 아직 정하지 않았다.
## 관계
- **JPA N+1 정량 진단 기준**
왕복 행수를 다른 축으로 세는 기준이다.
왕복 수와 전송 행수를 서로 다른 축으로 나눠 센다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
왕복 수와 한 번에 읽는 행 수를 같은 조회에서 확인한 기록이다.
같은 조회에서 왕복 수와 한 번에 읽어 오는 행수를 함께 확인한다.
## 사실
- 하이라이트 개수는 순위 기반 편중 분포로 생성된다. 상한 500, 하한 1이다.
- 하이라이트 총량은 N에 정비례하지 않는다. N=10에서 1,285개인데 N=1,000에서 2,917개다.
- 조회 수는 하이라이트 총량이 아니라 부모 수 N에 정비례한다.
- N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, 왕복이 함께 늘어난다.
- 지연은 단일 스레드에서 7회 반복하고 앞 2회를 버린 뒤 5개 표본의 중앙값과 최댓값으로 기록했다.
- 하이라이트 총량은 N에 정비례하지 않는다. N=10에서 이미 1,285개이고 그중 500개가 첫 아이템 하나에서 나오며, N을 100배인 1,000으로 키워도 2,917개에 그친다.
- 컬렉션 초기화 수(collectionFetches)는 하이라이트 총량이 아니라 FeedItem 수 N에 정비례한다.
- 왕복은 FeedItem 수에 비례해 늘고, 전송 행수 엔티티 생성은 한 FeedItem이 가진 Highlight 수에 비례해 늘어난다.
- 지연은 단일 스레드에서 7회 반복하고 앞 2회를 워밍업으로 버린 뒤 남은 5개 표본의 중앙값과 최댓값으로 기록했다.
- 격리 데이터셋 세 종류를 계획했지만 아직 실행하지 않았다.
## 가정
- 왕복 수와 전송 행수는 지연 서로 다른 방식으로 기여한다.
- 부모 수만 바꾸고 자식 수를 고정하면 왕복의 기여를 분리할 수 있다.
- 부모 수를 고정하고 자식 수만 바꾸면 과조회의 기여를 분리할 수 있다.
- 왕복 수와 전송 행수는 지연 서로 다른 방식으로 늘린다.
- FeedItem 수만 바꾸고 FeedItem마다 Highlight를 1개로 고정하면 왕복이 지연에 얼마나 붙는지 따로 잴 수 있다.
- FeedItem 수를 고정하고 Highlight 수만 바꾸면 한 번에 읽어 오는 행수가 지연을 얼마나 늘리는지 따로 볼 수 있다.
## 미지수
- 격리 데이터셋을 추가로 유지할 가치가 있는가. 시더와 테스트가 늘어난다.
- 부모 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- 자식 수만 바꾼 데이터셋에서 지연이 전송 행수에 어떻게 붙는가.
- 두 기여를 분리해도 전략 선택이 달라지는가. 이미 배치와 프로젝션으로 둘 다 줄였다.
- FeedItem 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- Highlight 수만 바꾼 데이터셋에서 지연이 전송 행수에 비례하는가, 아니면 어느 행수부터 더 가파르게 늘어나는가.
- 왕복과 행수를 갈라 재도 전략 선택이 달라지는가. 이미 배치로 왕복을 2,022개에서 23개로, 프로젝션으로 엔티티 로드를 1,569개에서 0개로 줄였다.
- 편중 분포를 유지한 데이터셋과 격리 데이터셋을 모두 유지할 것인가, 격리 데이터셋으로 대체할 것인가.
## 제약
- 현재 지연 값은 단일 스레드·warm cache 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 이 한계는 그대로다.
- 실행하기 전에는 수치를 채우지 않는다. 예상값으로 표를 메우지 않는다.
- 시더가 복잡해지면 기존 측정의 재현성에 영향을 줄 수 있다. 기존 데이터셋은 유지한 채 추가해야 한다.
- 현재 지연 값은 단일 스레드에서 같은 JVM으로, DB 캐시가 데워진 상태에서 잰 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 측정 조건이 같아서 이 값으로는 여전히 N에 따른 증가 방향만 말할 수 있다.
- 시더가 복잡해지면 지금까지 잰 값을 다시 재현하지 못할 수 있다. 기존 데이터셋은 그대로 두고 새 데이터셋을 더한다.
- 편중 분포는 실제 라이너 데이터와 같다고 주장하려고 만든 것이 아니라 일부 페이지에 하이라이트가 매우 많을 수 있다는 조건을 통제된 방식으로 재현하려고 만든 합성 분포다. 하이라이트 개수를 균일하게 두면 한 아이템에 수백 개가 몰려서 생기는 전송량과 메모리 압박도, 아이템마다 최신 3개를 뽑아야 하는 필요도 데이터셋에서 사라진다.
## 선택지
### 1. 세 데이터셋을 모두 만든다
부모 수만 바꾼 것, 자식 수만 바꾼 것, 편중 유지한 것 세 가지를 유지한다. 각 변수의 기여를 따로 수 있다.
FeedItem 수만 바꾼 것, Highlight 수만 바꾼 것, 편중 분포를 유지한 것 세 가지를 함께 둔다. 왕복과 전송 행수가 지연을 각각 얼마나 늘리는지 따로 수 있다.
시더와 테스트가 늘어나고 실행 시간도 길어진다.
### 2. 편중 데이터셋만 유지하고 격리는 하지 않는다
전략 선택이 이미 정해졌다면 원인 분해가 결정지 않는다. 현재 데이터셋으로 회귀만 지킨다.
전략 선택이 이미 정해졌다면 원인을 갈라도 결정지 않는다. 현재 데이터셋으로 회귀만 지킨다.
나중에 지연 원인을 따져야 할 때 다시 만들어야 한다.
### 3. 필요할 때만 한시적으로 만든다
특정 판단이 필요해지는 시점에 격리 데이터셋을 만들 측정한 뒤 남기지 않는다.
왕복과 행수를 갈라 봐야 하는 판단이 생겼을 때 격리 데이터셋을 만들 측정하고 남기지 않는다.
측정 시점마다 시더를 다시 맞춰야 해서 재현성이 떨어진다.
측정할 때마다 시더를 다시 맞춰야 해서 같은 값을 다시 얻기 어렵다.
## 다음 검증
1. 부모 수만 바꾼 데이터셋을 만든다. 부모마다 자식을 정확히 1개씩 둔다.
1. FeedItem을 10 / 100 / 1,000으로 바꾸고 FeedItem마다 Highlight를 정확히 1개씩 두는 데이터셋을 만든다.
2. 부모 수를 고정하고 자식 수만 바꾼 데이터셋을 만든다.
2. FeedItem을 20개로 고정하고 Highlight를 1 / 10 / 100 / 500으로 바꾸는 데이터셋을 만든다.
3. 두 데이터셋에서 왕복 수, 전송 행수, 지연을 각각 측정한다.
4. 지연이 어느 변수에 어떻게 붙는지 확인한다.
4. 왕복 수가 늘 때와 전송 행수가 늘 때 지연이 각각 얼마나 늘어나는지 비교한다.
5. 분해 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할 필요가 있는지 다시 판단한다.
5. 갈라 잰 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할지 다시 판단한다.
@@ -4,72 +4,77 @@ kind: QUESTION
slug: nullable-first-highlighted-at
title: Highlight 없는 FeedItem을 허용할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#3-1
- final/document.md#14-4
---
# Highlight 없는 FeedItem을 허용할 것인가
정렬키 first_highlighted_at nullable이라 하이라이트 없는 FeedItem이 존재할 수 있는 스키마다. 시더는 하이라이트가 만든 FeedItem만 넣어 항상 값이 차지만, 허용 여부를 정하지 않으면 정렬과 커서 비교식의 경계 동작이 정의되지 않는다.
정렬키 first_highlighted_at은 지금 null을 허용한다. 하이라이트가 하나도 없는 FeedItem이 스키마 위에서는 존재할 수 있뜻이다. FeedSeedFixture.seed(N)이 하이라이트가 만든 FeedItem만 넣다 보니 지금까지 쓴 데이터셋에는 이 컬럼이 빈 행이 없었다. 허용할지 말지를 정하기 전에는 정렬에서 그 행을 어디에 둘지도, 커서 비교식에 무엇을 담을지도 정할 수 없다.
## 관계
- **Keyset Pagination 설계 기준**
정렬키 null 처리가 커서 설계에 걸리는 지점이다.
정렬키 null이 섞이면 여기서 세운 커서 설계가 그대로 성립하지 않는다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
같은 정렬키 인덱스를 다루는 기록이다.
거기서 플래너가 쓰지 못한 인덱스가 이 질문의 정렬키 인덱스와 같은 ix_feed_items_keyset이다.
## 사실
- 현재 스키마의 first_highlighted_at은 timestamptz nullable이다. NOT NULL이 아니다.
- 시더는 하이라이트가 만든 FeedItem만 생성하므로 이 을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- first_highlighted_at은 timestamptz이고 null을 허용한다. NOT NULL 제약이 붙어 있지 않다.
- FeedSeedFixture.seed(N)은 하이라이트가 만든 FeedItem만 생성하므로 이 컬럼을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- FeedItem은 (user, page) 조합당 하나이고 UNIQUE(user_id, page_id) 제약이 있다.
- keyset 커서는 (first_highlighted_at, id)를 비교식으로 사용한다.
- 정렬키 전용 인덱스 (first_highlighted_at DESC, id DESC)로 만들었다.
- keyset 커서는 (first_highlighted_at, id)를 비교식에 담는다. 시각이 같은 행도 안정적으로 넘기려고 id까지 함께 넣었다.
- 정렬키 전용 인덱스 ix_feed_items_keyset은 (first_highlighted_at DESC, id DESC)로 만들었다.
## 가정
- 하이라이트가 하나도 없는 FeedItem이 생기는 경로가 실제로 있을 수 있다.
- 하이라이트가 하나도 없는 FeedItem이 실제로 만들어지는 경로가 도메인에 있을 수 있다.
- null이 섞이면 커서 비교식이 경계에서 행을 빠뜨리거나 중복시킬 수 있다.
- 정렬 위치를 정하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
- 정렬 위치를 정하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
## 미지수
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 존재하는가. 존재한다면 어떤 상황인가.
- NOT NULL로 좁힐 것인가, null을 허용하고 정렬 위치를 정의할 것인가.
- null을 허용한다면 정렬에서 어디에 두는가. 그 위치를 인덱스가 지원하는가.
- 커서가 null을 만났을 때 비교식을 어떻게 표현하는가.
- 부분 인덱스 조건을 쓴다면 null 행이 인덱스에서 빠지는데 그 행은 어떻게 조회되는가.
- FeedItem 생성과 첫 하이라이트 시이 다를 수 있는가.
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 는가. 있다면 FeedItem을 만드는 코드 중 어느 것인가.
- 스키마를 NOT NULL로 좁히는가, null을 허용하고 정렬 위치를 정하는가.
- null을 허용한다면 정렬에서 맨 앞과 맨 뒤 중 어디에 두는가. 그 순서를 (first_highlighted_at DESC, id DESC) 인덱스가 그대로 주는가.
- 커서에 담긴 시각이 null인 페이지를 넘길 때 (first_highlighted_at, id) 비교식에 무엇을 넣는가.
- 부분 인덱스 조건을 면 null 행이 인덱스에서 빠지는데, 그 행은 어느 쿼리로 읽는가.
- FeedItem을 만든과 첫 하이라이트가 생긴이 다를 수 있는가.
## 제약
- 결정 시점은 keyset 페이징을 프로덕션에 반영하기 전이다. 커서 비교식과 인덱스 정의가 이 결정에 달려 있다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 없어야 한다. 마이그레이션 전에 확인이 필요하다.
- 현재 시더로는 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 검증할 수 있다.
- 커서 비교식과 정렬키 인덱스 정의가 이 결정에 달려 있어서, keyset 페이징을 프로덕션에 반영하기 전에 정해야 한다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 하나도 없어야 하므로, 마이그레이션을 돌리기 전에 세어 봐야 한다.
- 지금 데이터셋에는 null 행이 없어 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 확인할 수 있다. 시더가 이 컬럼의 값을 시간에 흩어 놓은 것은 시간순 페이징과 정렬 인덱스를 실험할 바탕을 마련하려는 구성이다. 값이 비는 행을 만드는 것은 그 구성에 들어 있지 않다.
## 선택지
### 1. NOT NULL로 좁힌다
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키 NOT NULL로 정의한다. 커서 비교식이 단순해지고 인덱스도 그대로 쓸 수 있다.
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키 NOT NULL을 건다. 커서는 지금처럼 (first_highlighted_at, id) 두 값만 비교하면 되고 인덱스도 정의를 바꾸지 않는다.
대신 하이라이트 없는 FeedItem을 만드는 경로가 나중에 필요해지면 스키마와 생성 흐름을 다시 바꿔야 한다.
### 2. null을 허용하고 정렬 위치를 정의한다
정렬에서 null을 어디에 둘지 명시하고 인덱스도 같은 위치로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
정렬에서 null을 맨 앞에 둘지 맨 뒤에 둘지 쿼리에 적고, 인덱스도 같은 순서로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
생성 흐름은 자유로워지지만 커서 표현과 인덱스 정의가 복잡해진다.
FeedItem을 만드는 흐름은 하이라이트 없이도 되지만, 커서에 무엇을 담을지와 인덱스를 어느 순서로 만들지를 둘 다 따로 정해야 한다.
### 3. 부분 인덱스로 null 행을 제외한다
정렬키가 있는 행만 인덱스에 담는다. 피드 목록에 하이라이트가 있는 항목만 노출한다는 정책이 된다.
정렬키가 채워진 행만 인덱스에 담는다. 피드 목록에 하이라이트가 있는 항목만 내보낸다고 정하는 것과 같다.
인덱스는 작아지지만 null 행을 조회하는 별도 경로가 필요하다.
인덱스는 작아지지만 null 행을 읽는 쿼리를 따로 둬야 한다.
## 다음 검증
@@ -77,8 +82,8 @@ FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키를 NOT
2. 기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다.
3. null이 섞인 데이터셋을 만들어 현재 커서 비교식이 경계에서 어떻게 동작하는지 재현한다.
3. null이 섞인 데이터셋을 만들어 지금 커서 비교식이 경계에서 행을 빠뜨리는지 중복시키는지 재현한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 행·같은 순서인지 대조한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 20행·같은 순서인지 FeedKeysetIT.l15KeysetWalkMatchesOffsetPages와 같은 방식으로 대조한다.
5. 정렬 위치를 정한 경우 인덱스가 그 순서를 그대로 주는지 실행계획로 확인한다.
5. null 정렬 위치를 정한 경우 ix_feed_items_keyset이 그 순서를 그대로 주는지, 실행계획에 Sort가 다시 나오는지로 확인한다.
@@ -4,87 +4,93 @@ kind: QUESTION
slug: promote-feed-visible-to-cqrs
title: feed_visible을 Production CQRS로 승격할 것인가
topic: jpa-feed-query-performance
topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화
topicName: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5088ce14-b096-41d3-abba-64b7afb48bb9/edit"
questionStatus: OPEN
sourceRevision: n+1liner-lab@2026-08
source:
- final/document.md#15-5
- final/document.md#17-3
- final/document.md#18
---
# feed_visible을 Production CQRS로 승격할 것인가
사용자별 가시성을 미리 계산한 테이블은 조회 커버링 인덱스 하나로 만들었다. 상시 유지하려면 원본 변경을 투영에 동기화해야 하고, 이는 별도 물리 읽기 저장소를 두는 결정이 된다.
사용자별 가시성을 미리 펼친 feed_visible 테이블을 두자 피드 조회 커버링 인덱스 하나만 읽는 단일 스캔으로 바뀌었다. 이 테이블을 상시 유지하려면 원본이 바뀔 때마다 투영을 갱신해야 하고, 그러면 쓰기와 분리된 물리 읽기 저장소를 두는 풀 CQRS(Command Query Responsibility Segregation, 명령과 조회를 다른 모델로 나누는 설계)로 넘어간다. 그 비용을 이번 범위에서 바로 받아들일지는 따로 판단해야 할 일로 두었다.
## 관계
- **Feed Visibility Query Pattern**
세 방식을 비교한 기준이다.
단일 OR, UNION 분해, 사전계산 세 방식을 같은 결과 집합으로 비교했다.
- **현재 Read Model은 CQRS-lite로 유지한다**
지금 유지하기로 한 범위다.
이번 범위에서는 저장소를 나누지 않고 읽기 모델만 분리하기로 했다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
사전계산이 풀려던 문제다.
keyset으로 푼 페이지 조회에 가시성 세 분기를 얹자 BitmapOr와 재정렬이 다시 나타났고, 사전계산은 이것을 없애려던 방식이다.
## 사실
- 사전계산 조회는 커버링 인덱스의 단일 스캔이었다. OR도 조인도 정렬도 없었다.
- 단일 OR은 후보 1,500을 훑 상위 20을 정렬로 만들었다. UNION 분해는 분기별로 스캔했다.
- 세 방식은 같은 조회 사용자에게 같은 항목 집합을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 깊은 페이지에서 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 었다.
- 현재 구현은 사전계산 테이블을 테스트 안에서 만들고 지운다. 상시 유지하지 않는다.
- 현재 읽기 경로는 쓰기와 같은 저장소 위에 읽기 전용 포트·DTO·쿼리만 분리한 형태다.
- 사전계산 조회는 feed_visible 커버링 인덱스 하나만 읽었다. OR도 조인도 정렬도 없이 20행에 접근했고 buffers는 1이었다.
- 단일 OR은 세 분기를 BitmapOr로 합쳐 후보 1,500을 훑은 뒤 정렬로 상위 20을 냈고 buffers는 122였다. UNION 분해는 요청할 때마다 세 분기를 각각 스캔했고 buffers는 200으로 단일 OR보다 컸다.
- 세 방식은 seed 2,000에서 뷰어 user008에게 같은 20개 항목을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 가장 깊은 페이지에서 ix_feed_visible 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 었다.
- feed_visible은 FeedVisibilityIT와 FeedCrownIT 안에서 만들고 지우는 실험용 테이블이라 상시 유지하지 않는다.
- 지금 읽기 경로는 쓰기와 같은 저장소를 쓰면서 읽기 전용 포트·DTO·쿼리만 분리한 CQRS-lite다.
## 가정
- 고트래픽 읽기에서는 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 원본 변경마다 투영 갱신이 필요하다.
- 투영이 어긋나면 사용자가 볼 수 없는 항목을 보거나 볼 수 있는 항목을 놓친다.
- 고트래픽 읽기에서는 세 방식의 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 피드·멘션·가시성이 바뀔 때마다 투영을 다시 써야 한다.
- 투영이 어긋나면 사용자가 볼 수 없어야 할 항목이 화면에 나오거나 볼 수 있는 항목이 빠진다.
## 미지수
- 현재 트래픽에서 단일 OR이나 UNION 분해로 충분한가. 사전계산이 필요한 임계가 어디인가.
- 동기화를 어떤 방식으로 하는가. 도메인 이벤트인가 아웃박스인가.
- 투영 갱신이 늦어졌을 때 허용 가능한 지연은 얼마인가.
- 지금 트래픽 단일 OR이나 UNION 분해로 감당할 수 있는가. 사전계산이 필요해지는 읽기 요청량이 어디부터인가.
- 쓰기 변경을 투영으로 어떻게 옮기는가. 도메인 이벤트로 보내는가, 아웃박스 테이블을 거치는가.
- 투영 갱신이 늦어졌을 때 지연을 어디까지 허용하는가.
- 가시성이 바뀌는 사건이 무엇인가. 아이템 공개 범위 변경, 멘션 추가·삭제, 사용자 삭제까지 포함하는가.
- 사용자 수만큼 늘어나는 저장 공간이 감당 가능한가.
- 투영이 어긋났을 때 어떻게 발견하고 복구하는가.
- 이 변경이 현재 정한 계약의 범위를 넘는가. 넘는다면 계약과 가드레일을 어떻게 개정하는가.
- 저장 공간은 사용자 수만큼 늘어나는데 그 크기를 감당할 수 있는가.
- 투영이 원본과 어긋났을 때 무엇으로 발견하고 어떻게 되돌리는가.
- 승격이 현재 계약의 범위를 넘는다는 것은 확인했고, 계약과 가드레일의 어느 조항을 어떻게 고칠지는 아직 정하지 않았다.
## 제약
- 현재 계약에서 별도 물리 읽기 저장소 에스컬레이션 대상으로 남겨 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이다. 투영이 어긋나면 노출 사고가 된다. 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지의 측정은 단일 스레드 로컬 값이다. 고트래픽에서 어느 방식이 필요한지는 이 측정으로 답할 수 없다.
- 현재 계약 별도 물리 읽기 저장소 에스컬레이션 대상으로 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이 투영이 어긋나면 볼 수 없어야 할 항목이 노출된다. 그래서 동기화 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지 잰 값은 모두 단일 스레드 로컬 측정이라, 고트래픽에서 어느 방식이 필요한지는 이 으로 답할 수 없다.
## 선택지
### 1. 현재 범위를 유지하고 요청 시 조회로 푼다
단일 OR이나 UNION 분해로 조회한다. 동기화 비용이 없고 정합성 문제도 없다.
단일 OR이나 UNION 분해로 요청할 때마다 조회한다. 투영을 두지 않으니 동기화 비용도, 원본과 어긋날 위험도 없다.
고트래픽에서 조회 비용이 그대로 남는다.
요청마다 가시성 세 분기를 다시 풀기 때문에 조회 비용은 줄지 않는다. 로컬에서 잰 buffers는 단일 OR 122, UNION 분해 200이었고 고트래픽에서 이 값이 어떤 부하가 되는지는 재지 않았다.
### 2. 사전계산을 상시 유지하는 읽기 저장소로 승격한다
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 조회 비용이 가장 낮다.
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 세 방식 가운데 이것만 buffers 1로 20행을 읽었다.
동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다. 계약 개정도 필요하다.
대신 동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 하고 계약도 함께 개정해야 한다.
### 3. 일부만 사전계산한다
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청 조회한다.
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청할 때 조회한다.
두 경로를 함께 운영해야 하고 어느 구간을 투영에 둘지 정하는 기준이 필요하다.
조회 경로를 함께 운영해야 하고, 어느 구간을 투영에 둘지 무엇을 보고 가를지 먼저 정해야 한다.
## 다음 검증
1. 부하 테스트로 현재 조회 방식이 목표 트래픽을 견디는지 확인한다. 이 판단은 단일 스레드 측정으로 대신할 수 없다.
1. 부하 테스트로 지금의 조회 방식이 목표 트래픽을 견디는지 확인한다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행에 영향을 주는지 정리한다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행을 건드리는지 정리한다.
3. 사용자 수와 아이템 수를 곱 투영 크기를 계산한다.
3. 사용자 수와 아이템 수를 곱 투영이 몇 행까지 커지는지 계산한다.
4. 동기화 지연 허용치를 정한다. 가시성 보안 조건이므로 이 값이 설계를 좌우한다.
4. 동기화 지연을 어디까지 허용할지 정한다. 가시성 보안 조건이 이 값을 정해야 동기화 방식을 고를 수 있다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 복구 절차를 정의한다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 되돌리는 절차를 정의한다.
6. 위 결과를 보고 계약을 개정할지 판단한다.
6. 다섯 가지 결과를 보고 계약을 개정할지 판단한다.