기존 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>
464 lines
30 KiB
JavaScript
464 lines
30 KiB
JavaScript
// 이 파일이 하는 일: "시스템 설계 훈련 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 /{key}</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 /{key}</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>
|
||
);
|
||
}
|