CDN 캐시 키를 잘못 잡으면 생기는 문제

CDN 캐시 키를 잘못 잡으면 생기는 문제

한눈에 보기

CDN 캐시 키는 단순히 캐시 파일의 이름을 정하는 옵션이 아니다. 두 요청이 같은 응답을 받아도 되는지 판단하는 동등성 조건이다. 응답을 바꾸는 입력을 키에서 빼면 다른 언어·권한·사용자의 응답이 섞이고, 응답과 무관한 값을 너무 많이 넣으면 캐시 적중률이 무너진다. 이 글에서는 이 두 실패 사이에서 안전한 키를 설계하는 과정을 정리한다.

목차

캐시 키는 응답의 동등성 조건이다

CDN은 사용자와 원본 서버 사이에 있는 공유 캐시다. 어떤 요청이 들어왔을 때 CDN은 요청 전체를 원본에 그대로 보내기 전에 캐시 키를 계산한다. 그 키와 일치하는 신선한 객체가 있으면 원본 서버를 호출하지 않고 저장된 응답을 반환한다.

sequenceDiagram
    participant C as Client
    participant E as CDN Edge
    participant O as Origin

    C->>E: GET /articles/42?preview=false
    E->>E: cache key 계산
    alt fresh object 존재
        E-->>C: cached response
    else miss 또는 stale
        E->>O: origin request
        O-->>E: response + cache policy
        E->>E: object 저장
        E-->>C: response
    end

보통 URL의 호스트와 경로가 기본 키에 들어간다. CDN 정책에 따라 쿼리 문자열, 요청 헤더, 쿠키, 압축 형식 같은 값도 추가된다. 이를 수식처럼 단순화하면 다음과 같다.

K(request) =
    host
  + path
  + selected_query_parameters
  + selected_headers
  + selected_cookies
  + representation_variant

중요한 질문은 “무엇을 키에 넣을까?”보다 아래 문장에 가깝다.

캐시 키를 정할 때 던질 질문

이 두 요청의 캐시 키가 같다면, 어느 쪽에서 먼저 생성한 응답을 다른 쪽에 그대로 반환해도 올바른가?

이 질문에 아니오라고 답하게 만드는 요청 값은 키에 포함하거나, 엣지에서 작은 값으로 정규화하거나, 아예 공유 캐시 대상에서 제외해야 한다.

예를 들어 같은 /home 요청이라도 원본이 Accept-Language에 따라 한국어와 영어 HTML을 만든다면 두 요청은 같은 표현이 아니다. 반면 utm_source를 분석 로그에만 쓰고 페이지 내용에는 사용하지 않는다면 그 값은 응답을 나누는 기준이 아니다.

요청 값 원본 응답에 미치는 영향 일반적인 처리
locale=ko 본문 언어가 달라짐 정규화 후 키에 포함
page=2 결과 목록이 달라짐 키에 포함
utm_source=letter 분석에만 사용 원본 전달 가능, 키에서는 제외
세션 쿠키 사용자별 응답이 달라질 수 있음 공유 캐시 제외가 우선
Accept-Encoding 압축 표현이 달라짐 CDN의 압축 정책 사용
요청 ID 응답 본문과 무관 키에서 제외

키에서 값을 빠뜨리면 어떤 일이 생길까

가장 위험한 실패는 서로 달라야 할 응답이 같은 캐시 객체를 공유하는 것이다.

가령 원본 서버가 다음처럼 요청 헤더에 따라 환영 문구를 만든다고 하자.

type RequestLike = {
  headers: Record<string, string | undefined>;
};

function renderHome(request: RequestLike): string {
  const language = request.headers["accept-language"] ?? "en";

  if (language.startsWith("ko")) {
    return "<h1>오늘의 개발 글</h1>";
  }

  return "<h1>Today's engineering notes</h1>";
}

그런데 CDN 키가 host + path만 사용하면 다음 순서에서 문제가 생긴다.

sequenceDiagram
    participant K as Korean user
    participant E as Edge cache
    participant O as Origin
    participant U as English user

    K->>E: GET /home, Accept-Language: ko
    E->>O: cache miss
    O-->>E: 한국어 HTML
    E->>E: key=/home 로 저장
    E-->>K: 한국어 HTML
    U->>E: GET /home, Accept-Language: en
    E-->>U: 한국어 HTML 반환

언어가 잘못 보이는 정도라면 금방 발견할 수 있다. 그러나 아래처럼 권한이나 사용자를 기준으로 응답이 바뀌면 정보 노출 사고가 된다.

interface DashboardRequest {
  user?: {
    id: string;
    displayName: string;
    plan: "free" | "pro";
  };
}

function renderDashboard(request: DashboardRequest) {
  if (!request.user) {
    return { status: 401, body: { error: "login_required" } };
  }

  return {
    status: 200,
    body: {
      greeting: `${request.user.displayName}님, 반갑습니다.`,
      plan: request.user.plan,
    },
  };
}

/dashboard를 공개 캐시하고 인증 헤더나 세션을 키에서 제외하면 먼저 접근한 사용자의 응답이 뒤 사용자에게 전달될 수 있다. 그렇다고 사용자 ID나 액세스 토큰을 키에 넣으면 해결된다고 생각해서도 안 된다. 민감한 개인화 응답은 공유 캐시에 저장하지 않는 것이 기본값이어야 한다.

HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
사용자 ID를 키에 넣는 것만으로 안전해지지 않는다

키 생성 로직의 버그, 누락된 인증 변형, 로그에 남은 토큰, 낮은 적중률이라는 문제가 그대로 남는다. 공개 콘텐츠와 개인화 콘텐츠를 URL·캐시 동작 단위로 분리하는 편이 훨씬 안전하다.

반대로 너무 많이 넣어도 문제다

안전을 위해 요청의 모든 헤더, 쿠키, 쿼리 문자열을 키에 넣으면 어떨까. 응답이 섞일 가능성은 줄지만 사실상 요청마다 새 객체가 만들어진다.

/docs/cache?utm_source=newsletter
/docs/cache?utm_source=search
/docs/cache?utm_source=social

세 URL이 완전히 같은 문서를 반환하더라도 utm_source 전체를 키에 넣으면 세 객체가 따로 저장된다. 여기에 세션 쿠키와 상세한 User-Agent까지 들어가면 조합 수가 폭발한다.

variant count
  = locale(3)
  × device header(12)
  × campaign query(30)
  × session cookie(100,000)
  = 108,000,000

실제로 모든 조합이 나타나지 않더라도 객체가 지나치게 잘게 나뉜다. 각 객체는 몇 번 사용되지 못한 채 만료되고, 엣지는 원본 요청을 계속 발생시킨다.

과소 설계 과대 설계
다른 표현이 한 키에 합쳐짐 같은 표현이 여러 키로 분리됨
잘못된 언어·기기 응답 낮은 cache hit ratio
개인 정보나 권한 정보 노출 가능 원본 부하와 비용 증가
발견하기 어려운 간헐적 오류 캐시 용량 낭비
정확성과 보안 문제 성능과 비용 문제

따라서 목표는 키를 최대한 작게 또는 크게 만드는 것이 아니다. 응답을 유일하게 결정하는 최소 입력 집합을 찾는 것이다.

응답을 바꾸는 입력부터 찾는다

캐시 정책을 CDN 콘솔에서 바로 만들기보다 원본의 응답 결정 과정을 먼저 표로 적는 편이 좋다. 다음은 문서 목록 API를 가정한 예다.

GET /api/articles?page=2&sort=latest&utm_source=weekly
Accept-Language: ko-KR,ko;q=0.9,en;q=0.8
Cookie: session_id=example; theme=dark
Authorization: Bearer example-token

원본 코드를 따라가며 각 값이 상태 코드, 헤더, 본문 중 하나라도 바꾸는지 확인한다.

입력 원본에서 사용하는 목적 표현을 바꾸는가 결정
page 페이지네이션 키에 포함
sort 정렬 방식 허용 값을 정규화해 포함
utm_source 유입 분석 아니오 키에서 제외
Accept-Language 언어 선택 ko, en, ja로 축약
theme 쿠키 CSS 변수 선택 경우에 따라 URL 또는 작은 variant로 변경
session_id 로그인 판별 이 경로는 공유 캐시하지 않음
Authorization 사용자 권한 별도 비캐시 API로 분리

이때 “원본에 전달한다”와 “캐시 키에 포함한다”는 다른 결정이다. 분석을 위해 utm_source를 원본 로그로 보낼 수는 있지만, 응답을 바꾸지 않는다면 캐시 키에는 넣지 않는다. CloudFront 같은 CDN도 캐시 정책과 원본 요청 정책을 분리해 이 구분을 표현한다.

코드 검색만으로 끝내지 않는다

애플리케이션뿐 아니라 리버스 프록시, 엣지 함수, A/B 테스트 도구, 번역 미들웨어가 응답을 바꿀 수 있다. 최종 응답을 만드는 전체 경로를 확인해야 한다.

응답을 바꾸는 축이 너무 많다면 캐시 키가 아니라 API 경계가 잘못 잡힌 것일 수 있다. 공개 상품 정보와 사용자별 찜 여부를 한 응답에 섞는 대신 다음처럼 분리할 수 있다.

GET /api/products/42          # 공개 데이터, shared cache 가능
GET /api/me/favorites/42      # 개인화 데이터, private/no-store

프런트엔드가 두 응답을 합치면 공개 데이터는 높은 적중률을 유지하고 개인 데이터는 공유 캐시에 들어가지 않는다.

언어별 응답은 원본 헤더 대신 정규화한다

다국어 응답에서 흔한 첫 시도는 Accept-Language 전체를 캐시 키에 넣는 것이다. 하지만 실제 헤더 값은 브라우저와 사용자 설정에 따라 매우 다양하다.

Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Accept-Language: ko,en-US;q=0.9,en;q=0.8
Accept-Language: ko-KR

세 요청 모두 한국어 페이지를 반환해도 원문 문자열을 키에 사용하면 객체가 셋으로 나뉜다. 지원 언어가 ko, en, ja뿐이라면 엣지나 애플리케이션 입구에서 하나로 정규화한다.

const SUPPORTED_LOCALES = new Set(["ko", "en", "ja"]);

function normalizeLocale(
  pathLocale: string | undefined,
  acceptLanguage: string | undefined,
): "ko" | "en" | "ja" {
  if (pathLocale && SUPPORTED_LOCALES.has(pathLocale)) {
    return pathLocale as "ko" | "en" | "ja";
  }

  const candidates = (acceptLanguage ?? "")
    .split(",")
    .map((entry) => entry.trim().split(";")[0])
    .map((tag) => tag.toLowerCase().split("-")[0]);

  return candidates.find((tag) => SUPPORTED_LOCALES.has(tag)) as
    | "ko"
    | "en"
    | "ja"
    | undefined ?? "en";
}

더 명시적인 방법은 locale을 URL에 넣는 것이다.

/ko/articles/cache-key
/en/articles/cache-key
/ja/articles/cache-key

이 방식은 CDN뿐 아니라 검색 엔진, 링크 공유, 서버 렌더링, 클라이언트 라우팅이 모두 같은 언어를 바라보게 한다. locale을 쿠키·경로·헤더에서 각각 추론할 때 생기는 불일치도 줄어든다. 여러 런타임에 locale을 동기화하는 문제는 다국어 앱에서 locale을 여러 런타임에 동기화하기에서 따로 정리했다.

HTTP/1.1 200 OK
Content-Language: ko
Vary: Accept-Encoding
Cache-Control: public, s-maxage=300

경로가 이미 언어를 구분한다면 Accept-Language를 다시 Vary나 CDN 키에 넣을 필요가 없다. 같은 정보를 중복해서 키에 넣으면 캐시만 더 잘게 나뉜다.

쿼리 문자열은 허용 목록으로 다룬다

쿼리 문자열 전체를 제외하면 page=1page=2가 같은 응답으로 합쳐질 수 있다. 반대로 전체를 포함하면 추적 파라미터, 순서, 의미 없는 기본값 때문에 같은 응답이 여러 객체로 나뉜다.

/articles?page=1&sort=latest
/articles?sort=latest&page=1
/articles?page=1&sort=latest&utm_source=mail
/articles?page=1&sort=latest&debug=false

원본이 네 요청에 같은 응답을 준다면 캐시 키도 같아야 한다. 해결 방법은 “모두 포함”보다 응답에 영향을 주는 파라미터의 허용 목록을 만들고 값을 정규화하는 것이다.

const ALLOWED_SORTS = new Set(["latest", "popular"]);

function normalizeArticleQuery(rawUrl: string): string {
  const url = new URL(rawUrl, "https://example.test");

  const rawPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
  const page = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;

  const requestedSort = url.searchParams.get("sort") ?? "latest";
  const sort = ALLOWED_SORTS.has(requestedSort) ? requestedSort : "latest";

  const normalized = new URLSearchParams();
  normalized.set("page", String(page));
  normalized.set("sort", sort);

  return normalized.toString();
}

console.log(
  normalizeArticleQuery("/articles?utm_source=mail&sort=latest&page=01"),
);
// page=1&sort=latest

정규화에는 다음 정책이 명확해야 한다.

  1. 어떤 파라미터가 응답을 바꾸는가
  2. 누락됐을 때 기본값은 무엇인가
  3. 허용하지 않는 값은 거부할지 기본값으로 바꿀지
  4. 배열과 중복 파라미터의 순서는 의미가 있는가
  5. 대소문자와 퍼센트 인코딩을 어떻게 다룰 것인가
CDN과 원본이 서로 다르게 정규화하면 안 된다

엣지는 page=01page=1과 같은 키로 보는데 원본은 다르게 처리한다면 먼저 채워진 응답이 둘 다에 사용된다. 정규화 결과를 원본 요청에도 반영하거나, 애초에 표준 URL로 리다이렉트해 하나의 계약으로 만든다.

검색 API처럼 쿼리 자체가 결과를 결정한다면 당연히 포함해야 한다. 다만 무제한 문자열을 장기간 캐시하면 객체 수가 커질 수 있으므로 TTL, 요청 빈도, 최대 길이, 허용 문자도 함께 제한한다.

쿠키와 인증 응답은 캐시 가능성부터 판단한다

쿠키는 편리하지만 캐시 키 관점에서는 위험한 입력이다. 세션 ID처럼 요청마다 다른 쿠키를 포함하면 사실상 사용자별 캐시가 만들어진다. 반대로 키에서 제외한 채 원본 응답에 영향을 주게 두면 사용자 응답이 섞인다.

쿠키별 의도를 먼저 분류한다.

쿠키 예시 목적 권장 처리
session_id 인증 세션 개인화 경로는 공유 캐시 제외
locale 언어 선택 경로 또는 정규화 헤더로 승격
theme 화면 테마 클라이언트 적용 또는 작은 variant
experiment A/B 그룹 검증된 제한 값만 키에 포함
_analytics_id 방문 분석 키에서 제외, 필요 시 원본에만 전달

인증된 요청은 특히 보수적으로 다룬다. HTTP 캐시 규격은 Authorization이 있는 요청의 응답을 공유 캐시가 재사용하는 조건을 제한한다. 애플리케이션이 공개 캐시를 의도적으로 허용하는 지시자를 정확히 보내지 않는 한, 인증 응답을 공유 캐시한다고 기대하면 안 된다.

공개 데이터와 사용자별 데이터를 분리하기 어렵다면 다음처럼 명시적으로 비공개 처리한다.

function sendPrivateProfile(response: {
  setHeader(name: string, value: string): void;
  json(body: unknown): void;
}) {
  response.setHeader("Cache-Control", "private, no-store");
  response.setHeader("Pragma", "no-cache");
  response.json({
    name: "example-user",
    notificationCount: 3,
  });
}

private는 공유 캐시가 저장하면 안 된다는 뜻이고, no-store는 캐시가 응답을 저장하지 말라는 더 강한 지시다. 다만 CDN의 최소 TTL 설정이 원본 헤더를 덮어쓰는 제품 설정도 있으므로 헤더만 믿지 말고 실제 배포 정책을 확인해야 한다.

액세스 토큰을 캐시 키에 직접 포함하지 않는다

토큰은 높은 카디널리티 때문에 적중률을 없애고, 캐시 로그나 디버깅 화면에 노출될 가능성도 만든다. 인증된 개인 응답은 캐시 동작을 분리하고 저장하지 않는 것이 기본이다.

서명 URL이나 서명 쿠키를 사용한다고 해서 콘텐츠가 자동으로 사용자별로 안전하게 분리되는 것도 아니다. 서명은 “이 객체에 접근할 수 있는가”를 검증할 뿐, 원본이 사용자별로 다른 본문을 만들 때 필요한 캐시 키 설계를 대신하지 않는다.

Vary와 CDN 정책의 역할을 구분한다

HTTP 응답의 Vary 헤더는 어떤 요청 헤더에 따라 표현이 달라지는지 캐시에 알려 준다.

HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=300
Vary: Accept-Encoding, Accept-Language
Content-Type: text/html; charset=utf-8

이 응답은 압축 방식과 언어가 다르면 기존 응답을 그대로 재사용하지 말아야 한다는 의미다. 그러나 운영 중인 CDN이 Vary를 어떻게 해석하는지, 캐시 정책에서 어떤 헤더를 명시적으로 포함하는지 확인해야 한다. 제품 설정과 원본 헤더가 어긋나면 “헤더를 보냈으니 안전할 것”이라는 가정이 깨질 수 있다.

또한 Vary에 원본 헤더를 무작정 추가하는 것도 좋지 않다.

Vary: User-Agent

User-Agent는 값의 종류가 많아 같은 모바일 UI도 수많은 객체로 나눌 수 있다. 원본 문자열 대신 엣지에서 mobile, desktop처럼 제한된 값으로 분류하는 편이 낫다.

function classifyDevice(userAgent: string | undefined): "mobile" | "desktop" {
  if (!userAgent) return "desktop";
  return /Android|iPhone|Mobile/i.test(userAgent) ? "mobile" : "desktop";
}

다만 가능하면 반응형 HTML과 CSS를 사용해 기기별 HTML 캐시 자체를 없애는 것이 더 단순하다.

압축은 CDN의 내장 압축 정책을 우선 사용한다. gzip, br, 압축 없음이 서로 다른 바이트 표현이므로 구분은 필요하지만, CDN이 Accept-Encoding을 정규화해 키에 반영한다면 애플리케이션 정책에서 원문 헤더를 중복 추가하지 않는다.

도구 담당하는 질문
Cache-Control 저장해도 되는가, 얼마나 신선한가
Vary 어떤 요청 헤더가 표현을 구분하는가
CDN cache policy 실제 엣지 키에 무엇을 포함하는가
origin request policy 키와 무관하게 원본에 무엇을 전달하는가
엣지 함수 입력을 어떤 제한된 값으로 정규화하는가

재구성한 캐시 키 구현 예제

다음 예제는 특정 프로젝트 코드를 옮긴 것이 아니라, 공개 문서 목록을 캐시한다는 가정으로 재구성한 의사 코드다. 요구사항은 다음과 같다.

type ViewerRequest = {
  host: string;
  path: string;
  query: URLSearchParams;
  headers: Record<string, string | undefined>;
  cookies: Record<string, string | undefined>;
};

type CacheDecision =
  | { cacheable: false; reason: string }
  | { cacheable: true; key: string };

function buildCacheDecision(request: ViewerRequest): CacheDecision {
  const authorization = request.headers.authorization;
  const sessionId = request.cookies.session_id;

  if (authorization || sessionId) {
    return {
      cacheable: false,
      reason: "authenticated-or-session-request",
    };
  }

  const locale = normalizeLocale(
    request.headers["x-path-locale"],
    request.headers["accept-language"],
  );

  const page = normalizePositiveInteger(request.query.get("page"), 1);
  const sort = normalizeEnum(
    request.query.get("sort"),
    ["latest", "popular"] as const,
    "latest",
  );

  const version = normalizeVersion(
    request.headers["x-content-version"] ?? "stable",
  );

  const keyParts = [
    `host=${request.host.toLowerCase()}`,
    `path=${normalizePath(request.path)}`,
    `locale=${locale}`,
    `page=${page}`,
    `sort=${sort}`,
    `version=${version}`,
  ];

  return {
    cacheable: true,
    key: keyParts.join("|"),
  };
}

보조 함수는 입력을 그대로 신뢰하지 않고 제한된 결과만 반환한다.

function normalizePositiveInteger(
  value: string | null,
  fallback: number,
): number {
  if (value === null || !/^[0-9]+$/.test(value)) return fallback;

  const parsed = Number(value);
  if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1_000) {
    return fallback;
  }

  return parsed;
}

function normalizeEnum<const T extends readonly string[]>(
  value: string | null,
  allowed: T,
  fallback: T[number],
): T[number] {
  return value && allowed.includes(value) ? value : fallback;
}

function normalizeVersion(value: string): "stable" | "next" {
  return value === "next" ? "next" : "stable";
}

function normalizePath(path: string): string {
  const withoutDuplicateSlashes = path.replace(/\/{2,}/g, "/");
  return withoutDuplicateSlashes !== "/" && withoutDuplicateSlashes.endsWith("/")
    ? withoutDuplicateSlashes.slice(0, -1)
    : withoutDuplicateSlashes;
}

예상 결과를 테이블 테스트로 고정해 두면 정책 변경 때 회귀를 찾기 쉽다.

import { describe, expect, it } from "vitest";

describe("buildCacheDecision", () => {
  it.each([
    {
      name: "추적 파라미터는 키를 바꾸지 않는다",
      first: "?page=1&utm_source=mail",
      second: "?utm_source=search&page=01",
    },
    {
      name: "쿼리 순서는 키를 바꾸지 않는다",
      first: "?sort=latest&page=2",
      second: "?page=2&sort=latest",
    },
  ])("$name", ({ first, second }) => {
    const a = createPublicRequest(first);
    const b = createPublicRequest(second);

    expect(buildCacheDecision(a)).toEqual(buildCacheDecision(b));
  });

  it("페이지가 다르면 키도 달라진다", () => {
    const first = buildCacheDecision(createPublicRequest("?page=1"));
    const second = buildCacheDecision(createPublicRequest("?page=2"));

    expect(first).not.toEqual(second);
  });

  it("세션 요청은 공유 캐시하지 않는다", () => {
    const request = createPublicRequest("?page=1");
    request.cookies.session_id = "example-session";

    expect(buildCacheDecision(request)).toEqual({
      cacheable: false,
      reason: "authenticated-or-session-request",
    });
  });
});

실제 CDN에서는 임의 문자열 키를 직접 만드는 대신 캐시 정책의 헤더·쿠키·쿼리 허용 목록과 엣지 함수를 조합할 수 있다. 핵심은 제품 문법보다 위 테스트가 표현하는 계약이다.

# 특정 공급자 설정을 그대로 복사한 것이 아닌 개념 예제
cache_behavior:
  path_pattern: "/api/articles*"
  cache_policy:
    query_allow_list:
      - page
      - sort
    header_allow_list:
      - x-normalized-locale
      - x-content-version
    cookies: none
    compression:
      gzip: true
      brotli: true
    ttl:
      min_seconds: 0
      default_seconds: 300
      max_seconds: 3600
  origin_request_policy:
    query_allow_list:
      - page
      - sort
      - utm_source
    header_allow_list:
      - x-request-id

여기서 utm_sourcex-request-id는 원본에 전달되지만 캐시 키에는 들어가지 않는다. 원본이 이 값으로 본문을 바꾸지 않는다는 계약이 있기 때문이다.

TTL과 무효화도 키 설계의 일부다

키가 정확해도 오래된 객체가 지나치게 오래 남으면 잘못된 응답이 된다. 반대로 TTL이 너무 짧으면 매번 원본에 가까운 트래픽이 발생한다.

Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30

콘텐츠 특성에 따라 전략을 나눈다.

콘텐츠 키 예시 TTL·갱신 전략
해시가 붙은 JS/CSS /assets/app.a1b2.js 긴 TTL, immutable
기술 문서 HTML path + locale + version 중간 TTL, 배포 시 purge
인기 글 목록 path + page + sort 짧은 TTL, stale 허용
사용자 프로필 공유 캐시 없음 private/no-store
존재하지 않는 페이지 path 짧은 negative cache

정적 자산은 파일명에 콘텐츠 해시를 넣는 것이 가장 단순하다.

app.js           # 덮어쓰면 이전 캐시와 충돌
app.8f24c1.js    # 내용이 바뀌면 URL도 바뀜

새 배포 버전을 키에 넣는 방식도 blue/green 배포에서 유용하다. 구버전과 신버전 HTML 구조가 다른데 같은 키를 쓰면 전환 도중 응답이 섞일 수 있다. 다만 모든 배포마다 키 축을 무한히 늘리지 말고, 이전 버전 정리와 TTL 정책을 함께 둔다.

오류 응답도 주의한다. 순간적인 원본 장애에서 반환된 500을 길게 캐시하면 원본이 회복해도 장애가 계속되는 것처럼 보인다. 반대로 존재하지 않는 경로의 404를 짧게 캐시하면 반복적인 봇 요청을 줄일 수 있다. 상태 코드별 캐시 가능 여부와 TTL을 명시해야 한다.

최소 TTL이 원본 지시보다 강할 수 있다

일부 CDN 정책은 최소 TTL이 0보다 크면 원본의 no-cache, no-store, private보다 설정된 최소 TTL을 우선할 수 있다. 개인화 경로는 “원본 헤더가 알아서 막겠지”라고 두지 말고 캐시 동작 자체를 분리한다.

캐시 오염과 보안 문제를 점검한다

캐시 키 누락은 정확성 문제를 넘어 cache poisoning으로 이어질 수 있다. 공격자가 원본 응답을 바꾸는 입력을 보냈는데 그 입력이 캐시 키에는 포함되지 않으면, 공격자에게만 보여야 할 변형이 일반 키에 저장될 수 있다.

예를 들어 원본이 신뢰하지 말아야 할 헤더로 절대 URL을 만든다고 가정하자.

function buildCanonicalUrl(request: {
  headers: Record<string, string | undefined>;
  path: string;
}) {
  const forwardedHost =
    request.headers["x-forwarded-host"] ?? "docs.example.test";

  return `https://${forwardedHost}${request.path}`;
}

CDN은 X-Forwarded-Host를 키에서 제외하지만 원본은 이 값으로 HTML의 링크를 바꾼다. 조작된 요청이 먼저 캐시되면 다른 사용자도 공격자가 넣은 호스트를 볼 수 있다.

해결책은 무조건 해당 헤더를 키에 추가하는 것이 아니다. 허용된 호스트만 원본에 전달하고, 애플리케이션도 배포 설정에 있는 canonical host를 사용해야 한다.

const CANONICAL_HOST = "docs.example.test";

function safeCanonicalUrl(path: string): string {
  if (!path.startsWith("/")) {
    throw new Error("path must start with a slash");
  }

  return `https://${CANONICAL_HOST}${path}`;
}

보안 검토에서는 다음 항목을 확인한다.

캐시 정책은 인프라 설정이지만 애플리케이션 코드와 함께 리뷰해야 한다. 한쪽만 보면 “원본은 이 값을 사용하지만 CDN은 모르는” 틈이 생긴다.

관측 지표와 테스트 시나리오

캐시는 로컬 개발 환경에서 잘 드러나지 않는다. 첫 요청은 정상이고 특정 엣지에 이전 객체가 존재할 때만 문제가 생기기 때문이다. 배포 전에는 같은 URL을 여러 조건으로 반복 호출해 실제 응답과 캐시 상태를 함께 확인한다.

curl -sS -D - \
  -H 'Accept-Language: ko-KR,ko;q=0.9' \
  'https://example.test/articles?page=1&utm_source=mail' \
  -o /tmp/articles-ko.html

curl -sS -D - \
  -H 'Accept-Language: en-US,en;q=0.9' \
  'https://example.test/articles?utm_source=search&page=01' \
  -o /tmp/articles-en.html

예제의 도메인과 파일은 설명용이다. 실제 검증에서는 응답 헤더의 Age, 공급자별 cache hit/miss 헤더, Cache-Status 지원 여부를 확인한다. 한 번의 요청으로 결론 내리지 말고 같은 엣지에서 miss 다음 hit가 되는 순서를 재현한다.

추천하는 테스트 행렬은 다음과 같다.

변경 조건 기대 결과
locale koen 다른 표현, 다른 키
page=1page=2 다른 본문, 다른 키
쿼리 파라미터 순서 변경 같은 본문, 같은 키
utm_source 값 변경 같은 본문, 같은 키
gzip → Brotli 올바른 압축 표현
비로그인 → 로그인 공유 캐시 객체 재사용 금지
콘텐츠 버전 stable → next 다른 키
일시적 500 후 정상화 오류가 장기 고착되지 않음

운영 지표는 전체 적중률 하나만 보면 부족하다.

예를 들어 전체 hit ratio가 90%라도 가장 비싼 검색 API가 5%라면 원본 비용은 높을 수 있다. 평균 하나보다 분포와 세부 구간을 봐야 한다는 점은 운영 지표에서 평균보다 백분위수를 보는 이유와도 연결된다.

장애를 재현할 때

“영어 사용자가 가끔 한국어 페이지를 본다”면 브라우저만 새로 고치지 않는다. 먼저 어떤 POP에서 어떤 키가 계산됐는지, 문제 객체의 Age가 얼마인지, 첫 캐시 fill 요청의 언어와 쿠키가 무엇이었는지 추적한다.

배포 순서도 정해 둔다.

  1. 새 키 정책과 원본 응답 규칙을 스테이징에서 검증한다.
  2. 대표 URL로 서로 달라야 하는 요청과 같아야 하는 요청을 테스트한다.
  3. 정책 변경으로 기존 객체와 충돌하는지 판단한다.
  4. 필요하면 제한된 경로만 무효화한다.
  5. 변경 직후 hit ratio와 원본 요청 수를 함께 감시한다.
  6. 오류율이 오르면 이전 정책으로 되돌릴 수 있게 준비한다.

마무리

CDN 캐시 키의 핵심은 옵션의 개수가 아니라 요청과 응답 사이의 계약이다.

응답을 바꾸는 입력은 키에 포함하거나 제한된 값으로 정규화하고, 응답을 바꾸지 않는 값은 키에서 제외한다. 개인화 응답은 키를 복잡하게 만들기보다 공유 캐시에서 분리한다.

이를 실제 설계 순서로 줄이면 다음과 같다.

  1. 원본 응답을 바꾸는 모든 입력을 찾는다.
  2. 캐시 가능한 공개 응답과 개인화 응답을 분리한다.
  3. locale, device, version처럼 큰 입력 공간을 작은 값으로 정규화한다.
  4. 쿼리·헤더·쿠키는 허용 목록으로 관리한다.
  5. 캐시 정책과 원본 요청 정책을 구분한다.
  6. TTL, 오류 캐시, 무효화와 배포 전략을 함께 설계한다.
  7. 같아야 할 요청과 달라야 할 요청을 자동 테스트한다.
  8. 적중률뿐 아니라 원본 부하, 키 카디널리티, 오류 캐시를 관측한다.

CDN은 잘 설정하면 사용자의 지연 시간과 원본 비용을 크게 줄인다. 그러나 잘못된 키는 빠르게 잘못된 응답을 전달하는 장치가 된다. 그래서 캐시 키 변경은 성능 튜닝이면서 동시에 데이터 정합성과 보안 변경으로 리뷰해야 한다.

참고 자료

관련 노트