왜 동적 콘텐츠 캐싱이 어려운가
애플리케이션 서버는 요청마다 DB 조회, 템플릿 렌더링, 외부 API 호출을 반복한다. 트래픽이 몰리면 이 구간이 병목이 되어 응답 지연과 커넥션 고갈이 발생한다. 정적 파일은 CDN으로 밀어내면 되지만, 동적 콘텐츠는 "매 요청 새로 만들어야 한다"는 인식 때문에 캐싱 대상에서 제외되곤 한다.
하지만 상품 상세, 뉴스 기사, 카테고리 목록처럼 초 단위로는 거의 변하지 않는 페이지가 대부분이다. 이런 콘텐츠를 리버스 프록시 앞단에서 수 초만 캐싱해도 오리진 부하는 극적으로 줄어든다. Varnish는 이 지점을 위한 인메모리 HTTP 가속기다.
Varnish가 기본 캐싱을 포기하는 조건
실무에서 가장 흔한 함정은 "Varnish를 붙였는데 hit이 안 난다"는 것이다. 원인은 대부분 두 가지다. Varnish는 기본 정책상 요청에 Cookie 헤더가 있거나 Authorization이 있으면 캐싱하지 않고, 응답에 Set-Cookie가 있으면 저장하지 않는다.
세션 쿠키 하나만 붙어도 모든 요청이 오리진으로 흘러간다는 뜻이다. 따라서 동적 캐싱의 핵심은 "어떤 요청을 캐시 대상으로 볼 것인가"를 VCL에서 명시적으로 재정의하는 작업이다.
VCL로 캐시 대상 재정의하기
아래는 로그인 세션과 무관하게 공개 페이지를 캐싱하는 최소 구성이다. 인증이 필요한 경로는 통과시키고, 나머지는 추적용 쿠키를 제거해 캐시 키를 안정화한다.
vcl 4.1;
backend default {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
# 로그인/장바구니 등 개인화 경로는 캐싱 우회
if (req.url ~ "^/(account|cart|checkout)") {
return (pass);
}
# GET/HEAD 외에는 캐싱하지 않음
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# 분석용 쿠키(_ga 등)만 있으면 제거해 캐시 히트를 유도
set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(_ga|_gid|utm_[^;]+)=[^;]*", "");
if (req.http.Cookie ~ "^\s*$") {
unset req.http.Cookie;
}
}
여기서 중요한 건 쿠키를 무작정 지우는 게 아니라, 개인화에 영향을 주지 않는 쿠키만 골라 제거한다는 점이다. 세션 쿠키가 남아 있으면 vcl_recv에서 return(pass)로 안전하게 우회시키는 것이 원칙이다.
TTL은 오리진이 정하게 한다
TTL을 VCL에 하드코딩하면 콘텐츠 종류별로 관리가 어려워진다. 권장 방식은 오리진 애플리케이션이 Cache-Control 또는 Surrogate-Control 헤더로 캐시 수명을 내려주고, Varnish가 이를 따르는 것이다.
from flask import Flask, make_response
app = Flask(__name__)
@app.route("/products/<pid>")
def product(pid):
body = render_product(pid)
resp = make_response(body)
# 브라우저에는 캐시 금지, Varnish에만 30초 캐싱 지시
resp.headers["Cache-Control"] = "no-store"
resp.headers["Surrogate-Control"] = "max-age=30"
return resp
Surrogate-Control은 중간 캐시(Varnish)만 해석하고 브라우저는 무시한다. 덕분에 사용자 브라우저에는 항상 최신 페이지를 주면서, 오리진과 Varnish 사이에서만 30초 캐싱하는 분리가 가능하다. Varnish는 응답 처리 후 이 헤더를 제거해 클라이언트로 새지 않게 한다.
변경 즉시 반영: 무효화 전략
30초 TTL이라도 "가격을 방금 바꿨는데 반영이 안 된다"는 요구는 반드시 나온다. 이때 필요한 것이 능동적 무효화다. Varnish는 두 가지 방식을 제공한다.
| 방식 | 동작 | 적합한 경우 |
|---|---|---|
| PURGE | 특정 URL의 캐시를 즉시 삭제 | 단일 페이지 갱신 |
| BAN | 정규식 조건에 맞는 항목을 무효화 표시 | 여러 URL 일괄 무효화 |
sub vcl_recv {
if (req.method == "PURGE") {
if (client.ip !~ purge_acl) {
return (synth(403, "Forbidden"));
}
return (purge);
}
if (req.method == "BAN") {
ban("obj.http.x-url ~ " + req.http.x-ban-pattern);
return (synth(200, "Banned"));
}
}
상품 정보를 수정하는 애플리케이션 로직에서 PURGE /products/123 요청을 Varnish로 보내면 해당 캐시만 즉시 사라진다. BAN은 obj.http.x-url 같은 헤더를 응답에 심어 두어야 매칭이 동작하니, vcl_backend_response에서 URL을 헤더로 저장해 두는 준비가 필요하다.
운영에서 놓치기 쉬운 주의점
- Vary 헤더 폭발:
Vary: Cookie나 다양한Accept-Encoding은 캐시 키를 쪼개 히트율을 떨어뜨린다. 인코딩은 gzip/그 외로 정규화하라. - PURGE 권한: 무효화 엔드포인트는 반드시 ACL로 내부망만 허용한다. 외부에 열리면 캐시 무력화 공격에 노출된다.
- Stale 서빙:
grace설정으로 오리진 장애 시에도 만료된 캐시를 잠시 제공하면, 백엔드가 죽어도 서비스가 버틴다. - 개인화 혼입: 로그인 사용자 이름 같은 개인 데이터가 공개 캐시에 섞이면 심각한 정보 노출이다. 개인화 경로는 항상
pass로 명확히 분리하라.
동적 캐싱은 "얼마나 오래"보다 "무엇을, 언제 버릴 것인가"의 설계다. 짧은 TTL과 정확한 무효화를 조합하면, 개인화를 해치지 않으면서 오리진 부하를 몇 분의 일로 줄일 수 있다.