컴퓨터공학 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 를 숨겨 주고 정적 자원 트래픽을 흡수해 가정 회선의 업로드 대역폭을 아껴 준다.

확인 문제

  1. Cache-Control: max-age=60, s-maxage=600 응답을 CDN 과 브라우저는 각각 몇 초 동안 신선하다고 보는가?
  2. no-cache 와 no-store 의 차이는?
  3. 304 Not Modified 응답이 대역폭을 아끼는 원리는?
  4. 쿠키로 사용자를 구별하는 페이지를 public, max-age=300 으로 내보내면 어떤 사고가 날 수 있는가?
  5. app.js 를 고친 뒤 사용자들이 옛 버전을 받는 문제를 퍼지 없이 근본적으로 막는 방법은?

풀이

  1. CDN(공유 캐시)은 s-maxage 가 우선이라 600초, 브라우저(사설 캐시)는 max-age 인 60초.
  2. no-cache 는 저장은 하되 사용할 때마다 원본에 재검증해야 하고, no-store 는 아예 저장하지 않는다.
  3. 캐시가 가진 응답의 검증자(ETag 등)를 보내 원본이 “그대로다”라고만 답하므로, 본문을 다시 전송하지 않는다.
  4. 첫 사용자의 개인화된 페이지가 공유 캐시에 저장되어 5분 동안 다른 사용자에게 그대로 전달될 수 있다.
  5. 파일 이름에 내용 해시를 넣어(app.3f9a1c.js) 내용이 바뀌면 URL 자체가 바뀌게 하고, 이를 참조하는 HTML 은 짧은 TTL 로 둔다.

더 읽을거리 (References)