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,86 @@
---
id: e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff
kind: QUESTION
slug: cardinality-estimate-after-analyze
title: ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/e1e0e2a0-c6b6-45bf-be42-f697ba5e2fff/edit"
questionStatus: OPEN
---
# ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가
반복되는 하이라이트 조회의 실행계획에서 추정 행수는 1이고 실제 행수는 500이었다. 대량 데이터를 넣은 직후 통계를 갱신하지 않아 편중을 담지 못했다는 가설을 세웠지만 아직 검증하지 않았다.
## 관계
- **PostgreSQL Query Plan 측정 기준**
추정과 실제의 차이를 기록하는 기준이다.
- **Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1**
이 실행계획이 나온 기록이다.
## 사실
- 대량 시드 직후 측정한 계획에서 추정 rows는 1, 실제 rows는 500이었다. 500배 차이다.
- 이 계획은 feed_item_id 조건을 인덱스로 처리했고 실행시간은 0.173 ms였다.
- 읽은 블록은 모두 캐시에서 왔다. 디스크 읽기는 0이었다.
- 하이라이트 개수는 순위 기반 편중 분포라 feed_item_id별 자식 수가 크게 다르다. 상한 500, 하한 1이다.
- fetch join 조인 계획에서도 추정 4,202와 실제 1,961의 차이가 있었다.
- 시드 직후 통계 갱신 명령을 실행하지 않았다.
## 가정
- 통계를 갱신하면 feed_item_id별 분포가 반영되어 추정이 실제에 가까워진다.
- 추정이 달라지면 플래너가 다른 계획을 고를 수 있다.
- 편중이 큰 컬럼은 기본 통계 대상 수로 부족할 수 있다.
## 미지수
- 통계를 갱신한 뒤 추정 행수가 실제에 얼마나 가까워지는가.
- 추정이 바뀌면 스캔 방식이 바뀌는가. 인덱스에서 순차 스캔으로, 또는 그 반대로 뒤집히는가.
- 편중이 큰 컬럼에 통계 대상 수를 늘리면 추정이 더 좋아지는가.
- 실행시간과 읽은 블록 수가 달라지는가.
- 이 차이가 지금까지의 결론을 바꾸는가. 왕복 수와 전송 행수에 관한 판단은 통계와 무관하다.
- 운영에서 대량 적재 후 통계 갱신을 절차에 넣을 것인가.
## 제약
- 통계 갱신 전후를 비교하려면 같은 데이터에서 연속으로 재야 한다. 캐시 상태가 섞이면 비교가 흐려진다.
- 지금까지 기록한 실행계획은 모두 갱신 전 값이다. 갱신 후 값과 섞어 읽지 않도록 표기를 구분해야 한다.
- 실행하기 전에는 수치를 채우지 않는다.
## 선택지
### 1. 통계를 갱신하고 전후를 비교한다
같은 데이터에서 갱신 전후 계획을 나란히 기록한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 대조한다.
측정이 한 번 더 필요하지만 가설을 닫을 수 있다.
### 2. 통계 대상 수까지 조절해 본다
편중이 큰 컬럼의 통계 대상 수를 늘린 뒤 다시 잰다. 기본값으로 부족한지 확인한다.
변수가 하나 더 늘어 비교가 복잡해진다.
### 3. 갱신 전 값만 두고 넘어간다
왕복 수와 전송 행수에 관한 결론은 통계와 무관하다. 추정 차이를 한계로만 적고 진행한다.
플래너가 다른 계획을 고를 가능성을 확인하지 못한 채 남는다.
## 다음 검증
1. 대량 시드 직후 현재 계획을 다시 기록한다. 갱신 전 값임을 명시한다.
2. 통계를 갱신한다.
3. 같은 쿼리를 같은 실행 안에서 다시 EXPLAIN한다. 추정 행수, 스캔 방식, 읽은 블록, 실행시간을 기록한다.
4. 두 계획을 나란히 두고 무엇이 달라졌는지 적는다.
5. 계획이 바뀌었다면 지금까지의 결론 중 영향을 받는 항목이 있는지 확인한다.
6. 운영 절차에 대량 적재 후 통계 갱신을 넣을지 판단한다.
@@ -0,0 +1,87 @@
---
id: 6cbe963f-86f8-4df6-be5a-900712970d01
kind: QUESTION
slug: concurrency-stability
title: 실제 동시 트래픽에서도 이 구조가 안정적인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/6cbe963f-86f8-4df6-be5a-900712970d01/edit"
questionStatus: OPEN
---
# 실제 동시 트래픽에서도 이 구조가 안정적인가
지금까지의 측정은 단일 스레드 퍼시스턴스 통합 테스트에서 조회 횟수의 증가 형태를 확인한 것이다. 처리량, 커넥션 풀 안정성, 동시성은 이 측정의 범위 밖이라 조회 구조가 실제 부하를 견디는지 아직 모른다.
## 관계
- **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행을 읽었다.
- 문서 첫머리에 고트래픽 처리량, 커넥션 풀 안정성, 동시성이 범위 밖임을 명시했다.
## 가정
- 왕복 수가 줄면 같은 트래픽에서 DB 부하도 줄어든다.
- 요청당 왕복이 처리량에 곱해지므로 왕복 감소는 처리량 한계를 올린다.
- 단일 스레드에서 확인한 조회 형태는 동시 실행에서도 유지된다.
## 미지수
- 목표 처리량에서 커넥션 풀이 포화되는가. 풀 크기와 대기 시간은 어떻게 되는가.
- 동시 실행에서 지연 분포가 어떻게 되는가. 꼬리 지연이 어디까지 늘어나는가.
- 배치 크기 설정이 동시 실행에서 어떻게 작동하는가. 세션마다 독립인가.
- 깊은 페이지 요청이 섞이면 전체 지연에 어떤 영향을 주는가.
- 캐시가 차갑거나 통계가 갱신되지 않은 상태에서 계획이 달라지는가.
- 어느 지표를 운영 알람 기준으로 삼을 것인가.
## 제약
- 단일 스레드 값으로는 동시성 질문에 답할 수 없다. 도구를 바꿔야 한다.
- 안정적인 꼬리 지연을 말하려면 워밍업 후 반복 횟수를 크게 늘린 독립 세트가 여러 개 필요하다.
- 부하 테스트 환경이 운영과 다르면 결과를 그대로 옮길 수 없다. 데이터 규모와 하드웨어를 맞춰야 한다.
- 로컬에서 확인한 것을 운영에서 확인한 것으로 승격하지 않는다.
## 선택지
### 1. 부하 테스트와 APM으로 확인한다
목표 처리량을 정하고 종단 지연, 처리량, 커넥션 풀 지표를 함께 잰다. 조회 구조의 개선이 부하에서도 나타나는지 본다.
환경 구성과 데이터 준비에 시간이 든다.
### 2. 반복 횟수를 늘린 지연 측정부터 한다
같은 단일 스레드 조건에서 표본을 크게 늘려 꼬리 지연을 먼저 안정화한다. 동시성은 아직 다루지 않는다.
동시성 질문에는 여전히 답하지 못한다.
### 3. 현재 범위를 명시하고 운영 판단은 미룬다
조회 형태 개선까지만 주장하고 처리량은 다루지 않는다. 필요해질 때 부하 테스트를 연다.
운영에 올린 뒤 문제를 발견할 위험이 남는다.
## 다음 검증
1. 목표 처리량과 허용 지연을 먼저 정한다. 기준이 없으면 결과를 판정할 수 없다.
2. 운영과 비슷한 데이터 규모를 준비한다. 편중 분포를 유지한다.
3. 부하 테스트로 종단 지연과 처리량을 측정한다. 커넥션 풀 사용률과 대기 시간을 함께 본다.
4. 개선 전후 구조를 같은 조건에서 비교한다. 왕복 감소가 처리량으로 이어지는지 확인한다.
5. 깊은 페이지와 얕은 페이지 요청을 섞어 지연 분포를 본다.
6. 운영 알람으로 쓸 지표를 정한다.
@@ -0,0 +1,83 @@
---
id: 5159c415-232d-424a-970a-b0db52746767
kind: QUESTION
slug: isolate-round-trip-and-row-volume
title: Round Trip과 Row Volume을 독립 측정할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5159c415-232d-424a-970a-b0db52746767/edit"
questionStatus: OPEN
---
# Round Trip과 Row Volume을 독립 측정할 것인가
현재 데이터셋은 N을 키우면 반환 부모 수, 자식 총 행수, 엔티티 생성량, 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개 표본의 중앙값과 최댓값으로 기록했다.
- 격리 데이터셋 세 종류를 계획했지만 아직 실행하지 않았다.
## 가정
- 왕복 수와 전송 행수는 지연에 서로 다른 방식으로 기여한다.
- 부모 수만 바꾸고 자식 수를 고정하면 왕복의 기여를 분리할 수 있다.
- 부모 수를 고정하고 자식 수만 바꾸면 과조회의 기여를 분리할 수 있다.
## 미지수
- 격리 데이터셋을 추가로 유지할 가치가 있는가. 시더와 테스트가 늘어난다.
- 부모 수만 바꾼 데이터셋에서 지연이 왕복 수에 선형으로 붙는가.
- 자식 수만 바꾼 데이터셋에서 지연이 전송 행수에 어떻게 붙는가.
- 두 기여를 분리해도 전략 선택이 달라지는가. 이미 배치와 프로젝션으로 둘 다 줄였다.
- 편중 분포를 유지한 데이터셋과 격리 데이터셋을 모두 유지할 것인가, 격리 데이터셋으로 대체할 것인가.
## 제약
- 현재 지연 값은 단일 스레드·warm cache 상대값이라 절대값 비교에 쓸 수 없다. 격리 데이터셋을 만들어도 이 한계는 그대로다.
- 실행하기 전에는 수치를 채우지 않는다. 예상값으로 표를 메우지 않는다.
- 시더가 복잡해지면 기존 측정의 재현성에 영향을 줄 수 있다. 기존 데이터셋은 유지한 채 추가해야 한다.
## 선택지
### 1. 세 데이터셋을 모두 만든다
부모 수만 바꾼 것, 자식 수만 바꾼 것, 편중을 유지한 것 세 가지를 유지한다. 각 변수의 기여를 따로 볼 수 있다.
시더와 테스트가 늘어나고 실행 시간도 길어진다.
### 2. 편중 데이터셋만 유지하고 격리는 하지 않는다
전략 선택이 이미 정해졌다면 원인 분해가 결정을 바꾸지 않는다. 현재 데이터셋으로 회귀만 지킨다.
나중에 지연 원인을 따져야 할 때 다시 만들어야 한다.
### 3. 필요할 때만 한시적으로 만든다
특정 판단이 필요해지는 시점에 격리 데이터셋을 만들고 측정한 뒤 남기지 않는다.
측정 시점마다 시더를 다시 맞춰야 해서 재현성이 떨어진다.
## 다음 검증
1. 부모 수만 바꾼 데이터셋을 만든다. 부모마다 자식을 정확히 1개씩 둔다.
2. 부모 수를 고정하고 자식 수만 바꾼 데이터셋을 만든다.
3. 두 데이터셋에서 왕복 수, 전송 행수, 지연을 각각 측정한다.
4. 지연이 어느 변수에 어떻게 붙는지 확인한다.
5. 분해 결과가 이미 내린 전략 선택을 바꾸는지 본다. 바꾸지 않는다면 격리 데이터셋을 상시 유지할 필요가 있는지 다시 판단한다.
@@ -0,0 +1,83 @@
---
id: b099ca65-bf9f-4d61-814c-74722453fa3c
kind: QUESTION
slug: nullable-first-highlighted-at
title: Highlight 없는 FeedItem을 허용할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit"
questionStatus: OPEN
---
# Highlight 없는 FeedItem을 허용할 것인가
정렬키 first_highlighted_at이 nullable이라 하이라이트 없는 FeedItem이 존재할 수 있는 스키마다. 시더는 하이라이트가 만든 FeedItem만 넣어 항상 값이 차지만, 허용 여부를 정하지 않으면 정렬과 커서 비교식의 경계 동작이 정의되지 않는다.
## 관계
- **Keyset Pagination 설계 기준**
정렬키의 null 처리가 커서 설계에 걸리는 지점이다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
같은 정렬키 인덱스를 다루는 기록이다.
## 사실
- 현재 스키마의 first_highlighted_at은 timestamptz nullable이다. NOT NULL이 아니다.
- 시더는 하이라이트가 만든 FeedItem만 생성하므로 이 값을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- FeedItem은 (user, page) 조합당 하나이고 UNIQUE(user_id, page_id) 제약이 있다.
- keyset 커서는 (first_highlighted_at, id)를 비교식으로 사용한다.
- 정렬키 전용 인덱스는 (first_highlighted_at DESC, id DESC)로 만들었다.
## 가정
- 하이라이트가 하나도 없는 FeedItem이 생기는 경로가 실제로 있을 수 있다.
- null이 섞이면 커서 비교식이 경계에서 행을 빠뜨리거나 중복시킬 수 있다.
- 정렬 위치를 정의하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
## 미지수
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 존재하는가. 존재한다면 어떤 상황인가.
- NOT NULL로 좁힐 것인가, null을 허용하고 정렬 위치를 정의할 것인가.
- null을 허용한다면 정렬에서 어디에 두는가. 그 위치를 인덱스가 지원하는가.
- 커서가 null을 만났을 때 비교식을 어떻게 표현하는가.
- 부분 인덱스 조건을 쓴다면 null 행이 인덱스에서 빠지는데 그 행은 어떻게 조회되는가.
- FeedItem 생성 시점과 첫 하이라이트 시점이 다를 수 있는가.
## 제약
- 결정 시점은 keyset 페이징을 프로덕션에 반영하기 전이다. 커서 비교식과 인덱스 정의가 이 결정에 달려 있다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 없어야 한다. 마이그레이션 전에 확인이 필요하다.
- 현재 시더로는 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 검증할 수 있다.
## 선택지
### 1. NOT NULL로 좁힌다
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키를 NOT NULL로 정의한다. 커서 비교식이 단순해지고 인덱스도 그대로 쓸 수 있다.
대신 하이라이트 없는 FeedItem을 만드는 경로가 나중에 필요해지면 스키마와 생성 흐름을 다시 바꿔야 한다.
### 2. null을 허용하고 정렬 위치를 정의한다
정렬에서 null을 어디에 둘지 명시하고 인덱스도 같은 위치로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
생성 흐름은 자유로워지지만 커서 표현과 인덱스 정의가 복잡해진다.
### 3. 부분 인덱스로 null 행을 제외한다
정렬키가 있는 행만 인덱스에 담는다. 피드 목록에는 하이라이트가 있는 항목만 노출한다는 정책이 된다.
인덱스는 작아지지만 null 행을 조회하는 별도 경로가 필요하다.
## 다음 검증
1. 도메인에서 하이라이트 없는 FeedItem이 생기는 경로가 있는지 확인한다.
2. 기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다.
3. null이 섞인 데이터셋을 만들어 현재 커서 비교식이 경계에서 어떻게 동작하는지 재현한다.
4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 행·같은 순서인지 대조한다.
5. 정렬 위치를 정의한 경우 인덱스가 그 순서를 그대로 주는지 실행계획으로 확인한다.
@@ -0,0 +1,89 @@
---
id: 5088ce14-b096-41d3-abba-64b7afb48bb9
kind: QUESTION
slug: promote-feed-visible-to-cqrs
title: feed_visible을 Production CQRS로 승격할 것인가
topic: JPA 피드 조회 성능
project: Liner N + 1문제
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5088ce14-b096-41d3-abba-64b7afb48bb9/edit"
questionStatus: OPEN
---
# feed_visible을 Production CQRS로 승격할 것인가
사용자별 가시성을 미리 계산한 테이블은 조회를 커버링 인덱스 하나로 만들었다. 상시 유지하려면 원본 변경을 투영에 동기화해야 하고, 이는 별도 물리 읽기 저장소를 두는 결정이 된다.
## 관계
- **Feed Visibility Query Pattern**
세 방식을 비교한 기준이다.
- **현재 Read Model은 CQRS-lite로 유지한다**
지금 유지하기로 한 범위다.
- **Visibility OR이 Keyset Index를 깨뜨린 문제**
사전계산이 풀려던 문제다.
## 사실
- 사전계산 조회는 커버링 인덱스의 단일 스캔이었다. OR도 조인도 정렬도 없었다.
- 단일 OR은 후보 1,500을 훑고 상위 20을 정렬로 만들었다. UNION 분해는 분기별로 스캔했다.
- 세 방식은 같은 조회 사용자에게 같은 항목 집합을 반환했다.
- 통합 쿼리에서 부모 선택을 사전계산으로 두면 깊은 페이지에서 인덱스 범위로 19행만 읽었다. 단일 OR로 두면 가시성 분기와 멘션 조건을 다시 계산하며 200행을 읽었다.
- 현재 구현은 사전계산 테이블을 테스트 안에서 만들고 지운다. 상시 유지하지 않는다.
- 현재 읽기 경로는 쓰기와 같은 저장소 위에 읽기 전용 포트·DTO·쿼리만 분리한 형태다.
## 가정
- 고트래픽 읽기에서는 조회 비용 차이가 실제 부하로 나타난다.
- 상시 유지하면 원본 변경마다 투영 갱신이 필요하다.
- 투영이 어긋나면 사용자가 볼 수 없는 항목을 보거나 볼 수 있는 항목을 놓친다.
## 미지수
- 현재 트래픽에서 단일 OR이나 UNION 분해로 충분한가. 사전계산이 필요한 임계가 어디인가.
- 동기화를 어떤 방식으로 하는가. 도메인 이벤트인가 아웃박스인가.
- 투영 갱신이 늦어졌을 때 허용 가능한 지연은 얼마인가.
- 가시성이 바뀌는 사건이 무엇인가. 아이템 공개 범위 변경, 멘션 추가·삭제, 사용자 삭제까지 포함하는가.
- 사용자 수만큼 늘어나는 저장 공간이 감당 가능한가.
- 투영이 어긋났을 때 어떻게 발견하고 복구하는가.
- 이 변경이 현재 정한 계약의 범위를 넘는가. 넘는다면 계약과 가드레일을 어떻게 개정하는가.
## 제약
- 현재 계약에서 별도 물리 읽기 저장소는 에스컬레이션 대상으로 남겨 두었다. 승격하려면 계약을 먼저 개정해야 한다.
- 가시성은 보안에 걸린 조건이다. 투영이 어긋나면 노출 사고가 된다. 지연 허용치를 느슨하게 잡을 수 없다.
- 지금까지의 측정은 단일 스레드 로컬 값이다. 고트래픽에서 어느 방식이 필요한지는 이 측정으로 답할 수 없다.
## 선택지
### 1. 현재 범위를 유지하고 요청 시 조회로 푼다
단일 OR이나 UNION 분해로 조회한다. 동기화 비용이 없고 정합성 문제도 없다.
고트래픽에서 조회 비용이 그대로 남는다.
### 2. 사전계산을 상시 유지하는 읽기 저장소로 승격한다
쓰기 변경을 투영에 반영하고 조회는 투영만 읽는다. 조회 비용이 가장 낮다.
동기화 경로, 지연 허용치, 정합성 검증, 복구 절차를 모두 설계해야 한다. 계약 개정도 필요하다.
### 3. 일부만 사전계산한다
접근이 잦은 구간만 투영으로 유지하고 나머지는 요청 시 조회한다.
두 경로를 함께 운영해야 하고 어느 구간을 투영에 둘지 정하는 기준이 필요하다.
## 다음 검증
1. 부하 테스트로 현재 조회 방식이 목표 트래픽을 견디는지 확인한다. 이 판단은 단일 스레드 측정으로 대신할 수 없다.
2. 가시성이 바뀌는 사건을 모두 열거하고 각각이 투영의 어느 행에 영향을 주는지 정리한다.
3. 사용자 수와 아이템 수를 곱한 투영 크기를 계산한다.
4. 동기화 지연의 허용치를 정한다. 가시성은 보안 조건이므로 이 값이 설계를 좌우한다.
5. 투영과 원본이 어긋났는지 확인하는 방법과 복구 절차를 정의한다.
6. 위 결과를 보고 계약을 개정할지 판단한다.