본문 바로가기

CDN

빛의 속도가 정하는 지연의 하한에서 출발해, 사용자와 가까운 엣지에 응답을 복사해 두는 CDN의 동작과 원본 실드의 효과를 계산합니다. 공유 캐시와 브라우저가 같은 응답을 다르게 오래 쓰는 이유, 배포한 코드를 사용자에게 바로 보이게 하는 무효화 전략, 개인화된 응답을 캐시하는 위험과 운영 지표를 정리합니다.

지금까지 네트워크의 계층마다 프로토콜이 어떻게 동작하는지 보았습니다. 이 장에서는 실제 서비스를 운영할 때 그 위에 쌓는 인프라를 다룹니다. 사용자와 가까운 곳에서 응답하는 CDN, 트래픽을 여러 서버로 나누는 로드 밸런서, 요청을 대신 전하는 프록시와 사설망을 잇는 VPN, 그리고 클라우드와 컨테이너의 네트워크입니다. 첫 글은 CDN(content delivery network)입니다.

거리는 지연의 하한을 정한다

빛은 광섬유 안에서 진공에서보다 느려 초당 약 20만 km를 갑니다. 서울에서 지구 반대편에 가까운 18,000km 떨어진 곳까지 직선으로 케이블이 깔려 있더라도, 왕복에는 2 × 18,000 ÷ 200,000 = 0.18초, 곧 180ms가 걸립니다. 실제 해저 케이블은 직선이 아니고 라우터마다 처리 시간이 더해지므로 이보다 깁니다. 1장 2편에서 본 것처럼 이 지연은 대역폭을 늘린다고 줄지 않습니다.

이 지연은 한 번으로 끝나지 않습니다. 10장 2편에서 본 것처럼 TCP와 TLS 1.3의 핸드셰이크를 마치고 첫 응답을 받기까지 RTT 세 번이 필요하므로, RTT가 180ms라면 첫 응답까지 0.5초가 넘게 걸립니다. 같은 응답을 RTT가 10ms인 가까운 서버에서 받는다면 30ms면 됩니다. CDN은 전 세계 곳곳에 엣지(edge) 서버를 두고 응답의 복사본을 캐시해, 사용자와 가까운 엣지에서 답하게 함으로써 이 거리를 줄입니다.

여기서 가깝다는 것은 지도의 거리만이 아닙니다. CDN은 8장 3편의 GeoDNS로 사용자의 리졸버에 가까운 엣지의 주소를 알려 주거나, 7장 3편의 애니캐스트로 여러 엣지가 같은 주소를 알려 BGP가 가장 가까운 곳으로 보내게 합니다. 통신사와의 피어링 관계, 엣지의 부하도 어느 엣지가 답할지에 영향을 줍니다.

엣지가 답하고, 없으면 원본에 묻는다

요청이 엣지에 도착하면 엣지는 캐시에 맞는 응답이 있는지 찾습니다. 찾는 열쇠는 보통 호스트와 경로, 쿼리이고, 응답에 Vary가 있으면 그 요청 헤더도 열쇠에 들어갑니다. 신선한 응답이 있으면 그대로 돌려주고(적중, hit), 없거나 오래되었으면 원본(origin) 서버에 가져오거나 확인합니다(미스, miss). 재사용을 판단하는 규칙은 9장 4편의 HTTP 캐시 규칙과 같습니다.

공유 캐시가 저장된 응답을 재사용하는 분기

캐시 키와 Vary가 맞는 저장 응답을 찾습니다. 304의 메타데이터를 반영하고 저장 본문을 사용합니다. 각 단계의 값과 전환 이유를 비교합니다.

CDN · 공유 캐시 판단

저장본이 있어도, 즉시 재사용할 수 있는지 판단합니다

캐시 키·Vary가 맞는 저장 응답과 신선도·지시문을 확인합니다. no-cache는 재검증을 요구하며, 저장본이 없으면 가져옵니다. stale 확장 정책은 아래 기본 분기에서 제외합니다.

공유 캐시가 저장된 응답을 재사용하는 분기캐시 키가 일치하는 저장 응답을 찾은 뒤, 신선도와 요청·응답 지시문이 즉시 재사용을 허용하는지 판단하는 기본 흐름입니다. no-cache는 재검증을 요구하며, 저장 응답이 없으면 응답을 가져옵니다. stale 확장 정책은 이 기본 분기에서 제외합니다.캐시 키로 조회즉시 재사용가능?예아니요재검증 또는 가져오기상위 캐시 · 원본 서버저장본으로 응답
공유 캐시가 저장된 응답을 재사용하는 분기캐시 키가 일치하는 저장 응답을 찾은 뒤, 신선도와 요청·응답 지시문이 즉시 재사용을 허용하는지 판단하는 기본 흐름입니다. no-cache는 재검증을 요구하며, 저장 응답이 없으면 응답을 가져옵니다. stale 확장 정책은 이 기본 분기에서 제외합니다.캐시 키 · Vary저장 응답 확인지금 저장본을재사용해도되는가?응답저장본 사용재검증 또는가져오기상위 캐시 · 원본예아니요

청록색 예 경로는 저장본의 즉시 재사용입니다. 아니요는 상위 캐시 또는 원본에서 재검증·가져오기를 진행합니다. 재검증의 304·새 응답·오류 분기는 이 기본 그림에서 생략했습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 저장본 조회부터 본문 재사용까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장본 조회

캐시 키와 Vary가 맞는 저장 응답을 찾습니다.

  • 공유 캐시·지시 1: stored response
  • 공유 캐시·지시 2: current_age1200
  • 공유 캐시·지시 3: max-age3600
  • 현재 나이 1200초인 저장본이 존재
재사용 판단

신선한 응답에도 no-cache 지시를 적용합니다.

  • 공유 캐시·지시 1: 1200<3600
  • 공유 캐시·지시 2: no-cache
  • 1200 < 3600이어도 검증 없는 즉시 재사용은 불가
조건부 검증

저장 ETag로 상위 캐시나 원본에 검증합니다.

  • 공유 캐시·지시 1: 상위 캐시/원본
  • 공유 캐시·지시 2: ETag 일치 304
  • 일치한 304로 성공 검증; 저장 금지인 no-store와 구별
본문 재사용

304의 메타데이터를 반영하고 저장 본문을 사용합니다.

  • 공유 캐시·지시 1: 기존 본문 사용
  • 공유 캐시·지시 2: 304 새 본문 없음
  • 304에 새 본문은 없음; stale 확장 경로는 이 기본 예 밖

공유 캐시가 저장된 응답을 재사용하는 분기

캐시 키와 Vary가 맞는 저장 응답을 찾습니다. 304의 메타데이터를 반영하고 저장 본문을 사용합니다. 각 단계의 값과 전환 이유를 비교합니다.

CDN · 공유 캐시 판단

저장본이 있어도, 즉시 재사용할 수 있는지 판단합니다

캐시 키·Vary가 맞는 저장 응답과 신선도·지시문을 확인합니다. no-cache는 재검증을 요구하며, 저장본이 없으면 가져옵니다. stale 확장 정책은 아래 기본 분기에서 제외합니다.

공유 캐시가 저장된 응답을 재사용하는 분기캐시 키가 일치하는 저장 응답을 찾은 뒤, 신선도와 요청·응답 지시문이 즉시 재사용을 허용하는지 판단하는 기본 흐름입니다. no-cache는 재검증을 요구하며, 저장 응답이 없으면 응답을 가져옵니다. stale 확장 정책은 이 기본 분기에서 제외합니다.캐시 키로 조회즉시 재사용가능?예아니요재검증 또는 가져오기상위 캐시 · 원본 서버저장본으로 응답
공유 캐시가 저장된 응답을 재사용하는 분기캐시 키가 일치하는 저장 응답을 찾은 뒤, 신선도와 요청·응답 지시문이 즉시 재사용을 허용하는지 판단하는 기본 흐름입니다. no-cache는 재검증을 요구하며, 저장 응답이 없으면 응답을 가져옵니다. stale 확장 정책은 이 기본 분기에서 제외합니다.캐시 키 · Vary저장 응답 확인지금 저장본을재사용해도되는가?응답저장본 사용재검증 또는가져오기상위 캐시 · 원본예아니요

청록색 예 경로는 저장본의 즉시 재사용입니다. 아니요는 상위 캐시 또는 원본에서 재검증·가져오기를 진행합니다. 재검증의 304·새 응답·오류 분기는 이 기본 그림에서 생략했습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 저장본 조회부터 본문 재사용까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장본 조회

캐시 키와 Vary가 맞는 저장 응답을 찾습니다.

  • 공유 캐시·지시 1: stored response
  • 공유 캐시·지시 2: current_age1200
  • 공유 캐시·지시 3: max-age3600
  • 현재 나이 1200초인 저장본이 존재
재사용 판단

신선한 응답에도 no-cache 지시를 적용합니다.

  • 공유 캐시·지시 1: 1200<3600
  • 공유 캐시·지시 2: no-cache
  • 1200 < 3600이어도 검증 없는 즉시 재사용은 불가
조건부 검증

저장 ETag로 상위 캐시나 원본에 검증합니다.

  • 공유 캐시·지시 1: 상위 캐시/원본
  • 공유 캐시·지시 2: ETag 일치 304
  • 일치한 304로 성공 검증; 저장 금지인 no-store와 구별
본문 재사용

304의 메타데이터를 반영하고 저장 본문을 사용합니다.

  • 공유 캐시·지시 1: 기존 본문 사용
  • 공유 캐시·지시 2: 304 새 본문 없음
  • 304에 새 본문은 없음; stale 확장 경로는 이 기본 예 밖

엣지가 수백 곳이라면, 같은 파일이라도 엣지마다 처음 한 번씩은 원본에 가져와야 하므로 원본이 받는 요청도 그만큼 늘어납니다. 이를 줄이려고 엣지와 원본 사이에 한 층의 캐시를 더 두는데, 이를 원본 실드(origin shield)나 계층형 캐시라고 합니다. 엣지에서 미스가 나면 원본이 아니라 실드에 묻고, 실드에도 없을 때만 원본에 갑니다. 다음 코드는 일곱 번의 요청이 네 곳의 엣지에 들어올 때 원본이 받는 요청의 수를 비교합니다.

def origin_requests(requests_by_edge, shield):
    cached_edges, shield_cached, origin = set(), False, 0
    for edge in requests_by_edge:                         # 요청이 들어온 엣지의 순서
        if edge in cached_edges:
            continue                                      # 엣지에 있으면 엣지에서 끝난다
        cached_edges.add(edge)
        if not shield:
            origin += 1                                   # 엣지마다 처음 한 번은 원본에 간다
        elif not shield_cached:
            origin += 1                                   # 실드에도 없을 때만 원본에 간다
            shield_cached = True
    return origin

requests = ["서울", "도쿄", "서울", "싱가포르", "도쿄", "프랑크푸르트", "서울"]
print("실드 없이 원본 요청:", origin_requests(requests, shield=False))
print("실드를 두면 원본 요청:", origin_requests(requests, shield=True))
print("엣지 적중률:", f"{1 - len(set(requests)) / len(requests):.0%}")
실드 없이 원본 요청: 4
실드를 두면 원본 요청: 1
엣지 적중률: 43%

일곱 번 가운데 세 번은 이미 캐시한 엣지에 와서 적중했고, 엣지 네 곳의 첫 요청은 미스였습니다. 실드가 없으면 그 네 번이 모두 원본에 가지만, 실드를 두면 처음 한 번만 원본에 가고 나머지 세 엣지는 실드에서 받아 갑니다. 인기 있는 파일의 캐시가 한꺼번에 만료되면 수많은 요청이 동시에 원본으로 몰릴 수 있는데, CDN은 같은 파일에 대한 동시의 미스를 하나로 묶어 원본에 한 번만 묻는 요청 병합(request collapsing)으로 이를 막기도 합니다.

공유 캐시와 브라우저의 수명은 다르다

CDN은 여러 사용자가 함께 쓰는 공유 캐시이므로, 브라우저의 사설 캐시와 다른 수명을 줄 수 있습니다. s-maxage는 공유 캐시에만 적용되는 신선한 시간으로, 공유 캐시에서는 max-age보다 우선합니다(RFC 9111).

Cache-Control: max-age=86400                  → 신선한 시간 24시간
Cache-Control: max-age=86400, s-maxage=3600   → 브라우저는 24시간, CDN은 1시간
Cache-Control: no-cache                       → 저장해도 되지만 쓰기 전에 확인한다
Cache-Control: no-store                       → 저장하지 않는다
Cache-Control: max-age=60, stale-while-revalidate=30   → 만료 뒤 30초 동안은 예전 응답을 먼저 준다

max-age=86400, s-maxage=3600인 응답의 나이가 지금 2,400초라면, CDN에게는 3,600 − 2,400 = 1,200초가 남았고 브라우저에게는 86,400 − 2,400 = 84,000초가 남았습니다. 같은 응답인데 CDN은 20분 뒤에 원본에 확인하고 브라우저는 하루 가까이 그대로 씁니다. CDN의 내용은 운영자가 퍼지로 지울 수 있지만 사용자의 브라우저에 든 사본은 지울 방법이 없으므로, 자주 바뀌는 응답에는 브라우저 쪽 수명을 짧게 두고 CDN 쪽만 길게 두는 조합을 씁니다.

더 깊이: CDN에게만 따로 말하는 CDN-Cache-Control

s-maxage는 CDN만이 아니라 회사의 프록시처럼 경로 위의 모든 공유 캐시에 적용됩니다. 그래서 CDN에서는 오래 캐시하되 다른 공유 캐시에서는 짧게 두고 싶다면 Cache-Control만으로는 나누어 말할 수 없습니다. RFC 9213은 이를 위해 특정 캐시에게만 지시를 보내는 대상 지정 필드(targeted field)라는 방식을 정하고, 그 첫 번째로 CDN의 캐시를 가리키는 CDN-Cache-Control을 정의했습니다.

RFC의 예처럼 Cache-Control: max-age=60, s-maxage=120과 CDN-Cache-Control: max-age=600을 함께 보내면, CDN은 응답을 600초 동안 신선하다고 보고 다른 공유 캐시는 120초, 브라우저는 60초를 씁니다. 이 필드를 아는 캐시는 자기가 따를 대상 지정 필드의 이름을 우선순위대로 적은 목록(target list)을 두고, 응답에 있는 필드 가운데 이 목록에서 가장 앞서고 값이 올바른 것 하나를 따르며, 그때는 Cache-Control과 Expires를 무시합니다. 그래서 CDN 사업자는 ExampleCDN-Cache-Control처럼 자기 이름의 필드를 따로 정해 이 목록에서 CDN-Cache-Control보다 앞에 둘 수도 있습니다. 값은 구조화 필드(structured field)의 사전 형식으로 적고, 비어 있거나 해석할 수 없는 필드는 없는 것처럼 다룹니다.

배포한 코드를 바로 보이게 하는 법

새 코드를 배포했는데 CDN이 예전 파일을 계속 주면 사용자는 예전 버전을 봅니다. 이를 다루는 방법은 여러 가지입니다.

방법하는 일조건
파일 이름에 해시 넣기내용이 바뀌면 URL도 바뀌게 한다그 URL을 가리키는 HTML도 함께 새로 배포해야 한다
퍼지(purge)CDN의 API로 캐시를 지운다지우는 범위와 전 세계에 퍼지는 시간이 CDN마다 다르다
캐시 태그관련된 응답에 태그를 붙여 한꺼번에 지운다CDN이 지원해야 하고 태그를 설계해야 한다
짧은 TTL신선한 시간을 짧게 둔다원본에 묻는 요청이 늘어난다
버전 쿼리?v=2처럼 URL을 바꾼다쿼리가 CDN의 캐시 열쇠에 들어가는지 확인해야 한다

가장 확실한 것은 파일 이름에 내용의 해시를 넣는 방법입니다. 빌드 도구가 파일 내용의 해시로 이름을 만들면, 내용이 같은 동안은 이름도 같아 오래 캐시해도 되고, 한 글자라도 바뀌면 새 이름이 되어 캐시와 상관없이 새로 받습니다.

import hashlib

def hashed_name(name, content):
    digest = hashlib.sha256(content).hexdigest()[:8]      # 내용이 바뀌면 이름도 바뀐다
    stem, ext = name.rsplit(".", 1)
    return f"{stem}.{digest}.{ext}"

print(hashed_name("main.js", b"abc"))
main.ba7816bf.js

내용이 abc인 파일의 SHA-256은 10장 1편에서 본 시험값 ba7816bf...로 시작하므로 이름이 main.ba7816bf.js가 됩니다. 이런 파일은 Cache-Control: public, max-age=31536000, immutable로 1년 동안 캐시하고, 그 이름을 알려 주는 HTML은 no-cache로 매번 확인하게 합니다. 그러면 HTML만 새로 받으면 새 파일을 쓰게 되므로 퍼지가 거의 필요 없습니다.

개인화된 응답을 캐시하는 위험

CDN은 정적 파일만이 아니라 API 응답도 캐시할 수 있고, 엣지에서 요청을 고치거나 이미지 크기를 바꾸는 코드를 돌리기도 합니다. 그만큼 잘못 캐시했을 때의 피해도 큽니다. 사용자마다 다른 응답이 공유 캐시에 저장되면, 한 사용자의 개인정보가 다른 사용자에게 갑니다. 로그인한 사용자의 페이지나 Set-Cookie가 붙은 응답에는 Cache-Control: private이나 no-store를 붙이고, CDN의 설정이 이를 지키는지 확인해야 합니다.

캐시 규칙이 원본의 동작과 어긋나는 곳을 노리는 공격도 있습니다. CDN이 .css로 끝나는 경로는 정적 파일로 보고 캐시하도록 설정되어 있고, 원본은 /account/profile.css처럼 경로 뒤에 붙은 부분을 무시하고 /account/profile 페이지를 돌려준다고 합시다. 공격자가 피해자에게 이 주소를 열게 하면, 피해자의 개인 페이지가 .css 파일로 캐시되고 공격자는 같은 주소로 그 페이지를 받아 갑니다. 웹 캐시 기만(web cache deception)이라고 부르는 이 공격을 막으려면, CDN이 확장자가 아니라 원본이 보낸 Cache-Control을 기준으로 캐시하게 하고, 원본은 없는 경로에 다른 페이지를 돌려주지 않아야 합니다.

대표적인 서비스와 운영 지표

서비스특징주로 쓰는 곳
CloudflareCDN과 DDoS 방어, WAF, DNS를 함께 제공한다범용
Amazon CloudFrontAWS의 다른 서비스와 묶어 쓴다AWS 위의 서비스
Akamai오래된 대형 글로벌 CDN대기업
Fastly빠른 퍼지와 엣지에서 돌리는 코드API와 동적 콘텐츠
Vercel, Netlify프론트엔드 배포와 CDN을 묶었다정적 사이트와 프론트엔드

요금과 무료 사용량은 요금제와 시점에 따라 달라지므로 Cloudflare, CloudFront, Fastly, Vercel, Netlify의 현재 조건을 직접 확인해야 합니다.

운영할 때는 적중률 하나만 보지 않습니다. 요청 수로 센 적중률과 바이트로 센 적중률은 다르고, 정말 줄여야 하는 것은 원본이 받는 요청의 수와 양입니다. 응답의 Age 헤더는 그 응답이 캐시에 얼마나 머물렀는지를, CDN마다 붙이는 cf-cache-status나 x-cache 같은 헤더는 적중했는지를 알려 주므로, 사용자가 예전 버전을 본다고 할 때 그 응답이 어디서 왔는지 이 헤더들로 확인합니다.

정리

  • 광섬유 안의 빛은 초당 약 20만 km를 가므로 먼 서버와의 RTT에는 줄일 수 없는 하한이 있고, 핸드셰이크마다 그 RTT가 되풀이됩니다.
  • CDN은 사용자와 가까운 엣지에 응답을 캐시해 거리를 줄이며, 가까운 엣지는 GeoDNS와 애니캐스트, 피어링과 부하로 정해집니다.
  • 엣지는 캐시 열쇠로 응답을 찾아 적중하면 바로 답하고, 미스이면 원본에 묻습니다. 원본 실드와 요청 병합은 원본에 가는 요청을 줄입니다.
  • s-maxage는 공유 캐시에만 적용되므로 CDN과 브라우저가 같은 응답을 다른 시간 동안 쓰며, 브라우저의 사본은 퍼지로 지울 수 없습니다.
  • 파일 이름에 내용의 해시를 넣고 HTML만 매번 확인하게 하면 퍼지 없이도 새 코드가 바로 보입니다.
  • 사용자마다 다른 응답은 private이나 no-store로 공유 캐시에서 빼고, 확장자로 캐시를 정하는 규칙이 개인 페이지를 캐시하지 않게 합니다.

연습 문제

1. 두 캐시의 남은 시간

Cache-Control: max-age=86400, s-maxage=3600인 응답의 나이가 지금 3,000초입니다. CDN과 브라우저에게 남은 신선한 시간은 각각 얼마인가요? 이 응답의 내용이 지금 바뀌었다면, 운영자가 CDN을 퍼지한 뒤에도 예전 내용을 보는 사용자가 있을까요?

해설 보기

CDN은 s-maxage를 따르므로 3,600 − 3,000 = 600초가 남았고, 브라우저는 max-age를 따르므로 86,400 − 3,000 = 83,400초가 남았습니다. CDN을 퍼지하면 엣지의 사본은 지워지지만, 이미 이 응답을 받아 둔 브라우저는 남은 83,400초 동안 서버에 묻지 않고 예전 내용을 씁니다. 자주 바뀌는 응답이라면 브라우저 쪽의 max-age를 짧게 두거나, 파일 이름이나 URL 자체를 바꾸는 방법을 써야 합니다.

2. 신선한데도 확인해야 할 때

캐시 열쇠와 Vary가 맞는 저장 응답의 나이가 1,200초이고, 응답에 max-age=3600, no-cache가 함께 있습니다. CDN은 이 응답을 바로 돌려줄 수 있나요? 저장해 둔 ETag로 조건부 요청을 보내 304를 받았다면 어떻게 하나요?

해설 보기

나이로는 아직 신선하지만, no-cache는 다시 쓰기 전에 반드시 확인하라는 뜻이므로 바로 돌려줄 수 없습니다. 조건부 요청에 304가 오면 저장한 응답이 지금도 같다는 뜻이므로, 304에 실린 헤더로 저장한 응답의 정보를 고치고 저장해 둔 본문을 돌려줍니다. 304에는 본문이 없으므로 새 본문을 기다릴 필요가 없습니다. 이 확인은 실드 같은 상위 캐시가 답할 수도 있어서 꼭 원본까지 가는 것은 아닙니다.

3. 캐시된 개인 페이지

어느 서비스의 CDN이 "확장자가 .css, .js, .png인 경로는 1일 동안 캐시한다"는 규칙을 쓰고, 원본의 웹 프레임워크는 /account/profile.css로 요청해도 /account/profile 페이지를 돌려줍니다. 공격자가 로그인한 피해자에게 /account/profile.css를 열게 하면 어떤 일이 생기나요? 어떻게 고쳐야 하나요?

해설 보기

피해자의 브라우저가 /account/profile.css를 요청하면 원본은 피해자의 개인 페이지를 돌려주고, CDN은 확장자만 보고 이를 하루 동안 캐시합니다. 공격자가 같은 주소를 요청하면 CDN이 캐시한 피해자의 개인 페이지를 받게 됩니다. CDN이 확장자가 아니라 원본의 Cache-Control을 따르도록 바꾸고, 원본은 개인 페이지에 Cache-Control: private, no-store를 붙이며, 없는 경로에는 다른 페이지 대신 404를 돌려주게 해야 합니다. 이 가운데 없는 경로에 404를 돌려주는 것은 그것만으로도 이 공격을 막습니다. 하지만 private, no-store는 CDN이 확장자 규칙으로 원본의 헤더를 무시하는 동안에는 소용이 없고, CDN이 원본의 Cache-Control을 따르게 해도 원본이 그 헤더를 보내지 않으면 CDN의 기본 TTL로 캐시될 수 있으므로, 세 가지를 함께 지켜야 안전합니다.

다음 글 로드 밸런서에서는 들어온 트래픽을 여러 서버로 나누는 로드 밸런서의 L4와 L7 방식, 분산 알고리즘과 상태 확인을 살펴봅니다.