diff --git a/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md b/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md new file mode 100644 index 0000000..40f15ac --- /dev/null +++ b/docs/backend/implements/BI-38-2026-08-03-massive-scale-plan-observation.md @@ -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다 — 같은 인스턴스·같은 설정이므로 플래너 조건은 동일하나, + 통계 표본은 원본과 미세하게 다를 수 있다. diff --git a/docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md b/docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md new file mode 100644 index 0000000..5681c02 --- /dev/null +++ b/docs/backend/worklog/2026-08-03-S15P11A705-284-plan-observation.md @@ -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) diff --git a/loadtest/sql/explain-matrix.sql b/loadtest/sql/explain-matrix.sql new file mode 100644 index 0000000..4877e25 --- /dev/null +++ b/loadtest/sql/explain-matrix.sql @@ -0,0 +1,165 @@ +-- 주요 읽기 경로 4종 EXPLAIN 매트릭스 (S15P11A705-284). +-- +-- 같은 파일을 두 규모(pinlog=천만 · pinlog_base=117k 재구성)에 돌려 대조한다: +-- docker exec -i back-postgres-1 psql -U pinlog -d pinlog -v ON_ERROR_STOP=1 -f explain-matrix.sql +-- docker exec -i back-postgres-1 psql -U pinlog -d pinlog_base -v ON_ERROR_STOP=1 -f explain-matrix.sql +-- +-- **DB마다 컨테이너를 따로 콜드 재기동한 뒤 돌릴 것.** 연달아 돌리면 뒤에 잰 쪽이 앞선 +-- 측정에 캐시를 빼앗겨(1Gi 한도) 규모 간 절대값 비교가 깨진다. 한 파일 안에서도 뒤에 오는 +-- 항목이 앞선 항목의 워밍 이득을 받으므로, 규모 비교는 같은 항목끼리만 한다. +-- +-- 대상 id는 두 규모 모두에 존재하는 것으로 고정한다 — member 2792(레코드 697 · 컬렉션 59), +-- collection 11736(링크 20). 그래야 "데이터가 다른" 효과가 아니라 "표 크기가 다른" 효과만 남는다. +-- 쿼리 텍스트는 리포지터리의 JPQL이 만드는 SQL을 그대로 옮긴 것이다(각 항목에 출처 주석). +-- @SQLRestriction("deleted_at IS NULL")은 Hibernate가 WHERE에 덧붙이므로 여기서도 명시한다. + +\timing on +SELECT current_database() AS "측정 DB", + (SELECT count(*) FROM core.record) AS record_rows, + (SELECT count(*) FROM core.collection) AS collection_rows; + +------------------------------------------------------------------------------ +\echo '' +\echo '######## [1] 커서 페이지네이션 — CollectionRepository.findFirstPageByMemberIdDesc / findPageByMemberIdAfterDesc' +------------------------------------------------------------------------------ + +\echo '--- [1a] 첫 페이지 (member 2792, size 20+1) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.collection c +WHERE c.deleted_at IS NULL AND c.member_id = 2792 +ORDER BY c.created_at DESC, c.id DESC +LIMIT 21; + +-- 깊은 페이지 커서(50번째 항목)를 먼저 뽑아 리터럴로 쓴다 — 서브쿼리를 EXPLAIN 안에 두면 +-- 그 비용이 측정에 섞인다. +SELECT created_at AS cur_ts, id AS cur_id FROM core.collection +WHERE deleted_at IS NULL AND member_id = 2792 +ORDER BY created_at DESC, id DESC +OFFSET 49 LIMIT 1 \gset + +\echo '--- [1b] 깊은 페이지 (50번째 항목 이후, keyset) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.collection c +WHERE c.deleted_at IS NULL AND c.member_id = 2792 + AND (c.created_at < :'cur_ts' OR (c.created_at = :'cur_ts' AND c.id < :cur_id)) +ORDER BY c.created_at DESC, c.id DESC +LIMIT 21; + +------------------------------------------------------------------------------ +\echo '' +\echo '######## [2] 컬렉션 상세 배치 5쿼리 — CollectionService.getDetail (collection 11736, 링크 20)' +------------------------------------------------------------------------------ + +\echo '--- [2a] Collection 단건 (findById) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.collection c WHERE c.deleted_at IS NULL AND c.id = 11736; + +\echo '--- [2b] 링크 페이지 (findFirstPageByCollectionId, size 50+1) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.collection_record cr +WHERE cr.deleted_at IS NULL AND cr.collection_id = 11736 +ORDER BY cr.created_at DESC, cr.id DESC +LIMIT 51; + +-- 링크의 record id 목록을 뽑아 다음 세 배치의 입력으로 쓴다(서비스가 하는 그대로). +SELECT string_agg(record_id::text, ',') AS rec_ids FROM ( + SELECT record_id FROM core.collection_record + WHERE deleted_at IS NULL AND collection_id = 11736 + ORDER BY created_at DESC, id DESC LIMIT 50) s \gset + +\echo '--- [2c] Record 일괄 (findAllById) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.record r +WHERE r.deleted_at IS NULL AND r.id = ANY (string_to_array(:'rec_ids', ',')::bigint[]); + +SELECT string_agg(DISTINCT place_id::text, ',') AS place_ids FROM core.record +WHERE deleted_at IS NULL AND id = ANY (string_to_array(:'rec_ids', ',')::bigint[]) \gset + +\echo '--- [2d] Place 일괄 (findAllById) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.place p +WHERE p.id = ANY (string_to_array(:'place_ids', ',')::bigint[]); + +\echo '--- [2e] Context 일괄 (findByRecordIdInOrderByOriginCreatedAtAscIdAsc) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT * FROM core.context ctx +WHERE ctx.deleted_at IS NULL + AND ctx.record_id = ANY (string_to_array(:'rec_ids', ',')::bigint[]) +ORDER BY ctx.origin_created_at ASC, ctx.id ASC; + +------------------------------------------------------------------------------ +\echo '' +\echo '######## [3] 지도 — RecordRepository (member 2792)' +------------------------------------------------------------------------------ +-- 두 갈래를 **둘 다** 잰다. 컨트롤러가 bbox를 required=false로 받으므로 파라미터가 없으면 +-- findMarkers(전량), 있으면 findMarkersWithinBounds로 갈린다. 부하 시나리오(load-read.js)는 +-- bbox 없이 부르고 실제 프론트는 뷰포트를 보내므로, 한쪽만 재면 부하 결과와 계획 관측이 +-- 서로 다른 쿼리를 보게 된다(BI-38 개선 후보 3에서 발견). + +\echo '--- [3a] bbox 없음 — findMarkers (부하 시나리오가 부르는 쪽) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT r.id, p.id, p.name, p.lat, p.lng +FROM core.record r JOIN core.place p ON p.id = r.place_id +WHERE r.deleted_at IS NULL AND r.member_id = 2792; + +-- bbox는 **도시 단위**로 준다. 전국 범위(위도 33~39)를 주면 아무 행도 걸러지지 않아 +-- bbox 없는 경우와 결과가 같아지고, 필터의 효과를 재는 의미가 사라진다. +-- 값은 functional.js가 쓰는 서울 뷰포트와 맞춘다. +\echo '--- [3b] bbox 있음(서울) — findMarkersWithinBounds (프론트 지도 화면 경로) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT r.id, p.id, p.name, p.lat, p.lng +FROM core.record r JOIN core.place p ON p.id = r.place_id +WHERE r.deleted_at IS NULL AND r.member_id = 2792 + AND p.lat BETWEEN 37.4 AND 37.7 + AND p.lng BETWEEN 126.8 AND 127.1; + +------------------------------------------------------------------------------ +\echo '' +\echo '######## [4] 피드 후보 3채널 — FeedCandidateRepository (member 2792)' +------------------------------------------------------------------------------ + +\echo '--- [4a] 최신 채널 (RECENT_SQL, 현재 코드: COALESCE 정렬키) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT c.id AS collection_id, c.member_id AS owner_id, + COALESCE(c.published_at, c.created_at) AS published_at +FROM core.collection c JOIN core.member m ON m.id = c.member_id +WHERE c.deleted_at IS NULL AND c.is_published = true AND c.record_count > 0 + AND c.member_id <> 2792 AND m.deleted_at IS NULL +ORDER BY published_at DESC, c.id DESC +LIMIT 100; + +\echo '--- [4a-대조] 같은 채널, COALESCE 없이 (원인 판정용 — 코드 아님) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT c.id AS collection_id, c.member_id AS owner_id, c.published_at +FROM core.collection c JOIN core.member m ON m.id = c.member_id +WHERE c.deleted_at IS NULL AND c.is_published = true AND c.record_count > 0 + AND c.member_id <> 2792 AND m.deleted_at IS NULL +ORDER BY c.published_at DESC, c.id DESC +LIMIT 100; + +\echo '--- [4b] 팔로우 채널 (FOLLOWED_SQL) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT c.id AS collection_id, c.member_id AS owner_id, + COALESCE(c.published_at, c.created_at) AS published_at +FROM core.follow f +JOIN core.collection c ON c.member_id = f.followee_member_id +JOIN core.member m ON m.id = c.member_id +WHERE f.follower_member_id = 2792 AND f.deleted_at IS NULL + AND c.deleted_at IS NULL AND c.is_published = true AND c.record_count > 0 + AND c.member_id <> 2792 AND m.deleted_at IS NULL +ORDER BY published_at DESC, c.id DESC +LIMIT 80; + +\echo '--- [4c] 무작위 채널 (SAMPLE_FROM_PIVOT_SQL, pivot 고정 42) ---' +EXPLAIN (ANALYZE, BUFFERS) +SELECT c.id AS collection_id, c.member_id AS owner_id, + COALESCE(c.published_at, c.created_at) AS published_at +FROM core.collection c JOIN core.member m ON m.id = c.member_id +WHERE c.id >= 42 + AND c.deleted_at IS NULL AND c.is_published = true AND c.record_count > 0 + AND c.member_id <> 2792 AND m.deleted_at IS NULL +ORDER BY c.id +LIMIT 20; + +\echo '' +\echo '######## 매트릭스 끝'