[CS300 #156] CDN — 원본 대신 가까운 곳에서 답하는 캐시의 그물
컴퓨터공학 300 주제 시리즈의 156번째 글이다. 전체 지도는 여기.
한 줄 요약
CDN(Content Delivery Network)은 사용자 가까이에 둔 캐시 서버(엣지)들의 망이다. HTTP 캐시 규칙에 따라 원본(origin) 응답을 저장해 두었다가 대신 돌려줘, 지연을 줄이고 원본 부하를 덜어 준다.
왜 필요한가
빛의 속도는 줄일 수 없다. 서울의 사용자가 미국 동부 서버에 요청하면 왕복 지연만으로 상당한 시간이 든다. TCP 와 TLS 핸드셰이크가 왕복을 몇 번 더 요구하니 체감 지연은 더 커진다(147, 154번 글). 가까운 엣지가 응답하면 이 왕복이 짧아진다.
부하 측면도 있다. 이미지·스크립트·영상 같은 정적 자원 요청 대부분을 엣지가 처리하면 원본 서버는 동적 요청에만 집중할 수 있다. 트래픽 급증이나 DDoS 를 흡수하는 완충 역할도 한다.
그러나 캐시는 “오래된 데이터를 보여 주는 장치”이기도 하다. “배포했는데 옛 화면이 보인다”, “로그인한 남의 정보가 보였다” 같은 사고는 캐시 규칙을 잘못 쓴 결과다.
핵심 개념
구성 요소
사용자 ──> (DNS 또는 애니캐스트로 가까운 엣지 선택) ──> 엣지 PoP
│ 캐시 적중(HIT): 바로 응답
│ 캐시 실패(MISS)
▼
(중간 계층 캐시) ──> 원본 서버
- 엣지/PoP(Point of Presence): 여러 지역에 흩어진 캐시 서버 묶음.
- 원본(origin): 진짜 콘텐츠를 가진 서버나 객체 저장소.
- 가까운 엣지 선택: 대표적으로 두 방식이 있다. DNS 기반은 리졸버 위치를 보고 가까운 엣지 IP 를 돌려준다. 애니캐스트는 여러 지역이 같은 IP 를 BGP 로 광고해, 라우팅이 가장 가까운 곳으로 보내게 한다(145번 글).
- 중간 계층(tiered cache, origin shield): 엣지들이 원본으로 바로 가지 않고 한 곳을 거치게 해 원본 요청을 더 줄인다.
HTTP 캐시 규칙
CDN 은 HTTP 캐시 명세 RFC 9111 을 따르는 공유 캐시(shared cache)다. 원본은 응답 헤더로 캐시 동작을 지시한다.
| 지시어 | 의미 |
|---|---|
Cache-Control: max-age=N |
N 초 동안 신선(fresh). 브라우저와 공유 캐시 모두 적용 |
s-maxage=N |
공유 캐시(CDN, 프록시)에만 적용되는 수명. max-age 보다 우선 |
public / private |
private 이면 공유 캐시에 저장 금지. 사용자별 응답에 필수 |
no-cache |
저장은 하되 쓰기 전에 매번 원본에 재검증 |
no-store |
아예 저장 금지 |
stale-while-revalidate=N |
만료 후 N 초 동안은 옛 응답을 주면서 뒤에서 갱신(RFC 5861) |
- 재검증: 만료된 응답에
ETag나Last-Modified가 있으면 캐시는If-None-Match/If-Modified-Since로 원본에 묻는다. 바뀌지 않았으면 원본은 본문 없이304 Not Modified만 보낸다. Age헤더: 응답이 캐시에 머문 초. 디버깅 때 캐시에서 왔는지 보는 단서다.
캐시 키
캐시가 “같은 요청”이라고 판단하는 기준이 캐시 키다. 기본은 메서드 + 호스트 + 경로 + 쿼리 문자열이다. 원본 응답의 Vary 헤더(예: Vary: Accept-Encoding)는 그 요청 헤더 값도 키에 넣으라는 뜻이다.
캐시 키가 너무 넓으면 사고가 난다. 쿠키로 사용자를 구별하는 페이지를 public 으로 캐시하면, 첫 사용자의 개인 페이지가 다음 사용자에게 보인다. 반대로 키가 너무 좁으면(추적용 쿼리 파라미터까지 포함) 적중률이 떨어진다.
무효화 전략
| 방법 | 설명 |
|---|---|
| 짧은 TTL | 단순하지만 원본 부하가 늘고 적중률이 떨어진다 |
| 퍼지(purge) | CDN API 로 특정 경로나 태그를 즉시 삭제 |
| 버전이 든 파일 이름 | app.3f9a1c.js 처럼 내용 해시를 이름에 넣고 아주 긴 TTL 을 준다. 내용이 바뀌면 이름이 바뀌므로 무효화가 필요 없다 |
정적 자원은 버전 이름 + 긴 TTL, HTML 처럼 이름을 바꿀 수 없는 진입점은 짧은 TTL 이나 no-cache 로 두는 조합이 흔하다.
직접 해 보기
엣지 캐시 하나를 시뮬레이션한다. Cache-Control 을 해석해 저장 여부와 TTL 을 정하고, 인기도가 치우친(지프 분포) 요청을 흘려 캐시 크기별 적중률을 본다.
import random
from collections import OrderedDict
def ttl_from(cache_control):
d = {}
for part in cache_control.split(","):
k, _, v = part.strip().partition("=")
d[k.lower()] = v
if "no-store" in d or "private" in d:
return None # 공유 캐시에 저장 금지
if "s-maxage" in d: return int(d["s-maxage"])
if "max-age" in d: return int(d["max-age"])
return 0
for cc in ["public, max-age=60", "max-age=60, s-maxage=3600", "private, max-age=600", "no-store"]:
print(f"{cc:28s} -> 공유 캐시 TTL {ttl_from(cc)}")
class EdgeCache:
def __init__(self, capacity):
self.cap, self.store = capacity, OrderedDict() # key -> 만료 시각
self.hit = self.miss = 0
def get(self, key, now, ttl):
exp = self.store.get(key)
if exp is not None and exp > now:
self.store.move_to_end(key); self.hit += 1; return "HIT"
self.miss += 1 # 원본에 요청
self.store[key] = now + ttl; self.store.move_to_end(key)
if len(self.store) > self.cap: self.store.popitem(last=False) # LRU 축출
return "MISS"
random.seed(42)
N_OBJECTS = 10000
weights = [1 / (i + 1) for i in range(N_OBJECTS)] # 지프 분포: 소수 객체가 인기
reqs = random.choices(range(N_OBJECTS), weights=weights, k=200000)
for cap in (100, 1000, 5000):
c = EdgeCache(cap)
for t, obj in enumerate(reqs):
c.get(f"/img/{obj}.jpg", now=t, ttl=50000)
print(f"캐시 {cap:5d}개 -> 적중률 {c.hit / len(reqs) * 100:5.1f}%, 원본 요청 {c.miss}")
public, max-age=60 -> 공유 캐시 TTL 60
max-age=60, s-maxage=3600 -> 공유 캐시 TTL 3600
private, max-age=600 -> 공유 캐시 TTL None
no-store -> 공유 캐시 TTL None
캐시 100개 -> 적중률 38.9%, 원본 요청 122129
캐시 1000개 -> 적중률 67.1%, 원본 요청 65736
캐시 5000개 -> 적중률 86.6%, 원본 요청 26827
전체 객체 1만 개 중 1%(100개)만 담아도 요청의 약 39%를 엣지가 처리했다. 인기가 소수에 몰리는 분포에서는 작은 캐시도 효과가 크다는 뜻이다. 캐시를 50%(5,000개)로 늘리면 원본 요청이 캐시가 없을 때(20만 건)의 약 7분의 1 수준으로 준다. 실제 트래픽의 분포는 서비스마다 다르므로, 이 숫자 자체보다 “캐시 크기와 적중률은 선형이 아니다”라는 모양을 기억하면 된다. private 과 no-store 응답은 TTL 이 None, 곧 공유 캐시에 저장되지 않는다.
현업에서는
curl -sI https://예시도메인/자원으로 응답 헤더를 보고Cache-Control,Age, CDN 이 붙이는 캐시 상태 헤더(이름은 업체마다 다르다)를 확인한다. 같은 요청을 두 번 보내Age가 늘면 캐시에서 온 응답이다.- 로그인 사용자용 API 응답에는
Cache-Control: private이나no-store를 명시한다. 원본이 아무 헤더도 안 주면 CDN 의 기본 정책이 적용되는데, 그 기본값을 확신하지 말아야 한다. - 프런트엔드 빌드 도구가 만드는 해시 파일 이름과 긴 TTL, 그리고 HTML 의 짧은 TTL 조합이 “배포했는데 옛 화면” 문제를 없앤다.
- 홈랩처럼 원본이 가정 회선 뒤에 있으면, CDN 이 원본 IP 를 숨겨 주고 정적 자원 트래픽을 흡수해 가정 회선의 업로드 대역폭을 아껴 준다.
확인 문제
Cache-Control: max-age=60, s-maxage=600응답을 CDN 과 브라우저는 각각 몇 초 동안 신선하다고 보는가?no-cache와no-store의 차이는?- 304 Not Modified 응답이 대역폭을 아끼는 원리는?
- 쿠키로 사용자를 구별하는 페이지를
public, max-age=300으로 내보내면 어떤 사고가 날 수 있는가? app.js를 고친 뒤 사용자들이 옛 버전을 받는 문제를 퍼지 없이 근본적으로 막는 방법은?
풀이
- CDN(공유 캐시)은 s-maxage 가 우선이라 600초, 브라우저(사설 캐시)는 max-age 인 60초.
- no-cache 는 저장은 하되 사용할 때마다 원본에 재검증해야 하고, no-store 는 아예 저장하지 않는다.
- 캐시가 가진 응답의 검증자(ETag 등)를 보내 원본이 “그대로다”라고만 답하므로, 본문을 다시 전송하지 않는다.
- 첫 사용자의 개인화된 페이지가 공유 캐시에 저장되어 5분 동안 다른 사용자에게 그대로 전달될 수 있다.
- 파일 이름에 내용 해시를 넣어(
app.3f9a1c.js) 내용이 바뀌면 URL 자체가 바뀌게 하고, 이를 참조하는 HTML 은 짧은 TTL 로 둔다.
더 읽을거리 (References)
- RFC 9111, HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111.html
- RFC 5861, HTTP Cache-Control Extensions for Stale Content: https://www.rfc-editor.org/rfc/rfc5861.html
- MDN, HTTP caching: https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
- MDN, CDN (Glossary): https://developer.mozilla.org/en-US/docs/Glossary/CDN