단일 서버에서 시작해 로드밸런서·캐시·DB 복제까지 - 규모 확장성을 단계별로 직접 만들어보는 절차
책 1~3장(규모 확장성, 개략적 추정, 면접 공략법)을 요약 대신 Docker Compose로 단계별로 직접 쌓아본다. 단일 서버 → DB 분리 → 로드밸런서+레플리카 → DB 읽기 복제 → 캐시까지, 각 단계마다 k6로 처리량을 실측해 책의 주장을 검증하는 절차.
목표
책 1장이 말하는 "단일 서버 → 수백만 사용자"까지의 확장 경로를 순서 그대로 Docker Compose로 쌓아 올린다. 각 단계에서 "왜 이걸 추가하는가"(직전 단계의 어떤 한계 때문에)와 "실제로 얼마나 나아지는가"(k6로 처리량 실측)를 둘 다 남긴다. 2장(개략적 추정)의 계산 방식과 3장(면접 공략법)의 질문 프레임워크는 별도 구현 대상이 없어 이 글의 개념 절에 흡수한다.
개념: 개략적 추정이 왜 먼저 나오는가 (2장)
시스템을 설계하기 전에 "이 서비스가 초당 몇 건의 요청을 받을지, 데이터가 하루에 얼마나 쌓일지"를 먼저 어림잡아야 한다 - 이 숫자가 "단일 서버로 충분한지, 언제부터 분산이 필요한지"를 결정하기 때문이다. 예를 들어 DAU(일간 활성 사용자) 1천만 명이 하루 평균 10개의 요청을 보낸다면 하루 요청 수는 1억 건, 초당 평균 요청(QPS)은 1억÷86400 ≈ 1,157건이다. 여기에 트래픽이 특정 시간대에 몰리는 걸 감안해 피크 QPS는 평균의 2~3배로 잡는 식이다. 이 글의 실습에서는 실제 사용자 수 대신 k6로 발생시키는 가상 유저 수 자체가 이 추정치 역할을 한다 - "이 정도 트래픽까지 견디는지"를 계산이 아니라 직접 걸어서 확인하는 것이다.
개념: 단일 서버에서 무엇이 순서대로 늘어나는가 (1장)
책이 제시하는 확장 순서는 이렇다 - 이 실습도 정확히 같은 순서로 간다.
1. 단일 서버 (앱+DB+정적 자산이 한 곳에)
2. DB를 별도 서버로 분리 (앱과 DB를 독립적으로 확장 가능하게)
3. 로드밸런서 + 앱 서버 다중화 (단일 장애점 제거, 수평 확장)
4. DB 복제 (읽기/쓰기 분리 - 읽기가 압도적으로 많은 워크로드 대응)
5. 캐시 추가 (DB 부하 자체를 줄임)
6. (확장) CDN - 정적 자산을 사용자와 가까운 곳에서 서빙
각 단계는 직전 단계의 구체적인 한계를 해소하기 위해 추가된다 - 단계 3(로드밸런서)은 앱 서버 1대가 죽으면 서비스 전체가 죽는 문제를, 단계 4(DB 복제)는 읽기 요청이 쓰기 요청보다 훨씬 많은 전형적인 웹 서비스에서 마스터 DB 하나가 모든 읽기까지 떠안는 병목을, 단계 5(캐시)는 매 요청마다 DB까지 가는 낭비를 해소한다.
실제 구축 절차
0. 실습 대상 - 카운터 API
복잡한 도메인 대신 상태를 명확히 관찰할 수 있는 단순한 API를 쓴다 - 요청마다 카운터를 1 증가시키고 현재 값을 반환하는 FastAPI 서비스. 카운터 값이 DB에 정확히 반영되는지, 캐시가 낀 뒤에도 정합성이 깨지지 않는지를 그대로 관찰할 수 있다.
# app/main.py
from fastapi import FastAPI
import psycopg2, os
app = FastAPI()
DB_HOST = os.environ.get("DB_HOST", "db")
def get_conn():
return psycopg2.connect(host=DB_HOST, dbname="counter", user="postgres", password="postgres")
@app.on_event("startup")
def init_db():
with get_conn() as conn, conn.cursor() as cur:
cur.execute("CREATE TABLE IF NOT EXISTS counter (id INT PRIMARY KEY, value INT)")
cur.execute("INSERT INTO counter VALUES (1, 0) ON CONFLICT DO NOTHING")
conn.commit()
@app.post("/hit")
def hit():
with get_conn() as conn, conn.cursor() as cur:
cur.execute("UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value")
value = cur.fetchone()[0]
conn.commit()
return {"value": value}
@app.get("/healthz")
def healthz():
return {"status": "ok"}
1단계: 단일 서버
앱과 DB를 컨테이너 하나씩, 별도 로드밸런서 없이 직접 붙인다.
# docker-compose.step1.yml
services:
app:
build: ./app
ports: ["8000:8000"]
environment:
DB_HOST: db
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_DB: counter
POSTGRES_PASSWORD: postgres
docker compose -f docker-compose.step1.yml up -d
k6 run --vus 20 --duration 30s - <<'EOF'
import http from 'k6/http';
export default function () {
http.post('http://localhost:8000/hit');
}
EOF
이 단계에서 확인할 것: app 컨테이너를 강제로 죽이면(docker kill) API가 즉시 전부 실패한다 - 단일 장애점이 실제로 단일 장애점인지 눈으로 확인하는 게 이 단계의 핵심이다.
2단계: 앱 다중화 + 로드밸런서
Nginx를 로드밸런서로 앞에 두고 앱을 2개로 늘린다. DB는 아직 1대 그대로 - 이번 단계가 해소하는 건 "앱 서버의" 단일 장애점뿐이라는 걸 명확히 하기 위해서다.
# nginx.conf
upstream app_servers {
server app1:8000;
server app2:8000;
}
server {
listen 80;
location / {
proxy_pass http://app_servers;
}
}
# docker-compose.step2.yml (일부)
services:
nginx:
image: nginx:alpine
ports: ["8000:80"]
volumes: ["./nginx.conf:/etc/nginx/conf.d/default.conf"]
depends_on: [app1, app2]
app1:
build: ./app
environment: { DB_HOST: db }
app2:
build: ./app
environment: { DB_HOST: db }
db:
image: postgres:16
environment: { POSTGRES_DB: counter, POSTGRES_PASSWORD: postgres }
이번엔 app1을 죽여도 서비스가 계속 응답하는지 확인한다 - 1단계와 대조되는 지점이다. 카운터 값이 두 앱 인스턴스를 오가며 요청해도 정확히 순차적으로 증가하는지(DB가 정합성을 보장하는지)도 같이 확인한다.
3단계: DB 읽기 복제본 추가
쓰기(/hit)와 읽기(/count, 새로 추가) 엔드포인트를 분리하고, 읽기는 복제본으로 보낸다.
@app.get("/count")
def count():
with get_conn(read=True) as conn, conn.cursor() as cur: # 복제본으로 연결
cur.execute("SELECT value FROM counter WHERE id = 1")
return {"value": cur.fetchone()[0]}
Postgres 복제본은 스트리밍 복제(primary_conninfo 설정)로 구성한다. 검증 포인트는 복제 지연(replication lag)이다 - /hit으로 값을 올린 직후 바로 /count를 읽으면 복제 지연 때문에 이전 값이 보일 수 있다(최종 일관성). k6로 쓰기 직후 읽기를 반복하며 이 지연이 실제로 몇 ms인지 재본다.
4단계: 캐시 추가
/count 앞에 Redis 캐시를 붙인다.
@app.get("/count")
def count():
cached = redis_client.get("counter:value")
if cached is not None:
return {"value": int(cached), "source": "cache"}
with get_conn(read=True) as conn, conn.cursor() as cur:
cur.execute("SELECT value FROM counter WHERE id = 1")
value = cur.fetchone()[0]
redis_client.setex("counter:value", 2, value) # TTL 2초
return {"value": value, "source": "db"}
TTL을 짧게 잡은 이유를 그대로 보여주는 게 목적이다 - TTL이 너무 길면 /hit으로 값이 바뀌어도 /count가 오래된 값을 계속 반환한다(캐시 무효화 문제). k6로 /count를 반복 호출하며 응답에 source: cache가 섞이는 비율과, DB 부하(활성 커넥션 수)가 캐시 도입 전후로 얼마나 줄어드는지 비교한다.
검증 방법
각 단계마다 동일한 k6 시나리오(--vus 50 --duration 1m)로 처리량(RPS)과 p95 응답시간을 재고, 표로 남긴다.
| 단계 | 구성 | 확인할 것 |
|---|---|---|
| 1 | 단일 서버 | 기준 처리량, 앱 강제 종료 시 전체 다운 |
| 2 | LB + 앱 2대 | 처리량 변화, 앱 1대 종료해도 서비스 유지 |
| 3 | + DB 읽기 복제 | 복제 지연 실측(ms), 쓰기 직후 읽기 시 stale 값 재현 |
| 4 | + 캐시 | DB 활성 커넥션 수 변화, 캐시 TTL과 정합성의 트레이드오프 |
체크리스트
- 1단계: 단일 서버 구성, 기준 처리량 측정, 단일 장애점 재현
- 2단계: Nginx LB + 앱 2대, 처리량 비교, 장애 시 가용성 확인
- 3단계: Postgres 스트리밍 복제 구성, 복제 지연 실측
- 4단계: Redis 캐시 추가, TTL별 정합성/성능 트레이드오프 비교
- (선택) CDN 시뮬레이션 - 정적 자산을 별도 Nginx로 분리해 캐싱 헤더 효과 확인
- 4단계까지의 처리량·응답시간 변화를 표 하나로 정리