캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유
엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
해결은 뻔해 보였습니다. 발행이 끝나면 그 URL의 캐시를 지우라고 CDN에 알리면 됩니다. 코드를 붙이고, 토큰을 넣고, 배포했습니다.
그리고 아무것도 나아지지 않았습니다.
퍼지는 성공했는데 내용은 그대로였습니다
먼저 확인한 것은 퍼지 API 응답이었습니다.
{"success":true,"errors":[],"messages":[]}성공입니다. DB도 확인했습니다. 새 내용이 들어가 있었습니다. 그런데 공개 페이지를 스무 번 넘게 눌러도 옛 글이 나왔습니다.
혹시나 해서 1분쯤 지난 뒤에 손으로 다시 퍼지해봤습니다. 1초 만에 새 내용이 나왔습니다.
같은 명령인데 결과가 달랐습니다. 다른 것은 시점뿐이었습니다.
캐시가 한 겹이 아니었습니다
그제야 구조를 다시 봤습니다.
방문자 → CDN 엣지 캐시 (s-maxage 300초)
↓
오리진 앱의 데이터 캐시 (60초)
↓
데이터베이스블로그 앱은 DB 질의를 unstable_cache로 감싸고 있었습니다.
const queryPost = unstable_cache(
async (slug: string) => { /* DB 조회 */ },
["blog-post"],
{ revalidate: 60, tags: [POST_CACHE_TAG] }
);DB에 새 내용을 넣어도 이 앱은 최대 60초 동안 옛 값을 렌더합니다.
그러면 발행 직후에 이런 일이 벌어집니다.
- DB에 새 내용이 들어갑니다
- 엣지를 퍼지합니다 — 비워집니다
- 다음 방문자가 옵니다. 엣지에 없으니 오리진에 물어봅니다
- 오리진이 옛 내용을 줍니다 (데이터 캐시가 아직 살아 있음)
- 엣지가 그 옛 내용을 받아 다시 5분 저장합니다
퍼지가 낡은 사본을 지운 게 아니라 새로 고정시켰습니다.
이게 특히 고약한 이유는, 퍼지를 안 했으면 원래 캐시가 더 일찍 만료됐을 수도 있다는 점입니다. 발행 직후에 누가 접속하기만 하면 그 순간의 낡은 내용에 5분 타이머가 새로 걸립니다. 안 하느니만 못한 상태였습니다.
순서를 뒤집었습니다
고치는 방향은 분명했습니다. 안쪽 캐시를 먼저 비우고, 그다음 바깥쪽을 비웁니다.
뷰어 앱에 무효화 엔드포인트를 만들었습니다.
export async function POST(req: NextRequest) {
const secret = process.env.SYNC_TOKEN;
const token = req.headers.get("x-sync-token");
if (!secret || !token || !safeEqual(token, secret)) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
revalidateTag(POST_CACHE_TAG, "max");
return NextResponse.json({ revalidated: true });
}발행 경로에서는 이 순서로 부릅니다.
await revalidateViewers(); // 안쪽 먼저
await purgeEdge(url); // 그다음 바깥쪽배포하고 다시 시험했습니다. 글 수정은 5초 만에 반영됐습니다. 됐다고 생각했습니다.
그런데 발행 취소가 안 됐습니다
수정은 되는데 내리는 게 안 됐습니다. 발행을 취소해도 공개 URL이 계속 200을 돌려줬습니다.
오리진을 직접 찔러 확인했습니다.
오리진 직접: 404
엣지 경유: 200오리진은 이미 없다고 하는데 엣지가 옛 페이지를 붙들고 있었습니다. 손으로 퍼지하니 즉시 404가 됐고요.
원인은 퍼지 대상 주소를 만드는 코드에 있었습니다.
// 고치기 전
await purgeEdge(publicUrl(visibility, result.publicSlug));result는 수신 측의 응답입니다. 문서를 내리거나 지우면 수신 측이 슬러그를 돌려주지 않습니다. 그러면 주소가 null이 되고, 목록 지면만 지운 채 정작 그 문서 URL은 손도 대지 않습니다.
즉 "내렸는데 아직 공개 중"이라는, 이 발행 구조가 애초에 막으려고 만든 상황이 캐시 계층에서 되살아난 것이었습니다.
푸시하기 전에 로컬이 들고 있던 슬러그를 함께 읽어, 이전 주소와 새 주소를 모두 지우게 했습니다.
// 고친 뒤 — 이전 주소와 새 주소 둘 다
const before = publicUrl(doc.visibility, doc.publicSlug);
const after = publicUrl(desired.visibility, result.publicSlug);
await purgeEdge([before, after]);제목이 바뀌어 슬러그가 달라지는 경우에도 옛 주소가 남지 않습니다.
그래도 발행 취소가 안 됐습니다
여기서 한 번 더 막혔습니다. 주소는 이제 맞게 만들어지는데 여전히 200이었습니다.
revalidateTag가 무엇을 하는지 다시 읽어봤습니다. 이 함수는 "이 태그의 캐시는 낡았다"고 표시만 합니다. 새 값은 그다음 요청이 들어올 때 만들어집니다.
그러니까 이런 일이 벌어집니다.
- 태그를 무효로 표시합니다 — 아직 새 값은 없습니다
- 엣지를 퍼지합니다
- 방문자의 첫 요청이 옵니다. 오리진이 그제서야 재생성을 시작하는데, 그 요청에는 아직 옛 값이 나갑니다
- 엣지가 그것을 다시 붙듭니다
앞에서 고친 것과 정확히 같은 문제가 한 겹 안쪽에서 반복되고 있었습니다. 바깥 캐시와 안쪽 캐시의 관계였던 것이, 이번엔 "무효 표시"와 "실제 재생성"의 관계로 나타났을 뿐입니다.
그래서 단계가 셋이 됐습니다.
표시(revalidateTag) → 재생성(예열) → 엣지 퍼지예열은 오리진 내부 주소로 요청을 한 번 보내 실제로 새 값을 만들게 하는 것입니다.
/* 오리진이 새 값을 실제로 만들어 두게 한다. 응답 내용은 쓰지 않는다 —
목적은 재생성을 일으키는 것뿐이라 상태 코드도 보지 않는다(내려간 문서는 404 가 정상). */
export async function primeOrigin(urls: string[]): Promise<void> {
await Promise.all(
urls.map((url) =>
fetch(url, { signal: AbortSignal.timeout(10_000), cache: "no-store" })
.catch(() => undefined),
),
);
}상태 코드를 보지 않는 게 중요합니다. 내려간 문서는 404가 정상이고, 여기서 하려는 일은 판정이 아니라 재생성을 일으키는 것뿐입니다.
결과
수정 → 재발행 2초
발행 취소 1초 (404)전에는 최대 6분이었습니다. 오리진 60초에 엣지 5분을 더한 값입니다.
검증하면서 제가 한 실수
원인을 늦게 찾은 건 절반이 제 검증 방법 탓이었습니다.
표식을 HTML 주석으로 넣었습니다. 반영 여부를 확인하려고 본문 끝에 <!-- 표식 -->을 붙였는데, 이 사이트는 렌더 결과를 DOMPurify로 정화합니다. 주석은 제거됩니다. 나올 수 없는 것을 찾으면서 "아직 반영이 안 됐다"고 몇 번을 기록했습니다.
없는 API를 부르고 실패를 못 알아챘습니다. 본문을 읽으려고 단건 조회 엔드포인트를 불렀는데 405가 돌아왔습니다. 그러면 본문 수정 자체가 안 된 것인데, 그걸 모르고 결과만 보며 "미반영"이라고 판단했습니다.
실제 발행된 글에 표식을 넣고 시험했습니다. 복원하긴 했지만 경솔했습니다. 그다음부터는 임시 문서를 만들어 시험하고 끝나면 지웠습니다. 검증 대상은 버려도 되는 것이어야 합니다.
남는 이야기
캐시 계층은 겹칩니다. 바깥을 비우기 전에 안쪽이 새것인지 확인해야 합니다. 아니면 비우는 행위가 낡은 것을 새로 고정시킵니다.
무효화와 재생성은 다릅니다. "낡았다고 표시"는 새 값을 만들어주지 않습니다. 표시와 실제 생성 사이에 틈이 있고, 그 틈에 요청이 들어오면 옛 값이 굳습니다.
그리고 하나 더. 발행만 시험하고 끝내지 마세요. 저는 수정이 5초 만에 반영되는 걸 보고 다 됐다고 생각했습니다. 실제로 위험한 방향은 내리는 쪽입니다. 안 보여야 할 것이 계속 보이는 게 늦게 보이는 것보다 나쁩니다.
함께 읽기
- Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.
- 한국에서 Cloudflare 엣지가 서울이 아닐 때: 무료 플랜의 POP 배정이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.
- 엣지 캐시를 켜기 전 확인할 것: 비공개 응답의 Cache-Control전에 CDN의 파일 경로에 확장자가 없어서 Cloudflare가 캐시하지 않던 문제를 고쳤습니다. 헤더를 아무리 강하게 줘도 CF-Cache-Status가 DYNAMIC으로 나오던 그 이야기입니다.
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
- 태그 485개를 전부 색인시키고 있었습니다: 블로그 SEO를 다시 손본 기록블로그에는 이미 canonical, sitemap, robots.txt, 글별 Open Graph 이미지, 구조화 데이터가 들어가 있었습니다. RSS와 Atom, JSON Feed도 있었고 관련 글 링크도 붙어 있었습니다. 그래서 처음에는 큰 구멍보다는 메타 설명을 조금 다듬는 정도를 예상했습니다.