인프라

배포 후 캐시 무효화: 무엇을 퍼지하고, 무엇을 하지 말아야 하는가

stale 엣지나 오리진 과부하 없이 매일 배포하세요. 애셋을, 절대 퍼지하지 않는 불변의 해시 키와 경로로 퍼지하는 가변의 HTML로 분류하세요.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

CDN 엣지 캐시는 여러분의 사이트가 빠른 이유이자 배포가 보이지 않는 이유입니다. 가까운 도시에서 10밀리초 만에 페이지를 제공하는 동일한 캐시가 여러분이 수정 사항을 배포한 후에도 한 시간 동안 지난주 버전을 계속 제공할 것입니다. 순진한 대응은 매 배포 시 모든 것을 퍼지(purge)하는 것이지만, 이는 캐시를 비우고 다음 트래픽의 물결을 여러분의 오리진(origin)으로 바로 보내게 됩니다. 이는 캐시가 흡수하기 위해 존재했던 바로 그 부하입니다. 실행 가능한 해답은 단 하나의 설정이 아닙니다. 그것은 애셋 타입(asset type)별로 선택된 작은 규칙들의 집합으로, 이 규칙에 따라 대부분의 바이트는 영원히 캐시되고 절대 퍼지되지 않으며, 실제로 변경된 소수의 파일만 무효화됩니다. 이 게시물에서는 이러한 규칙들, 이를 뒷받침하는 캐시 헤더(cache headers), 그리고 정밀한 작업을 수행하는 퍼지 호출(purge calls)에 대해 설명합니다.

배포 후 에지 캐시가 오래된 콘텐츠를 제공하는 이유

CDN은 보통 max-age가 포함된 Cache-Control 헤더를 통해 사용자가 지시한 대로 응답을 캐시합니다. 에지 노드에 사본이 생기면, 수명이 만료되거나 사용자가 명시적으로 제거할 때까지 해당 사본으로 응답합니다. 에지는 사용자가 배포했다는 사실을 알지 못합니다. 에지는 사용자의 git 기록이나 릴리스 파이프라인을 감시하지 않습니다. 에지의 관점에서는 아무 일도 일어나지 않았으므로, 가지고 있는 것을 계속 제공합니다.

이러한 격차가 바로 모든 문제의 원인입니다. 두 가지 실패 모드는 하나의 다이얼 양쪽 끝에 있습니다. 다이얼을 긴 캐시 수명과 높은 적중률 쪽으로 돌리면 배포가 사용자에게 도달하는 데 한 시간이 걸릴 수 있으며, 브라우저도 사본을 가지고 있다면 더 오래 걸릴 수 있습니다. 짧은 수명과 공격적인 제거 쪽으로 돌리면, 모든 배포가 오리진에 콜드 트래픽을 쏟아붓고 적중률은 떨어지며, 더 자주 배포할수록 지연 시간 수치는 악화됩니다. 어느 쪽도 여러분이 원하는 지점은 아닙니다.

해결책은 사이트를 하나의 캐시 가능한 대상으로 취급하는 것을 멈추는 것입니다. 파일마다 변경되는 주기가 다르므로 각기 다른 규칙을 적용해야 합니다.

애셋을 불변(immutable)과 가변(mutable)으로 분리하기

콘텐츠 사이트가 제공하는 거의 모든 파일은 두 가지 버킷 중 하나에 속하며, 이 분리가 나머지 전략을 간단하게 만듭니다.

첫 번째 버킷은 일단 빌드되면 절대 변경되지 않는 콘텐츠입니다. 번들된 JavaScript 파일, 스타일시트, 글꼴, 처리된 이미지 등이 해당됩니다. 바이트는 고정되어 있습니다. 소스를 편집하면 빌드는 다른 파일을 생성하며, 기존 파일을 직접 수정하는 것보다 새로운 파일로 제공하는 것이 더 좋습니다.

두 번째 버킷은 고정된 주소에서 내용이 변경되는 콘텐츠입니다. HTML 페이지가 가장 명확한 예입니다. URL /blog/some-post는 계속 작동해야 하며 해당 게시물의 현재 버전을 계속 가리켜야 합니다. 오타를 수정해도 주소는 변경되지 않지만 그 뒤에 있는 바이트는 변경됩니다.

이 두 버킷은 서로 반대되는 캐시 규칙을 필요로 합니다. 아래 표는 전체 전략을 한 페이지에 요약한 것이며, 이후 게시물 내용에서 각 행을 설명합니다.

애셋 유형 주소 스타일 Cache-Control 배포 작업
JS, CSS, 글꼴, 빌드된 이미지 해시된 이름, app.a1b2c3.js public, immutable, max-age=31536000 없음, 새 빌드는 새 이름이 됨
렌더링된 HTML 페이지 고정 경로, /blog/post public, max-age=60, stale-while-revalidate=86400 변경된 경로만 퍼지(purge)
API 및 JSON 조각 고정 경로 public, max-age=30, stale-while-revalidate=300 서로게이트 키(surrogate key)로 퍼지
사이트맵, 피드 고정 경로 public, max-age=300 게시 시 퍼지
사용자별 응답 고정 경로 private, no-store 에지(edge)에서 절대 캐시되지 않음

한 줄 요약: 불변 콘텐츠는 1년 동안 캐시되고 절대 퍼지되지 않으며, 가변 콘텐츠는 짧게 캐시되고 정밀하게 퍼지됩니다. 바이트의 거의 대부분이 불변이기 때문에, 트래픽의 거의 대부분은 퍼지를 필요로 하지 않아야 합니다.

불변 자산: 이름을 해시하고 절대 제거하지 않기

첫 번째 버킷의 비결은 파일 콘텐츠의 해시를 이름에 넣는 것입니다. 빌드 단계에서 app.js의 최종 바이트를 읽고, 짧은 다이제스트를 계산한 다음, app.a1b2c3d4.js를 생성합니다. 이를 로드하는 HTML은 바로 그 정확한 이름을 참조합니다. 소스 한 줄만 변경해도 다이제스트가 변경되므로, 다음 빌드에서는 app.e5f6g7h8.js가 생성되고 HTML은 대신 그 파일을 가리키게 됩니다.

이름이 콘텐츠에서 파생되기 때문에, 특정 이름은 항상 정확히 하나의 바이트 집합만을 의미할 수 있습니다. 이를 통해 웹이 가진 가장 강력한 캐시 헤더를 보낼 수 있습니다:

Cache-Control: public, immutable, max-age=31536000

max-age=31536000은 1년입니다. immutable 지시어는 브라우저에게 새로고침 시 재검증할 필요가 없다고 알려줍니다. 왜냐하면 이 이름의 바이트는 이름이 존재하는 동안 변경되지 않을 것이라는 약속이 진정으로 사실이기 때문입니다. CDN은 파일을 한 번 캐시하고 사용되지 않아 제거될 때까지 엣지에서 제공합니다. 오리진 서버는 해당 파일에 대한 반복 트래픽을 거의 보지 못합니다.

배포 시 얻는 이점은 이러한 파일을 절대 제거하지 않는다는 것입니다. 배포는 app.a1b2c3d4.js를 덮어쓰지 않습니다. 새로운 이름으로 새 파일을 제공하고 HTML 참조를 업데이트합니다. 새 이름은 어떤 캐시에도 없었으므로 정리할 오래된 데이터가 없으며, 이전 이름은 여전히 해당 파일을 요청하는 모든 페이지에 이전 바이트를 계속 제공합니다. 가장 큰 자산에 대한 캐시 무효화는 더 이상 수행하는 작업이 아니게 됩니다. 이름의 의미가 절대 변하지 않기 때문에 발생할 수 없는 상태가 됩니다.

변경 가능한 HTML: 짧은 TTL과 타겟 퍼지

HTML 페이지는 링크, 북마크, 검색 엔진을 위해 주소가 안정적으로 유지되어야 하므로 해시된 이름을 사용할 수 없습니다. 그래서 정반대의 처리를 받습니다. 즉, 짧은 캐시 수명과 함께 변경된 특정 경로에 대한 명시적인 퍼지를 적용합니다.

짧은 TTL만으로도 작동할 수 있지만, 그 자체만으로는 최신성과 오리진 부하 사이에서 선택을 강요합니다. stale-while-revalidate는 이러한 긴장감을 해소하며, 아래 섹션에서 이에 대해 다룹니다. 현재 헤더는 다음과 같습니다.

Cache-Control: public, max-age=60, stale-while-revalidate=86400

max-age=60은 에지가 1분 동안 페이지를 최신으로 취급한다는 것을 의미하며, 이는 인기 있는 URL로 몰리는 폭발적인 트래픽을 흡수합니다. 하지만 실제 배포가 반영되기까지 1분은 기다리기에는 너무 긴 시간이므로, 만료에 의존하지 않습니다. 배포가 끝나면 변경한 정확한 경로를 퍼지합니다.

퍼지는 특정 캐시된 객체를 삭제하는 CDN의 API에 대한 인증된 호출입니다. 그 형태는 제공업체마다 동일합니다. 즉, 삭제할 대상을 지정합니다.

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": [
        "https://example.com/blog/some-post",
        "https://example.com/blog/some-post/"
      ]}'

중요한 단어는 '타겟팅된(targeted)'입니다. 전체 영역(zone)이 아니라 이번 배포에서 변경된 페이지만 퍼지(purge)합니다. 릴리스에서 세 개의 게시물과 인덱스 페이지가 변경되었다면, 네 개의 URL을 퍼지합니다. 다른 모든 캐시된 페이지는 활성 상태(hot)로 유지되므로, 배포는 나머지 트래픽에 보이지 않으며 오리진(origin) 서버는 배포가 일어났다는 사실을 전혀 인지하지 못합니다.

문제는 어떤 경로가 변경되었는지 알아야 한다는 것입니다. 빌드에서 이미 매니페스트(manifest)를 생성한다면, 마지막 두 빌드 결과물의 diff를 통해 변경된 집합을 얻을 수 있습니다. 그렇지 않다면, 다음 섹션에서는 URL을 전혀 열거하지 않고 퍼지하는 방법을 제공합니다.

URL이 아닌 서로게이트 키로 퍼지하기

하나의 변경 사항이 여러 페이지에 퍼져나갈 때 URL로 퍼지하는 방식은 제대로 작동하지 않습니다. 저자 이름을 수정하면 해당 저자의 모든 게시물, 저자 페이지, 그리고 몇몇 태그 페이지를 새로고침해야 할 수 있습니다. 이러한 URL을 직접 나열하는 것은 깨지기 쉽고 실수하기도 쉽습니다.

서로게이트 키는 이 문제를 해결합니다. 오리진이 응답을 렌더링할 때, 하나 이상의 레이블로 응답에 태그를 지정하는 헤더를 첨부합니다. 대부분의 CDN은 Surrogate-Key와 같은 헤더나 캐시 태그 헤더를 읽고 모든 캐시된 객체를 태그별로 인덱싱합니다.

Surrogate-Key: post-1024 author-42 tag-infra

게시물 페이지에는 자체 ID, 작성자 ID, 태그가 포함됩니다. 나중에 작성자가 변경되면, 키 하나를 제거하면 CDN은 그 수가 얼마이든 상관없이 해당 키를 가진 모든 객체를 삭제합니다:

curl -X POST "https://api.cdn.example/v1/zones/$ZONE/purge" \
  -H "Authorization: Bearer $CDN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["author-42"]}'

더 이상 어떤 URL이 영향을 받는지 추적하지 않습니다. 응답이 의존하는 것을 기준으로 응답에 태그를 지정하고, 변경된 의존성을 기준으로 퍼지(purge)합니다. 이렇게 하면 영향 범위가 넓을 때도 퍼지를 정확하게 유지할 수 있으며, 배포 단계에서 URL 구조에 대해 추론할 필요가 없습니다. 각 페이지에 어떤 데이터가 들어갔는지 이미 알고 있는 오리진(origin)은 이러한 의존성을 선언하기에 적합한 곳입니다.

배포 마지막 단계에서 퍼지 자동화

사람이 기억해야 하는 퍼지는 건너뛰기 쉬우며, 건너뛴 퍼지는 오래된 사이트가 되어 고장난 배포처럼 보이게 됩니다. 해결책은 새 버전이 라이브된 후, 파이프라인의 마지막 단계에서 퍼지가 자동으로 실행되도록 하는 것입니다.

순서가 중요합니다. 먼저 새 오리진을 배포하고, 서비스 중인지 확인한 다음, 퍼지합니다. 새 버전이 라이브되기 전에 퍼지하면 엣지는 오리진에서 이전 바이트를 다시 가져와 다시 캐시하므로, 잠시 추가 부하가 발생하는 것 외에는 아무것도 얻을 수 없습니다. 이후에 퍼지하면 삭제된 각 경로에 대한 다음 요청은 엣지에서 캐시를 찾지 못하고(misses the edge), 새로운 오리진에 도달하여 새 버전을 다시 캐시합니다.

# 1. deploy and wait for the new revision to receive traffic
deploy_release

# 2. compute the changed paths from the build manifest diff
CHANGED=$(diff_manifest build/prev build/current)

# 3. purge only those paths, at the very end
purge_paths "$CHANGED"

배포에 퍼지를 연결하면 무엇이 무효화되었는지 확인할 수 있는 신뢰할 수 있는 단일 출처를 확보하게 됩니다. 퍼지된 경로 또는 키를 기록하세요. 누군가 페이지가 왜 업데이트되었는지, 또는 왜 업데이트되지 않았는지 물어볼 때, 그 답은 누군가의 기억이 아니라 배포 로그에 있습니다.

재검증하는 동안 오래된 콘텐츠 제공

stale-while-revalidate는 지연 시간으로 인한 비용을 치르지 않고 TTL을 짧게 유지할 수 있게 해주는 것입니다. 이는 위 HTML 헤더에 나타나며, 잦은 만료를 두려워할 마지막 이유를 없애주기 때문에 그 자체로도 이해할 가치가 있습니다.

Cache-Control: public, max-age=60, stale-while-revalidate=86400

처음 60초 동안 에지는 캐시된 페이지를 최신(fresh) 상태로 제공합니다. 그 후 다음 하루 동안 에지는 백그라운드에서 오리진으로부터 새 사본을 가져오는 동안 오래된(stale) 사본을 즉시 제공할 수 있습니다. 새로고침을 트리거한 사용자는 오리진을 기다리지 않습니다. 그들은 약간 오래된 페이지를 에지 속도로 받고, 다음 방문자는 업데이트된 페이지를 받게 됩니다. 신선도는 전체 오리진 왕복 시간 대신 요청 한 번만큼 지연됩니다.

이는 짧은 max-age가 어떻게 느껴지는지를 바꿉니다. 이것이 없으면 60초의 TTL은 URL당 1분에 한 명의 운 나쁜 사용자가 오리진을 기다려야 함을 의미합니다. 이것이 있으면 그 사용자는 즉각적인 응답을 받고 오리진 리필은 대역 외(out of band)에서 발생합니다. 거의 실시간에 가까운 신선도와 사용자 측에서는 거의 0에 가까운 오리진 지연 시간을 동시에 얻을 수 있습니다. 명시적인 퍼지(purge)는 필요할 때 즉각적인 새로고침을 강제하므로 이 둘은 함께 작동합니다. 즉, 퍼지는 배포를 처리하고, stale-while-revalidate는 배포 사이의 느린 변경 사항(drift)을 처리합니다.

장단점, 그리고 전체 퍼지가 괜찮은 경우

이 중 어느 것도 공짜는 아닙니다. 해시 처리된 불변의 이름은 빌드 파이프라인이 다이제스트를 계산하고, HTML의 모든 참조를 다시 작성하며, 이전 버전을 아직 로드 중인 페이지가 깨지지 않도록 이전 파일을 충분히 오래 보관해야 함을 의미합니다. 이는 실질적인 복잡성이며, 빌드 도구에 영원히 남게 됩니다.

타겟 퍼징(Targeted purging)에도 자체적인 비용이 따릅니다. 무엇이 변경되었는지 알아야 합니다. 빌드에서 diff할 수 있는 매니페스트를 생성하거나, 오리진(origin)이 올바르게 유지 관리하는 서로게이트 키(surrogate key)로 응답에 태그를 지정해야 합니다. 오래된 태그나 누락된 매니페스트 항목은 페이지가 조용히 새로고침되지 않음을 의미하며, 이는 전체적으로 뒤처진 사이트보다 알아차리기 더 어렵습니다.

이러한 비용과 비교되는 것이 전체 퍼지이며, 전체 퍼지에는 이 중 어느 것도 필요하지 않습니다. 한 번의 호출로 영역(zone)이 지워지고 모든 것이 최신 상태임이 보장됩니다. 몇 가지 경우에는 이것이 정직하고 올바른 해답입니다. 트래픽이 적은 소규모 사이트는 아무도 눈치채지 못하게 콜드 오리진 히트(cold origin hit)를 감당할 수 있습니다. 제공되어서는 안 되는 페이지를 내리는 것과 같은 긴급 상황에서는 다소 무식한 방법이라도 사용할 가치가 있습니다. 그리고 모든 페이지에 실제로 영향을 미치는 마이그레이션은 정밀함에서 얻을 것이 없습니다.

유효한 규칙은 다음과 같습니다. 대부분의 바이트를 불변(immutable)으로 만들어 퍼지가 전혀 필요 없게 하고, 변경 가능한 나머지 부분은 경로 또는 키로 퍼지하며, 상황이 신경 쓸 필요가 없을 만큼 작거나 생각할 겨를이 없을 만큼 긴급할 때 사용하는 도구로 전체 퍼지를 유지하는 것입니다. 일일 배포는 몇 개의 경로에만 영향을 미치고 나머지 캐시는 배포하기 직전과 똑같이 웜(warm) 상태로 두어야 합니다.

관련 글