// 이 파일이 하는 일: "DB 인덱스와 실행계획 읽기" 코스 — 쿼리가 왜 느린지 감으로 찍지 말고
// EXPLAIN으로 증명하는 법을 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "고급 과정 —
// 잘 만드는 법": 서비스를 이미 만들어 본 사람이 설계·성능·보안을 고민하는 단계에서,
// B-Tree의 원리 → 실행계획 해석 → 인덱스 설계 → 함정 → 우리 스키마 실습 → 운영 → 측정 루프로 이어진다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.

import { Link } from 'react-router-dom';

// ── 예제 코드 상수들 ──

const CODE_BOOK = `풀 스캔(Seq Scan)                    인덱스 스캔(Index Scan)
──────────────────                   ─────────────────────
책을 1쪽부터 끝까지 넘기며           책 뒤 '색인'에서 단어를 찾고
원하는 단어를 찾는다                 적힌 쪽 번호로 바로 펼친다

100만 행 → 100만 행 다 읽음          100만 행 → 트리 몇 단계 + 해당 행만
데이터가 늘수록 비례해서 느려짐       데이터가 10배 늘어도 한두 단계만 깊어짐

핵심: 인덱스는 "정렬된 별도의 찾아보기 표"다.
테이블 본문은 그대로 두고, (컬럼값 → 행 위치) 쌍을
B-Tree라는 정렬 트리에 따로 보관해 두는 것.`;

const CODE_BTREE = `-- B-Tree가 '범위 검색'에 강한 이유: 잎(leaf) 노드가 정렬된 채 이어져 있다
--
--            [ 500 ]                ← 루트: 몇 단계만 내려가면
--           /       \\
--      [200 350]   [700 900]        ← 중간 노드
--       /  |  \\     /  |  \\
--   [..] [..] [..] [..] [..] [..]   ← 잎 노드: 정렬 + 옆으로 연결
--
-- WHERE created_at >= '2026-07-01'  같은 범위 조건이면
-- ① 트리를 타고 시작점(7월 1일)까지 내려간 뒤
-- ② 잎 노드를 옆으로 쭉 훑기만 하면 된다. 전체를 뒤질 필요가 없다.

-- 단, 인덱스는 공짜가 아니다:
-- · 쓰기 비용 — INSERT/UPDATE/DELETE 때마다 트리도 함께 고쳐야 한다
-- · 용량 비용 — 인덱스도 디스크를 먹는다 (큰 테이블에선 본문만큼 클 수도)
-- 그래서 "일단 다 걸자"는 설계가 아니라, 읽기 패턴을 보고 고르는 것.`;

const CODE_EXPLAIN_BEFORE = `-- EXPLAIN ANALYZE: 계획(추정)만 보는 EXPLAIN과 달리, 실제로 실행해서 실측치까지 보여 준다
EXPLAIN ANALYZE
SELECT * FROM svc_log.audit_events
 WHERE actor = 'user-1042'
 ORDER BY created_at DESC
 LIMIT 20;

--                          ↓ 인덱스가 없을 때의 전형적인 모습
Limit  (cost=25439.02..25439.07 rows=20 width=182)
       (actual time=312.441..312.448 rows=20 loops=1)
  ->  Sort  (cost=25439.02..25451.55 rows=5012 width=182)
            (actual time=312.439..312.442 rows=20 loops=1)
        Sort Key: created_at DESC
        ->  Seq Scan on audit_events            ← 테이블 전체를 읽었다!
              (cost=0.00..25105.00 rows=5012 width=182)
              (actual time=0.031..305.877 rows=4890 loops=1)
              Filter: (actor = 'user-1042'::text)
              Rows Removed by Filter: 995110    ← 99만 행을 읽고 버렸다
Planning Time: 0.184 ms
Execution Time: 312.501 ms`;

const CODE_EXPLAIN_READ = `실행계획 숫자 읽는 법 — 괄호 두 쌍을 나란히 비교한다

(cost=시작비용..총비용 rows=추정행수 width=행평균바이트)   ← 플래너의 '추정'
(actual time=시작ms..총ms rows=실제행수 loops=반복횟수)    ← 실행한 '실측'

· cost      단위 없는 상대값. 플래너가 계획을 고를 때 쓰는 점수표
· rows      추정 rows와 actual rows가 크게 다르면 → 통계가 낡았다는 신호
· actual time  loops만큼 반복됐다면 (시간 × loops)가 진짜 소요 시간
· Rows Removed by Filter  읽고 나서 버린 행 수 — 클수록 헛수고가 많았다는 뜻

스캔 종류 세 가지:
· Seq Scan     테이블 처음부터 끝까지 (작은 테이블·대부분 행이 필요할 땐 오히려 정답)
· Index Scan   인덱스로 위치를 찾아 행을 하나씩 짚어 감 (소수 행 조회에 최적)
· Bitmap Scan  인덱스로 '해당 페이지 지도'를 먼저 그린 뒤 묶어서 읽음 (중간 규모에 유리)`;

const CODE_EXPLAIN_AFTER = `-- 조회 패턴에 맞는 복합 인덱스를 걸어 보자
CREATE INDEX idx_audit_events_actor_created
    ON svc_log.audit_events (actor, created_at DESC);

EXPLAIN ANALYZE
SELECT * FROM svc_log.audit_events
 WHERE actor = 'user-1042'
 ORDER BY created_at DESC
 LIMIT 20;

Limit  (cost=0.43..24.85 rows=20 width=182)
       (actual time=0.049..0.101 rows=20 loops=1)
  ->  Index Scan using idx_audit_events_actor_created on audit_events
        (cost=0.43..6120.32 rows=5012 width=182)
        (actual time=0.047..0.096 rows=20 loops=1)
        Index Cond: (actor = 'user-1042'::text)
Planning Time: 0.201 ms
Execution Time: 0.128 ms          ← 312.5ms → 0.13ms

-- 눈여겨볼 것: Sort 노드가 통째로 사라졌다.
-- 인덱스가 이미 (actor, created_at DESC) 순으로 정렬돼 있어서
-- ORDER BY를 위해 따로 정렬할 필요가 없어진 것.`;

const CODE_COMPOSITE = `-- 복합 인덱스의 컬럼 순서: '등호(=) 조건 먼저, 범위·정렬은 뒤에'
-- 전화번호부가 (성, 이름) 순인 것과 같다. 성을 모르면 이름만으론 못 찾는다.

CREATE INDEX idx_a ON orders (user_id, created_at);

WHERE user_id = 7 AND created_at > '2026-07-01'   -- 인덱스 풀가동 O
WHERE user_id = 7                                  -- 앞 컬럼만 → O
WHERE created_at > '2026-07-01'                    -- 뒤 컬럼만 → 이 인덱스 못 씀

-- 선택도(selectivity): 조건이 행을 얼마나 좁히는가
-- user_id = 7        → 100만 중 50행   (잘 좁힘 = 선택도 높음)
-- status = 'DONE'    → 100만 중 90만 행 (거의 못 좁힘 = 선택도 낮음)
-- 잘 좁히는 컬럼을 앞에 둘수록 트리에서 일찍 후보가 줄어든다.`;

const CODE_COVERING = `-- 커버링 인덱스: 필요한 컬럼이 전부 인덱스 안에 있으면 테이블에 갈 필요가 없다
CREATE INDEX idx_noti_user_read
    ON svc_auth.notifications (user_id, is_read)
    INCLUDE (title, created_at);   -- 검색 키는 아니지만 '동봉'해 둔다

SELECT title, created_at
  FROM svc_auth.notifications
 WHERE user_id = 7 AND is_read = false;

-- 실행계획에 이렇게 뜨면 성공:
Index Only Scan using idx_noti_user_read on notifications
  Heap Fetches: 0        ← 테이블 본문(heap)을 한 번도 안 건드렸다

-- 반대로 카디널리티가 낮은 컬럼 단독 인덱스는 헛돈다:
CREATE INDEX idx_bad ON users (is_active);   -- true/false 두 값뿐
-- 절반이 true라면 인덱스로 50만 행을 하나하나 짚는 것보다
-- 그냥 순서대로 다 읽는(Seq Scan) 게 빠르다. 플래너도 그렇게 판단해서
-- 이 인덱스는 만들어 두고도 영영 안 쓰일 가능성이 크다.`;

const CODE_BROKEN = `-- 인덱스가 '있는데도' 안 먹는 대표 패턴들

-- ① 컬럼을 함수로 감싸면 → 인덱스는 원본값으로 정렬돼 있어서 못 쓴다
WHERE lower(email) = 'kim@awesomedev.dev'        -- X (email 인덱스 무용지물)
WHERE email = 'kim@awesomedev.dev'               -- O
-- 꼭 필요하면 함수 기반 인덱스를 따로 만든다:
CREATE INDEX idx_users_email_lower ON users (lower(email));

-- ② 암묵적 형변환 — 타입이 다르면 DB가 컬럼 쪽을 변환해 버린다
WHERE phone = 01012345678       -- phone이 varchar면 X (숫자와 비교하려고 캐스팅)
WHERE phone = '01012345678'     -- O

-- ③ 앞이 열린 LIKE — 시작 글자를 모르면 정렬 트리를 탈 수 없다
WHERE title LIKE '%보안%'       -- X (앞이 %면 못 탐)
WHERE title LIKE '보안%'        -- △ 전방 일치는 원리상 범위 검색이지만,
                               --    Postgres 기본 로케일(ko_KR 등)에선 일반 B-Tree가 못 탄다.
                               --    text_pattern_ops 인덱스를 만들어야 탄다:
                               --    CREATE INDEX ix_title_pat ON t (title text_pattern_ops);

-- ④ OR — 양쪽 다 인덱스가 있어야 Bitmap OR로 합쳐 쓸 수 있다
WHERE actor = 'a' OR ip = '1.2.3.4'   -- 한쪽만 인덱스면 결국 Seq Scan

-- ⑤ NULL — 'col = NULL'은 항상 거짓. IS NULL을 써야 하고,
--    Postgres B-Tree는 NULL도 인덱싱하지만 통계상 분포가 쏠리면 플랜이 흔들린다.`;

const CODE_STATS = `-- 플래너는 '통계 정보'로 계획을 고른다. 통계가 낡으면 오판한다.
-- 증상: EXPLAIN의 추정 rows와 ANALYZE의 actual rows가 수십 배 차이

Seq Scan on audit_events (cost=... rows=120 ...)    ← 플래너: "120행쯤이겠지"
                         (actual ... rows=48200 ...) ← 현실: 4만 8천 행

-- 대량 INSERT·DELETE 직후에 자주 생긴다. 처방은 통계 갱신:
ANALYZE svc_log.audit_events;

-- 다시 EXPLAIN 해 보면 추정치가 현실에 가까워지고,
-- 플래너가 Seq Scan ↔ Index Scan 선택을 바로잡는 경우가 많다.`;

const CODE_OUR_SCHEMA = `-- 우리 운영 DB는 Postgres 17, 서비스마다 스키마를 나눠 쓴다(schema-per-service)
-- 지금 어떤 인덱스가 있는지부터 확인:
SELECT schemaname, tablename, indexname, indexdef
  FROM pg_indexes
 WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
 ORDER BY schemaname, tablename;

-- PK·UNIQUE 제약에는 인덱스가 자동으로 따라온다.
-- 문제는 '조회 패턴'에 맞춘 보조 인덱스 — 이건 사람이 판단해서 걸어야 한다.

-- 후보 ①: 감사 로그. 관리자 화면이 "누가, 최근에"로 조회한다
CREATE INDEX idx_audit_events_actor_created
    ON svc_log.audit_events (actor, created_at DESC);

-- 후보 ②: 알림함. "내 것 중 안 읽은 것"이 압도적 다수 쿼리
CREATE INDEX idx_notifications_user_unread
    ON svc_auth.notifications (user_id, created_at DESC)
    WHERE is_read = false;   -- 부분(partial) 인덱스: 안 읽은 행만 담아 작고 빠르다`;

const CODE_SLOWLOG = `-- 최적화 후보는 감이 아니라 '느린 쿼리 로그'로 찾는다
-- postgresql.conf (또는 ALTER SYSTEM):
ALTER SYSTEM SET log_min_duration_statement = '200ms';  -- 200ms 넘는 쿼리만 기록
SELECT pg_reload_conf();

-- 로그에 이런 줄이 쌓인다:
LOG:  duration: 312.501 ms
      statement: SELECT * FROM svc_log.audit_events WHERE actor = ...

-- 더 체계적으로는 pg_stat_statements 확장:
SELECT calls, mean_exec_time, query
  FROM pg_stat_statements
 ORDER BY mean_exec_time * calls DESC   -- '총 소요 시간' 순 = 개선 효과 큰 순
 LIMIT 10;`;

const CODE_INDEX_OPS = `-- ① 안 쓰는 인덱스 찾기: 스캔 횟수가 0인데 자리만 차지하는 것들
SELECT schemaname, relname, indexrelname,
       idx_scan,                                   -- 이 인덱스가 사용된 횟수
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
  FROM pg_stat_user_indexes
 WHERE idx_scan = 0
 ORDER BY pg_relation_size(indexrelid) DESC;
-- 단, 통계 리셋 직후이거나 월배치용일 수 있으니 충분히 관찰한 뒤 DROP.

-- ② 중복 인덱스: (a)와 (a, b)가 둘 다 있으면 (a)는 대개 잉여다
--    — (a, b) 인덱스가 a 단독 조건도 처리할 수 있기 때문.

-- ③ VACUUM과 인덱스 팽창(bloat)
--    Postgres는 UPDATE/DELETE된 행을 바로 지우지 않고 '죽은 행'으로 남긴다.
--    VACUUM이 이를 회수하는데, 회수가 밀리면 인덱스가 부풀어 스캔이 느려진다.
VACUUM (VERBOSE) svc_log.audit_events;   -- 죽은 공간 회수
ANALYZE svc_log.audit_events;            -- 통계 갱신 (섹션 4의 오판 예방)
-- 심하게 부풀었다면 서비스 중에도 가능한 재구축:
REINDEX INDEX CONCURRENTLY svc_log.idx_audit_events_actor_created;`;

const CODE_MEASURE = `-- 실습 리포트 양식: '전후 비교표' — 숫자가 없으면 개선이 아니라 주장일 뿐
-- 같은 쿼리를 인덱스 추가 전/후로 EXPLAIN ANALYZE 3회씩 돌려 중앙값을 적는다.
-- (첫 실행은 캐시가 비어 느릴 수 있으니 1회는 워밍업으로 버린다)

항목                  | 개선 전            | 개선 후
──────────────────────┼────────────────────┼─────────────────────
스캔 방식             | Seq Scan           | Index Scan
Execution Time        | 312.501 ms         | 0.128 ms
읽고 버린 행          | 995,110            | 0
Sort 노드             | 있음               | 없음 (인덱스 정렬 활용)
추가된 인덱스 크기    | -                  | 31 MB
INSERT 영향(참고)     | -                  | 건당 소폭 증가

-- 결론 문장 예시:
-- "actor+created_at 복합 인덱스로 감사 로그 조회가 312ms → 0.13ms.
--  쓰기 부하 증가는 실측상 무시 가능 수준. 인덱스 크기 31MB 감수."`;

// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
function Section({ n, title, sub, children }) {
  return (
    <section className="step-card" id={`sec-${n}`}>
      <div className="step-head">
        <span className="step-num">{n}</span>
        <div>
          <h3>{title}</h3>
          {sub && <span className="step-sub">{sub}</span>}
        </div>
      </div>
      <div className="step-body">{children}</div>
    </section>
  );
}

function Code({ children }) {
  return <pre className="code-block"><code>{children}</code></pre>;
}

const SECTIONS = [
  { n: 1, label: '인덱스는 왜 빠른가' },
  { n: 2, label: '실행계획 읽기' },
  { n: 3, label: '좋은 인덱스 설계' },
  { n: 4, label: '안 먹는 순간' },
  { n: 5, label: '우리 스키마 실습' },
  { n: 6, label: '인덱스 운영' },
  { n: 7, label: '측정으로 마무리' },
];

export default function Lv3DbIndexingPage() {
  return (
    <div>
      {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
      <div className="hero">
        <div className="eyebrow">Course · 고급 과정</div>
        <h1>🔍 DB 인덱스와<br />실행계획 읽기</h1>
        <p>
          "이 쿼리 느린 것 같은데요"는 관찰이지 진단이 아니에요. 데이터베이스는{' '}
          <strong>자기가 무슨 짓을 했는지 전부 실행계획으로 자백</strong>합니다.
          쿼리가 왜 느린지 감으로 찍지 말고 <strong>EXPLAIN으로 증명하는 법</strong> —
          B-Tree의 원리에서 출발해, 실행계획의 숫자를 읽고, 좋은 인덱스를 설계하고,
          우리 Postgres 스키마에 직접 적용해 전후를 측정하는 데까지 갑니다.
        </p>
        <div className="chip-row">
          <span className="chip">예상 소요 65분</span>
          <span className="chip">실습: EXPLAIN ANALYZE 전후 비교표</span>
          <span className="chip">선수 지식: SQL·서비스 개발 경험</span>
        </div>
      </div>

      {/* 섹션 바로가기 */}
      <nav className="pill-nav">
        {SECTIONS.map((s) => (
          <a key={s.n} href={`#sec-${s.n}`}>
            <b>{s.n}</b>
            {s.label}
          </a>
        ))}
      </nav>

      <Section n={1} title="인덱스는 왜 빠른가 — 풀스캔 vs 인덱스 스캔" sub="책 뒤 색인의 비유, 그리고 공짜가 아닌 이유">
        <p>
          500쪽짜리 책에서 "트랜잭션"이라는 단어가 나온 곳을 찾는다고 해 봅시다. 방법은 둘이에요.
          1쪽부터 끝까지 넘기며 눈으로 훑거나(<strong>풀 스캔</strong>), 책 뒤{' '}
          <strong>색인(index)</strong>에서 "트랜잭션 — 87, 214쪽"을 찾아 바로 펼치거나.
          데이터베이스 인덱스가 정확히 이 색인이에요. 테이블 본문은 그대로 두고,{' '}
          <strong>(컬럼값 → 행 위치)</strong> 쌍을 정렬해 따로 보관해 둔 찾아보기 표죠.
        </p>
        <Code>{CODE_BOOK}</Code>
        <p>
          그 정렬표의 자료구조가 <strong>B-Tree</strong>입니다. 값들을 정렬된 트리로 쌓아 두면
          100만 행에서도 몇 단계 만에 목표 지점에 도달하고, 데이터가 10배로 늘어도 트리가
          한두 단계 깊어질 뿐이에요. 특히 잎 노드들이 <strong>정렬된 채 옆으로 이어져</strong>{' '}
          있어서, "7월 1일 이후" 같은 <strong>범위 검색</strong>은 시작점만 찾으면 옆으로
          훑기만 하면 됩니다.
        </p>
        <Code>{CODE_BTREE}</Code>
        {/* 학습 포인트: 인덱스의 대가(쓰기·용량)를 처음부터 못 박는다 — "일단 다 걸자"를 예방. */}
        <div className="warn">
          <b>인덱스는 공짜가 아니다</b> 색인이 있는 책에 새 페이지를 끼워 넣으면{' '}
          <strong>색인도 고쳐야</strong> 하죠. 마찬가지로 인덱스가 5개 걸린 테이블에{' '}
          <span className="icode">INSERT</span> 한 번은 사실상 <strong>쓰기 6번</strong>입니다
          (본문 1 + 인덱스 5). 디스크도 먹고요. 인덱스 설계란 "읽기를 얼마나 벌고, 쓰기를
          얼마나 내줄 것인가"의 <strong>거래</strong>예요. 그래서 이 코스의 결론은 늘
          같습니다 — 걸기 전에 재고, 건 뒤에 다시 잰다.
        </div>
      </Section>

      <Section n={2} title="실행계획 읽기 — EXPLAIN ANALYZE" sub="추정과 실측을 나란히 놓고 비교하는 법">
        <p>
          <span className="icode">EXPLAIN</span>은 플래너(planner)가 세운 <strong>계획과 추정치</strong>만
          보여 줍니다. 뒤에 <span className="icode">ANALYZE</span>를 붙이면{' '}
          <strong>실제로 실행</strong>해서 실측 시간·실제 행 수까지 같이 보여 줘요. 성능 진단의
          기본기는 이 <strong>추정(cost·rows)과 실측(actual time·rows)을 나란히 읽는 것</strong>입니다.
          우리 감사 로그 테이블에서 특정 사용자의 최근 이력을 뽑는 쿼리로 볼게요.
        </p>
        <Code>{CODE_EXPLAIN_BEFORE}</Code>
        <p>
          범인이 그대로 드러나죠. <span className="icode">Seq Scan</span>이 테이블 전체를 읽었고,{' '}
          <span className="icode">Rows Removed by Filter: 995110</span> — 100만 행을 읽어서{' '}
          <strong>99만 5천 행을 버렸어요.</strong> 필요한 건 20행인데요. 이 "읽고 버린 행"이
          클수록 인덱스가 필요하다는 신호입니다.
        </p>
        <Code>{CODE_EXPLAIN_READ}</Code>
        {/* 학습 포인트: Seq Scan = 항상 악당이 아니다 — 플래너의 선택엔 이유가 있다는 감각. */}
        <div className="tip">
          <b>Seq Scan이 항상 나쁜 게 아니에요</b> 행이 수백 개뿐인 테이블, 혹은 어차피{' '}
          <strong>대부분의 행이 필요한</strong> 집계 쿼리라면 순서대로 쭉 읽는 게 제일 빠릅니다.
          플래너가 인덱스를 두고도 Seq Scan을 고르는 건 대개 합리적인 계산의 결과예요.
          "Seq Scan이 보인다 → 무조건 인덱스 추가"가 아니라, <strong>읽고 버린 행이 많은가</strong>를
          먼저 보세요.
        </div>
        <div className="warn">
          <b>ANALYZE는 진짜 실행한다</b> <span className="icode">EXPLAIN ANALYZE</span>는 쿼리를
          실제로 돌립니다. <span className="icode">SELECT</span>는 안전하지만{' '}
          <span className="icode">UPDATE</span>·<span className="icode">DELETE</span>에 붙이면{' '}
          <strong>정말로 데이터가 바뀌어요.</strong> 쓰기 쿼리를 진단할 땐{' '}
          <span className="icode">BEGIN;</span> → EXPLAIN ANALYZE →{' '}
          <span className="icode">ROLLBACK;</span>으로 감싸는 습관을.
        </div>
      </Section>

      <Section n={3} title="좋은 인덱스 설계" sub="복합 인덱스의 컬럼 순서, 커버링, 그리고 카디널리티">
        <p>
          섹션 2의 느린 쿼리를 고쳐 봅시다. 조회 패턴이 "<strong>actor로 좁히고</strong>,{' '}
          <strong>created_at 역순으로 최근 20개</strong>"이므로, 그 모양 그대로{' '}
          <strong>복합 인덱스</strong>를 겁니다.
        </p>
        <Code>{CODE_EXPLAIN_AFTER}</Code>
        <p>
          312ms가 0.13ms로 — <strong>약 2,400배</strong>입니다. 더 눈여겨볼 건{' '}
          <span className="icode">Sort</span> 노드가 사라진 점이에요. 인덱스가 이미 원하는 순서로
          정렬돼 있으니 <span className="icode">ORDER BY</span>가 공짜가 됐죠. 그럼 복합 인덱스의{' '}
          <strong>컬럼 순서</strong>는 어떻게 정할까요? 원칙은 전화번호부와 같습니다 —{' '}
          <strong>(성, 이름)</strong> 순으로 정렬된 명부에서 성을 모르면 이름만으론 못 찾아요.
        </p>
        <Code>{CODE_COMPOSITE}</Code>
        <p>
          여기서 <strong>선택도(selectivity)</strong>라는 개념이 나옵니다. 조건이 행을 얼마나
          좁히느냐 — <span className="icode">user_id = 7</span>처럼 잘 좁히는(값 종류가 많은 ={' '}
          <strong>카디널리티 높은</strong>) 컬럼을 앞에 둘수록 트리에서 일찍 후보가 줄어요.
          한 발 더 나아가, 조회할 컬럼을 전부 인덱스에 담으면 테이블 본문에 갈 필요조차
          없어집니다 — 이게 <strong>커버링 인덱스</strong>예요.
        </p>
        <Code>{CODE_COVERING}</Code>
        {/* 학습 포인트: 카디널리티 낮은 컬럼 단독 인덱스 = 대표적인 '만들었는데 안 쓰이는' 인덱스. */}
        <div className="tip">
          <b>정리 — 설계 3원칙</b> ① 인덱스는 <strong>쿼리 모양</strong>(WHERE·ORDER BY)을 보고
          만든다, 테이블만 보고 만들지 않는다. ② 복합 인덱스는 <strong>등호 조건·선택도 높은
          컬럼을 앞에</strong>. ③ <span className="icode">is_active</span>처럼 값이 두어 종류뿐인
          컬럼의 단독 인덱스는 플래너가 외면한다 — 정말 필요하면 섹션 5의{' '}
          <strong>부분 인덱스</strong>처럼 조건을 걸어 좁혀라.
        </div>
      </Section>

      <Section n={4} title="인덱스가 안 먹는 순간" sub="함수·형변환·LIKE·OR·NULL, 그리고 플래너의 오판">
        <p>
          "인덱스를 걸었는데도 느려요"의 대부분은 인덱스가 없어서가 아니라,{' '}
          <strong>있는 인덱스를 못 쓰게 쿼리를 쓴 경우</strong>예요. 핵심 원리는 하나입니다 —
          인덱스는 <strong>원본 컬럼값 그대로</strong> 정렬돼 있어서, 컬럼을 가공하는 순간
          그 정렬표는 무용지물이 됩니다.
        </p>
        <Code>{CODE_BROKEN}</Code>
        <p>
          다섯 패턴 모두 진단법은 같아요 — <strong>EXPLAIN을 떠서 Seq Scan인지 확인</strong>.
          특히 ②번 암묵적 형변환은 코드 리뷰에서도 눈에 잘 안 띄는 단골 범인입니다. ORM이
          파라미터 타입을 잘못 바인딩할 때도 똑같은 일이 벌어져요.
        </p>
        <p>
          쿼리는 멀쩡한데 플래너가 <strong>이상한 계획</strong>을 고르는 경우도 있습니다.
          플래너는 테이블의 <strong>통계 정보</strong>(값 분포 표본)로 행 수를 추정하는데,
          대량 적재·삭제 직후엔 이 통계가 현실과 어긋나요.
        </p>
        <Code>{CODE_STATS}</Code>
        {/* 학습 포인트: '추정 rows vs actual rows 괴리 = 통계 문제'라는 진단 공식 하나를 심는다. */}
        <div className="tip">
          <b>진단 공식</b> EXPLAIN ANALYZE에서 <strong>추정 rows와 actual rows가 수십 배
          어긋나면</strong>, 인덱스 문제가 아니라 <strong>통계 문제</strong>를 먼저 의심하세요.
          처방은 <span className="icode">ANALYZE 테이블명;</span> 한 줄 — 인덱스를 새로 만드는
          것보다 훨씬 싸고, 의외로 자주 이걸로 끝납니다.
        </div>
      </Section>

      <Section n={5} title="우리 스키마로 실습" sub="Postgres 17 · schema-per-service에서 직접 걸어 보기">
        <p>
          이제 우리 운영 환경으로 옵니다. 우리 플랫폼은 <strong>Postgres 17 하나에
          서비스별 스키마</strong>(schema-per-service)를 나눠 쓰죠 — 감사 로그, 알림, 계정이
          각자의 스키마에 삽니다. 실습 첫걸음은 <strong>지금 뭐가 걸려 있는지</strong> 확인이에요.
        </p>
        <Code>{CODE_OUR_SCHEMA}</Code>
        <p>
          두 후보의 성격이 달라요. 감사 로그는 "누가(actor), 최근에(created_at DESC)"라는 관리자
          화면의 조회 모양 그대로 <strong>복합 인덱스</strong>. 알림함은 전체 행 중{' '}
          <strong>안 읽은 것만</strong> 조회가 몰리니, <span className="icode">WHERE is_read =
          false</span> 조건을 인덱스 자체에 박은 <strong>부분(partial) 인덱스</strong> — 읽은
          알림이 수백만 건 쌓여도 인덱스는 작고 가볍게 유지됩니다.
        </p>
        <p>
          그런데 애초에 <strong>어느 쿼리부터</strong> 손대야 할까요? 감이 아니라 로그로 찾습니다.
        </p>
        <Code>{CODE_SLOWLOG}</Code>
        {/* 학습 포인트: 우선순위 = mean_exec_time이 아니라 (평균 × 호출수). 한 번 느린 배치보다
            매초 도는 200ms 쿼리가 서비스엔 더 아프다. */}
        <div className="tip">
          <b>우선순위는 총량으로</b> 평균 2초짜리 일일 배치와 평균 200ms인데 초당 50번 도는
          조회 쿼리 — 서비스를 더 아프게 하는 건 후자예요. 그래서 위 쿼리도{' '}
          <span className="icode">mean_exec_time × calls</span>, 즉 <strong>총 소요 시간</strong>{' '}
          순으로 정렬합니다. 개선 효과가 큰 순서와 같거든요.
        </div>
        <div className="warn">
          <b>운영 DB에서 인덱스 만들 땐</b> <span className="icode">CREATE INDEX</span>는 기본적으로
          그 테이블의 <strong>쓰기를 잠가요.</strong> 트래픽이 있는 테이블엔{' '}
          <span className="icode">CREATE INDEX CONCURRENTLY</span>를 쓰세요 — 시간은 더 걸리지만
          서비스를 멈추지 않습니다. 실습은 로컬 DB에서 마음껏, 운영 반영은 반드시 이 방식으로.
        </div>
      </Section>

      <Section n={6} title="인덱스 운영 — 걷어내기와 건강 관리" sub="미사용·중복 인덱스, VACUUM, 그리고 ddl-auto의 함정">
        <p>
          인덱스는 만드는 것보다 <strong>치우는 게</strong> 어렵습니다. 섹션 1에서 봤듯 인덱스마다
          쓰기 비용이 붙으니, 안 쓰는 인덱스는 <strong>세금만 내는 자산</strong>이에요. 다행히
          Postgres는 인덱스별 사용 횟수를 세고 있어서, 통계로 걷어낼 후보를 찾을 수 있습니다.
        </p>
        <Code>{CODE_INDEX_OPS}</Code>
        <p>
          <strong>VACUUM</strong> 얘기를 조금 더 하면 — Postgres는 <span className="icode">UPDATE</span>·
          <span className="icode">DELETE</span>된 행을 즉시 지우지 않고 "죽은 행"으로 남겨 둡니다
          (동시성을 위한 MVCC 설계). VACUUM이 이를 회수하는데, 회수가 밀리면 테이블과 인덱스가{' '}
          <strong>부풀어(bloat)</strong> 같은 스캔에 더 많은 페이지를 읽게 돼요. "인덱스가 있는데
          점점 느려져요"의 흔한 원인입니다. 평소엔 autovacuum이 알아서 하지만, 대량
          삭제·갱신 뒤엔 수동 <span className="icode">VACUUM</span> + <span className="icode">ANALYZE</span>를
          챙기세요.
        </p>
        {/* 학습 포인트: ddl-auto: update는 컬럼은 더해 줘도 인덱스 운영은 못 한다 —
            인덱스는 '코드 밖 자산'이라는 인식. 마이그레이션 도구 도입의 복선이기도 하다. */}
        <div className="warn">
          <b>ddl-auto: update 환경의 함정</b> 우리 백엔드처럼 JPA가{' '}
          <span className="icode">ddl-auto: update</span>로 스키마를 자동 반영하는 환경에선 착각하기
          쉬워요 — <strong>이 설정은 인덱스를 관리해 주지 않습니다.</strong> 엔티티에{' '}
          <span className="icode">@Index</span>를 달면 새로 만들어 주긴 하지만, 어노테이션을{' '}
          <strong>지워도 DB의 인덱스는 남고</strong>, 컬럼 순서 변경 같은 개편도 반영되지 않아요.
          즉 DB에 직접 만든 인덱스는 코드 어디에도 기록이 없는 <strong>유령 자산</strong>이 되기
          쉽습니다. 최소한 어떤 인덱스를 왜 만들었는지 문서(또는 마이그레이션 스크립트)로 남기는
          습관이 필요해요 — 이게 나중에 Flyway 같은 마이그레이션 도구로 넘어가는 이유이기도 합니다.
        </div>
      </Section>

      <Section n={7} title="측정으로 마무리 — 가설·측정·반영 루프" sub="개선은 숫자로 증명될 때까지 주장일 뿐">
        <p>
          이제 이 코스의 실습 과제를 완성할 차례예요. <strong>느린 SELECT 하나</strong>를 골라
          (섹션 5의 느린 쿼리 로그에서), 인덱스 추가 <strong>전후로 EXPLAIN ANALYZE를 각각
          돌려</strong> 아래 양식의 비교표로 정리합니다. 실행 시간은 캐시 상태에 따라 출렁이니{' '}
          <strong>3회 돌려 중앙값</strong>을 적으세요.
        </p>
        <Code>{CODE_MEASURE}</Code>
        <p>
          한 가지 정직하게 짚고 갈 것 — <strong>인덱스 추가가 오히려 느리게 만든 사례</strong>도
          실제로 있습니다. 쓰기가 압도적으로 많은 테이블에 조회용 인덱스를 여럿 얹었다가{' '}
          <span className="icode">INSERT</span> 처리량이 주저앉는 경우, 카디널리티 낮은 컬럼
          인덱스를 플래너가 잘못 집어 들어 Bitmap Scan이 Seq Scan보다 느려지는 경우.
          그래서 비교표에 <strong>쓰기 영향</strong> 줄이 있는 거예요 — 읽기만 재고 끝내면
          반쪽짜리 측정입니다.
        </p>
        {/* 학습 포인트: 이 코스의 최종 습관 — 가설 → 측정 → 반영 → 재측정. 성능 작업의
            산출물은 인덱스가 아니라 '전후 숫자가 담긴 근거'다. */}
        <div className="tip">
          <b>몸에 남길 루프</b> ① <strong>가설</strong> — "이 쿼리는 actor 조건에 인덱스가 없어
          느릴 것이다" ② <strong>측정</strong> — EXPLAIN ANALYZE로 전후 실측 ③{' '}
          <strong>반영</strong> — 근거와 함께 적용(운영은 CONCURRENTLY) ④ <strong>재측정</strong> —
          며칠 뒤 <span className="icode">pg_stat_user_indexes</span>로 정말 쓰이는지 확인.
          "느린 것 같아서 인덱스 걸었어요"와 "312ms가 0.13ms가 됐고 근거는 이 실행계획입니다"의
          차이 — 그게 이 과정이 목표하는 개발자의 말하기 방식이에요.
        </div>
      </Section>

      <div className="step-card">
        <div className="step-body">
          <h3 style={{ marginBottom: 6 }}>🔍 여기까지 왔다면</h3>
          <p className="muted">
            이제 느린 쿼리 앞에서 감으로 찍는 대신, <strong>실행계획을 떠서 읽고 →
            쿼리 모양에 맞는 인덱스를 설계하고 → 전후를 숫자로 증명</strong>하는 한 바퀴를 돌 수
            있게 됐어요. 그런데 인덱스로 조회가 빨라지고 트래픽이 몰리기 시작하면, 다음 질문이
            기다립니다 — 여러 요청이 <strong>같은 행을 동시에</strong> 읽고 쓸 때 무슨 일이
            벌어질까요? 잔액 차감이 두 번 실행되면? 읽는 도중에 값이 바뀌면? 다음{' '}
            <Link to="/learn/lv3-transaction-isolation"><strong>트랜잭션과 격리 수준</strong></Link>{' '}
            코스에서, 동시성이라는 다음 층의 지형을 이어서 읽어 봅시다.
          </p>
        </div>
      </div>
    </div>
  );
}
