Fuwari Banner
지니제스트Tech Archive
Cloudflare7분 소요

Cloudflare Workers 정적 파일 캐싱 문제와 Cache API 심층 분석

•
ggeniezst

Cloudflare Workers 환경에서 정적 파일 캐싱이 예상대로 동작하지 않을 때, Cache API의 동작 원리와 Cache-Control 헤더의 상호작용을 분석하고 실질적인 해결 방안을 제시합니다.

Sponsored

Cloudflare Workers를 사용하여 웹 애플리케이션의 엣지 로직을 구현하다 보면, 정적 파일(CSS, JS, 이미지 등)의 캐싱 동작이 예상과 다르게 작동하여 개발자를 혼란스럽게 하는 경우가 종종 발생합니다. 특히, Cache-Control 헤더를 명확히 설정했음에도 불구하고 Workers 스크립트가 개입하면 브라우저나 Cloudflare 엣지에서 기대했던 캐싱이 제대로 이루어지지 않아 성능 저하를 야기할 수 있습니다. 이는 Workers의 실행 컨텍스트와 Cloudflare의 다양한 캐싱 계층이 복합적으로 작용하기 때문이며, 단순히 HTTP 헤더만을 조작하는 것을 넘어 Workers의 Cache API 동작 방식을 이해해야 해결할 수 있습니다.

최근 한 프로젝트에서 Cloudflare R2 스토리지에 저장된 정적 자산을 Workers를 통해 서빙하는 아키텍처를 구축했습니다. 초기에는 모든 요청을 R2로 프록시하는 간단한 Workers 스크립트를 사용했지만, 배포 후 CSS 파일이 변경되었음에도 불구하고 사용자 브라우저에 구 버전이 계속 표시되는 문제가 발생했습니다. R2 버킷의 파일은 분명히 업데이트되었고, Cache-Control: max-age=3600, public 헤더도 명시적으로 설정했지만, Workers를 거치면 브라우저는 물론 Cloudflare 엣지에서도 캐시 무효화가 제대로 작동하지 않는 듯 보였습니다. 개발자 도구의 네트워크 탭을 확인해보면 200 OK 응답이 오지만, 실제 내용은 이전 버전의 파일이었습니다.


Cloudflare Workers 캐싱 계층 이해와 문제 재현

Cloudflare는 여러 계층의 캐싱 메커니즘을 가지고 있습니다. 가장 바깥쪽에는 Cloudflare CDN 엣지 캐시가 있고, 그 안에는 Workers 스크립트 내부에서 접근할 수 있는 Cache API가 있습니다. 그리고 최종적으로 원본 서버(이 경우 R2 스토리지)가 존재합니다. 문제가 발생하는 지점은 Workers 스크립트가 요청을 가로채어 처리할 때, 기본적으로 Cloudflare CDN 엣지 캐시의 동작을 재정의하거나 우회할 수 있다는 점입니다. 특히 fetch API를 사용하여 원본 서버로 요청을 보낼 때, Workers 스크립트 내에서 명시적으로 Cache API를 사용하지 않으면, Cloudflare 엣지 캐시가 아닌 Workers 자체의 로직에 따라 응답이 처리됩니다.

문제 재현을 위해 다음과 같은 간단한 Workers 스크립트를 가정해봅시다. 이 스크립트는 R2 버킷에서 index.css 파일을 가져와 응답합니다.

JAVASCRIPT
// worker.js
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const url = new URL(request.url);
  const assetPath = url.pathname.substring(1); // URL 경로에서 파일명 추출

  // R2 버킷에서 파일 가져오기
  // 이 예시에서는 R2 객체 스토리지 접근 로직을 단순화했습니다.
  // 실제 환경에서는 'env.R2_BUCKET.get(assetPath)'와 같은 방식으로 접근합니다.
  if (assetPath === 'index.css') {
    const response = await fetch(`https://your-r2-bucket-url.r2.dev/${assetPath}`);
    // R2에서 받은 응답에 Cache-Control 헤더가 있을 것입니다.
    // 하지만 Workers가 개입하면 이 헤더가 항상 기대대로 작동하지 않을 수 있습니다.
    return response;
  }

  return new Response('Not Found', { status: 404 });
}

위 Workers 스크립트를 배포하고 index.css를 요청하면, R2 버킷에서 최신 파일을 가져와 응답할 것입니다. 하지만 index.css 파일 내용을 변경하고 R2에 다시 업로드해도, 브라우저가 여전히 이전 버전을 캐시하고 있거나, Cloudflare 엣지에서 이전 버전을 응답하는 현상을 목격할 수 있습니다. 이는 Workers가 fetch 요청을 할 때, 기본적으로 브라우저의 캐싱 메커니즘이나 Cloudflare 엣지 캐시의 Cache-Control 헤더 해석 방식과 다르게 동작할 수 있기 때문입니다.


Cache API 내부 아키텍처 원인 규명

Workers 환경에서 Cache API는 caches.default라는 전역 객체를 통해 접근할 수 있으며, 이는 Cloudflare 엣지 서버의 디스크에 저장되는 고성능 키-값 저장소와 유사하게 동작합니다. Workers 스크립트가 요청을 처리할 때 Cache API를 사용하지 않고 단순히 fetch를 통해 원본 서버로 요청을 보내면, Cloudflare 엣지 캐시(CDN 캐시)는 Workers의 응답을 일반적인 HTTP 응답으로 간주하고, 그 응답 헤더에 따라 캐싱 여부를 결정합니다. 문제는 Workers가 fetch를 통해 원본 서버에서 가져온 응답 객체를 그대로 반환할 때, 이 응답 객체에 포함된 Cache-Control 헤더가 Cloudflare 엣지 캐시와 브라우저 캐시에 항상 일관되게 적용되지 않을 수 있다는 점입니다.

특히, fetch 요청 시 cache: 'no-store'와 같은 옵션을 명시하지 않는 이상, Workers 내부의 fetch는 자체적으로 응답을 캐시할 수 있는 여지를 남깁니다. 또한, Cache API를 명시적으로 사용하지 않으면, Workers 스크립트가 실행될 때마다 원본 서버로 요청을 보내게 되어 비효율적이며, Cache-Control 헤더만으로는 강력한 캐싱 제어가 어렵습니다.

핵심은 Cloudflare Workers 스크립트가 실행되면, 요청 흐름의 제어권이 Workers로 넘어가며, Cloudflare CDN 엣지 캐시의 기본 동작 방식이 Workers 스크립트의 로직에 의해 재정의될 수 있다는 것입니다. 따라서 Workers 내부에서 Cache API를 사용하여 캐싱 로직을 명시적으로 구현해야, 개발자가 의도한 대로 정적 자산의 캐싱 및 무효화가 이루어집니다.


실무 검증 터미널 명령어 및 설정 파일 코드 블록

wrangler.toml 파일은 Workers 프로젝트의 설정 파일이며, 여기서는 특정 라우트에 대한 캐싱 동작을 제어할 수 있습니다. 하지만 이는 Workers 스크립트가 실행되기 전의 CDN 엣지 캐싱 동작을 제어하는 것이며, Workers 스크립트 내부의 캐싱 로직과는 별개입니다.

TOML
# wrangler.toml 예시
name = "my-worker"
main = "src/worker.js"
compatibility_date = "2023-10-27"

# 라우트별 캐싱 규칙 설정 (Workers 외부, CDN 엣지 캐시)
# 이 설정은 Workers 스크립트가 응답하기 전에 Cloudflare 엣지 캐시가 어떻게 동작할지 정의합니다.
# Workers 스크립트가 응답을 생성하면, Workers의 캐싱 로직이 우선시될 수 있습니다.
[[routes]]
pattern = "your-domain.com/static/*"
custom_metadata = { cache_level = "aggressive" } # 캐싱 레벨을 aggressive로 설정

[[routes]]
pattern = "your-domain.com/api/*"
custom_metadata = { cache_level = "bypass" } # API 요청은 캐시 우회

Cloudflare Workers CLI인 wrangler를 사용하여 Workers를 배포하고 로그를 확인하는 명령어입니다.

BASH
# wrangler CLI를 사용하여 Workers 배포
wrangler deploy --minify --env production # 프로덕션 환경에 Workers 배포, 코드 압축

# Workers 로그 실시간 확인
wrangler tail # Workers 스크립트의 console.log() 출력 및 에러를 실시간으로 확인

Cache API를 활용한 단계별 조치 방법 및 검증 절차

정적 파일 캐싱 문제를 해결하기 위한 가장 효과적인 방법은 Cloudflare Workers의 Cache API를 명시적으로 사용하는 것입니다. 이를 통해 개발자가 캐싱 정책을 세밀하게 제어하고, 필요할 때 캐시를 무효화할 수 있습니다.

1단계: Cache API를 사용하도록 Workers 스크립트 수정

Cache API를 사용하여 요청을 처리하기 전에 캐시에서 응답을 찾아보고, 없으면 원본 서버(R2)에서 가져온 후 캐시에 저장하는 로직을 추가합니다.

JAVASCRIPT
// src/worker.js
addEventListener('fetch', event => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const request = event.request;
  const url = new URL(request.url);
  const assetPath = url.pathname.substring(1);

  // 정적 자산 경로 확인 (예: .css, .js, .png 등)
  if (assetPath.endsWith('.css') || assetPath.endsWith('.js') || assetPath.endsWith('.png')) {
    const cacheKey = new Request(url.toString(), request); // 캐시 키 생성
    const cache = caches.default; // 기본 Cache API 인스턴스

    // 1. 캐시에서 응답 찾기
    let response = await cache.match(cacheKey);

    if (!response) {
      // 2. 캐시에 없으면 원본 서버 (R2)에서 가져오기
      // 실제 R2 접근 로직은 env.R2_BUCKET.get(assetPath) 형태가 됩니다.
      // 여기서는 예시를 위해 fetch를 사용합니다.
      const r2Url = `https://your-r2-bucket-url.r2.dev/${assetPath}`;
      const r2Response = await fetch(r2Url, {
        cf: {
          cacheEverything: true, // Cloudflare 엣지 캐시를 통해 R2 응답도 캐시하도록 지시
          cacheTtlByStatus: { '200-299': 3600, '404': 1, '500-599': 0 } // 상태 코드별 TTL 설정
        }
      });

      // R2 응답이 유효한지 확인
      if (!r2Response.ok) {
        return new Response('Asset Not Found', { status: 404 });
      }

      // 3. 캐시할 응답 생성
      // 원본 응답 헤더를 그대로 사용하되, Cache-Control을 명시적으로 설정
      response = new Response(r2Response.body, r2Response);
      response.headers.set('Cache-Control', 'public, max-age=3600, immutable'); // 1시간 캐시, 변경되지 않음

      // 4. 캐시에 저장 (Cache API)
      // event.waitUntil을 사용하여 Workers 스크립트가 응답을 보낸 후에도 캐시 저장이 완료되도록 보장
      event.waitUntil(cache.put(cacheKey, response.clone()));
    }

    return response;
  }

  // 그 외 요청은 기본 처리
  return new Response('Hello from Workers!', { status: 200 });
}

2단계: Workers 배포 및 캐시 무효화 테스트

스크립트를 수정한 후 wrangler deploy 명령어로 Workers를 배포합니다.

BASH
wrangler deploy --minify # 수정된 Workers 스크립트 배포

배포 후, index.css 파일의 내용을 변경하고 R2 버킷에 다시 업로드합니다. 브라우저에서 your-domain.com/index.css에 접근하여, 개발자 도구의 네트워크 탭에서 응답 헤더를 확인합니다. cf-cache-status: HIT이 표시되면 Cloudflare 엣지 캐시에서 응답된 것이며, Cache-Control 헤더가 public, max-age=3600, immutable로 설정되어 있는지 확인합니다.

파일을 변경한 후에는 강제로 캐시를 무효화해야 합니다. Cloudflare 대시보드에서 해당 Workers 라우트에 대한 캐시를 퍼지하거나, Workers 스크립트 내부에서 캐시 키를 변경하는 방식으로 무효화할 수 있습니다. 예를 들어, 파일 이름에 버전 해시를 추가하는 빌드 시스템을 사용하면 캐시 무효화를 자연스럽게 처리할 수 있습니다 (index.css?v=abcdef123).

3단계: 검증 (Verification)

  1. 초기 요청: 브라우저에서 index.css를 요청합니다. cf-cache-status: MISS가 표시되고, 응답 헤더에 Cache-Control: public, max-age=3600, immutable이 설정되어 있는지 확인합니다.
  2. 두 번째 요청: 동일한 브라우저에서 다시 index.css를 요청합니다. cf-cache-status: HIT가 표시되어야 하며, 이는 Cloudflare 엣지 캐시(Workers Cache API에 의해 관리되는)에서 응답이 제공되었음을 의미합니다.
  3. 파일 변경 및 캐시 퍼지: R2 버킷의 index.css 파일을 변경하고 Cloudflare 대시보드에서 해당 URL의 캐시를 퍼지합니다.
  4. 새로운 요청: 브라우저에서 index.css를 다시 요청합니다. cf-cache-status: MISS가 표시되고, 새로운 파일 내용이 응답되는지 확인합니다. 이후 다시 HIT으로 전환되어야 합니다.

사이드 이펙트 방지 트러블슈팅 FAQ

Q. Cache-Control: immutable 헤더를 사용했는데도 캐시 무효화가 안 됩니다.

immutable 지시어는 브라우저에게 해당 리소스가 변경되지 않을 것이므로, 캐시된 사본을 영구적으로 사용할 수 있다고 알려줍니다. 따라서 파일이 실제로 변경되었을 때는 immutable 헤더가 문제가 될 수 있습니다. 이 경우, 파일 이름에 버전 해시나 타임스탬프를 포함하여 URL 자체를 변경하는 캐시 버스팅(Cache Busting) 전략을 사용하는 것이 가장 좋습니다. 예를 들어, style.css?v=12345 대신 style.abcdef12.css와 같이 파일 이름을 변경하면 새로운 URL로 간주되어 캐시가 자동으로 무효화됩니다. Workers Cache API 사용 시에도 캐시 키가 URL을 기반으로 하므로, URL이 변경되면 새로운 캐시 엔트리로 처리됩니다.

Q. Workers 스크립트에서 fetch 요청 시 cf 객체 옵션은 무엇인가요?

fetch 함수에 전달되는 RequestInit 객체 내의 cf 객체는 Cloudflare 고유의 옵션을 설정하는 데 사용됩니다. 위 예시에서 cf: { cacheEverything: true, cacheTtlByStatus: {...} }는 Workers가 원본 서버(R2)로 요청을 보낼 때, Cloudflare 엣지 캐시가 해당 응답을 어떻게 처리할지 지시하는 것입니다. cacheEverything: true는 응답을 무조건 캐시하라는 의미이며, cacheTtlByStatus는 HTTP 상태 코드별로 캐시 TTL(Time-To-Live)을 설정합니다. 이는 Workers Cache API와는 별개로, Cloudflare CDN 엣지 캐시가 Workers의 fetch 요청에 대한 원본 서버 응답을 캐시하는 방식을 제어합니다. 이 두 캐싱 계층이 혼동되지 않도록 주의해야 합니다.

Q. Cache API를 사용했는데도 특정 상황에서 캐시 히트율이 낮게 나옵니다.

캐시 히트율이 낮은 원인은 다양합니다.

  1. 캐시 키의 일관성 부족: cache.match(cacheKey)에서 사용하는 cacheKey가 요청마다 미묘하게 달라지는 경우(예: 쿼리 파라미터가 불규칙하게 추가되는 경우), 캐시 히트가 되지 않습니다. 캐시 키를 생성할 때 new Request(url.toString(), request)처럼 request 객체를 그대로 사용하는 대신, new Request(new URL(request.url).origin + new URL(request.url).pathname)처럼 쿼리 파라미터를 제거하여 표준화된 키를 사용하는 것을 고려해볼 수 있습니다.
  2. 캐시 TTL이 너무 짧음: Cache-Control 헤더의 max-age 값이 너무 짧으면 캐시가 빠르게 만료되어 재요청이 많아집니다.
  3. 캐시 저장 실패: event.waitUntil(cache.put(cacheKey, response.clone()))이 제대로 호출되지 않거나, response.clone()을 하지 않아 이미 사용된 응답 객체를 캐시하려 할 때 문제가 발생할 수 있습니다. put 메서드는 응답 객체를 한 번만 사용할 수 있으므로, 응답을 브라우저로 보내기 전에 반드시 clone()해야 합니다.
  4. Vary 헤더: 응답에 Vary 헤더가 포함되어 있으면, 해당 헤더에 지정된 요청 헤더 값에 따라 별도의 캐시 엔트리가 생성됩니다. 예를 들어 Vary: Accept-Encoding은 압축 방식에 따라 다른 캐시를 만듭니다. 이는 의도된 동작이지만, 불필요하게 많은 캐시 엔트리를 만들 수 있습니다.

이러한 문제들을 진단하기 위해서는 wrangler tail을 통해 Workers 로그를 면밀히 분석하고, Cloudflare 대시보드의 애널리틱스에서 캐시 히트율 관련 지표를 확인하는 것이 중요합니다.

Sponsored