CDN이 가까이 있다는 건 무슨 뜻일까
CDN을 붙였더니 빨라졌는데 왜 빨라졌는지는 막연한 순간이 있어요.
처음 서비스에 CDN을 붙여봤을 때 솔직히 뭐가 빨라진 건지 와닿지 않았어요. dist/ 폴더에 들어 있던 파일이 다른 호스트에서 내려올 뿐인데, LCP가 700ms 줄어들고 origin 서버 CPU 그래프가 절반으로 떨어졌거든요. 그 빠름은 사실 두 가지가 동시에 작동해서 만들어집니다. 거리가 짧아지는 게 절반, 두 번째 요청부턴 멀리 다녀오지 않는 게 나머지 절반이에요.
요청은 어디까지 다녀오는가
한국에서 미국 동부 origin까지 한 번 갔다 오는 동안 패킷은 해저 케이블을 두 번 건너요. 라운드 트립 한 번이 200ms대인데, TCP 연결 수립과 TLS handshake에 라운드 트립이 두세 번 더 들어가거든요. 첫 응답 헤더가 돌아오기 전에 0.5초가 그냥 사라져요.
CDN은 이 문제를 "사용자 가까이에 있는 데이터센터를 응답자로 둔다"로 풉니다. 전 세계 곳곳에 edge POP(Point of Presence)이라는 거점을 두고, 같은 IP 주소를 모든 거점에서 동시에 광고하는 방식(Anycast)으로 라우팅을 처리해요. 한국 사용자의 요청은 BGP 라우터들이 자기 입장에서 가장 가까운 edge로 자동으로 흘려보내요. origin이 어디에 있든, 사용자는 늘 자기 도시 근처 edge하고만 대화하는 셈이에요.
origin 직접 연결
한국 사용자 → 해저 케이블 → 미국 origin. RTT 200ms대, TLS까지 합치면 첫 바이트가 500ms 뒤.
edge 경유
한국 사용자 → 서울 edge POP. 같은 IP를 Anycast로 광고해 BGP가 가까운 거점으로 라우팅.
edge에서 끝
edge에 응답이 캐시돼 있으면 origin까지 갈 일이 없음. 첫 바이트 30ms 안쪽도 가능.
CDN이라는 캐시 계층
CDN 동작을 이해하는 가장 간단한 방법은 캐시를 둘로 갈라보는 거예요. MDN의 HTTP Caching 문서가 이 구분을 깔끔하게 정리해놨어요.
"Managed caches are explicitly deployed by service developers to offload the origin server and to deliver content efficiently." - MDN HTTP Caching
운영자가 의도해서 박아 둔 캐시가 managed cache예요. CDN이 바로 여기에 해당해요.
브라우저가 자기 디스크에 보관하는 캐시는 사용자 한 명만 쓰는 private cache예요. 반면 CDN의 edge가 들고 있는 캐시는 같은 응답을 여러 사용자에게 돌려주는 shared cache고요. 그래서 둘은 받아들일 수 있는 응답이 달라요. 사용자별로 다른 응답(로그인된 대시보드, 개인화된 추천)은 shared cache에 저장되면 안 되거든요.
CDN은 그중에서도 운영자가 의도적으로 배치한 managed cache예요. 표준 HTTP 캐시 헤더 외에도 API로 즉시 비울 수 있고, 객체에 태그를 붙여 그룹 단위로 비우거나 만료된 객체를 임시로 살려둘 수도 있어요. 표준 너머의 제어가 가능한 게 managed cache의 정체예요.
3계층은 이렇게 정리돼요. browser private cache, 그 아래 CDN shared cache, 그 아래 origin. 요청은 위에서 아래로 내려가다가 어디선가 hit가 나면 거기서 멈추고 응답이 거꾸로 올라옵니다.
무엇을 키로 캐시하는가
캐시는 "어떤 키에 어떤 응답을 저장할지"가 핵심이에요. 가장 단순한 키는 URL이지만, 그게 끝이면 같은 URL인데 한국어/영어 응답이 섞여버릴 수 있어요. content negotiation 때문이에요.
이때 Vary 헤더가 등장해요. 응답에 Vary: Accept-Encoding을 붙이면, 같은 URL이라도 클라이언트가 보낸 Accept-Encoding이 다르면 다른 캐시 항목으로 저장돼요. Accept-Language를 추가하면 언어별로도 분리되고요.
그래서 cache key는 사실상 "URL + Vary에 나열된 헤더 조합"이라고 이해하면 편해요. Vary를 너무 많이 늘리면 같은 URL인데도 캐시 항목 수가 폭발해서 적중률이 떨어지니까, 정말 분기가 필요한 헤더만 골라서 쓰는 게 정석이에요.
얼마 동안 캐시할 것인가
Cache-Control 디렉티브가 캐시의 수명을 정해요. 우리가 자주 보는 디렉티브들이 사실 역할이 다 달라요.
max-age=N은 응답이 fresh로 남는 시간(초)이에요. s-maxage는 shared cache 전용 max-age인데, CDN은 이 값을 우선 보고 브라우저는 무시해요. 그래서 "브라우저는 짧게, CDN은 길게" 같은 정책이 가능해져요. 예를 들어 Cache-Control: max-age=60, s-maxage=3600이면 브라우저는 1분, CDN은 1시간 캐시예요.
public은 shared cache에 저장 가능, private은 브라우저 로컬에만 저장이에요. 사용자별 응답이면 반드시 private이고요. immutable은 fresh 동안에는 절대 안 바뀐다는 약속이라, 브라우저가 검증조차 안 하고 그대로 써요. 해시가 들어간 정적 파일(app.a1b2c3.js)에 어울리는 디렉티브예요.
자원 종류별로 권고되는 조합은 web.dev 가이드에서 잘 보여줘요.
# 해시된 자산 (파일명에 버전이 박혀 있음)
Cache-Control: public, max-age=31536000, immutable
# HTML (URL이 그대로니 매번 검증)
Cache-Control: no-cache
# 사용자별 API 응답
Cache-Control: private, max-age=0, must-revalidateno-cache는 "저장은 하되 매번 origin에 검증부터"이고, no-store는 아예 저장 금지예요. 둘이 헷갈리기 쉬운데, 검증을 거친다는 점에서 no-cache도 캐시 자체는 작동해요.
stale이 되었을 때
fresh 시간이 지나면 응답이 stale 상태가 돼요. 이때부터 흥미로워져요.
ETag가 붙어 있으면, 클라이언트가 다음 요청에 If-None-Match: <etag>를 같이 보내요. origin이 자기가 들고 있는 ETag와 비교해서 같으면 본문 없이 304 Not Modified만 돌려줘요. 본문이 없으니 응답 크기가 헤더 몇 줄 수준이고, 클라이언트는 가지고 있던 캐시를 그대로 다시 fresh로 갱신해요.
여기에 한 가지 재미있는 동작이 더 있어요. 같은 URL에 대해 짧은 시간 안에 동시에 1000개의 cache miss가 발생했다고 해볼게요. CDN이 이걸 그대로 origin으로 던지면 origin이 죽어요. Cloudflare 문서가 설명하는 request collapsing(또는 cache lock)은 그 1000개 중 첫 요청만 origin으로 보내고, 나머지 999개는 첫 응답이 도착할 때까지 줄을 서서 같은 응답을 스트리밍 받아가요. origin이 받는 부담은 1번이에요.
stale-while-revalidate 디렉티브는 한 발 더 나가요. stale 응답을 사용자에게 즉시 돌려주면서, 백그라운드로 origin에 재검증 요청을 보내요. 사용자 입장에서 응답 latency가 0이고, 다음 요청은 갱신된 캐시로 돌아와요.
무효화는 어떻게 일어나는가
TTL이 지나기 전에 응답을 강제로 비워야 하는 순간이 있어요. 블로그 글 하나를 수정했는데 한 시간짜리 캐시 만료를 그대로 기다릴 수 없을 때요.
가장 단순한 건 URL purge예요. "이 URL의 캐시를 지금 비워라"를 API로 호출하면, Fastly의 경우 약 150ms 안에 전 세계 캐시 노드에 전파돼요. 빠릿한 편이에요.
다만 한 글을 수정하면 그 글의 본문 페이지 외에도 카테고리 페이지, RSS 피드, 홈 최신 글 목록 같은 게 같이 stale이 돼요. 그걸 일일이 URL로 비우면 누락이 잦거든요. 이때 Surrogate Key가 등장해요. 응답에 Surrogate-Key: post-42 category-tech feed처럼 토큰을 붙여두면, 나중에 post-42 키 하나로 그 토큰을 공유하는 모든 객체를 한 번에 비울 수 있어요.
purge에는 hard와 soft 두 종류가 있어요. hard purge는 즉시 캐시에서 제거예요. 사용자가 지금 요청을 보내면 cache miss가 나서 origin까지 가요. soft purge는 객체를 stale로만 표시해두고, stale-while-revalidate와 결합되면 사용자는 옛날 응답을 즉시 받고 백그라운드에서 갱신이 일어나요. 무중단 갱신이 이렇게 만들어져요.
한 걸음 더
여기까지 따라오면 CDN이 빠른 두 가지 이유가 한 그림에 들어와요. 거리가 짧아져서 RTT가 줄고, 두 번째 요청부터는 edge에서 끝나서 origin까지 갈 일이 없는 거예요. 그 둘을 가능하게 만드는 게 cache key, Cache-Control, ETag, Vary, 그리고 운영자가 명시적으로 거는 purge예요.
렌더링 전략 정리 글에서 SSG와 ISR이 왜 CDN 친화적인 전략인지 함께 보면, "어떤 응답을 캐시할 수 있게 만들 것인가"를 코드 레벨에서 결정하는 감이 더 분명해져요.
참고 자료
- MDN - HTTP caching
private/shared/managed cache 분류와 CDN의 위치 정의
- MDN - Cache-Control 헤더
max-age, s-maxage, immutable, stale-while-revalidate 디렉티브 정의
- MDN - ETag 헤더
If-None-Match 검증과 304 Not Modified 흐름
- MDN - Vary 헤더
cache key가 URL + Vary 헤더 조합으로 확장되는 동작
- Cloudflare - Default cache behavior
request collapsing(cache lock) 동작과 기본 TTL 정책
- Fastly - Cache purging concepts
URL purge, Surrogate Key, hard/soft purge 동작
- web.dev - Prevent unnecessary network requests with the HTTP Cache
자원 종류별 Cache-Control 권고 조합