[Network] HTTP 레벨 캐싱(헤더 기반 캐시 동작 원리)

개요

같은 데이터를 매번 서버까지 가서 새로 받아오는 것은 낭비다. 한 번 받은 응답이 당분간 바뀌지 않는다면, 그 응답을 가까운 곳에 저장해 두고 재사용하는 편이 빠르고 이점이 크다. HTTP(HyperText Transfer Protocol) 레벨 캐싱은 이 재사용을 애플리케이션 코드가 아니라 프로토콜로 자신이 정의한 헤더로 제어하는 방식이다.

핵심은 캐시 주체가 서버가 아니라는 점이다. 응답에 붙은 몇 개의 헤더만 보고 브라우저, 프록시(Proxy), 콘텐츠 전송망(CDN, Content Delivery Network)이 알아서 저장하고 재사용한다. 서버는 이 응답을 믿고 써도 되는지만 헤더로 선언하면 된다. 그래서 같은 코드, 같은 서버라도 헤더 한 줄 차이로 트래픽이 절반으로 줄기도 하고, 캐시가 동작하지 않아 요청이 서버까지 도달하기도 한다.

HTTP 레벨 캐싱이 노리는 효과는 세 가지다. 응답이 가까운 캐시에서 즉시 나가므로 지연(Latency)이 줄고, 서버까지 도달하는 요청 수가 줄어 서버 부하가 내려가며, 본문을 다시 전송하지 않으므로 네트워크 비용이 절감된다. 정적 자산(이미지, 자바스크립트, 스타일시트)을 많이 쓰는 서비스일수록 이 효과가 크다.

HTTP 캐싱이 어떻게 동작하는지, 캐시가 어디에 있는지, 어떤 헤더로 무엇을 제어하는지, 그리고 정적 자산과 API(Application Programming Interface) 응답에 각각 어떤 전략을 써야 하는지를 정리한다.

용어의미
유효유효기간 안이라 서버에 묻지 않고 바로 써도 되는 상태
만료유효기간이 지나 그대로 쓰기 전에 재검증이 필요한 상태
유효성응답을 언제까지 그대로 써도 되는지를 정하는 규칙
재검증(Validation)저장본이 아직 유효한지 서버에 조건부로 묻는 절차
검증자(Validator)변경 여부 판단 기준이 되는 값(ETag, Last-Modified)
Private 캐시한 사용자 전용 캐시(브라우저)
Shared 캐시여러 사용자가 공유하는 캐시(CDN, 프록시)

HTTP 레벨 캐싱이란

HTTP 레벨 캐싱은 HTTP 응답 헤더로 캐시 동작을 제어하는 방법이다. 응답에 Cache-Control, ETag, Last-Modified, Vary 같은 헤더가 붙으면, 그 응답을 받은 중간 노드와 브라우저가 헤더 규칙에 따라 저장하고 재사용한다. 캐시 키는 기본적으로 요청 메서드와 URL(Uniform Resource Locator)이며, Vary 헤더가 있으면 특정 요청 헤더까지 키에 포함된다.

애플리케이션 캐싱과 다른 점은 제어 위치와 저장 대상이다. 애플리케이션 캐싱은 서버 직접 Redis나 로컬 메모리에 임의의 데이터(쿼리 결과, 계산값)를 관리하는 것이다. 반면 HTTP 캐싱은 서버 바깥(브라우저, CDN, 프록시)에서 일어나며 저장 대상은 HTTP 응답 그 자체다. 서버는 키를 직접 지우지 못하고, 유효기간이나 검증자를 통해 간접적으로 무효화한다.

가장 단순한 예는 응답에 유효기간을 명시하는 것이다.

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=3600
 
body...

Cache-Control: max-age=3600은 이 응답을 3600초 동안 그대로 써도 된다는 의미이다. 브라우저는 같은 URL을 3600초 안에 다시 요청할 때 서버로 요청하지 않고 디스크에 저장된 응답을 즉시 꺼내 쓴다. 서버 입장에서는 요청이 오지 않으므로 부하를 절감할 수 있다.

무엇이 캐시되는가

캐시는 요청 메서드와 응답 상태 코드, 그리고 헤더 조건을 함께 보고 저장 여부를 정한다.

메서드 기준으로는 조회성인 GET과 HEAD가 기본 캐시 대상이다. POST, PUT, DELETE처럼 상태를 바꾸는 메서드의 응답은 원칙적으로 캐시하지 않는다. 같은 요청을 다시 보냈을 때 같은 결과가 보장되지 않기 때문이다. (멱등성)

Authorization 헤더가 붙은 요청의 응답은 Shared 캐시가 저장하지 않는다. 사용자별 응답이 공유 캐시에 남는 것을 막기 위한 장치다. 공개해도 되는 응답이라면 Cache-Control: public으로 저장을 명시적으로 허용한다.

구분기본 캐시 대상비대상
메서드GET, HEADPOST, PUT, DELETE
상태 코드200, 301, 404500 등 일시 오류
인증 응답public 명시 시Authorization 기본

캐시 위치

캐시는 브라우저부터 서버 앞단까지 여러 계층에 동시에 존재하며, 요청은 가장 가까운 캐시부터 차례로 사용된다.

종류위치공유 범위관련 디렉티브
Private 캐시브라우저한 사용자 전용private, max-age
Shared 캐시CDN, 프록시, 게이트웨이여러 사용자 공유public, s-maxage

이 구분이 중요한 이유는 사용자별 응답 때문이다. 로그인한 사용자의 개인 정보가 담긴 응답을 Shared 캐시에 저장해 버리면, 다른 사용자가 같은 URL을 요청했을 때 남의 데이터를 받는 일이 발생할 수 있다. 그래서 사용자별로 달라지는 응답은 Cache-Control: private로 표시해 브라우저만 저장하게 막고, 모두에게 동일한 정적 자산만 public으로 열어 CDN이 공유 저장하도록 한다.

브라우저 캐시에서 적중(hit)하면 네트워크로 나가지도 않고, CDN에서 적중하면 origin 서버까지 가지 않는다.

계층이 깊어질수록 hit 시 이득이 크다. 브라우저 캐시 hit는 네트워크 왕복 자체를 없애고, CDN hit는 사용자와 지리적으로 가까운 노드에서 응답하므로 origin 왕복 지연을 없앤다. 반대로 모든 계층을 통과하면 origin 서버가 요청을 처리한다.

유효성

유효성은 캐시가 저장한 응답을 언제까지 서버에 묻지 않고 그대로 써도 되는지를 정한다. 이 구간 안에서는 네트워크 요청이 아예 발생하지 않으므로 가장 빠르다.

헤더적용 대상우선순위
Cache-Control: s-maxage=NShared 캐시 전용가장 높음
Cache-Control: max-age=N모든 캐시중간
Expires: <날짜>모든 캐시(구형)가장 낮음

max-age는 응답을 받은 시점부터 N초 동안 사용 가능하다는 뜻이다. s-maxage는 CDN 같은 Shared 캐시에만 적용되는 별도 기간으로, 브라우저는 max-age를 따르고 CDN은 s-maxage를 분리할 수 있다. Expires는 절대 시각으로 만료를 지정하던 예전 방식인데, 서버와 클라이언트의 시계가 어긋나면 부정확하므로 max-age를 우선으로 한다.

HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=600, s-maxage=86400

이 응답은 브라우저에서 600초, CDN에서 86400초(하루) 동안 이용 가능하다는 의미이다. 사용자 브라우저는 비교적 자주 재검증하되, CDN은 하루 동안 origin 부하 없이 응답을 처리한다.

유효 시간 계산

Shared 캐시는 응답을 저장한 시점만 보지 않고, 그 응답이 origin에서 생성된 뒤 지금까지 얼마나 흘렀는지를 누적해서 본다. 이 누적 경과 시간이 Age 이다. 캐시는 응답을 내보낼 때 Age를 함께 실어, 이 응답이 캐시 계층에 얼마나 존재했는지 알린다.

남은 시간은 유효기간에서 현재 age를 뺀 값이다. max-age=600인 응답의 Age가 580이면 앞으로 20초만 사용가능하고, 그 뒤에는 만료되어 재검증으로 넘어간다. 그래서 같은 응답이라도 받는 시점에 따라 남은 캐싱 시간이 다르다.

HTTP/1.1 200 OK
Cache-Control: max-age=600
Age: 580
Date: Sun, 21 Jun 2026 09:00:00 GMT

Date는 origin이 응답을 생성한 시각이고 Age는 그 이후 캐시에 머문 시간이다. 브라우저 앞에 여러 CDN 노드가 있는 다단계 캐시를 거치면 각 계층의 머문 시간이 Age에 합산되므로, 클라이언트는 응답이 얼마나 오래된 것인지를 Age 하나로 가늠한다.

재검증

캐싱 시간이 지나면 캐시는 저장본을 버리지 않고, 서버에 아직 유효한지 묻는다. 서버에서 바뀐 게 없다고 답하면 본문을 다시 받지 않고 저장본을 그대로 재사용한다. 본문 전송을 생략한다는 점이 재검증의 핵심 이득이다.

검증자응답 헤더(서버가 내려줌)조건부 요청 헤더(캐시가 보냄)
ETag(Entity Tag)ETag: "v1"If-None-Match: "v1"
수정 시각Last-Modified: <날짜>If-Modified-Since: <날짜>

ETag는 응답 본문의 내용을 식별하는 토큰이다. 내용이 바뀌면 ETag도 바뀐다. 캐시는 다음 재검증 때 가지고 있던 ETag를 If-None-Match에 담아 보낸다. 서버는 현재 ETag와 비교해 같으면 본문 없이 304 Not Modified만 돌려주고, 다르면 새 본문과 새 ETag를 200 OK로 돌려준다.

GET /style.css HTTP/1.1
If-None-Match: "v1"
HTTP/1.1 304 Not Modified
ETag: "v1"

요청 한 번의 전체 흐름

다음은 브라우저, CDN, origin 서버 사이의 메시지 흐름이다.

sequenceDiagram
    autonumber
    participant B as 브라우저
    participant C as 공유 캐시(CDN)
    participant O as Origin 서버

    B->>C: GET /style.css
    Note over C: 저장본이 max-age 이내(유효)
    C-->>B: 200 OK (캐시에서 즉시 반환)

    B->>C: GET /style.css (유효기간 경과 후)
    Note over C: 저장본이 만료 상태
    C->>O: GET /style.css, If-None-Match: "v1"
    alt 내용 변경 없음
        O-->>C: 304 Not Modified (본문 없음)
        Note over C: 저장본을 다시 유효 상태로 갱신
        C-->>B: 200 OK (캐시 본문 재사용)
    else 내용 변경됨
        O-->>C: 200 OK, ETag: "v2" (새 본문)
        Note over C: 새 본문으로 교체 저장
        C-->>B: 200 OK (새 본문)
    end

첫 요청은 캐싱 구간 안이라 CDN에서 즉시 끝난다. 유효기간이 지난 다음 요청부터는 CDN이 origin에 조건부 요청을 보내고, 변경이 없으면 304로 본문 전송을 생략한다. 즉 캐싱 구간에서는 네트워크 자체를 아끼고, 만료 후에는 본문 전송을 아끼는 식으로 단계별 절감이 이어진다.

Cache-Control

Cache-Control은 HTTP 캐싱의 중심 헤더이며 여러 디렉티브를 콤마로 나열한다. 자주 쓰는 것을 정리하면 다음과 같다.

디렉티브의미
max-age=NN초 동안 유효(모든 캐시)
s-maxage=NShared 캐시 전용 유효기간
no-cache저장은 하되 쓰기 전 매번 재검증
no-store저장 자체 금지
private브라우저(Private 캐시)만 저장 허용
publicShared 캐시도 저장 허용
must-revalidate만료 상태면 반드시 재검증, 저장본 임의 사용 금지
stale-while-revalidate=N일단 만료 응답을 주고 백그라운드로 갱신
immutable유효 구간에는 재검증조차 하지 않음

no-cache는 저장은 하되 쓸 때마다 서버에 재검증을 거치라는 뜻이라, 변경이 없으면 304로 캐싱 값을 사용한다. 반면 no-store는 어디에도 저장하지 말라는 것으로 민감한 개인 정보나 금융 응답에 쓴다.

HTTP/1.1 200 OK
Cache-Control: no-store

stale-while-revalidate는 응답 지연을 줄이는 데 유용하다. 유효기간이 막 지난 요청에서 사용자를 기다리게 하지 않고 저장본을 먼저 내보낸 뒤, 백그라운드에서 조용히 새 응답을 받아 다음 요청에 대비한다.

요청에 Cache-Control: no-cachemax-age=0을 담으면 캐시에 저장본을 그냥 쓰지 말고 재검증부터 하라고 요구하는 뜻이다. 브라우저의 강력 새로고침이 이 방식으로 동작한다.

Vary: 같은 URL, 다른 응답

캐시 키는 기본적으로 메서드와 URL이다. 그런데 같은 URL이라도 요청 헤더에 따라 응답이 달라지는 경우가 있다. 압축 여부(Accept-Encoding)나 언어(Accept-Language)가 대표적이다. 이때 Vary 헤더로 어떤 요청 헤더가 다르면 다른 캐시 항목으로 취급할지를 알려야 한다.

HTTP/1.1 200 OK
Content-Encoding: gzip
Vary: Accept-Encoding

Vary: Accept-Encoding이 있으면 캐시는 gzip을 받는 클라이언트용 응답과 압축을 받지 못하는 클라이언트용 응답을 별도로 저장한다. 만약 이 헤더가 없으면, gzip 응답이 압축을 지원하지 않는 클라이언트에게 그대로 전달되어 본문이 깨질 수 있다.

만료 응답 활용

유효기간이 지난 응답이라도 상황에 따라 잠깐 더 쓰는 편이 사용자 경험에 나을 수 있다. 이를 위한 두 디렉티브가 stale-while-revalidatestale-if-error다.

HTTP/1.1 200 OK
Cache-Control: max-age=60, stale-while-revalidate=600, stale-if-error=86400

stale-while-revalidate=600은 유효기간이 지난 뒤 600초 동안은 일단 저장본을 즉시 내보내고, 백그라운드에서 새 응답을 받아 둔다. 사용자는 만료된 응답을 기다리지 않고 빠른 응답을 받으며, 다음 요청부터 갱신된 응답을 받는다.

stale-if-error=86400은 origin이 오류를 내거나 응답하지 못할 때, 만료된 저장본을 하루 동안 대신 내보내도 된다는 허용이다. origin 장애가 사용자 화면의 오류로 번지지 않도록 막는 완충 장치다. 두 디렉티브 모두 과거의 응답을 잠깐 허용하는 대신 지연과 장애를 견디는 장치이다.

유효에서 만료로

저장된 응답 하나의 일생을 따라가면 캐싱 동작이 한눈에 들어온다. 응답은 저장 직후 유효 상태로 시작해, 유효기간이 지나면 만료 상태로 넘어가고, 재검증 결과에 따라 다시 유효 상태가 되거나 새 본문으로 교체된다.

상태 전이를 말로 풀면 다음과 같다. max-age 또는 s-maxage로 정해진 기간 안에서는 유효 상태라 서버에 묻지 않고 즉시 응답한다. 기간이 지나면 만료 상태가 되는데, 이때 응답을 버리는 것이 아니라 검증자를 들고 서버에 조건부 요청을 보낸다. 서버가 304를 주면 저장본은 다시 유효 상태로 되살아나고, 200과 새 본문을 주면 기존 저장본을 교체한 뒤 다시 유효 상태에서 일생을 새로 시작한다.

여기서 자주 하는 오해가 만료를 곧 삭제로 여기는 것이다. 만료는 그대로 쓰면 안 된다는 표시일 뿐, 재검증을 위한 검증자는 여전히 살아 있다. 그래서 변경이 없는 자원은 만료되어도 본문 재전송 없이 304만으로 계속 재사용된다.

애플리케이션 캐싱과의 차이

HTTP 레벨 캐싱과 애플리케이션 캐싱은 계층이 다르다. 둘을 함께 쓰면 가장 바깥(HTTP)에서 요청 자체를 걷어내고, 안쪽(애플리케이션)에서 남은 요청의 처리 비용을 줄인다.

구분HTTP 레벨 캐싱애플리케이션 캐싱(Redis 등)
제어HTTP 헤더서버 코드 로직
위치브라우저, CDN, 프록시서버 내부 또는 외부 저장소
대상HTTP 응답 전체임의 데이터(쿼리 결과, 계산값)
메서드 + URL + Vary 헤더코드가 정의한 임의 키
무효화유효기간 만료, 검증자 재검증코드가 키를 직접 삭제 또는 갱신
적중 시서버에 요청이 도달하지 않을 수 있음서버 안에서 DB 접근만 생략

가장 바깥의 CDN이 HTTP 캐싱으로 정적 리소스를, 그 안쪽 애플리케이션이 로컬 캐시, Redis로 남은 요청을 응답하여 원천 DB 조회를 줄인다. 바깥 계층에서 캐싱될수록 안쪽 계층에 도달하는 요청이 줄어들어, 두 캐시로 부하를 효과적으로 낮출 수 있다.

애플리케이션 캐싱은 데이터가 바뀌는 순간 코드가 해당 키를 즉시 지울 수 있어 정합성을 빠르게 맞춘다. 반면 HTTP 캐싱은 이미 브라우저나 CDN에 퍼진 응답을 서버가 관리하지 못한다. 그래서 자주 바뀌는 자원에 긴 max-age를 걸면, 내용이 바뀐 뒤에도 사용자가 옛 응답을 보게 되므로 주기를 어떻게 가져갈 것인지에 대한 고민이 필요하다.

캐싱 전략

캐싱은 한 곳에 두지 않고 요청 경로 위 여러 계층에 겹쳐 둔다. 바깥은 HTTP 레벨 캐싱(브라우저, CDN)이 맡고, 안쪽은 애플리케이션 캐싱(로컬 캐시, Redis)이 맡으며, 가장 안쪽 원본은 DB가 보관한다. 각 계층은 같은 Cache-Aside 읽기 흐름을 따르되, 무효화 방식만 계층 특성에 맞게 다르다.

요청은 바깥 계층부터 차례로 들른다. 각 계층은 자기 저장본을 먼저 확인하고, 있으면 즉시 응답하며, 없으면 다음 안쪽 계층으로 요청을 넘긴다. 응답은 반대로 안쪽에서 바깥으로 돌아오며 거쳐 온 계층을 채운다. 그래서 바깥 계층이 많이 캐싱될수록 안쪽 계층과 DB에 도달하는 요청이 줄어 부하를 낮춘다.

계층별 역할과 전략

계층컴포넌트캐시 종류전략
HTTP 레벨브라우저Private HTTP 캐시Cache-Aside 읽기, max-age·ETag
HTTP 레벨CDN / 프록시Shared HTTP 캐시Cache-Aside 읽기, s-maxage·ETag
애플리케이션애플리케이션 서버 + 로컬 캐시 / Redis임의 키 캐시Cache-Aside, 키 만료·무효화
원본DB원본 저장소변경의 원천

HTTP 레벨 캐싱은 응답 전체를 메서드와 URL 기준으로 저장하고, 애플리케이션 캐싱은 쿼리 결과 같은 임의 데이터를 코드가 정한 키로 저장한다. 저장 대상과 키가 다를 뿐, 먼저 캐시를 보고 없을 때 원본을 채우는 읽기 흐름은 동일하다.

Cache-Aside

Cache-Aside(Look-Aside)는 먼저 캐시를 조회하고, 없으면 원본에서 읽어 캐시에 채운 뒤 반환하는 패턴이다. 캐시가 죽어도 원본 조회로 이어지므로 가용성이 높다. 애플리케이션 캐싱 계층을 코드로 옮기면 다음과 같다.

fun getArticle(id: Long): Article {
    cache.get(id)?.let { return it }              // 1. 캐시 먼저 조회
    val article = articleRepository.findById(id)  // 2. 없으면 원본 조회
    cache.put(id, article)                        // 3. 캐시에 채워 둠
    return article
}

브라우저와 CDN의 HTTP 캐싱도 같은 구조다. 캐시가 먼저 저장본을 보고, 없으면 origin에서 읽어 응답을 저장한다.

계층마다 다른 무효화

읽기 흐름은 같아도 원본이 바뀌었을 때 캐시를 비우는 방식은 계층마다 다르다.

애플리케이션 캐싱은 데이터가 바뀌는 순간 코드가 해당 키를 즉시 삭제하거나 갱신할 수 있어 정합성을 빠르게 맞춘다. 반면 HTTP 캐싱은 이미 브라우저나 CDN에 퍼진 응답을 직접 관리하지 못한다. 그래서 유효기간 만료나 재검증으로 리프레시하고, 즉시 회수가 필요하면 CDN 무효화 기능을 쓴다. 바깥 계층일수록 관리가 어려우므로, 자주 바뀌는 자원에는 긴 유효기간을 걸지 않는 것이 안전하다.