안녕하세요! 오늘 함께 살펴볼 내용은 프론트엔드 개발자에게 너무나도 중요하고, 기술 면접에서도 단골로 등장하는 'HTTP 캐싱(HTTP Caching)'입니다.
우리가 Next.js나 React 기반 프로젝트를 진행하면서 SWR, Zustand 같은 상태 관리 라이브러리로 프론트엔드단에서 데이터를 캐싱하기도 하죠? 그것과 더불어, 브라우저가 네트워크 레벨에서 알아서 처리해 주는 이 'HTTP 캐싱' 메커니즘을 확실히 이해하고 계시면 다가올 면접이나 실무 성능 최적화에서 엄청난 강점을 가지게 될 거예요. 자, 원문 내용 하나하나 놓치지 않고 꼼꼼히, 그리고 알기 쉽게 설명해 드리겠습니다!
HTTP 캐시는 요청과 연관된 응답을 저장해 두었다가, 이후에 똑같은 요청이 발생했을 때 저장된 응답을 재사용하는 기술입니다.
응답을 재사용하면 여러 가지 엄청난 장점이 있어요. 첫째, 원본 서버(origin server)까지 요청을 다시 전달할 필요가 없기 때문에 클라이언트와 캐시 서버가 가까울수록 응답 속도가 훨씬 빨라집니다. 브라우저가 자체적으로 브라우저 요청에 대한 캐시를 저장하는 것이 가장 대표적인 예죠.
또한, 응답을 재사용할 수 있게 되면 원본 서버 입장에서는 요청을 처리할 필요가 없어집니다. 즉, 요청을 파싱하고 라우팅하거나, 쿠키를 기반으로 세션을 복원하거나, 결과를 얻기 위해 DB를 조회하거나, 템플릿 엔진을 렌더링하는 등의 복잡한 작업을 건너뛸 수 있죠. 결과적으로 서버의 부하를 획기적으로 줄여줍니다.
따라서 캐시가 제대로 작동하도록 설정하는 것은 시스템의 건강(health)을 유지하는 데 매우 중요합니다.
HTTP Caching 명세에 따르면, 캐시에는 크게 사설 캐시(private caches)와 공유 캐시(shared caches) 두 가지 주요 유형이 있습니다.
사설 캐시는 특정 단일 클라이언트(일반적으로 브라우저 캐시)에 묶여 있는 캐시를 말합니다. 저장된 응답이 다른 클라이언트와 공유되지 않기 때문에, 특정 유저만을 위한 개인화된 응답을 저장할 수 있습니다.
반대로, 만약 사설 캐시가 아닌 곳에 개인화된 콘텐츠가 저장된다면, 다른 사용자가 그 콘텐츠를 조회하게 될 수도 있고, 이는 의도치 않은 정보 유출을 유발할 수 있습니다.
💡 강사의 팁: 우리가 만들고 있는 독후감 사이트나 개인 프로필 페이지에서, '로그인한 본인만 볼 수 있는 내 정보'가 API 응답으로 온다고 가정해 볼까요? 이 데이터가 다른 사람에게 캐시되어 보이면 큰일 나겠죠! 이럴 때 바로 아래에 나오는
private디렉티브가 필수입니다.
만약 응답에 개인화된 콘텐츠가 포함되어 있고 이를 오직 사설 캐시에만 저장하고 싶다면, 반드시 private 디렉티브를 명시해야 합니다.
Cache-Control: private
개인화된 콘텐츠는 주로 쿠키에 의해 제어되지만, 쿠키가 존재한다고 해서 항상 그것이 사설(private)임을 의미하는 것은 아니며, 쿠키만으로는 응답을 완벽히 사설로 만들 수 없습니다.
공유 캐시는 클라이언트와 서버 사이에 위치하며, 여러 사용자 간에 공유될 수 있는 응답을 저장합니다. 공유 캐시는 다시 프록시 캐시(proxy caches)와 관리형 캐시(managed caches)로 세분화될 수 있습니다.
접근 제어(access control) 기능 외에도, 일부 프록시는 네트워크 밖으로 나가는 트래픽을 줄이기 위해 캐싱을 구현합니다. 이는 서비스 개발자가 직접 관리하는 부분이 아니기 때문에, 적절한 HTTP 헤더 등을 통해 제어해야 합니다. 하지만 과거에는 HTTP 캐싱 표준을 제대로 이해하지 못하는 구식 프록시 캐시 구현체들이 개발자들에게 종종 문제를 일으키곤 했습니다.
no-store와 같은 최신 HTTP 캐싱 명세의 디렉티브를 이해하지 못하는 "업데이트되지 않은 낡은 프록시 캐시" 구현체를 우회하기 위해, 과거에는 아래와 같이 모든 헤더를 다 때려 넣는 주방 싱크대(Kitchen-sink) 헤더가 사용되기도 했습니다.
Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate
하지만 최근 몇 년 동안 HTTPS가 널리 보급되고 클라이언트와 서버 간의 통신이 암호화되면서, 중간 경로에 있는 프록시 캐시는 응답을 단순히 터널링(전달)만 할 뿐 캐시로 동작하지 못하는 경우가 많아졌습니다. 따라서 이러한 시나리오에서는 응답의 내용조차 볼 수 없는 구식 프록시 캐시 구현체에 대해 걱정할 필요가 없어졌습니다.
반면, TLS 브릿지 프록시가 조직에서 관리하는 CA(인증 기관) 인증서를 PC에 설치하여 중간자(person-in-the-middle) 방식으로 모든 통신을 복호화하고 접근 제어 등을 수행한다면, 응답 내용을 보고 캐시하는 것이 가능합니다. 하지만 최근 CT(인증서 투명성)가 보편화되었고, 일부 브라우저는 SCT(서명된 인증서 타임스탬프)와 함께 발급된 인증서만 허용하기 때문에, 이러한 방식은 기업 정책(enterprise policy)이 적용된 환경에서만 가능합니다. 이렇게 엄격히 통제된 환경에서는 프록시 캐시가 "낡아서 업데이트되지 않았을" 걱정을 할 필요가 없습니다.
관리형 캐시는 원본 서버의 부하를 덜고 콘텐츠를 효율적으로 전송하기 위해 서비스 개발자가 명시적으로 배포하는 캐시입니다. 리버스 프록시(Reverse proxies), CDN(Content Delivery Networks), 그리고 Cache API와 결합된 서비스 워커(Service Workers) 등이 그 예시입니다.
💡 강사의 팁: Next.js를 Vercel에 배포해 보셨다면 CDN 캐싱이 얼마나 강력한지 이미 경험해 보셨을 겁니다! 정적 페이지나 이미지가 CDN 엣지(edge) 서버에 캐시되어 빛의 속도로 전송되는 것이 바로 이 '관리형 캐시'의 힘입니다.
관리형 캐시의 특성은 배포된 제품에 따라 다릅니다. 대부분의 경우 Cache-Control 헤더나 자체적인 설정 파일, 혹은 대시보드를 통해 캐시의 동작을 제어할 수 있습니다.
예를 들어, HTTP Caching 명세는 기본적으로 캐시를 '명시적으로 삭제'하는 방법을 정의하고 있지 않습니다. 하지만 관리형 캐시를 사용하면 대시보드 조작이나 API 호출, 재시작 등을 통해 저장된 응답을 언제든지 삭제할 수 있죠. 이를 통해 훨씬 더 능동적인 캐싱 전략을 구사할 수 있습니다.
또한 표준 HTTP 캐싱 명세를 무시하고 명시적인 조작을 우선시하는 것도 가능합니다. 예를 들어, 브라우저의 사설 캐시나 중간 프록시 캐시에서는 캐싱을 옵트아웃(거부)하도록 지정하면서도, 나만의 커스텀 전략을 사용해 오직 관리형 캐시에만 캐싱되도록 아래처럼 설정할 수도 있습니다.
Cache-Control: no-store
예를 들어, Varnish Cache는 캐시 저장을 처리하기 위해 VCL(Varnish Configuration Language, DSL의 일종) 로직을 사용하는 반면, Cache API와 결합된 서비스 워커는 JavaScript로 직접 캐시 로직을 작성할 수 있게 해줍니다.
이는 곧, 관리형 캐시가 의도적으로 no-store 디렉티브를 무시하더라도 그것이 표준을 "준수하지 않는" 것으로 여길 필요는 없다는 것을 의미합니다. 여러분이 해야 할 일은 지저분한 '주방 싱크대' 헤더를 쓰는 것을 피하고, 대신 여러분이 선택한 관리형 캐시 메커니즘의 문서를 주의 깊게 읽은 뒤, 해당 도구가 제공하는 올바른 방법으로 캐시를 꼼꼼히 제어하는 것입니다.
참고로 일부 CDN은 오직 해당 CDN에서만 유효한 자체적인 헤더(예: Surrogate-Control)를 제공하기도 합니다. 현재 이러한 것들을 표준화하기 위해 CDN-Cache-Control 헤더를 정의하는 작업이 진행 중입니다.
HTTP는 가능한 한 많은 것을 캐시하도록 설계되어 있습니다. 따라서 비록 Cache-Control 헤더가 주어지지 않았더라도, 특정 조건이 충족되면 응답이 저장되고 재사용됩니다. 이를 휴리스틱 캐싱(heuristic caching)이라고 부릅니다.
예를 들어, 아래의 응답을 살펴보겠습니다. 이 응답은 무려 1년 전에 마지막으로 업데이트되었습니다.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2021 22:22:22 GMT
<!doctype html>
…
"1년 내내 단 한 번도 업데이트되지 않은 콘텐츠라면, 앞으로도 당분간은 업데이트되지 않을 것이다"라고 경험적(휴리스틱하게)으로 추론할 수 있습니다. 따라서 클라이언트는 (max-age가 없음에도 불구하고) 이 응답을 저장하고 한동안 재사용합니다. 얼마나 오래 재사용할지는 구현체(브라우저 등) 마음이지만, 명세에서는 응답이 생성된 후 경과된 시간의 약 10%(이 경우 0.1년) 정도를 권장하고 있습니다.
휴리스틱 캐싱은 Cache-Control 지원이 널리 채택되기 전에 존재했던 임시방편 같은 것이며, 오늘날에는 기본적으로 모든 응답에 Cache-Control 헤더를 명시적으로 지정해 주어야 합니다.
저장된 HTTP 응답은 두 가지 상태를 가집니다: 신선한 상태(fresh) 와 만료된 상태(stale) 입니다. 신선한(fresh) 상태는 보통 응답이 여전히 유효하며 재사용할 수 있음을 의미하고, 만료된(stale) 상태는 캐시된 응답의 유효기간이 이미 끝났음을 의미합니다.
응답이 언제 신선하고 언제 만료되었는지를 결정하는 기준은 바로 수명(age)입니다. HTTP에서 age란 응답이 생성된 이후 경과된 시간을 뜻하죠. 이는 다른 캐싱 메커니즘에서 사용하는 TTL(Time To Live)과 유사한 개념입니다.
다음 응답 예시를 확인해 보세요 (604800초는 딱 1주일을 의미합니다):
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Cache-Control: max-age=604800
<!doctype html>
…
이 예시 응답을 저장한 캐시는 응답이 생성된 시점으로부터 경과된 시간을 계산하고, 그 결과를 응답의 수명(age)으로 사용합니다.
위 응답 예시에서 max-age가 의미하는 바는 다음과 같습니다:
저장된 응답이 신선함을 유지하는 한, 클라이언트의 요청을 처리하는 데 그대로 재사용됩니다.
만약 응답이 공유 캐시(shared cache)에 저장되어 있다면, 클라이언트에게 그 응답의 수명을 알려줄 수 있습니다. 위의 예시를 계속 이어가서, 공유 캐시가 이 응답을 하루(1일) 동안 보관했다고 쳐봅시다. 그러면 공유 캐시는 이후에 들어오는 클라이언트의 요청에 대해 다음과 같은 응답을 보냅니다.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Cache-Control: max-age=604800
Age: 86400
<!doctype html>
…
이 응답을 받은 클라이언트는 응답의 max-age(604800초)와 헤더로 넘어온 Age(86400초)의 차이인 518400초 동안 이 응답이 신선하게 유지될 것이라고 판단하게 됩니다.
HTTP/1.0 시절에는 신선도를 Expires 헤더로 지정하곤 했습니다.
Expires 헤더는 경과 시간이 아닌 명시적인 '절대 시간'을 사용하여 캐시의 수명을 지정합니다.
Expires: Tue, 28 Feb 2022 22:22:22 GMT
하지만 이런 날짜/시간 포맷은 파싱(해석)하기 어렵고 수많은 구현 버그가 발견되었으며, 시스템의 시계를 의도적으로 변경하여 문제를 유발할 수도 있었습니다. 이러한 이유로 HTTP/1.1에서는 경과 시간을 지정하는 max-age가 Cache-Control에 채택되었습니다.
만약 응답에 Expires와 Cache-Control: max-age가 모두 존재한다면, max-age가 우선시되도록 정의되어 있습니다. HTTP/1.1이 널리 쓰이는 오늘날에는 더 이상 Expires를 제공할 필요가 없습니다.
캐시가 서로 다른 응답을 구별하는 기본적인 방법은 그들의 URL을 비교하는 것입니다:
| URL | 응답 본문 (Response body) |
|---|---|
https://example.com/index.html | <!doctype html>... |
https://example.com/style.css | body { ... |
https://example.com/script.js | function main () { ... |
하지만 URL이 같다고 해서 응답 내용이 항상 똑같은 것은 아닙니다. 특히 콘텐츠 협상(content negotiation)이 수행될 때, 서버의 응답은 Accept, Accept-Language, Accept-Encoding 등의 요청 헤더 값에 따라 달라질 수 있습니다.
예를 들어, 클라이언트가 Accept-Language: en 헤더를 보내서 영어 콘텐츠를 받아 캐시해 두었는데, 그 후 다른 요청에서 Accept-Language: ja(일본어) 헤더를 보냈을 때 아까 캐시해 둔 영어 응답을 그대로 재사용해 버리면 매우 곤란하겠죠. 이런 경우, Vary 헤더의 값에 Accept-Language를 추가함으로써 캐시 서버가 '언어에 따라' 응답을 각각 따로 캐시하도록 지시할 수 있습니다.
Vary: Accept-Language
이렇게 하면 캐시는 응답 URL 하나만 기준으로 삼는 것이 아니라, 응답 URL과 Accept-Language 요청 헤더를 '조합'한 값을 캐시 키(Key)로 사용하게 됩니다.
| URL | Accept-Language | 응답 본문 |
|---|---|---|
https://example.com/index.html | ja-JP | <!doctype html>... |
https://example.com/index.html | en-US | <!doctype html>... |
https://example.com/style.css | ja-JP | body { ... |
https://example.com/script.js | ja-JP | function main () { ... |
또한 반응형 디자인 등을 위해 유저 에이전트(사용자 기기 환경)를 기반으로 콘텐츠 최적화를 제공하는 경우, Vary 헤더 값에 User-Agent를 포함하고 싶은 유혹을 느낄 수 있습니다. 하지만 User-Agent 요청 헤더는 일반적으로 그 변형(variation)의 수가 어마어마하게 많기 때문에, 캐시가 재사용될 확률을 극단적으로 떨어뜨립니다. 따라서 가능하다면 User-Agent 요청 헤더를 기준으로 동작을 다르게 하기보다는, 기능 탐지(feature detection)를 기반으로 변화를 주는 방법을 고려하시는 것이 좋습니다.
개인화된 콘텐츠가 캐시되어 다른 사람이 재사용하는 것을 막기 위해 쿠키(cookies)를 활용하는 애플리케이션의 경우, Vary 헤더에 쿠키를 지정하는 것보다는 Cache-Control: private을 지정하는 것이 올바른 방법입니다.
만료된(stale) 응답이라고 해서 무조건 바로 버려지는 것은 아닙니다. HTTP에는 만료된 응답에 대해 원본 서버에게 한 번 물어본 뒤, 변경 사항이 없으면 다시 신선한(fresh) 응답으로 탈바꿈시키는 메커니즘이 있습니다. 이를 검증(validation), 혹은 재검증(revalidation)이라고 부릅니다.
💡 강사의 팁: 다가올 프론트엔드 기술 면접에서 매우 중요한 파트입니다! 브라우저가 원본 서버에 "나 이거 캐시해 둔 거 있는데 아직 쓸 수 있어?"라고 물어보고, 원본 서버가 "어, 그거 안 변했으니까 그대로 써!"라고 대답해 주는 과정이 바로 이 검증입니다. 이때 응답 코드로
304 Not Modified가 내려오죠.
검증은 요청 헤더에 If-Modified-Since 또는 If-None-Match를 포함하는 조건부 요청(conditional request)을 사용하여 이루어집니다.
아래의 응답은 22:22:22에 생성되었으며 max-age가 1시간이므로, 23:22:22까지는 신선하다는 것을 알 수 있습니다.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600
<!doctype html>
…
23:22:22가 지나면 이 응답은 만료(stale)되며 캐시를 그냥 재사용할 수 없게 됩니다. 따라서 클라이언트는 지정된 시간 이후에 변경 사항이 있었는지 서버에 물어보기 위해, 아래와 같이 If-Modified-Since 요청 헤더를 포함하여 요청을 보냅니다.
GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-Modified-Since: Tue, 22 Feb 2022 22:00:00 GMT
지정된 시간 이후로 콘텐츠가 변경되지 않았다면, 서버는 304 Not Modified를 응답합니다.
이 응답은 단지 "변경된 게 없어"라는 사실만을 알려주기 때문에 응답 본문(body)이 전혀 없고 오직 상태 코드와 헤더만 존재합니다. 따라서 전송되는 데이터 크기가 극도로 작습니다.
HTTP/1.1 304 Not Modified
Content-Type: text/html
Date: Tue, 22 Feb 2022 23:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600
이 응답을 받은 클라이언트는 기존에 갖고 있던 만료된 응답의 수명을 다시 '신선함(fresh)'으로 되돌리고, 남은 1시간 동안 또다시 유용하게 재사용할 수 있습니다.
서버는 파일의 수정 시간을 운영체제의 파일 시스템에서 쉽게 얻어올 수 있으므로 정적 파일을 서빙할 때는 비교적 간단히 적용할 수 있습니다. 하지만 이 방식에는 몇 가지 문제가 있습니다. 시간 형식이 복잡하여 파싱하기 어렵고, 분산된 여러 대의 서버 간에 파일 업데이트 시간을 완벽히 동기화하기가 까다롭다는 점입니다.
이러한 문제들을 해결하기 위한 대안으로 표준화된 것이 바로 ETag 응답 헤더입니다.
ETag 응답 헤더의 값은 서버가 임의로 생성한 고유한 값입니다. 서버가 이 값을 어떻게 만들어야 한다는 제한은 없으므로, 서버는 본문 내용의 해시(hash) 값이나 버전 번호 등 원하는 방식을 자유롭게 사용하여 이 값을 설정할 수 있습니다.
예를 들어, index.html 리소스의 해시 값을 구해 ETag 헤더 값으로 사용했고 그 해시 값이 33a64df5라면, 응답은 다음과 같습니다:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
ETag: "33a64df5"
Cache-Control: max-age=3600
<!doctype html>
…
만약 이 응답이 시간이 지나 만료되었다면, 클라이언트는 캐시된 응답에 있던 ETag 값을 꺼내어 이번 요청의 If-None-Match 요청 헤더에 담습니다. 그리고 리소스가 수정되었는지 서버에 묻게 되죠:
GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-None-Match: "33a64df5"
요청받은 리소스에 대해 서버가 새롭게 계산한 ETag 값이 클라이언트가 If-None-Match에 담아 보낸 값과 동일하다면, 서버는 304 Not Modified를 반환합니다.
하지만 리소스가 업데이트되어서 서버가 이제 전혀 다른 새로운 ETag 값을 가져야 한다고 판단한다면, 서버는 200 OK 상태 코드와 함께 리소스의 최신 버전을 몽땅 담아서 새롭게 응답합니다.
참고 (Note):
RFC9110 명세에 따르면, 가능하다면 서버는200응답 시ETag와Last-Modified두 가지를 모두 보내는 것을 권장합니다.
캐시를 재검증할 때 만약If-Modified-Since와If-None-Match가 동시에 존재한다면, 검증 우선순위는If-None-Match가 갖게 됩니다.
순수하게 캐싱 측면에서만 고려한다면Last-Modified가 불필요하다고 생각하실 수도 있습니다.
하지만Last-Modified는 캐싱에만 유용한 것이 아닙니다. 이 표준 HTTP 헤더는 콘텐츠 관리 시스템(CMS)에서 최근 수정 시간을 표시하거나, 웹 크롤러가 크롤링 주기를 조정할 때 등 다양한 목적으로 널리 쓰입니다.
따라서 전체적인 HTTP 생태계를 고려했을 때,ETag와Last-Modified를 모두 제공하는 것이 훨씬 좋습니다.
응답이 함부로 재사용되는 것을 원치 않고, 매번 서버에서 최신 콘텐츠인지 확인받은 후 가져오길 원한다면 no-cache 디렉티브를 사용하여 재검증을 강제할 수 있습니다.
아래와 같이 응답에 Last-Modified 및 ETag와 더불어 Cache-Control: no-cache를 추가해 주면, 클라이언트는 해당 리소스가 업데이트되었을 때는 200 OK와 새 리소스를 받고, 업데이트되지 않았다면 304 Not Modified를 받게 됩니다.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
ETag: "deadbeef"
Cache-Control: no-cache
<!doctype html>
…
종종 max-age=0과 must-revalidate의 조합이 no-cache와 똑같은 의미를 가진다고 알려지곤 합니다.
Cache-Control: max-age=0, must-revalidate
max-age=0은 응답이 즉시 만료(stale)됨을 의미하고, must-revalidate는 만료된 후에는 재검증 없이는 절대 재사용할 수 없음을 뜻하므로, 이 둘을 합치면 겉보기에는 no-cache와 완벽히 같은 의미를 지니는 것처럼 보입니다.
하지만 max-age=0을 저렇게 사용하는 방식은 사실 HTTP/1.1 이전의 많은 구형 구현체들이 no-cache 디렉티브를 제대로 처리하지 못했던 시절의 잔재입니다. 그 한계를 극복하기 위한 임시방편(workaround)으로 max-age=0이 사용되었던 것이죠.
HTTP/1.1을 준수하는 서버가 널리 배포된 지금은 max-age=0과 must-revalidate를 결합하여 사용할 이유가 전혀 없습니다. 대신 깔끔하게 no-cache만 사용하시면 됩니다.
앞서 보셨듯 no-cache 디렉티브는 응답 자체의 저장을 막는 것이 아니라, 재검증 없이 응답이 재사용되는 것을 막는 것입니다.
만약 응답이 그 어떤 캐시 저장소에도 아예 물리적으로 저장되는 것조차 원치 않는다면, no-store를 사용해야 합니다.
Cache-Control: no-store
하지만 실무에서 "캐시하지 마세요"라는 요구사항이 주어졌을 때, 사실 그 속뜻을 들여다보면 보통 다음과 같은 상황들인 경우가 많습니다:
이러한 여러 상황에서 무조건적으로 no-store를 쓰는 것이 항상 최선의 선택은 아닙니다.
다음 섹션에서 이러한 상황들을 좀 더 자세히 뜯어보겠습니다.
개인화된 콘텐츠가 포함된 응답이 공유 캐시에 저장되어 예상치 못하게 다른 사용자에게 노출된다면 정말 끔찍한 문제가 될 것입니다.
이런 경우에는 private 디렉티브를 사용하여, 개인화된 응답이 오직 해당 클라이언트에만 저장되고 다른 캐시 사용자들에게는 절대 유출되지 않도록 할 수 있습니다.
Cache-Control: private
참고로 이 상황에서는 no-store를 사용하더라도 private을 반드시 함께 명시해 주어야 더욱 안전합니다.
no-store 디렉티브는 응답이 새롭게 '저장'되는 것을 막아줄 뿐, 동일한 URL로 이전에 이미 저장되어 있던 낡은 응답을 '삭제'해 주지는 않습니다.
다시 말해, 특정 URL에 대해 기존의 옛날 응답 캐시가 이미 존재한다면, 새로운 응답에서 no-store를 반환한다고 해서 낡은 응답이 재사용되는 것을 막을 수는 없다는 뜻입니다.
하지만 no-cache 디렉티브를 사용하면, 클라이언트가 기존에 저장된 캐시를 재사용하기 전에 무조건 서버에 한 번 검증 요청을 보내도록 강제할 수 있습니다.
Cache-Control: no-cache
만약 서버가 조건부 요청을 아예 지원하지 않는다면, 클라이언트는 매번 서버에 접근하게 될 것이고 항상 200 OK와 함께 가장 최신의 응답을 확실하게 받아갈 수 있습니다.
no-store를 뻔뻔하게 무시해 버리는 오래되고 낡은 캐시 서버들에 대처하기 위한 임시방편으로, 아래와 같이 주방 싱크대(kitchen-sink)처럼 온갖 헤더를 다 넣는 방식이 사용되는 것을 본 적 있으실 겁니다.
Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate
이런 구식 구현체들을 상대할 때는 그저 no-cache를 대안으로 사용하는 것이 권장되며, 처음부터 no-cache를 주면 어차피 서버가 항상 검증 요청을 받게 되므로 아무 문제가 발생하지 않습니다.
만약 그래도 중간에 껴있는 공유 캐시가 찜찜하다면, private까지 추가해 주어 의도치 않은 캐싱을 원천 차단할 수 있습니다:
Cache-Control: no-cache, private
단순히 캐싱을 피하고 싶을 때 no-store를 넣는 것이 가장 쉽고 올바른 방법이라고 생각하실 수도 있습니다.
하지만 no-store를 남발하는 것은 결코 권장하지 않습니다. 브라우저의 '뒤로 가기/앞으로 가기 캐시(bfcache)'를 비롯해 HTTP와 브라우저가 기본적으로 제공하는 수많은 엄청난 이점들을 몽땅 잃어버리게 되기 때문입니다.
따라서 웹 플랫폼이 제공하는 모든 기능의 이점을 누리고 싶다면, 가급적 no-store 대신 no-cache와 private을 조합하여 사용하시는 것이 좋습니다.
검증(Validation) 메커니즘은 응답뿐만 아니라 요청 시에도 수행될 수 있습니다.
우리가 흔히 쓰는 브라우저의 새로고침(reload)과 강제 새로고침(force reload) 기능이 바로 브라우저 측에서 주도하여 수행하는 대표적인 검증의 예시입니다.
화면이 깨지는 오류를 복구하거나 리소스의 최신 버전을 확인하기 위해 브라우저는 사용자에게 '새로고침(F5)' 기능을 제공합니다.
브라우저에서 새로고침이 일어날 때 전송되는 HTTP 요청을 아주 간단히 줄여보면 다음과 같습니다:
GET / HTTP/1.1
Host: example.com
Cache-Control: max-age=0
If-None-Match: "deadbeef"
If-Modified-Since: Tue, 22 Feb 2022 20:20:20 GMT
(Chrome, Edge, Firefox 브라우저의 요청은 대부분 위와 비슷하게 생겼고, Safari의 요청은 조금 다르게 보일 수 있습니다.)
요청 헤더에 담긴 max-age=0 디렉티브는 "수명(age)이 0 이하인 응답만 재사용하겠다"라고 명시하는 것과 같습니다. 따라서 실제로는 중간에 임시로 저장된 캐시들을 절대 바로 재사용하지 않겠다는 선언인 셈이죠.
결과적으로, 이 요청은 항상 If-None-Match와 If-Modified-Since 헤더를 통해 원본 서버로부터 꼼꼼하게 검증(Validation)을 받게 됩니다.
이러한 동작 방식은 Fetch 표준에도 잘 정의되어 있으며, 자바스크립트에서는 fetch() 함수 호출 시 cache 모드를 no-cache로 설정하면 똑같이 재현해 낼 수 있습니다 (참고로 이 경우 옵션 이름이 reload가 아님을 주의하세요!):
// 참고: 일반적인 새로고침 동작을 흉내 내려면 "reload"가 아니라 "no-cache" 모드가 맞습니다.
fetch("/", { cache: "no-cache" });
브라우저는 새로고침 시 과거 HTTP/1.1 이전 시절의 수많은 구식 구현체들이 no-cache를 이해하지 못했던 점을 고려한 하위 호환성을 위해 max-age=0을 사용합니다. 하지만 요즘 시대에는 이 사용 사례에서 no-cache를 사용해도 아무런 문제가 없으며, 나아가 강제 새로고침(Shift+F5 또는 Ctrl+F5) 은 캐시된 응답을 아예 완벽히 건너뛰어 버리는 강력한 추가 수단입니다.
브라우저에서 강제 새로고침 시 전송되는 HTTP 요청은 다음과 같습니다:
GET / HTTP/1.1
Host: example.com
Pragma: no-cache
Cache-Control: no-cache
(마찬가지로 Chrome, Edge, Firefox 브라우저는 위와 비슷하며 Safari는 조금 다를 수 있습니다.)
이 요청은 no-cache를 사용하고 있으면서도 조건부 헤더(If-None-Match 등)가 아예 존재하지 않습니다. 따라서 어떤 캐시도 묻고 따지지 않고 완전히 건너뛰어, 원본 서버로부터 무조건 새로운 리소스와 함께 200 OK를 받아올 수 있습니다.
이 동작 역시 Fetch 표준에 정의되어 있으며, 자바스크립트에서 fetch() 호출 시 cache 모드를 reload로 설정하여 재현할 수 있습니다 (여기서도 옵션 이름이 force-reload가 아니라 reload라는 점에 유의하세요!):
// 참고: "강제 새로고침(force reload)"을 흉내 내려면 "no-cache"가 아니라 "reload" 모드가 맞습니다.
fetch("/", { cache: "reload" });
내용이 결코 변하지 않는(immutable) 콘텐츠라면, 요청 URL에 버전 번호나 해시값 등을 포함하는 캐시 버스팅(cache busting) 기법을 사용하고 max-age를 아주 길게 주어 캐싱하는 것이 좋습니다.
하지만 이렇게 해두어도 사용자가 새로고침을 누르면, 서버는 이 콘텐츠가 절대 변하지 않을 것임을 알고 있음에도 불구하고 불필요한 재검증 요청이 계속 서버로 날아가게 됩니다.
이를 방지하기 위해, immutable 디렉티브를 사용하여 "이 콘텐츠는 절대 변하지 않으므로 재검증조차 필요 없다"고 명시적으로 브라우저에 알려줄 수 있습니다.
Cache-Control: max-age=31536000, immutable
이렇게 설정해 두면 사용자가 새로고침을 누르더라도 불필요한 재검증 요청을 원천 차단합니다.
다만 참고로, Chrome은 구현 방식을 아예 변경하여 저 디렉티브를 구현하는 대신 하위 리소스(subresources - 이미지, CSS, JS 등)에 대해서는 사용자가 새로고침을 해도 재검증 요청 자체를 아예 보내지 않도록 최적화했습니다.
중간 서버(프록시 등)에 매우 긴 max-age로 한 번 저장되어 버린 응답을 개발자가 임의로 삭제할 수 있는 마법 같은 방법은 존재하지 않습니다.
예를 들어, https://example.com/ 에서 다음과 같은 응답이 캐시 서버에 저장되었다고 상상해 보세요.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: max-age=31536000
<!doctype html>
…
만약 서버 쪽에서 콘텐츠를 업데이트했다 하더라도 저 캐시된 응답을 강제로 덮어씌울 수는 없습니다. 이미 긴 만료 기한이 캐시되었기 때문에, 클라이언트의 후속 요청들이 원본 서버까지 오지도 않고 중간에서 캐시로 처리되어 버리기 때문입니다.
명세에 언급된 방법 중 하나는 똑같은 URL에 대해 POST와 같이 안전하지 않은(unsafe) 메서드로 요청을 보내어 캐시를 무효화하는 것이지만, 많은 클라이언트 환경에서는 이 방법을 실행하기가 상당히 까다롭습니다.
Clear-Site-Data: cache 헤더와 디렉티브 값을 사용하면 사용자의 브라우저 캐시는 깔끔하게 비울 수 있지만, 중간에 껴 있는 캐시 서버들에는 전혀 영향을 미치지 않습니다.
그렇지 않은 이상, 사용자가 직접 수동으로 새로고침, 강제 새로고침, 혹은 브라우저 방문 기록 지우기 등을 실행하지 않는 한 max-age가 끝날 때까지 낡은 응답은 브라우저 캐시에 계속 남아있게 됩니다.
캐싱은 원본 서버로의 접근 빈도를 획기적으로 줄여주지만, 이는 곧 원본 서버가 그 URL에 대한 통제권을 일부분 잃게 됨을 의미합니다. 만약 수시로 리소스가 업데이트되어 서버가 URL에 대한 통제권을 잃고 싶지 않다면, 언제나 no-cache를 명시하여 서버가 항상 검증 요청을 받게 하고 의도한 최신 응답을 내려줄 수 있도록 해야 합니다.
공유 캐시(shared cache)는 주로 원본 서버의 앞단에 위치하여 원본 서버로 향하는 트래픽 부하를 줄여주기 위한 목적으로 사용됩니다.
따라서, 만약 서로 다른 수많은 클라이언트로부터 완벽히 똑같은 요청이 동시에 쏟아져 들어온다면, 중간의 공유 캐시는 모든 요청을 원본 서버로 보내지 않고 자기 자신을 대표로 딱 하나의 요청만 원본 서버로 넘깁니다. 그리고 원본 서버로부터 결과를 받아오면, 그 단일 응답을 기다리고 있던 수많은 클라이언트들에게 촥 뿌려서 재사용하게 합니다. 이 놀라운 현상을 요청 병합(request collapse)이라고 부릅니다.
요청 병합은 수많은 요청이 말 그대로 '동시에' 도착했을 때 발생하므로, 비록 응답 헤더에 max-age=0이나 심지어 no-cache가 설정되어 있더라도 발생할 수 있으며 해당 응답이 여러 클라이언트에게 재사용됩니다.
만약 응답이 특정 사용자 한 명만을 위해 개인화된 데이터라면 병합되어 다른 사람에게 공유되는 대참사가 일어나면 안 되겠죠? 이럴 때는 반드시 private 디렉티브를 명시해 주어야 합니다.
Cache-Control 명세에는 엄청나게 많은 디렉티브가 있고, 그것들을 전부 이해하고 적재적소에 쓰기란 어려울 수 있습니다. 하지만 걱정 마세요. 오늘날 웹사이트의 대부분은 몇 가지 한정된 패턴들의 조합만으로도 훌륭하게 최적화할 수 있습니다.
이 섹션에서는 캐시를 설계할 때 쓰이는 가장 흔하고 핵심적인 패턴들을 소개합니다.
앞서 언급했듯이, Cache-Control 헤더가 없는 응답에 대한 기본 동작은 '캐시 안 함'이 아니라 소위 말하는 '휴리스틱 캐싱'에 의해 맘대로 캐싱되어 버리는 것입니다.
이러한 예기치 않은 휴리스틱 캐싱을 막기 위해, 모든 응답에 명시적인 기본 Cache-Control 헤더를 달아주는 것이 좋습니다.
가장 기본적으로 항상 원본 서버의 최신 버전의 리소스가 전송되도록 보장하고 싶다면, 기본 Cache-Control 값에 no-cache를 포함시키는 것이 일반적인 모범 사례(best practice)입니다:
Cache-Control: no-cache
추가로, 만약 해당 서비스가 쿠키 등을 활용한 로그인 기능을 구현하고 있고 사용자마다 내용이 달라지는 개인화된 콘텐츠라면, 다른 사용자와 캐시가 공유되는 사고를 막기 위해 private도 반드시 함께 넣어주어야 합니다:
Cache-Control: no-cache, private
캐싱과 가장 찰떡궁합인 리소스는 콘텐츠 내용이 영원히 절대 변하지 않는 정적 파일들입니다.
하지만 내용이 언젠가 한 번쯤 바뀌어야 하는 리소스라면 어떨까요? 이 경우, 파일 내용이 업데이트될 때마다 URL 자체를 확 바꿔버려서 그 URL 단위로는 엄청나게 긴 시간 동안 맘 놓고 캐싱할 수 있게 만드는 것이 프론트엔드 업계의 아주 흔하고 훌륭한 모범 사례입니다.
💡 강사의 팁: Next.js나 React(Vite, Webpack) 프로젝트를 빌드(build)하고 났을 때 나오는 JS나 CSS 파일들 이름에 이상한 난수 해시값(
main.a918a4e7.js)이 자동으로 붙어서 생성되는 것을 보셨을 텐데요. 이게 바로 프레임워크가 알아서 '캐시 버스팅'을 해주는 과정이랍니다!
예를 들어, 다음과 같은 HTML 파일이 있다고 생각해 보겠습니다:
<script src="bundle.js"></script>
<link rel="stylesheet" href="build.css" />
<body>
hello
</body>
현대의 웹 개발에서 자바스크립트와 CSS 리소스는 기능이 추가되고 버그가 고쳐짐에 따라 수시로 업데이트됩니다. 만약 클라이언트가 쓰는 JS 버전과 CSS 버전이 캐시 불일치로 인해 어긋나버리면, 웹사이트 화면이 완전히 박살 나서 보이게 될 것입니다.
그렇기 때문에 위와 같은 평범한 HTML 구조로는 bundle.js나 build.css에 max-age를 길게 주어 맘 놓고 캐시하기가 무척 부담스럽습니다.
그래서 자바스크립트나 CSS 리소스의 URL 안에 버전 번호나 해시(Hash) 값을 포함시켜, 내용이 바뀔 때마다 URL의 일부분이 변하도록 서빙할 수 있습니다. 이를 구현하는 몇 가지 방법은 다음과 같습니다:
# 파일 이름에 버전을 명시
bundle.v123.js
# 쿼리 스트링에 버전을 명시
bundle.js?v=123
# 파일 이름에 해시값을 명시 (최신 번들러들이 기본 채택하는 방식)
bundle.YsAIAAAA-QG4G6kCMAMBAAAAAAAoK.js
# 쿼리 스트링에 해시값을 명시
bundle.js?v=YsAIAAAA-QG4G6kCMAMBAAAAAAAoK
캐시는 각 리소스를 철저히 URL을 기준으로 구분하기 때문에, 리소스가 업데이트되어 HTML 문서의 URL이 바뀌면 기존에 묶여있던 캐시는 절대 재사용되지 않습니다.
<script src="bundle.v123.js"></script>
<link rel="stylesheet" href="build.v123.css" />
<body>
hello
</body>
이렇게 설계하면 JS와 CSS 리소스 모두 캐시 만료 걱정 없이 엄청나게 긴 시간 동안 캐시해 둘 수 있습니다.
그렇다면 과연 max-age는 얼마나 길게 설정하는 것이 좋을까요? 이에 대한 해답은 QPACK 명세에서 찾을 수 있습니다.
QPACK은 HTTP 헤더 필드들을 압축하기 위한 표준이며, 자주 쓰이는 필드 값들을 모아둔 사전(Table)을 정의해두고 있습니다.
이 사전에 들어있는 캐시 관련 헤더 값들 중 일부는 다음과 같습니다.
36 cache-control max-age=0
37 cache-control max-age=604800
38 cache-control max-age=2592000
39 cache-control no-cache
40 cache-control no-store
41 cache-control public, max-age=31536000
저 목록에 있는 옵션 번호 중 하나를 선택하면, HTTP/3 환경에서 전송할 때 헤더 값을 단 1바이트로 압축하여 전송할 수 있는 엄청난 혜택이 있습니다!
여기서 37, 38, 41 번호는 각각 1주일, 1개월, 1년의 기간을 의미합니다.
캐시 저장소는 용량이 가득 차면 새 데이터를 저장하기 위해 오래된 데이터들을 밀어내어 삭제합니다. 그렇기 때문에 max-age를 1주일로 길게 잡더라도, 일주일이 지난 시점까지 그 캐시 데이터가 무사히 살아남아 있을 확률은 생각보다 높지 않습니다. 따라서 실무적으로 보았을 때 이 세 가지 중 어떤 것을 선택하든 체감되는 차이는 그리 크지 않습니다.
주목할 점은 가장 긴 1년(31536000초)이라는 max-age를 가진 41번 옵션에는 public 이라는 값이 함께 붙어있다는 것입니다.
public 디렉티브는 원래 요청 헤더에 보안과 관련된 Authorization 헤더가 존재할지라도 캐시가 이 응답을 마음껏 저장할 수 있도록 강제하는 효과가 있습니다.
참고 (Note):
public디렉티브는Authorization헤더가 세팅되어 있을 때에도 굳이 응답을 캐시해야 하는 경우에만 제한적으로 사용해야 합니다.
그 밖의 일반적인 상황에서는max-age만 명시되어 있어도 공유 캐시가 알아서 응답을 캐싱하기 때문에 굳이public을 넣을 필요가 없습니다.
따라서 만약 이 응답이 기본 인증(basic authentication) 등을 거쳐 특정 유저에게 개인화된 내용이라면 public이 붙어있는 것이 큰 보안 문제가 될 수 있습니다. 만약 이 점이 조금이라도 우려되신다면, public이 안 붙어있으면서 두 번째로 긴 값을 가진 38번 (1개월) 옵션을 선택하시면 됩니다.
# bundle.v123.js 에 대한 응답
# 만약 Authorization을 통해 개인화되는 응답이 절대 아니라고 확신한다면
Cache-Control: public, max-age=31536000
# 혹시나 개인화된 응답일 수도 있어서 확신할 수 없다면
Cache-Control: max-age=2592000
사용자가 새로고침을 했을 때 전체 리소스를 다시 다운받는 낭비를 막으려면, Last-Modified와 ETag 헤더를 세팅하는 것을 잊지 마세요. 사전 빌드(pre-built)된 정적 파일이라면 빌드 과정에서 이 헤더들을 생성하는 것은 아주 쉽습니다.
여기서 ETag 값은 해당 파일의 해시값일 수 있습니다.
# bundle.v123.js 에 대한 응답
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"
게다가 추가로 immutable을 덧붙여주면, 사용자가 새로고침을 누르더라도 브라우저가 원본 서버로 검증 요청을 보내는 것 자체를 막아버릴 수 있습니다.
이 모든 것을 결합한 최종 헤더의 모습은 다음과 같습니다.
# bundle.v123.js
HTTP/1.1 200 OK
Content-Type: text/javascript
Content-Length: 1024
Cache-Control: public, max-age=31536000, immutable
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: "YsAIAAAA-QG4G6kCMAMBAAAAAAAoK"
캐시 버스팅(Cache busting)은 내용이 변경될 때 URL 자체를 바꿔버림으로써 특정 응답을 아주 긴 시간 동안 안심하고 캐시할 수 있게 해주는 마법 같은 기술입니다. 이 테크닉은 자바스크립트나 CSS뿐만 아니라 이미지 등 모든 종류의 하위 리소스(subresources)에 적용될 수 있습니다.
참고 (Note):
immutable디렉티브와 앞서 말한 QPACK 압축을 동시에 사용할지 고민되실 텐데요:
만약immutable을 덧붙이는 바람에 QPACK에서 정의해 둔 미리 압축된 사전 값(예: 41번 옵션)과 달라져서 압축 혜택을 잃게 될까 걱정되신다면,Cache-Control값을 아예 두 줄로 분리하여immutable부분만 따로 인코딩하게 할 수 있습니다. 물론 이는 특정 QPACK 구현체가 어떤 인코딩 알고리즘을 쓰느냐에 따라 달라집니다.
Cache-Control: public, max-age=31536000
Cache-Control: immutable
하위 리소스들(이미지, CSS 등)과 달리, 메인 리소스(주로 HTML 문서)는 URL을 하위 리소스처럼 해시값으로 꾸며버릴 수가 없기 때문에 캐시 버스팅 기법을 사용할 수 없습니다. (우리가 접속하는 사이트 주소가 example.com/index.hash값.html 로 바뀌면 이상하겠죠?)
만약 아래와 같은 HTML 자체가 브라우저에 긴 시간 캐시되어 버린다면, 서버 측에서 내용을 아무리 업데이트해도 클라이언트는 영영 최신 버전을 화면에 띄울 수 없을 것입니다.
<script src="bundle.v123.js"></script>
<link rel="stylesheet" href="build.v123.css" />
<body>
hello
</body>
이런 경우, 아예 저장을 못 하게 막는 no-store 보다는 매번 서버로부터 최신 버전인지 검증을 강제하는 no-cache가 훨씬 적절합니다. 우리는 HTML 문서가 브라우저 캐시에 저장되는 것 자체를 싫어하는 게 아니라, 항상 '최신 상태로 동기화'되기를 원하기 때문입니다.
더 나아가, Last-Modified와 ETag를 함께 추가해주면 클라이언트가 아주 가벼운 '조건부 요청'을 보낼 수 있게 되고, HTML 자체에 아무런 변화가 없었다면 무거운 원본 파일 대신 304 Not Modified만 가볍게 내려주면 됩니다.
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: no-cache
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: "AAPuIbAOdvAGEETbgAAAAAAABAAE"
이 설정은 개인 정보가 들어가지 않은 일반적인 HTML에는 매우 훌륭합니다. 하지만 만약 로그인을 한 후 쿠키 등을 통해 '개인화된(personalized)' 응답이 HTML로 렌더링 되어 내려온다면, 중간 캐시에서 남에게 내 정보가 유출되지 않도록 반드시 private을 함께 명시하는 것을 잊지 마세요!
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: no-cache, private
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: "AAPuIbAOdvAGEETbgAAAAAAABAAE"
Set-Cookie: __Host-SID=AHNtAyt3fvJrUL5g5tnGwER; Secure; Path=/; HttpOnly
favicon.ico, manifest.json, .well-known 관련 파일들, 그리고 앞서 말한 캐시 버스팅 방식(URL 변경)을 맘대로 쓸 수 없는 API 엔드포인트 URL들에도 동일한 패턴을 완벽히 적용할 수 있습니다.
세상의 거의 모든 웹 콘텐츠는 지금까지 살펴본 이 두 가지 패턴의 조합만으로 훌륭히 캐싱을 최적화할 수 있습니다!
앞선 섹션에서 설명한 방식들을 이용하면, 하위 리소스들은 캐시 버스팅을 통해 긴 시간 동안 캐시할 수 있지만, 정작 메인 리소스(주로 HTML)는 그렇게 할 수 없었습니다.
메인 리소스의 캐싱이 어려운 근본적인 이유는, 표준 HTTP Caching 명세의 디렉티브들만으로는 "서버의 콘텐츠가 업데이트되었을 때" 이미 저장되어 버린 낡은 캐시를 강제로 날려버릴(delete) 수 있는 적극적인 방법이 없기 때문입니다.
하지만, CDN이나 서비스 워커(Service worker)와 같은 관리형 캐시(managed cache)를 앞에 배치한다면 이것이 가능해집니다!
예를 들어 API 호출이나 대시보드 버튼 하나로 캐시 퍼지(purge, 삭제) 기능을 지원하는 CDN을 사용 중이라면, 과감하게 HTML 메인 리소스까지 CDN 엣지 서버에 확 캐시해 두고, 서버에서 HTML 내용이 업데이트될 때마다 자동으로 CDN 퍼지 API를 호출해서 캐시를 날려버리는 매우 공격적인 캐싱 전략을 취할 수 있습니다.
또한, 오프라인 지원을 위해 쓰이는 서비스 워커 역시 서버에 업데이트가 발생했을 때 Cache API 안의 콘텐츠를 직접 프로그래밍으로 삭제할 수 있으므로 똑같이 강력한 동작을 구현할 수 있죠.
더 자세한 정보가 궁금하시다면 여러분이 사용 중인 CDN의 공식 문서를 확인해 보시고, 서비스 워커 공식 문서도 한 번 꼭 참고해 보세요.