// 이 파일이 하는 일: "DB 레플리케이션과 샤딩" 코스(특급 과정) — 하나의 데이터베이스가
// 병목이 되는 순서에서 출발해, 읽기를 늘리는 복제(레플리케이션)와 쓰기를 늘리는 샤딩,
// 그리고 그 둘이 반드시 데려오는 부작용(복제 지연, failover의 함정, 핫 샤드, 크로스 샤드
// 쿼리)까지 8개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "확장은 공짜가 아니다" —
// 대규모 트래픽 앞에서 DB를 늘리는 두 가지 축과 그 대가를 함께 계산하는 시야를 기른다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.

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

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

const CODE_BOTTLENECK = `단일 DB가 무너지는 '순서' — 대개 이 계단을 밟는다

① 커넥션 고갈      앱 서버가 늘면서 커넥션 요청이 max_connections를 초과
                   → 대응: 커넥션 풀(HikariCP) 정리, PgBouncer 같은 풀러
② 느린 쿼리        특정 쿼리가 CPU를 태우고 다른 쿼리까지 줄 세움
                   → 대응: EXPLAIN ANALYZE, 인덱스, 쿼리 리라이트
③ 디스크 IO 포화   데이터가 메모리(캐시)를 넘어서며 디스크를 두드리기 시작
                   → 대응: 캐시(Valkey), 더 큰 메모리, 읽기 분산
④ 쓰기 자체의 한계  아무리 튜닝해도 한 대가 받아낼 수 있는 쓰기엔 천장이 있다
                   → 대응: 여기서야 비로소 '샤딩'이 등판

핵심: 복제와 샤딩은 ①~③을 건너뛰는 마법이 아니라,
①~③을 다 하고도 모자랄 때 꺼내는 '마지막 카드'입니다.`;

const CODE_RW_SPLIT = `-- 확장을 결정하기 전에: 읽기 부하와 쓰기 부하를 '분리해서' 측정한다
-- Postgres 통계 뷰로 테이블별 읽기/쓰기 비율 훑어보기
SELECT relname,
       seq_scan + idx_scan              AS reads,     -- 읽기(스캔 횟수)
       n_tup_ins + n_tup_upd + n_tup_del AS writes    -- 쓰기(행 변경 수)
FROM pg_stat_user_tables
ORDER BY reads + writes DESC
LIMIT 10;

-- 읽기 : 쓰기 = 100 : 1  → 복제(읽기 분산)가 먼저
-- 읽기 : 쓰기 =   2 : 1  → 캐시로 읽기를 줄이고, 쓰기 한계라면 샤딩 검토
-- '어느 축이 아픈지'를 모르면 어느 약을 쓸지도 정할 수 없어요.`;

const CODE_REPLICATION = `# Postgres 스트리밍 복제 — 리더의 WAL을 팔로워가 실시간으로 재생
# 리더(primary) postgresql.conf
wal_level = replica              # WAL에 복제용 정보를 담아라
max_wal_senders = 5              # 동시에 WAL을 보낼 수 있는 팔로워 수
synchronous_commit = off         # off=비동기(빠름), on/remote_apply=동기(안전)

# 팔로워(standby)는 리더의 스냅샷에서 출발한다
pg_basebackup -h leader-host -D /var/lib/postgresql/data -R
#   -R : standby.signal 생성 → 이 서버는 '읽기 전용 팔로워'로 기동

# 팔로워에 쓰기를 시도하면?
INSERT INTO posts ...;
-- ERROR: cannot execute INSERT in a read-only transaction
-- 쓰기는 오직 리더로. 팔로워는 읽기 전용 창구입니다.`;

const CODE_LAG_BUG = `[복제 지연 300ms인 세상에서 벌어지는 일]

t=0ms    사용자가 글 작성 → 리더 DB에 INSERT 커밋 (성공 응답)
t=50ms   사용자가 "내 글 목록" 새로고침
t=51ms   로드밸런서가 이 SELECT를 '팔로워'로 보냄
t=52ms   팔로워엔 아직 그 글이 도착 전 → 빈 목록 응답
         → 사용자: "방금 쓴 글이 사라졌어요!" (read-your-writes 위반)
t=300ms  WAL이 팔로워에 재생 완료 → 이제야 글이 보임

DB는 한순간도 고장 나지 않았습니다.
'복제는 시간이 걸린다'는 물리 법칙을 설계가 잊었을 뿐이에요.

해결 패턴 세 가지:
① 쓰기 직후 N초는 리더에서 읽기 (가장 단순, 리더 부하 소폭 증가)
② 세션 고정: 같은 사용자는 같은 복제본으로 (핀 고정, sticky)
③ 타임스탬프 라우팅: 내 마지막 쓰기 시각 ≤ 복제본 재생 시각인 곳만 선택`;

const CODE_LAG_MONITOR = `-- 복제 지연 모니터링 — 리더에서 실행
SELECT client_addr,
       state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes,
       replay_lag                                         AS lag_time
FROM pg_stat_replication;

--  client_addr | state     | lag_bytes | lag_time
--  10.0.1.21   | streaming |     16384 | 00:00:00.12   ← 정상(수십 ms)
--  10.0.1.22   | streaming |  91238400 | 00:00:07.4    ← 7초?! 경보 대상

-- 허용 한계는 '기능이 정한다':
--   댓글 목록 1~2초 지연 → 대부분 무해
--   결제 잔액 조회 1초 지연 → 사고. 이런 읽기는 애초에 리더로 보낸다.`;

const CODE_FAILOVER = `[비동기 복제 + 자동 failover에서 쓰기가 '증발'하는 시나리오]

t=0     리더가 INSERT 커밋, 클라이언트에 "성공" 응답
        (비동기라서 WAL은 아직 팔로워로 전송 중)
t=10ms  리더 서버 전원 장애. 방금 그 WAL은 끝내 전달되지 못함
t=3s    감시자(Patroni 등)가 리더 사망 판정 → 팔로워를 새 리더로 승격
t=3s~   새 리더에겐 그 INSERT가 '없다'. 클라이언트는 성공했다고 믿는 중

→ 커밋 응답을 받았어도, 비동기 복제에선 그 쓰기가 사라질 수 있다.
  잃으면 안 되는 쓰기(결제·주문)는 동기 복제(remote_apply)나
  별도 원장(이벤트 로그)으로 지켜야 합니다.

[스플릿 브레인 — 더 무서운 쪽]
네트워크 단절로 '옛 리더는 살아 있는데' 감시자만 죽었다고 판단하면?
→ 리더가 둘. 양쪽 모두 쓰기를 받으면 데이터가 갈라져 봉합 불능.
→ 그래서 failover엔 펜싱(fencing: 옛 리더를 확실히 격리/차단)과
  과반수 합의(quorum)가 반드시 따라붙습니다. (분산 합의 강좌 재방문)`;

const CODE_SHARD_KEY = `// 해시 샤딩 — 샤드 키(user_id)를 해시해 N개 샤드에 균등 분배
// 학습 포인트: 어떤 키로 나누느냐가 샤딩 설계의 8할이다.
function shardOf(userId, shardCount) {
  const h = hashCode(String(userId));   // 문자열 → 정수 해시
  return Math.abs(h) % shardCount;      // 0 ~ shardCount-1
}

shardOf(1001, 2)  // → 0번 샤드 (postgres-a)
shardOf(1002, 2)  // → 1번 샤드 (postgres-b)

// 해시 샤딩: 균등 분포 O, 범위 조회(id 1000~2000) X
// 레인지 샤딩: 범위 조회 O, 최신 구간에 쓰기 몰림(핫 샤드) 주의
//
// 좋은 샤드 키의 두 조건 — 서로 당기는 힘이 반대라 '트레이드오프':
// ① 균등 분포: 특정 샤드에 데이터·트래픽이 쏠리지 않을 것
// ② 조회 지역성: 자주 함께 읽는 데이터가 같은 샤드에 있을 것`;

const CODE_SCATTER = `// 크로스 샤드 집계 — "전체 유저의 주문 총액"은 이제 한 방 쿼리가 아니다
// 스캐터(scatter): 같은 질문을 모든 샤드에 뿌리고
const partials = await Promise.all(
  shards.map((db) =>
    db.query('SELECT count(*) AS cnt, sum(amount) AS total FROM orders')
  )
);

// 개더(gather): 부분 결과를 애플리케이션에서 합친다
const cnt   = partials.reduce((acc, r) => acc + Number(r.rows[0].cnt), 0);
const total = partials.reduce((acc, r) => acc + Number(r.rows[0].total), 0);

// 단일 DB에선 SQL 한 줄이던 것이 — 네트워크 왕복 N번 + 앱 레벨 병합이 됐다.
// count/sum은 이렇게 합칠 수 있지만, 정확한 전체 정렬 상위 10(top-N)이나
// 크로스 샤드 JOIN·트랜잭션은 훨씬 어렵다. 샤딩의 진짜 비용은 여기에 있어요.`;

const CODE_TIME_PARTITION = `-- 시간 기반 파티셔닝 — 시세·로그 같은 시계열의 정석
CREATE TABLE ticks (
  symbol     text        NOT NULL,
  price      numeric     NOT NULL,
  traded_at  timestamptz NOT NULL
) PARTITION BY RANGE (traded_at);

CREATE TABLE ticks_2026_06 PARTITION OF ticks
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE ticks_2026_07 PARTITION OF ticks
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- 보존 정책: 90일 지난 데이터 정리
DELETE FROM ticks WHERE traded_at < now() - interval '90 days';
--   → 수억 행을 한 줄씩 지우고, 죽은 튜플이 남아 VACUUM 대란

DROP TABLE ticks_2026_03;   -- 또는 DETACH 후 DROP
--   → 파일째 삭제. 수억 행이 밀리초에 사라지고 부스러기도 없다
-- 오래된 데이터를 '지우는' 게 아니라 '통째로 떨어뜨리는' 설계.`;

const CODE_PRACTICE = `# 실습: user_id 해시 샤딩을 로컬 Postgres 2개로 흉내 내기
# ① 샤드 두 개 띄우기 (포트만 다른 동일한 Postgres)
docker run -d --name shard-a -e POSTGRES_PASSWORD=dev -p 5433:5432 postgres:17
docker run -d --name shard-b -e POSTGRES_PASSWORD=dev -p 5434:5432 postgres:17

# ② 양쪽에 같은 스키마 생성
#    CREATE TABLE orders (id bigserial, user_id bigint, amount numeric);

# ③ 시드 스크립트: user_id를 hash % 2로 라우팅해 1만 건 INSERT
#    shardOf(userId, 2) === 0 → 5433(shard-a), 1 → 5434(shard-b)

# ④ 크로스 샤드 집계를 스캐터-개더로 직접 구현
#    - "전체 주문 총액"  → 두 샤드에 sum 쿼리 후 앱에서 합산
#    - "주문 많은 유저 top 5" → 각 샤드 top 5 뽑아 병합 정렬 후 상위 5
#      (각 샤드에서 5개보다 적게 가져오면 왜 틀릴 수 있는지 생각해 보기)

# ⑤ 같은 데이터를 단일 DB에 넣고 같은 질문을 SQL 한 줄로 실행 → 코드량·왕복 비교
#    "샤딩은 쓰기를 사고, 쿼리의 단순함을 판다"를 손으로 체감하는 게 목표`;

// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. 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: 'failover의 함정' },
  { n: 5, label: '파티셔닝·샤딩' },
  { n: 6, label: '샤딩의 부작용' },
  { n: 7, label: '시계열 파티션' },
  { n: 8, label: '실전 판단' },
];

export default function Lv4DbReplicationShardingPage() {
  return (
    <div>
      {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
      <div className="hero">
        <div className="eyebrow">Course · 특급 과정</div>
        <h1>🗄️ DB 레플리케이션과 샤딩<br />— 데이터베이스를 늘리는 두 가지 축</h1>
        <p>
          앱 서버는 부족하면 한 대 더 띄우면 됩니다. 그런데 DB는요? 데이터가 <strong>한
          곳에</strong> 있어야 한다는 성질 때문에, DB 확장에는 전혀 다른 문법이 필요해요.
          축은 두 개입니다. <strong>읽기를 늘리는 복제(레플리케이션)</strong>,{' '}
          <strong>쓰기를 늘리는 샤딩</strong>. 그리고 두 축 모두 공짜가 아니에요 — 복제 지연,
          failover의 함정, 핫 샤드, 사라지는 JOIN까지, 대가를 함께 계산하는 설계를 배웁니다.
        </p>
        <div className="chip-row">
          <span className="chip">예상 소요 85분</span>
          <span className="chip">실습: 2샤드 스캐터-개더</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="DB가 병목이 되는 순서" sub="복제·샤딩은 마지막 카드다">
        <p>
          트래픽이 늘 때 단일 DB는 보통 <strong>정해진 순서로</strong> 무너집니다. 먼저
          커넥션이 고갈되고, 다음은 느린 쿼리가 CPU를 태우고, 그다음 데이터가 메모리를 넘어서며
          디스크 IO가 포화되고, 마지막에야 <strong>쓰기 처리량 자체의 한계</strong>가 옵니다.
          이 순서가 중요한 이유는 — 각 단계마다 <strong>복제·샤딩보다 훨씬 싼 처방</strong>이
          따로 있기 때문이에요.
        </p>
        <Code>{CODE_BOTTLENECK}</Code>
        <p>
          그래서 확장을 결정하기 전에 반드시 물어야 할 질문이 있습니다.{' '}
          <strong>"지금 아픈 건 읽기인가, 쓰기인가?"</strong> 읽기가 아프면 캐시와 복제가 약이고,
          쓰기가 아프면 그 둘은 아무 소용이 없어요(복제는 쓰기를 <strong>모든 복제본에
          반복</strong>시키니 오히려 짐입니다). 읽기 부하와 쓰기 부하를 분리해 측정하는 습관이
          설계의 출발점입니다.
        </p>
        <Code>{CODE_RW_SPLIT}</Code>
        <div className="tip">
          <b>순서를 지키는 이유</b> 인덱스 한 줄이면 끝날 문제를 샤딩으로 풀면, 얻는 건 없고
          운영 복잡도(섹션 6)만 평생 짊어집니다. 대부분의 서비스는 <strong>튜닝 + 캐시 +
          복제</strong>에서 끝나요. 샤딩까지 가는 서비스는 소수고, 가더라도 최대한 늦게 가는 게
          이기는 설계입니다.
        </div>
      </Section>

      <Section n={2} title="레플리케이션 기초 — 리더-팔로워 구조" sub="쓰기는 한 곳, 읽기는 여러 곳">
        <p>
          <strong>레플리케이션(복제)</strong>의 기본 형태는 <strong>리더-팔로워</strong>입니다.
          쓰기(INSERT·UPDATE·DELETE)는 오직 <strong>리더</strong> 한 대가 받고, 리더는 자신이
          받은 변경 기록 — Postgres에선 <strong>WAL(Write-Ahead Log)</strong> — 을 팔로워들에게
          흘려보냅니다. 팔로워는 그 로그를 <strong>같은 순서로 재생</strong>해서 리더와 같은
          상태를 따라가고, 대신 읽기 전용 창구로 일합니다.
        </p>
        <p>
          왜 데이터를 통째로 복사하지 않고 로그를 보낼까요? 로그는 <strong>"무슨 일이
          있었는지"의 순서 있는 기록</strong>이라, 같은 로그를 같은 순서로 재생하면 반드시 같은
          상태가 되기 때문이에요. 상태 복사보다 훨씬 싸고, 이어받기(중단 지점부터 재개)도
          쉽습니다.
        </p>
        <Code>{CODE_REPLICATION}</Code>
        <p>
          갈림길은 <strong>"리더가 언제 커밋 성공을 응답하느냐"</strong>입니다.{' '}
          <strong>비동기 복제</strong>는 리더가 자기 디스크에 쓰자마자 응답해요 — 빠르지만,
          팔로워에 도착하기 전에 리더가 죽으면 그 쓰기는 사라질 수 있습니다(섹션 4).{' '}
          <strong>동기 복제</strong>는 팔로워가 받았다는 확인까지 기다렸다가 응답해요 — 안전하지만
          모든 쓰기에 네트워크 왕복이 얹혀 느려지고, 팔로워가 죽으면 쓰기가 멈출 수도 있죠.{' '}
          <strong>내구성과 지연의 거래</strong>입니다.
        </p>
        <div className="tip">
          <b>우리 스택과의 연결</b> traderbox의 Postgres 앞단을 떠올려 보세요. 공시·아티클처럼
          읽기가 압도적인 데이터는 읽기 전용 레플리카를 붙여 SELECT를 분산하기 좋은 후보입니다.
          반대로 감사 로그처럼 쓰기 중심 데이터에 레플리카를 늘리는 건 복제 부담만 늘려요 —
          섹션 1의 "읽기/쓰기 분리 측정"이 여기서 판단 근거가 됩니다.
        </div>
      </Section>

      <Section n={3} title="복제 지연이 만드는 버그들" sub="글을 썼는데 새로고침하니 사라졌어요">
        <p>
          리더와 팔로워 사이에는 언제나 <strong>시간차(복제 지연, replication lag)</strong>가
          있습니다. 평소엔 수십 ms지만, 트래픽이 몰리거나 팔로워가 느려지면 초 단위로 벌어져요.
          그리고 이 시간차는 아주 구체적인 버그로 나타납니다 — <strong>방금 쓴 글이 새로고침하면
          안 보이는</strong> 현상, 즉 <strong>read-your-writes(내 쓰기 읽기) 위반</strong>입니다.
        </p>
        <Code>{CODE_LAG_BUG}</Code>
        <p>
          세 가지 해결 패턴엔 공통 원리가 있어요. <strong>"모든 읽기가 최신일 필요는 없다.
          단, '방금 쓴 사람의 읽기'만은 최신이어야 한다"</strong> — 전체를 동기 복제로 만드는
          비싼 길 대신, 문제가 되는 <strong>그 경로만</strong> 리더로 보내거나 충분히 따라잡은
          복제본으로 보내는 겁니다. 일관성을 전역이 아니라 <strong>경로 단위로 파는</strong>
          사고방식이죠.
        </p>
        <Code>{CODE_LAG_MONITOR}</Code>
        <div className="warn">
          <b>지연은 '있다/없다'가 아니라 '얼마까지 허용'의 문제</b> 복제 지연을 0으로 만들 수는
          없습니다. 대신 기능별로 허용 한계를 정하세요 — "게시글 목록은 2초까지 OK, 잔액·주문
          상태는 0초(리더 직행)". 그리고 <span className="icode">replay_lag</span>에 경보를 걸어
          한계를 넘으면 해당 팔로워를 라우팅에서 빼는 것까지가 운영입니다.
        </div>
      </Section>

      <Section n={4} title="장애 조치(failover)와 그 함정" sub="리더가 죽으면 누가 왕이 되는가">
        <p>
          리더는 한 대뿐이니, 리더가 죽으면 <strong>쓰기가 전부 멈춥니다</strong>. 그래서 팔로워
          중 하나를 새 리더로 <strong>승격(promote)</strong>하는 절차가 필요해요. 이걸 사람이
          하면 느리고(새벽 3시…), 자동으로 하면 빠르지만 — 자동 failover에는 분산 시스템의
          고전적 함정이 둘 따라옵니다.
        </p>
        <Code>{CODE_FAILOVER}</Code>
        <p>
          첫째 함정은 <strong>커밋의 증발</strong>입니다. 비동기 복제에서 "커밋 성공"은{' '}
          <strong>"리더 한 대에 적혔다"</strong>는 뜻일 뿐, 살아남는다는 보장이 아니에요. 둘째
          함정은 <strong>스플릿 브레인</strong> — 죽은 줄 알았던 옛 리더가 살아 있어서 리더가
          두 명이 되는 사고입니다. "느린 것과 죽은 것을 구분할 수 없다"는 분산 시스템의 근본
          문제가, DB failover에서 가장 비싼 형태로 되돌아오는 거죠.
        </p>
        <p>
          마지막 조각은 <strong>애플리케이션 쪽</strong>입니다. 리더가 바뀌면 앱의 커넥션 풀은
          죽은 옛 리더를 물고 있어요. 그래서 실전에선 ① 커넥션 문자열이 아닌{' '}
          <strong>가상 주소(DNS·프록시·VIP)</strong>로 리더를 가리키고, ② 커넥션 풀에{' '}
          <strong>연결 검증과 재연결</strong>을 켜 두고, ③ failover 직후 몰려드는 재시도 폭풍을{' '}
          <strong>백오프</strong>로 눌러 줍니다. 승격은 DB의 일이지만, 무중단은 앱까지 합작해야
          완성돼요.
        </p>
        <div className="tip">
          <b>왜 과반수인가</b> Patroni 같은 failover 도구가 etcd 등 <strong>합의 저장소의
          과반수(quorum)</strong>를 요구하는 이유 — 반쪽 네트워크에서 양쪽이 각자 리더를 뽑는
          사고를 막기 위해서예요. 과반수는 정의상 하나뿐이니까요. 분산 합의 강좌에서 본 원리가
          DB 운영의 심장부에서 그대로 재사용됩니다.
        </div>
      </Section>

      <Section n={5} title="파티셔닝과 샤딩 — 쓰기를 나누는 법" sub="샤드 키 선택이 모든 것을 결정한다">
        <p>
          복제는 읽기를 늘리지만 쓰기는 여전히 리더 한 대의 몫입니다. 쓰기 자체를 늘리려면{' '}
          <strong>데이터를 쪼개서 여러 대에 나눠 담아야</strong> 해요. 쪼개는 방향은 둘입니다.{' '}
          <strong>수직 분할</strong>은 테이블(관심사) 단위로 나누는 것 — "주문 DB와 로그 DB를
          분리"처럼요. <strong>수평 분할(샤딩)</strong>은 같은 테이블의 <strong>행</strong>을
          나누는 것 — "user_id 홀수는 A 서버, 짝수는 B 서버"처럼요.
        </p>
        <p>
          수평 분할에서 <strong>어떤 컬럼으로 나눌지</strong>가 바로 <strong>샤드 키</strong>이고,
          이 선택이 샤딩 설계의 8할입니다. 한번 정하면 데이터 전체가 그 기준으로 물리적으로
          흩어지기 때문에, 바꾸려면 전 데이터를 옮겨야(리샤딩, 섹션 6) 하거든요.
        </p>
        <Code>{CODE_SHARD_KEY}</Code>
        <p>
          나누는 방식은 크게 둘이에요. <strong>해시 샤딩</strong>은 키를 해시해 균등하게
          뿌립니다 — 분포는 고르지만 "user_id 1000~2000" 같은 <strong>범위 조회가 모든 샤드를
          두드리게</strong> 되죠. <strong>레인지 샤딩</strong>은 범위째 담습니다 — 범위 조회는
          한 샤드로 끝나지만, 시간순 키라면 <strong>최신 구간 샤드에만 쓰기가 몰리는</strong>{' '}
          문제가 생겨요. 균등 분포와 조회 지역성, 두 미덕은 서로 당기는 방향이 반대라 여기서도
          공짜 점심은 없습니다.
        </p>
        <div className="tip">
          <b>판단 기준 한 줄</b> "우리 서비스에서 <strong>가장 빈번한 쿼리</strong>가 무엇을
          기준으로 데이터를 찾는가?"에서 출발하세요. 조회의 9할이 user_id 기준이라면 user_id가
          유력한 샤드 키입니다 — 한 유저의 데이터가 한 샤드에 모여, 대부분의 쿼리가{' '}
          <strong>샤드 하나로 끝나기</strong> 때문이에요.
        </div>
      </Section>

      <Section n={6} title="샤딩의 부작용과 대응" sub="핫 샤드, 사라진 JOIN, 리샤딩의 고통">
        <p>
          샤딩의 첫 번째 부작용은 <strong>핫 샤드</strong>입니다. user_id로 균등하게 나눠도,{' '}
          <strong>트래픽</strong>은 균등하지 않아요. 팔로워 천만의 유명 유저 한 명이 배정된
          샤드는 혼자 불타고, 나머지 샤드는 한가합니다. 대응은 부하의 성격에 따라 다릅니다 —
          읽기 쏠림이면 <strong>그 키만 캐시</strong>로 막고, 쓰기 쏠림이면 뜨거운 키에 접미사를
          붙여 <strong>여러 조각으로 더 쪼개는(키 스플리팅)</strong> 식이죠.
        </p>
        <p>
          두 번째 부작용이 더 근본적입니다. 단일 DB의 최대 무기였던 <strong>JOIN·트랜잭션·집계가
          샤드 경계를 넘는 순간 무력화</strong>돼요. 다른 샤드의 두 행을 한 트랜잭션으로 묶을 수
          없고(분산 트랜잭션의 늪), JOIN은 네트워크 건너 데이터를 끌어와야 합니다. 우회는 두
          갈래예요 — 자주 함께 읽는 데이터를 미리 한 샤드에 <strong>비정규화</strong>해 두거나,
          모든 샤드에 질문을 뿌리고 결과를 합치는 <strong>스캐터-개더</strong>를 쓰거나.
        </p>
        <Code>{CODE_SCATTER}</Code>
        <p>
          세 번째는 <strong>리샤딩</strong>입니다. 샤드 2개를 4개로 늘리면? 순진한{' '}
          <span className="icode">hash % N</span> 방식에선 N이 바뀌는 순간 <strong>거의 모든
          키의 자리가 바뀌어</strong> 데이터 대이동이 벌어져요. <strong>컨시스턴트 해싱</strong>은
          키와 샤드를 같은 해시 링 위에 올려, 샤드를 추가해도 <strong>이웃 구간의 키만</strong>{' '}
          이동하게 만듭니다 — 이동량이 전체의 1/N 수준으로 줄죠. 캐시 분산에서 배운 그 기법이
          DB 샤딩에서도 같은 이유로 등장합니다.
        </p>
        <div className="warn">
          <b>샤딩 전 마지막 점검</b> 샤딩을 도입하는 순간 잃는 것들의 목록을 적어 보세요 —
          크로스 샤드 JOIN, 전역 유니크 제약, 단일 트랜잭션, 쉬운 백업·마이그레이션. 이 목록을
          보고도 쓰기 한계가 더 아프면 그때 샤딩입니다. 목록이 더 아파 보이면 아직 때가 아니에요.
        </div>
      </Section>

      <Section n={7} title="시계열·시세 데이터의 파티셔닝" sub="오래된 데이터는 지우지 말고 떨어뜨린다">
        <p>
          시세·로그·감사 이벤트 같은 <strong>시계열 데이터</strong>는 성질이 독특합니다. 쓰기는
          항상 '지금'에만 일어나고, 읽기는 최근에 몰리고, 오래된 데이터는 언젠가 버려져요. 이
          성질에 딱 맞는 도구가 <strong>시간 기반 파티셔닝</strong>입니다 — 한 서버 안에서
          테이블을 월·일 단위 조각으로 나누는, 말하자면 <strong>서버 내부의 레인지 샤딩</strong>
          이죠.
        </p>
        <Code>{CODE_TIME_PARTITION}</Code>
        <p>
          백미는 <strong>버리는 방식</strong>입니다. <span className="icode">DELETE</span>는 수억
          행을 한 줄씩 지우며 죽은 튜플과 VACUUM 부담을 남기지만,{' '}
          <span className="icode">DROP TABLE</span>은 파티션 <strong>파일째</strong> 떨어뜨려요 —
          밀리초에 끝나고 부스러기도 없죠. <strong>보존 정책(retention)</strong>이 있는 데이터라면
          "지울 수 있게 설계"가 아니라 <strong>"떨어뜨릴 수 있게 설계"</strong>가 정답입니다.
        </p>
        <p>
          한 걸음 더 — <strong>저장소 자체를 나누는 것</strong>도 같은 사고의 연장입니다.
          traderbox가 실시간 시세는 <strong>Valkey</strong>(휘발성·최신값 캐시)에, 공시·아티클은{' '}
          <strong>Postgres</strong>에 담는 이유가 그거예요. "초당 수천 번 갱신되고 1초 뒤면
          낡는 데이터"와 "한 번 쓰고 오래 읽는 데이터"는 요구 조건이 정반대니, 억지로 한 DB에
          담지 않고 <strong>성질별로 다른 저장소에 분할</strong>한 겁니다. 샤딩적 사고는 꼭
          같은 DB N대만 뜻하지 않아요.
        </p>
        <div className="tip">
          <b>파티션 수명 관리는 자동화가 기본</b> "매달 새 파티션 생성 + 기한 지난 파티션
          DROP"을 사람이 하면 언젠가 잊습니다 — 파티션이 없어 INSERT가 실패하는 사고가 월초에
          터지죠. <span className="icode">pg_partman</span> 같은 확장이나 크론 잡으로 생성과
          폐기를 묶어 자동화하는 것까지가 설계입니다.
        </div>
      </Section>

      <Section n={8} title="실전 판단 훈련" sub="유저 1천만, 일 쓰기 5천만 건 — 무엇부터?">
        <p>
          가상 시나리오로 근육을 써 봅시다. <strong>유저 1천만 명, 일 쓰기 5천만 건</strong>의
          서비스라면 복제 먼저일까요, 샤딩 먼저일까요? 계산부터 — 5천만 건/86,400초 ≈{' '}
          <strong>평균 초당 약 580건</strong>, 피크를 10배로 잡아도 수천 TPS입니다. 잘 튜닝된
          Postgres 한 대가 <strong>받아낼 수 있는 범위</strong>예요. 그럼 순서는 이렇게 나옵니다:
          읽기 부하는 캐시 + 읽기 레플리카로 받고(복제 먼저), 쓰기는 튜닝하며 버티다,
          쓰기 성장 곡선이 한 대의 천장에 닿을 <strong>전망이 보일 때</strong> 샤딩을 준비한다 —
          숫자 없이 "1천만이면 샤딩이지"라고 답했다면, 그게 이 강좌가 고치려는 반사신경입니다.
        </p>
        <p>
          <strong>관리형 서비스</strong>(RDS·Aurora)는 이 그림의 어디를 대신해 줄까요? 복제본
          생성, 자동 failover, 백업, 패치 — <strong>섹션 2·4의 운영 노동</strong>은 크게 덜어
          줍니다. 하지만 <strong>복제 지연은 물리 법칙이라 여전히 존재</strong>하고(read-your-writes
          설계는 내 몫), 샤드 키 선택·크로스 샤드 쿼리·핫 샤드 대응도 서비스가 대신 정해 주지
          않아요. 관리형은 <strong>운영을 사는 것이지, 설계 면제권을 사는 게 아닙니다.</strong>
        </p>
        <p>
          마지막으로 <strong>NewSQL(분산 SQL)</strong> — CockroachDB, Spanner, TiDB 같은
          시스템은 "샤딩을 DB 안으로 숨기고, 분산 트랜잭션과 SQL을 그대로 주겠다"고 약속합니다.
          실제로 리샤딩 자동화·분산 트랜잭션은 크게 편해져요. 대신 합의(quorum) 기반 쓰기라{' '}
          <strong>단일 노드 대비 지연이 길고</strong>, 핫스팟 문제는 여전하며, 운영 난도와 비용이
          만만치 않습니다. 도구가 바뀌어도 <strong>이 강좌에서 배운 트레이드오프의 축은 그대로</strong>
          예요 — 그래서 원리를 배운 겁니다.
        </p>
        <Code>{CODE_PRACTICE}</Code>
        <div className="tip">
          <b>실습에서 꼭 느껴야 할 것</b> ④의 top-N 병합에서 "각 샤드 top 5를 모아 다시 top 5"가
          왜 안전한지(그리고 각 샤드에서 3개만 가져오면 왜 답이 틀릴 수 있는지)를 스스로
          설명해 보세요. 크로스 샤드 쿼리의 미묘함이 이 한 문제에 압축돼 있습니다.
        </div>
      </Section>

      <div className="step-card">
        <div className="step-body">
          <h3 style={{ marginBottom: 6 }}>🗄️ 여기까지 왔다면</h3>
          <p className="muted">
            이제 "DB가 느려요"라는 말에 <strong>어느 축이 아픈지</strong>부터 묻게 됐을 거예요.
            병목의 순서, 읽기를 늘리는 복제와 그 그림자(지연·failover), 쓰기를 늘리는 샤딩과
            그 대가(핫 샤드·사라진 JOIN·리샤딩), 그리고 시계열은 떨어뜨리게 설계한다는 감각까지 —
            데이터 계층을 늘리는 두 축을 손에 쥐었습니다. 그런데 DB는 시스템의 한 층일 뿐이죠.
            로드밸런서부터 캐시, 큐, DB까지 <strong>요청이 지나는 전체 경로</strong>를 놓고
            병목을 설계하는 법은 다음{' '}
            <Link to="/learn/lv4-high-traffic-architecture"><strong>대용량 트래픽 설계 — 밀려오는
            요청 앞에서 무너지지 않기</strong></Link> 강좌에서 이어집니다.
          </p>
        </div>
      </div>
    </div>
  );
}
