5ub1n 2024. 12. 1. 04:56

HTTP 캐싱


개요 Overview

HTTP 캐시는 요청과 연관된 응답을 저장하고 이후의 요청에서 저장된 응답을 재사용한다.

 

재사용성에는 여러가지 장점이 있다.

첫째, 요청을 원본 서버에 전달할 필요가 없으므로 클라이언트와 캐시가 가까울수록 응답 속도가 빨라진다.

가장 일반적인 예는 브라우저 자체가 브라우저 요청에 대한 캐시를 저장하는 경우다.

 

또한, 응답을 재사용할 수 있는 경우, 원본 서버는 요청을 처리할 필요가 없다.

즉, 요청을 해석하고 라우팅하거나, 쿠키를 기반으로 세션을 복원하거나, 데이터베이스에서 결과를 조회하거나, 템플릿 엔진을 렌더링할 필요가 없다.

이는 서버의 부하를 줄이는 데 도움을 준다.

 

캐시가 올바르게 작동하는 것은 시스템의 안정성에 매우 중요하다.

 


캐시 유형 Types of caches

HTTP 캐싱 사양에는 두 가지 주요 유형의 캐시인 개인 캐시와 공유 캐시가 있다.


개인 캐시 Private cache

개인 캐시는 특정 클라이언트, 일반적으로 브라우저 캐시에 연결된 캐시를 말한다.

저장된 응답은 다른 클라이언트와 공유되지 않으므로 개인 캐시는 해당 사용자에 대한 개인화된 응답을 저장할 수 있다.

 

반면, 개인화된 콘텐츠가 개인 캐시가 아닌 다른 캐시에 저장될 경우, 다른 사용자가 해당 콘텐츠를 검색할 수 있어 의도하지 않은 정보 유출이 발생할 수 있다.

 

응답에 개인화된 콘텐츠가 포함되어 있고 이를 오직 개인 캐시에만 저장하고자 한다면, 반드시 private 지시어를 명시해야 한다.

 

Cache-Control: private

 

개인화된 콘텐츠는 일반적으로 쿠키로 제어되지만, 쿠키가 있다고 해서 반드시 그 콘텐츠가 개인적인 것이라는 의미는 아니다.

따라서 쿠키 자체만으로는 응답이 개인적인 것으로 간주되지 않는다.

 


공유 캐시 Shared cache

공유 캐시는 클라이언트와 서버 사이에 위치하며 사용자 간에 공유할 수 있는 응답을 저장할 수 있다.

그리고 공유 캐시는 프록시 캐시와 관리 캐시로 더 세분화될 수 있다.

 

  • 프록시 캐시 Proxy cache

액세스 제어 기능 외에도 일부 프록시는 네트워크 외부로의 트래픽을 줄이기 위해 캐싱을 구현한다.

이러한 캐싱은 보통 서비스 개발자가 관리하지 않으므로, 적절한 HTTP 헤더 등을 통해 제어해야 한다.

그러나 과거에는 HTTP 캐싱 표준을 제대로 이해하지 못하는 구형 프록시 캐시 구현과 같은 문제로 인해 개발자들에게 종종 문제가 발생하곤 했다.

 

다음과 같은 "잡다한 헤더(Kitchen-sink header)"는 최신 HTTP 캐싱 사양 지시어인 no-store 등을 이해하지 못하는 "구형 및 업데이트되지 않은 프록시 캐시" 구현을 우회하려는 목적으로 사용된다.

 

Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

 

최근 HTTPS가 널리 사용되고 클라이언트와 서버 간 통신이 암호화되면서, 경로에 있는 프록시 캐시는 대부분의 경우 단순히 응답을 터널링할 수 있을 뿐, 캐시로서의 역할을 수행하지 못하게 되었다.

따라서 이러한 상황에서는 응답 내용을 볼 수 없는 오래된 프록시 캐시 구현에 대해 걱정할 필요가 없다.

 

반면, TLS 프록시가 조직에서 관리하는 CA(인증 기관)의 인증서를 PC에 설치하여 통신을 해석하고, 이를 통해 접근 제어 등을 수행하는 방식으로 중간자 역할(person-in-the-middle)을 한다면, 응답 내용을 확인하고 이를 캐시하는 것이 가능하다.

그러나 최근 CT(인증서 투명성, Certificate Transparency)가 보편화되고 일부 브라우저는 SCT(서명된 인증서 타임스탬프, Certificate Timestamp)가 포함된 인증서만 허용하고 있기 때문에 이러한 방식은 엔터프라이즈 정책(회사 컴퓨터나 인터넷 사용에 대한 회사의 통제 방식)의 적용이 필요하다.

TLS 프록시는 특정 상황에서만 존재한다.
일반적으로 클라이언트와 서버는 TLS 암호화를 통해 직접 연결된다.
이 경우, 중간에 프록시가 존재하지 않고, 암호화된 데이터를 서버와 클라이언트만 해독한다.
그리고 현대의 웹 브라우징에서는 HTTPS 연결을 사용하는 것이 일반적이며, 대부분의 경우 클라이언트와 서버 사이에 프록시가 없거나, 필요한 경우에만 프록시가 적용된다.

CT와 SCT가 포함되지 않은 인증서를 사용하면 브라우저에서 신뢰하지 않지만, 브라우저나 시스템에 추가적인 설정(엔터프라이즈 정책)이 포함된다면 조직에서 관리하는 CA 인증서를 PC에 설치할 수 있다.
엔터프라이즈 정책을 통해 브라우저를 강제로 설정하기 위해선 사용자 동의(IT 정책이나 보안 규정을 명시적으로 고지)와 합법적 요구 사항(법적 규제 준수)을 충족해야 한다.

 

  • 관리형 캐시 Managed caches

관리형 캐시는 서비스 개발자에 의해 원본 서버의 부담을 줄이고 콘텐츠를 효율적으로 전달하기 위해 명시적으로 배포된다.

예시로는 리버스 프록시, CDN(콘텐츠 전송 네트워크, Content Delivery Network), Cache API와 결합한 서비스 워커가 있다.

 

관리형 캐시의 특성은 배포된 제품에 따라 다르다.

대부분의 경우, Cache-Control 헤더와 자체 구성 파일 또는 대시보드를 통해 캐시의 동작을 제어할 수 있다.

 

예를 들어, HTTP 캐싱 사양은 캐시를 명시적으로 삭제하는 방법을 정의하지 않는다.

하지만 관리형 캐시에서는 저장된 응답을 대시보드 작업, API 호출, 재시작 등을 통해 언제든지 삭제할 수 있다.

이를 통해 더 적극적인 캐싱 전략을 구현할 수 있다.

 

또한, 표준 HTTP 캐싱 사양 프로토콜을 무시하고 명시적으로 캐시를 조작할 수도 있다.

예를 들어, 다음과 같은 방식으로 프라이빗 캐시 또는 프록시 캐시를 사용하지 않도록 설정한 후, 관리형 캐시에만 캐싱하는 자체 전략을 사용할 수 있다.

 

Cache-Control: no-store

 

예를 들어, Varnish Cache는 VCL(Varnish Configuration Language, DSL유형) 로직을 사용하여 캐시 저장소를 처리하는 반면, Cache API와 결합한 서비스 워커를 사용하면 해당 로직을 JavaScript에서 만들 수 있다.

 

브라우저 및 많은 캐시 서버는 표준 헤더(Cache-Control, Expires, ETag 등)를 기반으로 캐싱 여부, 만료 시점 등을 결정한다.
Varnish Cache는 표준 헤더가 아닌 VCL을 통해 처리한다.
VCL을 사용하는 방식은 HTTP 캐싱 표준이 강제하는 규칙을 우회하거나 재정의하여 동작할 수 있으므로 표준 프로토콜을 무시하고 명시적으로 캐싱 전략을 설정할 수 있다.
이는 사용자 정의 로직을 통해 캐시 동작을 제어하려는 시스템 설계 요구를 충족시키는 데 유용하다.

 

이는 관리형 캐시가 no-store 지시어를 의도적으로 무시하더라도 이를 표준에 "비준수"로 간주할 필요가 없다는 것을 의미한다.

대신, 모든 캐시 지시어를 무작정 사용하는 방식을 피하고 사용 중인 관리형 캐시 메커니즘의 문서를 꼼꼼히 읽은 후, 선택한 메커니즘에서 제공하는 방법으로 캐시를 제대로 제어하도록 해야 한다.

 

일부 CDN은 해당 CDN에서만 효과가 있는 자체 헤더를 제공한다는 점에 유의한다.

현재 이러한 헤더를 표준화하기 위해 CDN-Cache-Control 헤더를 정의하는 작업이 진행 중이다.

 

요청 캐시, 공유 캐시, 관리형 캐시, 원본 서버(Origin)

각각의 웹 캐시가 요청을 처리해 원본 서버로의 트래픽을 줄인다.

 


휴리스틱 캐싱 Heuristic cachiing

 

HTTP는 가능한 한 많은 것을 캐싱하도록 설계되었기 때문에, Caching-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 헤더를 명시적으로 지정해야 한다.

 


나이에 따른 신선함과 오래됨 Fresh and stale bassed on age

HTTP 응답은 두 가지 상태를 가질 수 있다.

신선함(fresh)과 오래됨(stale)이다.

신선한 상태는 일반적으로 응답이 여전히 유효하며 재사용할 수 있음을 나타낸다.

오래된 상태는 캐시된 응답이 이미 만료되었음을 의미한다.

 

응답이 신선한지 오래되었는지를 결정하는 기준은 나이이다.

HTTP에서 나이는 응답이 생성된 이후 경과된 시간을 의미한다.

이는 다른 캐싱 메커니즘에서 사용하는 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>
...

 

예시 응답을 저장하는 캐시는 응답이 생성된 이후 경과된 시간을 계산하고, 이 결과를 응답의 나이로 사용한다.

문장에서는 "응답이 생성된 이후 경과된 시간"을 나이로 사용한다고 서술했지만, 실제로는 "응답이 캐시에 저장된 이후 경과된 시간"을 기준으로 나이가 계산된다.

 

예시 응답에서 max-age의 의미는 다음과 같다.

  • 응답의 나이가 1주일 미만인 경우, 응답은 신선한 상태다.
  • 응답의 나이가 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>
...

 

해당 응답을 받은 클라이언트는 응답의 최대 수명과 현재 나이의 차이인 518400초 동안 해당 응답이 유효하다고 판단할 것이다.

 


만료 또는 최대 수명 Expires or max-age

HTTP/1.0에서는 Expires 헤더를 통해 신선도를 지정하곤 했다.

 

Expires 헤더는 경과 시간을 지정하지 않고 명시적인 시간을 사용하여 캐시의 유효 기간을 지정한다.

Expires: Tue, 28 Feb 2022 22:22:22 GMT
위의 경우, 클라이언트는 캐시된 리소스가 2022년 2월 28일 22:22:22(GMT 기준)까지 유효하다고 판단한다.

 

그러나 시간 형식을 분석하기 어렵고, 많은 구현 버그가 발견되었으며, 시스템 시계를 의도적으로 변경함으로써 문제가 발생할 가능성이 있다.

따라서 HTTP/1.1에서는 경과 시간을 지정하는 max-age가 Cache-Control에서 채택되었다.

 

Expires와 Cache-Control: max-age를 모두 사용할 수 있는 경우, max-age가 우선적으로 적용된다.

따라서 HTTP/1.1이 널리 사용되고 있으므로 Expires를 제공할 필요가 없다.

 


Vary 헤더 Vary

각 응답을 구별하는 방법은 기본적으로 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을 가지더라도 항상 동일하지 않을 수 있다.

특히 콘텐츠 협상이 수행될 때 서버의 응답은 Accept, Accept-Language, Accept-Encoding 요청 헤더의 값에 따라 달라질 수 있다.

콘텐츠 협상은 클라이언트와 서버 간의 협상 과정을 통해, 클라이언트가 요청한 리소스의 가장 적합한 형식을 서버가 선택하여 제 공하는 기술이다.
이는 HTTP 프로토콜에서 지원하는 기능이며 클라이언트가 선호하는 언어, 미디어 형식, 문자 인코딩 등을 서버에 전달하고 서버는 그에 따라 최적의 콘텐츠를 반환한다.

 

예를 들어, Accept-Language: en 헤더로 영어 콘텐츠를 반환하고 이를 캐시한 경우, 이후에 Accept-Language: ja 요청 헤더를 가진 요청에 대해 해당 캐시된 응답을 재사용하는 것은 바람직하지 않다.

이 경우, Accept-Language를 Vary 헤더 값에 추가하여 언어를 기반으로 응답이 개별적으로 캐시되도록 설정할 수 있다.

Vary: Accept-Language

 

웹 서버는 클라이언트(브라우저)의 요청을 받고, 응답을 보낼 때 해당 응답을 캐시할 수 있다.
즉, 동일한 요청이 다시 들어오면 서버가 새로 생성하는 대신, 기존에 저장된 응답을 재사용하여 빠르게 응답할 수 있다.

예제 :
1. 사용자가 https://example.com/page에 접근한다.
2. 서버는 영어(en) 페이지를 반환하고, 이를 캐시에 저장한다.

캐시에 저장된 내용 :
URL : https://example.com/page
응답 데이터 : "Hello, wellcome!" (영어 페이지)

이제 다른 사용자가 일본어 페이지를 원한다고 가정한다.
1. 이 사용자는 Accept-Language: ja 헤더를 추가하여 https://example.com/page에 요청을 보낸다.
2. 하지만 캐시에는 이미 영어 페이지(en)가 저장되어 있다.
3. 서버가 요청을 새롭게 처리하지 않고, 캐시된 영어 페이지를 그대로 반환할 가능성이 있다.
즉, 일본어(ja) 페이지를 원했지만 영어(en) 페이지가 반환된다.

일본어 페이지가 필요하지만, 기존에 저장된 영어 페이지가 반환되는 문제가 발생한다.

이 문제를 해결하기 위해, 서버는 Vary: Accept-Language 헤더를 추가할 수 있다.
1. 서버가 응답을 보낼 때, Vary: Accept-Language 헤더를 추가한다.
2. 이제 캐시는 URL뿐만 아니라 Accept-Language 값도 고려하여 저장된다.
3. 즉, https://example.com/page에 대해 언어별로 캐시를 분리한다.

캐시 저장 방식 변경

요청 URL Accept-Language 캐시된 응답
https://example.com/page en "Hello, welcome"
https://example.com/page ja "こんにちは、ようこそ"
이제 올바른 캐시가 사용되어 각자 원하는 페이지에 접속할 수 있다.

Vary: Accept-Language 헤더는 서버 개발자가 개발 과정중에 직접 추가하는 방식으로 사용된다.
만약 이 헤더를 추가하지 않는다면 첫 방문한 페이지의 언어만 보게 되고, 그 이후에 언어 변경이 안될 수 있다.

 

이는 캐시가 응답 URL만을 기반으로 하는 것이 아니라, 응답 URL과 Accept-Language 요청 헤더를 조합한 값을 기준으로 저장되도록 한다.

URL Accept-Language Response body
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 () { ...

 

또한, 사용자 에이전트(User-Agent)를 기반으로 콘텐츠 최적화(예 : 반응형 디자인)를 제공하는 경우, Vary 헤더의 값에 User-Agent를 포함하고 싶을 수도 있다.

그러나 User-Agent 요청 헤더는 일반적으로 매우 많은 변형을 가지므로, 이를 Vary 헤더에 포함하면 캐시가 재사용될 가능성이 크게 감소한다.

따라서 가능하다면, User-Agent 요청 헤더를 기반으로 동작을 변경하는 대신, 기능 감지를 기반으로 동작을 다르게 하는 방법을 고려하는 것이 좋다.

 

개인화된 콘텐츠의 캐시된 응답이 다른 사람에게 재사용되는 것을 방지하기 위해 쿠키(개인화된 콘텐츠는 일반적으로 쿠키로 제어됨)를 사용하는 애플리케이션의 경우, Vary 헤더에 쿠키를 지정하는 대신 Cache-Control: private를 지정해야 한다.

 


검증 Validation

오래된 답변은 즉시 삭제되지 않는다.

HTTP는 원본 서버에 요청하여 오래된 응답을 새로운 응답으로 변환하는 메커니즘이 있다.

이를 검증이라고 하며, 때로는 재검증이라고도 한다.

 

유효성 검사는 If-Modified-Since 또는 If-None-Match 요청 헤더를 포함하는 조건부 요청을 사용하여 수행된다.

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:22:22 GMT
Cache-Control: max-age=3600

<!doctype html>
...

 

23:22:22에 응답이 오래되어 캐시를 재사용할 수 없게 된다.

따라서 아래 요청에서는 클라이언트가 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 상태 코드로 응답한다.

 

이 응답은 단순히 "변경 없음"을 나타낼 뿐이므로 응답 본문이 없으며, 상태 코드로만 포함되므로 전송 크기가 매우 작다.

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

 

 

이 응답을 받은 클라이언트는 저장된 오래된 응답을 다시 신선한 상태로 되돌리고 남은 1시간 동안 이를 재사용할 수 있다.

 

서버는 운영 체제 파일 시스템에서 수정 시간을 가져올 수 있으며, 정적 파일을 제공하는 경우 이는 비교적 쉽게 수행된다.

그러나 몇 가지 문제가 존재한다.

예를 들어, 시간 형식이 복잡하고 구문 분석이 어렵다는 점, 분산된 서버 간에 파일 수정 시간을 동기화하는 것이 어렵다는 점 등이 있다.

 

이러한 문제를 해결하기 위해 ETag 응답 헤더가 표준화되어 대안으로 사용된다.

 


ETag/If-None-Match

ETag 응답 헤더의 값은 서버에서 생성한 임의의 값이다.

서버가 값을 생성하는 방법에는 제한이 없으므로 서버는 본문 내용의 해시나 버전 번호 등 원하는 수단을 기반으로 값을 설정할 수 있다.

 

예를 들어, ETag 헤더에 해시 값이 사용되고 index.html 리소스의 해시 값이 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와 리소스의 최신 버전으로 응답한다.