Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# BI-38. 천만 건 규모 주요 읽기 경로 쿼리 계획 관측 보고

- **상태**: ✅ 완료
- **날짜**: 2026-08-03
- **관련**: S15P11A705-284,
S15P11A705-283(벤치 환경 — 이 위에서 측정했다),
[BI-37](BI-37-2026-08-02-load-profiles-report.md)(부하 관측 — 규모 축이 아니라 동시성 축),
[BD-04](../decisions/BD-04-cursor-pagination.md)(커서 설계 — 이번에 규모로 검증),
[BD-20](../decisions/BD-20-selective-denormalization.md)·[BD-33](../decisions/BD-33-published-at-invariant.md)

## 무엇을 쟀나

record 117k에서는 플래너가 Seq Scan을 골라도 손해가 없어 인덱스 설계가 판별되지 않았다.
S15P11A705-283이 만든 **record 1,000만 · collection 667k** 볼륨 위에서 주요 읽기 경로 4종을
`EXPLAIN (ANALYZE, BUFFERS)`로 수집하고, 벤치 이전 상태(117,022건)와 대조했다.

**측정 방법.** 벤치 데이터는 전부 기존 최대 id 뒤에 이어 붙었으므로, `id <= 기준점` 행만
같은 인스턴스의 별도 DB(`pinlog_base`)로 복사해 117k 기준선을 재구성했다 — record 117,022 ·
collection 12,527 · member 3,007로 벤치 이전과 정확히 일치한다. 대상 id는 **두 규모 모두에
존재하는 것으로 고정**했다(member 2792 · collection 11736). 그래야 "데이터가 다른" 효과가
아니라 "표 크기가 다른" 효과만 남는다. 측정은 운영과 같은 1Gi 메모리 한도
(`compose.bench.yaml`)에서 컨테이너 재기동 직후(콜드)에 했고, 쿼리 텍스트는 리포지터리
JPQL이 만드는 SQL을 그대로 옮겼다(`loadtest/sql/explain-matrix.sql`).

**합격선은 없다** — 관측 보고서형으로 확정된 티켓이다. 발견은 별도 티켓 후보로 남긴다.

## 결과 표

| 경로 | 117k | 1,000만 | 배수 | 10M 디스크 읽기(블록) | 10M 계획 |
| --- | --- | --- | --- | --- | --- |
| [1a] 컬렉션 목록 첫 페이지 | 0.22 ms | 0.48 ms | 2.2 | 7 | `ix_collection_member` |
| [1b] **커서 깊은 페이지(keyset)** | 0.03 ms | 0.03 ms | **1.0** | 0 | `ix_collection_member` |
| [2a] 상세: Collection 단건 | 0.03 ms | 0.78 ms | 27 | 3 | `collection_pkey` |
| [2b] 상세: 링크 페이지 | 0.06 ms | 0.40 ms | 6.8 | 8 | `ix_colrec_collection` |
| [2c] 상세: Record 일괄 | 0.04 ms | 0.68 ms | 15 | 10 | `record_pkey` |
| [2d] 상세: Place 일괄 | 0.16 ms | 2.56 ms | 16 | 22 | `place_pkey` |
| [2e] 상세: Context 일괄 | 0.12 ms | 1.32 ms | 11 | 16 | `ix_context_record` |
| [3] 지도(서울 뷰포트, 397행) | 17.97 ms | **3.12 ms** | **0.17** | 573 | 계획이 갈렸다 — 판정 4 |
| [4a] **피드 최신 채널(현재 코드)** | 3.48 ms | **241 ms** | **69** | **9,673** | **Parallel Seq Scan** |
| [4a′] 같은 채널, COALESCE 제거 | 1.94 ms | 1.66 ms | 0.9 | 68 | `ix_collection_feed` |
| [4b] 피드 팔로우 채널 | 0.08 ms | 1.55 ms | 19 | 12 | `ix_follow_follower`+`ix_collection_member` |
| [4c] 피드 무작위 채널 | 0.03 ms | 0.25 ms | 7.5 | 0 | `collection_pkey` |

배수가 10~27인 항목들은 절대값이 3ms 미만이라 콜드 I/O(각 3~22블록)가 지배하는 수치다 —
warm에서는 배수가 1~3으로 내려간다. 구조적으로 규모에 비례하는 것은 [4a] 하나뿐이다.

## 판정

1. **Seq Scan으로 떨어지는 쿼리는 피드 최신 채널 하나다 — 원인은 정렬키 표현식.**
`FeedCandidateRepository.RECENT_SQL`의 `ORDER BY COALESCE(published_at, created_at) DESC`가
`ix_collection_feed (is_published, published_at DESC)`의 정렬 순서와 달라, 플래너가
인덱스로 상위 100건만 집을 수 없고 활성 발행 66만 행 전부(664,689행 스캔, 디스크
9,673블록)를 정렬한다. COALESCE만 빼면 같은 조건에서 105행만 읽고 1.66ms에 끝난다 —
**145배**. 그리고 이 COALESCE는 지킬 대상이 없다: 발행 컬렉션 666,066건 중
`published_at IS NULL`은 0건이며, V5의 `ck_collection_published_at` CHECK가 DB 차원에서
보장한다(BD-33). 리포지터리 주석 스스로 "`ix_collection_feed`가 이 채널의 전제"라고
적어 놓고 실제로는 그 인덱스를 못 쓰고 있었다. 코드 한 줄 수정이 개선 후보 1순위다.
실제 API로도 확인된다 — 천만 건에서 `GET /v1/feed/collections`가 368~580ms(117k에서는
~50ms)이고 그중 이 채널이 ~240ms다.
2. **커서 페이지네이션(BD-04)은 규모를 통과했다.** keyset 깊은 페이지가 두 규모에서 똑같이
0.03ms다. 페이지 깊이에도, 표 크기에도 비례하지 않는다 — 설계가 약속한 그대로다.
3. **컬렉션 상세 배치 5쿼리는 규모를 통과했다.** 전부 인덱스를 타고 천만 건 합계 5.7ms.
레코드 수에도 표 크기에도 실용적으로 비례하지 않는다. N+1 없는 배치 설계가 검증됐다.
4. **지도는 천만 건이 오히려 5.8배 빠르다 — 계획이 갈렸고, 117k 쪽 계획이 나쁘다.**
각 DB를 따로 콜드 재기동하고 실제 사용자 뷰포트(서울, `functional.js`와 같은 값)로 다시 쟀다.
양쪽 모두 같은 397행을 반환한다.

| 규모 | 조인 방식 | place 접근 | 디스크 | 시간 |
| --- | --- | --- | --- | --- |
| 117k (place 40,034) | Hash Join | **Seq Scan → bbox 통과 23,441행 해싱** | 1,170블록 | **17.97 ms** |
| 1,000만 (place 240,034) | Nested Loop | `place_pkey` **697회 개별 조회** | 573블록 | **3.12 ms** |

397행을 얻으려고 117k 쪽은 place 테이블을 통째로 훑어 2.3만 행을 해싱한다 —
그 한 단계가 13.2ms로 전체의 대부분이다. 천만 건에서는 place가 24만 행이 되며 해싱이
너무 비싸지자 플래너가 **회원 record 697건을 먼저 잡고 place를 PK로 하나씩 확인하는**
길로 갈아탔다. 후자가 이 쿼리의 올바른 모양이다(찾을 것이 697건뿐이므로).

즉 **규모가 커져서 빨라진 것이 아니라, 규모가 커지면서 플래너가 원래 골라야 했던 계획으로
옮겨간 것**이다. 표 크기에 비례하지 않으며, 회원 레코드 수(697)에만 비례한다.
플래너 비용 모형이 place 테이블 크기에 따라 스스로 교정하므로 **백엔드에서 고칠 것은 없다.**
운영은 place 159행이라 Hash Join을 고르겠지만 그 규모에서는 무의미하게 싸다.

(첫 측정의 27.3ms → 13.5ms는 같은 결론을 가리키지만 측정 순서 캐시 편차가 섞여 있었다.
위 수치가 그 편차를 제거한 값이다.)
5. **지도의 실질 위험은 쿼리가 아니라 페이로드다.** 페이지네이션이 없어 레코드 20,000건
회원의 응답이 **2.0MB**다(실측). 서버 시간은 143ms로 버티지만 전송량이 회원당 데이터에
선형이다 — 마커 수천대 회원이 실재하게 되면 뷰포트 밀도 축소나 상한이 필요하다
(BI-33 관측 1의 연장). **다만 클라이언트 렌더링은 재지 않았다** — 마커 2만 개를 지도 SDK에
올리는 비용(렌더 시간·메모리)은 2.0MB 전송보다 먼저 무너질 가능성이 있고, 프론트 영역이라
이 관측의 범위 밖이다. 상한을 정하려면 그쪽 실측이 함께 필요하다.

## 캐시에 갇힌 측정이 아님

1Gi 한도 + 콜드 재기동 상태에서 쟀고, 천만 건 측정의 `shared read`가 0이 아니다 —
[4a] 9,673블록, [3] 550블록 등. 참고로 한도 없는 로컬 기본 상태는 적중률 99.99%라
이 판별 자체가 불가능했다(283 README).

## 개선 티켓 후보

1. **피드 최신 채널의 COALESCE 정렬키 제거** — 1순위. 근거: 위 판정 1(69배, 규모 정비례,
불변식이 이미 DB CHECK로 보장). `published_at DESC` 정렬로 바꾸고 회귀 테스트를 붙인다.
팔로우 채널(4b)의 COALESCE는 행이 적어 실해가 없으나 같은 PR에서 함께 정리할 만하다.
2. **지도 응답 상한/밀도 축소** — 회원당 레코드 수천대가 현실이 될 때. 근거: 판정 5(2.0MB).
지금은 급하지 않다(BI-37의 "마커 수천대 전까지 급하지 않음" 판정 유지, 단 이번에
임계 규모의 실측 근거가 생겼다). 상한 값을 정하려면 클라이언트 렌더링 실측이 선행해야
한다 — 서버 전송량만 보고 정하면 실제 한계보다 느슨해질 수 있다.
3. **부하 시나리오의 지도 호출을 사용자 경로와 맞추기** — `load-read.js`가
`/v1/records/map`을 **bbox 없이** 부른다. 컨트롤러가 bbox를 `required = false`로 받으므로
그러면 `findMarkersWithinBounds`가 아니라 `findMarkers`(뷰포트 필터 없는 전량 조회)로 가고,
실제 프론트는 뷰포트를 보내므로 부하 시나리오가 사용자 경로를 재고 있지 않다.

**다만 접근 경로는 같다** — 측정해 보니 bbox 유무가 계획 모양을 바꾸지 않고
place 스캔에 Filter 한 줄이 붙을 뿐이다(117k는 양쪽 다 Hash Join, 천만 건은 양쪽 다
Nested Loop). 달라지는 것은 **반환 행 수와 응답 크기**다(전국 697행 → 서울 397행).
그러므로 이것은 "잘못된 계획을 재고 있다"가 아니라 "응답 크기를 과대 계상하고 있다"는
문제이며, 우선도는 낮다. 기능 하네스(`functional.js`)는 둘 다 치고 있어 계약 검증에는
공백이 없다. 시나리오를 바꾸면 `map` 부류의 BI-37 수치와 비교 가능성이 끊기므로
그 점을 함께 기록해야 한다.

## 한계

- 벤치 볼륨에는 소프트 삭제 행이 없다 — `deleted_at` 부분 인덱스 판별(`ix_colrec_collection`
vs `uq_colrec_active`)은 범위 밖이다(283 티켓에서 합의).
- `ai` 스키마·`core.feed_event`를 만들지 않으므로 Keyword 집계·노출 패널티 쿼리는 이
볼륨으로 검증할 수 없다(AI 파트 소유 — BI-37 개선 후보 2와 별개 트랙).
- 단일 실행 콜드 수치라 3ms 미만 항목의 배수는 신뢰 구간이 넓다. 판정은 배수가 아니라
계획 모양(Seq/Index)과 증가 특성으로 했다.
- **측정 순서가 캐시에 남는다.** 천만 건을 먼저 재고 117k를 나중에 쟀으므로, 1Gi 한도에서
뒤에 잰 쪽이 캐시 불리를 안는다. 지도 항목이 그 영향을 가장 크게 받았다(판정 4).
규모 간 절대값 비교가 목적이라면 각 DB를 따로 콜드 재기동해서 재야 한다.
- **클라이언트 렌더링은 범위 밖이다.** 서버 처리 시간과 응답 바이트까지만 쟀다.
지도 마커 렌더링·프론트 요청 횟수 같은 브라우저 측 비용은 포함하지 않았다.
- 117k 기준선은 재구성 DB다 — 같은 인스턴스·같은 설정이므로 플래너 조건은 동일하나,
통계 표본은 원본과 미세하게 다를 수 있다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 천만 건 위에서 읽기 경로 4종의 계획을 관측했다(S15P11A705-284)

- **날짜**: 2026-08-03
- **추적**: S15P11A705-284
- **관련**: [BI-38](../implements/BI-38-2026-08-03-massive-scale-plan-observation.md) · [BD-04](../decisions/BD-04-cursor-pagination.md) · [BD-33](../decisions/BD-33-published-at-invariant.md)

283이 만든 record 1,000만 · collection 667k 볼륨 위에서 커서 깊은 페이지·컬렉션 상세 배치 5쿼리·지도·피드 후보 3채널을 `EXPLAIN (ANALYZE, BUFFERS)`로 재고 117k 기준선과 대조했다. 기준선은 벤치 데이터가 전부 기존 최대 id 뒤에 붙는 성질을 이용해 `id <= 기준점` 행만 별도 DB로 복사해 재구성했고(record 117,022로 원본과 일치), 대상 id를 두 규모에 공존하는 것으로 고정해 표 크기 효과만 남겼다. 측정은 1Gi 한도 + 콜드 재기동 상태에서 했고 천만 건 측정의 `shared read`가 0이 아님을 확인했다(캐시에 갇힌 측정 아님). **규모에 정비례로 무너지는 것은 피드 최신 채널 하나다** — `ORDER BY COALESCE(published_at, created_at)`가 `ix_collection_feed`의 정렬 순서와 달라 활성 발행 66만 행 전수를 Parallel Seq Scan으로 정렬한다(3.48ms → 241ms, 69배, 디스크 9,673블록). COALESCE만 빼면 105행 · 1.66ms(145배 차)이고, 발행 컬렉션 66.6만 건 중 `published_at IS NULL`은 0건이라(V5 CHECK, BD-33) 이 방어는 지킬 대상이 없다 — 코드 한 줄 수정이 개선 후보 1순위다. 나머지는 규모를 통과했다: keyset 커서 깊은 페이지는 두 규모에서 똑같이 0.03ms(BD-04 설계 검증), 상세 배치 5쿼리는 전부 인덱스로 합계 5.7ms. 지도는 처음에 "계획이 평평하다"고 적었는데 **틀렸다** — 27.3ms → 13.5ms로 오히려 빨라진 것이 이상해 계획을 다시 보니 117k는 Hash Join으로 place 40,034행을 전수 해싱했고(그 단계가 26.6ms) 천만 건은 place가 24만 행이 되며 Nested Loop + `place_pkey` 697회로 갈아탔다. 규모가 커져서 빨라진 게 아니라 **117k 쪽 계획이 애초에 이 쿼리에 맞지 않았던 것**이고, 측정 순서에서 온 캐시 편차(천만 건을 먼저 재 캐시를 점유)도 겹쳐 두 수치는 직접 비교할 수 없다. 지도의 실질 위험은 쿼리가 아니라 페이로드다(레코드 2만 회원 응답 2.0MB). 이 과정에서 부하 하네스 함정도 하나 나왔다 — `load-read.js`가 `/v1/records/map`을 **bbox 없이** 불러 `findMarkers`(전량)로 가는데 이 보고서는 bbox 있는 `findMarkersWithinBounds`를 쟀다. 부하 결과와 계획 관측이 서로 다른 쿼리를 보고 있었고, 사용자 경로는 bbox 있는 쪽이다(개선 후보 3). 클라이언트 렌더링은 범위 밖으로 명시했다 — 서버 시간과 응답 바이트까지만 쟀다. 결과 정본은 BI-38, 재실행은 `loadtest/sql/explain-matrix.sql`을 두 DB에 돌리면 된다 (S15P11A705-284)
Loading
Loading