mirim-app/frontend/src/pages/levels/Lv4SystemDesignUrlShortenerPage.jsx
AWESOMEDEV c5f875d990
All checks were successful
CI / backend-test (push) Successful in 1m1s
CI / frontend-build (push) Successful in 42s
CI / backend-dep-scan (push) Successful in 30s
feat(learn): 수준별 학습 과정 4종 — 초급·중급·고급·특급 48강좌 + 퀴즈 240문항
기존 84강좌는 수습용으로 유지하고, 헤더 셀렉트 박스로 과정을 전환한다.

과정 구성 (각 12강좌, 배우는 순서대로):
- 초급 — 코딩 첫걸음: 프로그램의 개념→HTML→JS→변수·조건·반복·함수→에러읽기→첫 게임
- 중급 — 실전 웹 개발: 비동기·배열→API설계→Express CRUD→DB·트랜잭션→인증→React 실전→테스트→배포
- 고급 — 아키텍처·성능·보안: 레이어드·패턴→인덱스·격리→캐싱·최적화→OWASP·인증설계→관측·무중단배포
- 특급 — 대규모 시스템: 분산기초·CAP→메시지큐→MSA→샤딩→대용량 설계→k8s→SRE→시스템설계 3연작→ADR

구현:
- trackContext(localStorage 유지) + 헤더 TrackSelect + 테스트 5개
- levelCatalog(lv1-~lv4- 슬러그, 기존과 충돌 없음) + 라우트·퀴즈 자동 연결
- LearnHubPage 과정 인식(과정별 인사말·진도), CertificatePage 분모 버그 선제 수정
- 퀴즈 240문항: quizzes.json 415→655 + 운영 DB 삽입(655/131 확인)

생성 파이프라인: 설계 4 → 작성 48(병렬) → 검수 4 에이전트.
검수 BLOCKER 2건(백틱 충돌·OpenAPI 표기) 수정 후 빌드 통과.
운영 검증: 4개 과정 전환·강좌 렌더·퀴즈 로드·선택 유지 브라우저 확인, 스모크 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:13:25 +09:00

464 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 이 파일이 하는 일: "시스템 설계 훈련 1 — URL 단축기를 처음부터 끝까지" 코스 —
// 가장 고전적인 설계 문제를 재료 삼아 '요구사항 → 용량 산정 → API → 데이터 모델 →
// 상세 설계 → 확장'이라는 설계 루틴을 처음부터 끝까지 한 바퀴 도는 정적 학습 페이지.
// 프레이밍은 "그림이 아니라 질문이 먼저" — 특급 과정답게 숫자로 산정하고,
// 트레이드오프를 말로 방어할 수 있는 상태를 목표로 한다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.
import { Link } from 'react-router-dom';
// ── 예제 코드 상수들 ──
const CODE_ROUTINE = `설계 문제를 받았을 때의 루틴 — 면접이든 실무든 순서는 같다
① 요구사항 명확화 "무엇을, 얼마나, 얼마나 빨리?" ← 여기가 절반
② 용량 산정 RPS · 저장 용량 · 대역폭을 숫자로
③ API 설계 바깥에서 보이는 계약부터 확정
④ 데이터 모델 테이블 · 키 · 인덱스
⑤ 상세 설계 핵심 알고리즘과 컴포넌트 다이어그램
⑥ 확장과 운영 캐시 · 샤딩 · 다중 리전 · SLO
흔한 실패: ①을 건너뛰고 ⑤의 그림부터 그리기 시작하는 것.
요구사항이 다르면 정답도 다르다 — "일단 그림부터"가 아니라 "일단 질문부터".`;
const CODE_CAPACITY = `가정: 단축 생성(쓰기) 하루 100만 건, 읽기:쓰기 = 100:1
■ RPS (초당 요청 수)
쓰기 = 1,000,000 / 86,400초 ≈ 12 writes/sec
읽기 = 12 × 100 ≈ 1,200 reads/sec
피크 = 평균의 2~3배 잡으면 ≈ 3,000~4,000 reads/sec
■ 저장 용량 (레코드 크기 × 건수 × 보존 기간)
레코드 1건 ≈ 500바이트 (키 + 원본 URL + 메타데이터)
하루 = 500B × 100만 = 500MB/일
1년 = 500MB × 365 ≈ 182GB/년
5년 보존 = 182GB × 5 ≈ 1TB — 단일 DB로도 감당 가능한 크기
■ 결론
쓰기는 한가하고(12/sec), 읽기가 지배한다(수천/sec).
→ 이 "읽기 편중"이라는 특성이 이후 모든 설계 결정을 지배한다.
캐시를 두껍게, 읽기 경로를 최단으로, 쓰기는 단순하게.`;
const CODE_BASE62 = `base62 = 0-9, a-z, A-Z (62개 문자)로 숫자를 표현하는 인코딩
키 길이별 표현 가능한 개수:
6자 → 62^6 ≈ 568억 개
7자 → 62^7 ≈ 3.5조 개 ← 하루 100만 건 × 100년을 써도 남는다
■ 방식 A: 해시 잘라 쓰기
md5(원본URL) 앞 7자 사용
장점: 무상태 — 어느 서버든 즉시 계산
단점: 충돌 — 다른 URL이 같은 7자가 될 수 있어 재시도 로직 필수
■ 방식 B: 카운터 기반 발급
전역 카운터 1, 2, 3... 을 base62로 변환 (1억 → "6LAze")
장점: 충돌이 원천적으로 없다
단점: 키가 순차적 → 다음 키를 예측해 남의 URL을 긁을 수 있고,
전역 카운터 자체가 단일 장애점 + 병목
■ 방식 C: 키 생성 서비스(KGS)
키 '범위'를 서버마다 미리 떼어 준다
서버1 ← [1 ~ 100만] 서버2 ← [100만+1 ~ 200만]
각 서버는 자기 범위 안에서 로컬 카운터만 증가 — 조율 없이 발급.
서버가 죽으면 그 범위만 버린다(3.5조 개 중 100만 개쯤은 아깝지 않다).`;
const CODE_API = `■ API — 바깥에서 보이는 계약은 딱 두 개
POST /shorten
body: 원본 URL (+ 선택: 커스텀 별칭, 만료일)
응답: 201 Created, 단축 URL
GET /:key
응답: 301 또는 302 + Location: 원본 URL
■ 301 vs 302 — 리다이렉트 한 줄에 숨은 트레이드오프
301 Moved Permanently
브라우저가 결과를 캐시 → 다음부터 우리 서버를 거치지 않는다
+ 서버 부하 감소 - 클릭 집계 불가, 목적지 변경 반영 안 됨
302 Found (임시)
매 클릭이 우리 서버를 통과
+ 클릭 분석 가능, 만료·차단 즉시 반영 - 부하는 그대로
→ 분석이 상품인 서비스(bit.ly류)는 302를 고른다. 요구사항이 결정한다.
■ 테이블 설계
urls (
key VARCHAR(7) PK, -- base62 단축 키
original TEXT NOT NULL,
created_by BIGINT, -- 생성자 (익명 허용 시 NULL)
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ -- NULL이면 무기한
)
조회는 오직 key 등가 조회 하나 — 조인도 범위 스캔도 없다.
■ 만료 처리 두 갈래
지연 삭제(lazy): 조회 시 expires_at 지났으면 404 주고 그때 지운다
배치 청소: 새벽마다 만료 행 일괄 삭제
→ 보통 둘 다: 정확성은 lazy가, 공간 회수는 배치가 맡는다.`;
const CODE_READPATH = `읽기 경로 — 요청 1,200/sec가 흐르는 길을 최단으로
GET /abc1234
① 캐시(Redis) 조회 ── 적중 ──→ 즉시 301/302 응답 (대부분 여기서 끝)
② 캐시 미스 → DB 조회 → 캐시에 적재 → 응답
③ DB에도 없음 → 404
■ 왜 캐시가 잘 먹히나
단축 URL 트래픽은 전형적인 멱법칙: 상위 20% 키가 트래픽의 80%.
전체 1TB를 다 캐시할 필요 없이, 뜨거운 20%만 메모리에 올려도
적중률 80%+ — DB가 받는 부하는 1/5 이하로 줄어든다.
■ 존재하지 않는 키 폭격 (캐시 관통, cache penetration)
공격자가 무작위 키를 대량 조회하면? 캐시엔 당연히 없으니
전부 DB로 관통 — DB가 먼저 쓰러진다. 방어 두 가지:
· 네거티브 캐시: "없음"이라는 사실도 짧은 TTL로 캐시
· 블룸 필터: "확실히 없다"를 메모리에서 판정하는 확률적 자료구조
(있다고 하면 진짜 있을 수도/없을 수도, 없다고 하면 확실히 없음)
■ 극단: CDN 레벨 리다이렉트
리다이렉트 응답 자체를 엣지(CDN)에 캐시하면
요청이 아예 우리 서버에 도착하지도 않는다.
단, 302로 분석을 하기로 했다면 이 최적화와 충돌 — 또 트레이드오프.`;
const CODE_SCALE = `■ 키 기반 샤딩 — 앞 강좌의 샤드 키 논의를 그대로 적용
shard = hash(key) % N
· key는 균등 분포(base62 랜덤/범위 발급) → 핫스팟 없음
· 조회가 전부 key 등가 조회 → 크로스 샤드 쿼리가 아예 없다
→ URL 단축기는 샤딩하기 '가장 쉬운' 워크로드다.
■ 다중 리전 — 어느 지역 유저가 어느 DB를 치나
서울 유저가 미국 DB를 치면 왕복 150ms+ — 리다이렉트가 눈에 띄게 느리다.
· 읽기: 리전마다 읽기 복제본(replica) + 캐시 → 가까운 곳에서 응답
· 쓰기: 생성은 상대적으로 드물다(12/sec) → 단일 프라이머리로 몰아도
복제 지연 몇 초는 허용 가능 ("방금 만든 링크가 지구 반대편에서
1초 늦게 열린다" — 대개 받아들일 수 있는 결과 정합성)
■ SLO — 단축기가 죽으면 '모든 링크'가 죽는다
이 서비스의 장애 반경은 자기 자신이 아니라, 이 링크를 박아 둔
전 세계의 문서·메일·광고 전부다. 그래서 가용성 요구가 유독 높다.
99.9% = 월 43분 장애 허용 — 링크 서비스엔 부족하다는 평가가 많다
99.99% = 월 4.3분 — 읽기 경로만큼은 이 수준을 노린다
힌트: 읽기(리다이렉트)와 쓰기(생성)의 SLO를 분리하라.
생성이 10분 죽는 것과 리다이렉트가 10분 죽는 것은 사고의 급이 다르다.`;
const CODE_ANALYTICS = `요구 추가: "클릭 통계를 보여 주세요" — 기존 설계를 어떻게 흔들까?
■ 후보 1: 동기 기록
리다이렉트 처리 중에 클릭 로그를 DB에 INSERT하고 응답
- 읽기 경로에 쓰기가 끼어든다 → 1,200/sec 읽기가 1,200/sec 쓰기로 둔갑
- DB가 느려지면 리다이렉트까지 느려진다 — 핵심 경로 오염
■ 후보 2: 이벤트 발행 (비동기)
리다이렉트는 즉시 응답, 클릭 이벤트만 Kafka에 발행
소비자(consumer)가 뒤에서 받아 집계 테이블에 반영
+ 읽기 경로는 그대로 빠르다, 집계가 밀려도 리다이렉트는 무사
- 통계는 몇 초~몇 분 지연 (준실시간)
■ 그래서 다시 요구사항 질문으로
"통계는 실시간이어야 하나요, 몇 분 늦어도 되나요?"
→ 대시보드용이면 준실시간으로 충분 — 후보 2 채택.
→ 기존 설계는 거의 그대로: 읽기 경로에 '이벤트 발행 한 줄'만 얹는다.
좋은 설계의 증거는 '기능 추가가 기존 구조를 크게 흔들지 않는 것'이다.`;
// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. 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: 'API·데이터 모델' },
{ n: 5, label: '읽기 경로 최적화' },
{ n: 6, label: '확장과 가용성' },
{ n: 7, label: '분석 기능 추가' },
];
export default function Lv4SystemDesignUrlShortenerPage() {
return (
<div>
{/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
<div className="hero">
<div className="eyebrow">Course · 특급 과정</div>
<h1>🔗 시스템 설계 훈련 1<br /> URL 단축기를 처음부터 끝까지</h1>
<p>
URL을 넣으면 <span className="icode">tdl.ink/abc1234</span>
돌려주는 서비스 기능만 보면 한나절 과제 같지만, <strong>하루 100 생성,
초당 수천 리다이렉트</strong> ··
소환됩니다. 그래서 문제는 시스템 설계의 가장 고전적인 훈련장이에요. 강좌에서{' '}
<strong>요구사항 용량 산정 API 데이터 확장</strong>
처음부터 끝까지 바퀴 돌며 몸에 새깁니다.
</p>
<div className="chip-row">
<span className="chip">예상 소요 75</span>
<span className="chip">실습: 45 화이트보드 전체 설계</span>
<span className="chip">선수 지식: DB·캐시·샤딩 기초</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="설계 문제에 접근하는 루틴" sub="요구사항 명확화가 절반이다">
<p>
설계 문제를 받으면 손이 먼저 그림을 그리고 싶어 합니다. 박스 , 화살표
그럴듯해 보이죠. 하지만 <strong>요구사항이 다르면 정답도 다릅니다.</strong>
1 건짜리 사내 도구와 하루 1 건짜리 글로벌 서비스는 이름만 같은 다른 문제예요.
그래서 훈련된 설계자는 그림 대신 <strong>질문</strong> .
</p>
<p>
질문은 갈래로 나눠요. <strong>기능 요구</strong>( , ,
만료, 커스텀 별칭?) <strong>비기능 요구</strong>( ? ?
가용성은?). 설계를 실제로 좌우하는 대개 후자입니다. 그리고 순서는 면접이든
실무든 같습니다.
</p>
<Code>{CODE_ROUTINE}</Code>
<p>
루틴의 힘은 <strong>되돌아갈 지점을 만들어 준다</strong> .
막히면 "요구사항을 잘못 잡았나?" 돌아가고, 기능이 추가되면(섹션 7) 다시 ①부터
짧게 도는 겁니다. 그림은 루틴의 <strong>결과물</strong> .
</p>
<div className="tip">
<b>실무에서도 똑같다</b> " "
오면, 훈련된 사람은 코드를 열기 전에 묻습니다 "몇 명에게? 지연은 몇 초까지 허용?
놓치면 안 되는 알림인가?" 질문의 답에 따라 구현이 폴링/SSE/푸시로 완전히
갈라지죠. 강좌의 루틴이 바로 근육입니다.
</div>
</Section>
<Section n={2} title="요구사항과 용량 산정" sub="봉투 뒷면 계산이 설계의 방향을 정한다">
<p>
요구사항이 잡혔으면 숫자로 바꿉니다. 이른바 <strong>봉투 뒷면 계산(back-of-the-envelope)</strong>
정밀할 필요는 없고, <strong>자릿수(order of magnitude)</strong> .
우리의 가정: 단축 생성(쓰기) 하루 100 , 읽기:쓰기 비율은 100:1.
</p>
<Code>{CODE_CAPACITY}</Code>
<p>
계산에서 가지 사실이 드러났어요. 첫째, <strong>저장 용량은 문제가 아닙니다.</strong>{' '}
5년치가 1TB 요즘 DB 대로 충분한 크기죠. 둘째, <strong>읽기가 쓰기의 100</strong>.
초당 12건의 쓰기는 한가하지만, 피크 수천 건의 읽기는 진짜 부하예요. 그러니 시스템의
설계 에너지는 온통 <strong>읽기 경로</strong> ( 5),
리다이렉트는 최단 거리로, 쓰기는 단순하게.
</p>
{/* 학습 포인트: 산정의 목적은 정확한 숫자가 아니라 '설계 결정의 근거'다.
"읽기 편중 → 캐시 중심" 같은 문장으로 연결되지 않는 계산은 장식일 뿐이다. */}
<div className="tip">
<b>산정이 틀리면?</b> . {' '}
<strong>방향 결정</strong>. 100 300 " , "
결론은 바뀝니다. 결론이 뒤집히는 지점(: 쓰기가 초당 수만 ) 어디인지 아는 ,
그게 산정의 진짜 수확이에요.
</div>
</Section>
<Section n={3} title="핵심 알고리즘 — 짧은 키 만들기" sub="base62, 그리고 충돌·예측가능성·단일 장애점">
<p>
이제 시스템의 심장, <strong>"긴 URL에 어떤 짧은 키를 붙일 것인가"</strong>.
먼저 키가 자여야 하는지부터 계산하죠. 재료는 <strong>base62</strong> 10,
소문자 26, 대문자 26개로 62진법 표기를 하는 겁니다. URL에 안전하게 들어가는
문자만 쓰는 거죠.
</p>
<Code>{CODE_BASE62}</Code>
<p>
<strong>7자면 3.5 .</strong> 100 100 365
넉넉합니다. 문제는 "그 키를 <strong>누가, 어떻게, 겹치지 않게</strong> 발급하나"예요.
해시 방식(A) 서버가 아무 상태도 가져서 좋지만 <strong>충돌</strong>
하고, 카운터 방식(B) 충돌이 없지만 키가 <strong>순차적이라 예측 가능</strong>
전역 카운터가 <strong>단일 장애점</strong> .
</p>
<p>
그래서 실전 답안이 <strong> 생성 서비스(KGS, Key Generation Service)</strong>{' '}
패턴이에요. <strong>범위</strong> ,
자기 범위 안에서 아무 조율 없이 로컬 카운터만 올리며 발급합니다. 범위가 바닥나면 다음
범위를 받아 가고요. 서버가 죽어 범위 일부가 버려져도 3.5 100 개는
<strong> 버려도 되는 비용</strong>. ,
전형적인 거래죠.
</p>
{/* 학습 포인트: 세 방식 중 '정답'을 외우는 게 아니라, 충돌·예측가능성·단일 장애점이라는
세 축으로 각 방식을 평가하는 눈을 기르는 것이 목적이다. */}
<div className="warn">
<b>순차 키의 함정</b> <span className="icode">abc1234</span> {' '}
<span className="icode">abc1235</span> {' '}
<strong>남이 만든 단축 URL을 전부 수집</strong> .
있다면 사고입니다. KGS로 범위를 무작위 배정하거나 발급 키를 섞는(셔플) 이유가
여기 있어요. 설계 논의에서 보안 관점을 번씩 스치는 습관을 들이세요.
</div>
</Section>
<Section n={4} title="API와 데이터 모델" sub="301이냐 302냐 — 리다이렉트 한 줄의 트레이드오프">
<p>
바깥에서 보이는 계약은 단출합니다. 만들기(<span className="icode">POST /shorten</span>)
이동하기(<span className="icode">GET /&#123;key&#125;</span>) .
리다이렉트 응답에서 상태 코드를 <strong>301 줄지 302 줄지</strong>
성격을 가릅니다.
</p>
<Code>{CODE_API}</Code>
<p>
<strong>301(영구 이동)</strong> ,
우리 서버에 <strong>오지도 않고</strong> .
클릭이 잡히니 <strong>통계가 불가능</strong>,
이미 캐시한 브라우저엔 반영이 돼요. <strong>302(임시)</strong> :
서버를 통과하니 분석·차단·만료가 살아 있는 대신 부하를 그대로 받습니다. 클릭 분석이
상품인 서비스가 302 고르는 이유죠. <strong>요구사항이 상태 코드 하나까지 결정합니다.</strong>
</p>
<p>
데이터 모델은 겸손할수록 좋아요. 테이블 하나, 조회는 <strong> 등가 조회 하나</strong>
조인도 범위 스캔도 없습니다. 단순함이 섹션 6에서 샤딩을 아주 쉽게 만들어 줘요.
만료 처리는 <strong>지연 삭제(lazy)</strong> <strong> </strong> .
조회 순간에 만료 여부를 판정하니 정확성은 lazy가 보장하고, 디스크 공간 회수는 새벽
배치가 맡는 분업이에요.
</p>
<div className="tip">
<b>커스텀 별칭이라는 복병</b> "<span className="icode">tdl.ink/summer-sale</span>
원하는 이름을 쓰게 주세요"라는 요구가 오면 — 발급이 아니라 <strong>선점</strong>
문제가 됩니다. 중복 확인과 동시 요청 처리(유니크 제약 + 충돌 시 409 응답)가 필요해지죠.
기능 한 줄이 데이터 모델에 어떤 파문을 일으키는지 감지하는 것도 설계 감각입니다.
</div>
</Section>
<Section n={5} title="읽기 경로 최적화" sub="상위 20% 키가 트래픽 80% 만든다">
<p>
섹션 2의 결론을 실행할 차례입니다. 초당 수천 건의 <span className="icode">GET /&#123;key&#125;</span>를
DB가 직접 받게 둘 수는 없어요. 다행히 단축 URL 트래픽은 전형적인{' '}
<strong>멱법칙 분포</strong>를 따릅니다 — 방금 SNS에 공유된 소수의 링크가 트래픽을
독식하고, 대부분의 링크는 잠들어 있죠. <strong>상위 20% 키가 트래픽의 80%</strong>를
차지하니, 전체 1TB가 아니라 <strong>뜨거운 소수만 메모리에 올려도</strong> 적중률이
치솟습니다.
</p>
<Code>{CODE_READPATH}</Code>
<p>
평화로운 시나리오만 있는 건 아닙니다. 누군가 <strong>존재하지 않는 키</strong>를
무작위로 대량 조회하면? 캐시엔 당연히 없으니 요청이 전부 DB로 <strong>관통(cache
penetration)</strong>합니다 — 캐시가 무용지물이 되고 DB부터 쓰러져요. 방어는 두 겹:
"없음"이라는 결과 자체를 짧게 캐시하는 <strong>네거티브 캐시</strong>, 그리고 " 키는
확실히 없다"를 메모리에서 판정해 주는 <strong>블룸 필터</strong>입니다. 블룸 필터는
'있다'는 답엔 오차가 있지만 <strong>'없다'는 답은 확실</strong>하다는 성질 덕분에,
없는 키 폭격을 DB 앞에서 걸러 내는 데 딱 맞아요.
</p>
{/* 학습 포인트: 최적화는 공짜가 아니다 — CDN 캐싱은 302로 확보한 '분석 가능성'과
정면충돌한다. 섹션 4의 결정이 섹션 5의 선택지를 제한하는 것, 이게 설계의 연쇄다. */}
<p>
더 극단으로 가면 <strong>CDN 레벨 리다이렉트</strong>가 있습니다. 리다이렉트 응답
자체를 엣지 서버에 캐시해서 요청이 우리 인프라에 <strong>도착조차 안 하게</strong>{' '}
만드는 거죠. 다만 여기서 섹션 4의 결정이 발목을 잡습니다 — 302를 골라 클릭 분석을
하기로 했다면, 엣지에서 응답을 돌려보내는 순간 클릭이 안 잡혀요. 최적화 하나하나가
앞선 결정과 <strong>연쇄</strong>되어 있다는 것, 이게 시스템 설계의 실감입니다.
</p>
<div className="tip">
<b>우리 스택과의 연결</b> 이 캐시 우선 조회는 우리 플랫폼이 시세를 다루는 방식과
같은 문법이에요 — Valkey에 <span className="icode">market:quote</span> 캐시를 두고
DB 아닌 메모리에서 응답하죠. "읽기가 지배하면 캐시를 앞세운다"는 원칙은 도메인이
바뀌어도 그대로입니다.
</div>
</Section>
<Section n={6} title="규모 확장과 가용성" sub="샤딩·다중 리전, 그리고 서비스의 SLO">
<p>
트래픽이 10배, 100배가 되면? 캐시로 버티다가 결국 DB를 쪼갤 때가 옵니다. 다행히
섹션 4에서 데이터 모델을 겸손하게 지어 둔 보상이 여기서 옵니다 — 조회가 전부{' '}
<strong>키 등가 조회</strong>뿐이라, 앞 강좌에서 배운 샤드 키 논의를 교과서처럼
적용할 수 있어요. <span className="icode">hash(key) % N</span>으로 나누면 크로스 샤드
쿼리가 <strong>아예 존재하지 않습니다.</strong>
</p>
<Code>{CODE_SCALE}</Code>
<p>
지리적 확장도 같은 논리로 풉니다. 읽기는 초당 수천 건이고 지연에 민감하니{' '}
<strong>리전마다 복제본과 캐시</strong>를 두고 가까운 곳에서 응답합니다. 쓰기는 초당
12건으로 한가하니 <strong>단일 프라이머리</strong>로 몰아도 되고, 복제가 몇 초 늦어
"방금 만든 링크가 지구 반대편에서 1 늦게 열리는" 정도는 대개 수용 가능한{' '}
<strong>결과 정합성</strong>이에요. 읽기와 쓰기의 요구 수준이 다르면, 아키텍처도
다르게 대접하는 겁니다.
</p>
<p>
마지막으로 가용성. 이 서비스의 무서운 점은 <strong>장애 반경이 자기 자신이 아니라는
것</strong>입니다. 단축기가 10분 죽으면, 그 10분 동안 전 세계 문서·메일·광고에 박힌{' '}
<strong>모든 링크가 함께 죽어요.</strong> 그래서 SLO 논의에서 이 서비스는 유독 높은
기준을 요구받습니다 — 그리고 영리한 답은 <strong>읽기와 쓰기의 SLO를 분리</strong>하는
거예요. 링크 생성이 10분 안 되는 것과 리다이렉트가 10분 안 되는 것은 사고의 급이
다르니까, 읽기 경로(리다이렉트)만큼은 99.99%를 노리고 그쪽에 이중화를 몰아 줍니다.
</p>
<div className="warn">
<b>숫자의 무게를 알기</b> 99.9%는 월 43분, 99.99%는 월 4.3분의 장애 허용입니다.
9 하나를 더 붙일 때마다 비용은 곱절로 뛰어요(다중 AZ → 다중 리전 → 자동 페일오버…).
"우리 서비스는 무조건 99.999%"라고 외치는 건 설계가 아니라 구호입니다 — 어느 경로에
어느 수준이 <strong>필요한지</strong>부터 말할 수 있어야 해요.
</div>
</Section>
<Section n={7} title="분석 기능 추가 설계 변경 연습" sub="좋은 설계는 기능 추가에 크게 흔들리지 않는다">
<p>
설계가 끝났다고 생각한 순간, 요구가 도착합니다 — <strong>"링크별 클릭 통계를 보여
주세요."</strong> 실무의 설계는 늘 이렇게 움직이는 표적이에요. 여기서 훈련할 것은
두 가지: <strong>핵심 경로를 오염시키지 않고 기능을 얹는 법</strong>, 그리고 루틴의
①로 돌아가 <strong>요구사항을 재질문하는 습관</strong>입니다.
</p>
<Code>{CODE_ANALYTICS}</Code>
<p>
동기 기록(후보 1)의 문제는 명확합니다. 리다이렉트라는 <strong>가장 빠르고 가장 높은
SLO를 받는 경로</strong>에 DB 쓰기를 끼워 넣는 순간, 읽기 1,200/sec가 쓰기 1,200/sec로
둔갑하고 통계 DB가 느려지면 리다이렉트까지 함께 느려져요. 이벤트 발행(후보 2)은 그
결합을 끊습니다 — 리다이렉트는 즉시 응답하고, 클릭 이벤트만 Kafka에 던지면 소비자가
뒤에서 집계하죠. 우리 플랫폼이 감사 로그를 <span className="icode">audit.events</span>{' '}
토픽으로 흘려보내는 것과 정확히 같은 패턴입니다.
</p>
<p>
다만 후보 2를 고르기 전에 반드시 물어야 합니다 — <strong>"통계는 실시간이어야
하나요?"</strong> 몇 분 늦어도 되는 대시보드라면 비동기 집계로 충분하지만, "지금
순간의 클릭"이 상품이라면 얘기가 달라지니까요. 기능 하나가 추가될 때마다 루틴의 ①로
짧게 돌아가는 것 — 이게 설계를 <strong>루틴</strong>이라고 부르는 이유입니다. 그리고
최종 그림을 보세요: 기존 설계는 거의 그대로고, 읽기 경로에 <strong>이벤트 발행 한
줄</strong>이 얹혔을 뿐입니다. 좋은 설계의 증거는 바로 이 <strong>변경의 작음</strong>이에요.
</p>
{/* 학습 포인트: 실습의 핵심은 '완성'이 아니라 '방어'다. 트래픽 100배 추궁에
"캐시 적중률·샤드 ·복제 구조가 어떻게 변하는가"를 숫자로 답할 수 있어야 한다. */}
<div className="tip">
<b>실습 — 45분 화이트보드</b> 타이머를 45분에 맞추고, 화이트보드(또는 excalidraw)에
URL 단축기 전체 설계를 완성하세요 — 요구사항 가정, 용량 산정, API, 테이블, 키 발급,
캐시, 샤딩까지 이 강좌의 루틴 순서대로. 끝나면 동료에게 부탁하세요: <strong>"트래픽이
100배가 되면?"</strong> 하고 추궁해 달라고. 캐시 적중률은? 샤드는 몇 개로? 프라이머리
쓰기는 버티나? — 숫자로 방어하지 못한 지점이 바로 다음에 공부할 지점입니다.
</div>
</Section>
<div className="step-card">
<div className="step-body">
<h3 style={{ marginBottom: 6 }}>🔗 여기까지 왔다면</h3>
<p className="muted">
축하해요 — 설계 문제 하나를 <strong>요구사항부터 확장까지</strong> 처음부터 끝까지
완주했습니다. base62와 KGS, 301과 302, 캐시 관통과 블룸 필터 같은 부품도 얻었지만,
진짜 수확은 <strong>루틴</strong>이에요. 어떤 설계 문제가 와도 "일단 질문부터,
그다음 숫자로" 시작할 수 있는 순서 감각이요. 다만 URL 단축기는 읽기 편중에
상태가 단순한, 어찌 보면 '착한' 문제였습니다. 다음{' '}
<Link to="/learn/lv4-system-design-chat"><strong>시스템 설계 훈련 2 실시간 채팅
시스템</strong></Link> , ,
표시까지 얽히는 <strong>상태 가득한 문제</strong> .
</p>
</div>
</div>
</div>
);
}