소개
공개 지원 카탈로그에는 동일한 언어와 카테고리에 대한 요청이 반복해서 들어옵니다. 응답을 재사용하면 반복 작업을 줄일 수 있지만, 캐시에 고객별 데이터가 섞이거나 오류가 캐시된 공개 콘텐츠로 바뀌어서는 안 됩니다. 캐시되지 않은 생성을 확인하고, 명시적인 캐시 정책을 추가한 다음, 만료와 대상 무효화를 테스트하고 배포 후 경계를 확인합니다.
이 독립 실습은 Node.js 22.22.0, 프로젝트 로컬 Wrangler 4.131.1, 격리된 평가에 사용하는 Miniflare 4.20260730.0 및 합성 응답 픽스처가 준비된 /home/labex/project/public-cache에서 시작합니다. 직접 만든 학습 계정과 이전에 학습한 인증, 배포 및 시크릿 작업 방식을 사용합니다. 이전 VM, 리소스, 구매한 도메인, 데이터베이스 또는 유료 업그레이드는 필요하지 않습니다. 요청은 일반적인 계정 사용량에 포함됩니다.
터미널 하나를 계속 열어 둡니다. 모든 카탈로그 데이터와 자격 증명은 합성 데이터입니다. Cache API 의 콘텐츠는 서비스를 제공하는 위치에 로컬로 저장됩니다. 전역 네트워크가 전역적으로 복제된 캐시를 의미하지는 않습니다. 마지막에는 Worker 를 삭제하고 로컬 시크릿을 제거한 다음 로그아웃합니다.
새로운 공개 카탈로그 응답 확인
이 단계에서는 제공된 합성 카탈로그를 살펴보고 캐시되지 않은 동작을 확인합니다. 픽스처는 각 응답에 새 UUID 를 생성하므로 시간 측정이나 데이터베이스 없이도 재사용 여부를 확인할 수 있습니다.
cd /home/labex/project/public-cache
node --version
npx wrangler --version
cat src/catalog.js
Node.js v22.22.0 과 Wrangler 4.131.1 이 표시되어야 합니다. 설정 과정에서 프로젝트의 정확한 종속 항목을 설치했으므로, lockfile 을 사용해 기존 설치를 재현할 때는 npm ci를 사용합니다. 평가 런타임도 호환성 날짜에 맞춰 Miniflare 4.20260730.0 을 사용합니다. 픽스처는 언어, 카테고리 및 합성 고객에 따라 달라집니다. X-Demo-Failure: 1을 사용하면 503 응답을 시뮬레이션할 수 있습니다. 이 값들은 테스트 입력이며 실제 신원 인증 정보가 아닙니다.
WORKER_NAME="labex-cache-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false
}
CONFIG
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
return deliver(catalog(request, language, category), 'BYPASS');
}
};
JS
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
요청을 보내기 전에 서버가 준비될 때까지 기다립니다. 시작 중이라면 cat dev.log를 다시 실행합니다. 셸 변수가 유지되도록 이 터미널을 계속 열어 둡니다.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
두 요청 모두 상태 코드 200 을 반환하며, audience는 public 이고 서로 다른 generation UUID 가 표시됩니다. X-Lab-Cache: BYPASS는 이 핸들러가 캐시를 조회하거나 캐시에 쓰지 않았다는 뜻입니다. 클라이언트에 전달되는 Cache-Control: no-store는 브라우저 또는 클라이언트 캐시가 실험에 영향을 주지 않게 합니다. 기준 동작을 바꾸기 전에 검증을 실행합니다.
캐시 가능한 공개 응답만 캐시하기
이 단계에서는 Cache API 조회와 저장을 추가합니다. jobs에 표시된 현재 개발 작업을 중지합니다. 아래 예시는 작업 번호가 1 이라고 가정합니다.
jobs
kill %1
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
캐시 키는 현재 origin 과 고정된 경로, 카테고리 및 언어를 사용합니다. 쿼리 매개변수의 순서는 정규화되지만 두 콘텐츠 차원은 서로 분리된 상태로 유지됩니다. 알 수 없는 매개변수와 중복된 차원은 키의 의미가 조용히 바뀌지 않도록 거부합니다.
조회 전에 캐시 적합성을 확인합니다. Authorization, Cookie 및 합성 고객 헤더가 있으면 이미 준비된 공개 항목을 사용하지 않습니다. 오류 픽스처도 조회를 우회하므로 캐시된 성공 응답이 오류를 가리지 않습니다. 상태 코드가 200 이고 Set-Cookie가 없는 응답만 저장합니다. 응답 본문은 스트림이므로 복제본을 만들고, 저장되는 복제본에 10 초 TTL 을 지정한 뒤 쓰기 작업이 끝날 때까지 기다립니다. 반환되는 응답은 계속 no-store를 사용하고, 내부 Cache API 항목은 별도의 캐시 정책을 사용합니다.
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
요청을 보내기 전에 서버가 준비될 때까지 기다립니다. 시작 중이라면 cat dev.log를 다시 실행합니다. 셸 변수가 유지되도록 이 터미널을 계속 열어 둡니다.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?category=network&lang=en"
curl -i "http://127.0.0.1:8080/api/catalog?lang=fr&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=printer"
처음 두 요청을 10 초 이내에 실행합니다. 처음 캐시되지 않은 응답에는 MISS 가 표시되고, 반복 요청에는 HIT 가 표시되며 같은 generation 이 유지됩니다. 쿼리 매개변수의 순서를 바꿔도 키는 달라지지 않습니다. 프랑스어와 printer 변형은 요청한 차원을 사용하며 서로 독립된 항목을 가집니다. 읽는 동안 TTL 이 만료되었다면 두 요청을 다시 빠르게 실행합니다. 캐시 내용이 영원히 유지된다고 가정하지 않습니다.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Cookie: demo=synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
고객 또는 신원 정보가 포함된 요청에는 BYPASS 가 표시되고 적절한 합성 audience 가 반환됩니다. 다른 고객의 결과가 반환되어서는 안 됩니다. 공개 데이터가 캐시에 준비되어 있어도 시뮬레이션된 오류는 503 BYPASS 를 반환합니다. 이후 공개 요청은 오류가 아니라 공개 데이터를 반환합니다. 로컬 서버를 실행한 상태에서 검증을 사용합니다. 이 검증은 격리된 로컬 런타임에서 핸들러를 실행할 뿐이며 클라우드 캐시를 변경하지 않습니다.
로컬 캐시 항목 만료 및 무효화
이 단계에서는 동일한 정규화 키를 사용하는 인증된 무효화 작업을 추가합니다. 이는 전역 삭제가 아니라 로컬 데이터 센터에서 수행하는 삭제입니다. 편집하기 전에 실제 개발 작업을 중지합니다.
jobs
kill %1
umask 077
PURGE_TOKEN=$(openssl rand -hex 24)
printf 'PURGE_TOKEN=%s\n' "$PURGE_TOKEN" > .dev.vars
cat .gitignore
합성 시크릿을 Git, 공개 설정, URL 및 로그에 노출하지 않습니다. 이 시크릿은 이 실습의 DELETE 작업을 보호하며 Cloudflare API 토큰이 아닙니다.
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method === 'DELETE') {
if (!env.PURGE_TOKEN) return Response.json({error: 'purge_unconfigured'}, {status: 503});
if (request.headers.get('Authorization') !== `Bearer ${env.PURGE_TOKEN}`) {
return Response.json({error: 'unauthorized'}, {status: 401, headers: {'Cache-Control': 'no-store'}});
}
const invalidated = await cache.delete(key);
return Response.json({invalidated, scope: 'this-location'}, {
headers: {'Cache-Control': 'no-store', 'X-Lab-Cache': 'BYPASS'}
});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET, DELETE'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
DELETE 는 조회 및 저장에 사용한 동일한 GET 키로 cache.delete를 호출하기 전에 자격 증명을 확인합니다. 반환되는 Boolean 값은 이 위치에 항목이 있었는지를 나타냅니다. 인증되지 않은 삭제는 기존 항목을 그대로 유지해야 합니다. 다른 위치에 보관된 항목은 해당 위치의 요청에서 여전히 반환될 수 있습니다.
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
요청을 보내기 전에 서버가 준비될 때까지 기다립니다. 시작 중이라면 cat dev.log를 다시 실행합니다. 셸 변수가 유지되도록 이 터미널을 계속 열어 둡니다.
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer $PURGE_TOKEN"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
인증되지 않은 DELETE 는 401 을 반환합니다. 유효한 DELETE 는 scope: this-location을 반환하며, 항목이 아직 유효하다면 일반적으로 invalidated: true가 표시됩니다. 짧은 TTL 이 이미 만료되었다면 false 도 올바른 결과입니다. 다음 GET 은 새 generation 과 함께 MISS 를 반환합니다. true 를 확인하려면 인증된 DELETE 직전에 GET 을 실행합니다.
sleep 11
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
11 초가 지나면 새 MISS 가 명시적인 삭제 없이 만료되었음을 보여 줍니다. 서버를 실행한 상태에서 검증을 사용합니다. 격리된 런타임은 재사용, 차원 분리, 비공개 및 오류 응답 제외, 거부된 삭제, 성공적인 대상 삭제, 다른 키의 보존 및 만료를 확인합니다. 이러한 제어된 로컬 검사는 전역 캐시 상태를 가정하지 않고 반복 가능한 증거를 제공합니다.
Cache API 문서에서 데이터 센터 범위, 응답 헤더 동작 및 cache.delete를 설명합니다. Worker 실행을 건너뛰는 Cache API 와 플랫폼 캐싱은 서로 다른 메커니즘입니다.
배포하고 캐시 경계 확인
이 단계에서는 완성된 핸들러를 학습 계정에 배포합니다. 실제 로컬 작업을 중지하고, 이 새 VM 을 인증한 다음 계정 정보를 확인합니다.
jobs
kill %1
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_tail:read
로그인한 브라우저에서 표시된 디바이스 링크와 코드를 완료하고, 변경되지 않은 권한과 Background Access 를 확인한 다음 학습 계정을 선택합니다. 터미널에 성공 메시지가 표시될 때까지 기다립니다.
npx wrangler whoami --json
대상 계정 이름이 맞는지 확인합니다. 아래의 YOUR_ACCOUNT_ID 를 실제 ID 로 바꾸되, 고유한 Worker 이름은 그대로 유지합니다.
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false,
"account_id": "YOUR_ACCOUNT_ID"
}
CONFIG
npx wrangler deploy
npx wrangler secret bulk .dev.vars
npx wrangler secret list
로컬 시크릿 파일은 deploy 과정에서 업로드되지 않습니다. 명시적인 bulk 명령이 PURGE_TOKEN을 secret_text 로 생성합니다. 배포 후 짧은 전파 시간을 기다립니다. 아래에 Wrangler 가 표시한 실제 공개 URL 을 복사합니다.
APP_URL="https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev"
curl -i "$APP_URL/api/catalog?lang=en&category=network"
curl -i "$APP_URL/api/catalog?category=network&lang=en"
curl -i "$APP_URL/api/catalog?lang=fr&category=network"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
공개 응답에는 요청한 언어와 카테고리, 그리고 public audience 가 표시되어야 합니다. 동일한 위치에서 TTL 이내에 반복 요청하면 HIT 가 표시되고 generation 이 유지될 수 있습니다. 다른 위치에서 요청하거나 TTL 이 만료되면 MISS 가 반환되는 것이 정상입니다. 두 요청만으로 캐시 내용이 전역적으로 공유된다고 판단하지 않습니다. 비공개 요청은 항상 우회해야 하며, 오류 요청은 503 BYPASS 를 반환해야 합니다.
동일한 Dashboard 계정에서 Compute → Workers & Pages 를 열고 정확한 Worker 와 해당 workers.dev URL 을 확인합니다. 검증을 사용하여 소유권, 배포된 시크릿 바인딩 및 응답 경계를 확인합니다. 이 작업은 클라우드 무효화를 수행하지 않습니다. 무효화 동작은 로컬에서 테스트했으며 cache.delete는 전역 삭제 메커니즘이 아닙니다. 배포가 아직 전파 중이라면 잠시 기다린 후 응답 확인을 반복합니다. 결과가 계속 다르면 그대로 성공으로 처리하지 말고 원인을 조사합니다.
임시 Worker 삭제
이 단계에서는 인증이 유지된 상태에서 이 실습의 배포를 제거합니다. 고유한 이름과 계정을 확인한 다음 이 Worker 만 삭제합니다.
cat wrangler.jsonc
npx wrangler delete
이름이 일치하는지 묻는 프롬프트에서 y 키를 한 번만 누릅니다. Wrangler 4.131.1 은 삭제 후 알려진 레거시 Workers Sites KV 인증 진단 메시지를 표시할 수 있습니다. 권한을 확대하지 말고, 해당 오류를 삭제 성공의 증거로 간주하지도 않습니다. Dashboard 를 새로 고친 다음 검증을 실행합니다. 인증된 인벤토리에서 이 정확한 Worker 가 사라졌는지 확인해야 합니다. 학습 계정과 해당 서브도메인은 유지합니다. Worker 삭제가 모든 캐시 항목을 전역적으로 삭제했다는 뜻은 아닙니다. 합성 항목은 10 초 TTL 을 사용하며 실행 중인 애플리케이션이 남아 있어서는 안 됩니다.
로컬 시크릿 제거 및 연결 해제
이 단계에서는 삭제가 확인된 후 로컬 임시 자격 증명을 제거하고 이 VM 의 연결을 해제합니다.
rm .dev.vars
unset PURGE_TOKEN
npx wrangler logout
npx wrangler whoami --json
loggedIn: false가 명시적으로 표시되어야 합니다. 인증되지 않은 구조화 명령은 0 이 아닌 종료 코드를 반환할 수 있습니다. 검증을 실행하고 VM 을 종료합니다. 브라우저 로그인은 유지될 수 있습니다. 로그아웃하거나 VM 을 종료해도 클라우드 배포가 자동으로 삭제되지는 않습니다.
요약
캐시되지 않은 카탈로그 생성을 명시적인 공개 응답 캐싱으로 바꾸고, 언어와 카테고리 키를 분리된 상태로 유지했으며, 조회 전에 비공개 요청과 오류 요청을 우회했습니다. 제어된 로컬 런타임에서 짧은 수명의 항목과 인증된 무효화를 테스트한 다음, 전역적으로 공유되는 캐시 내용을 가정하지 않고 배포된 애플리케이션의 신원과 응답 경계를 확인했습니다.
저장된 복제본의 TTL 과 호출자 캐시 정책은 서로 다른 목적을 가집니다. 명확한 캐시 적합성, 완전한 키 및 관찰 가능한 응답 generation 을 사용하면 이 차이를 테스트할 수 있습니다. VM 연결을 해제하기 전에 임시 배포와 로컬 자격 증명을 제거했습니다.

