RSS듀오랩스
웹 인프라

사이트맵이 6일째 멈춘 이유: revalidateTag 가 닿지 않는 자리

작성자
듀오랩스 대표·9분 읽기

글을 두 편 발행하고 사이트맵을 열어 봤습니다. 둘 다 없었습니다.

목록 페이지에는 멀쩡히 떠 있었고 글 주소도 정상으로 열렸습니다. 그런데 /blog/sitemap.xml 안에서 가장 최근 lastmod 가 엿새 전이었습니다. 마지막으로 배포한 날짜와 정확히 같았습니다.

목록에는 떠 있는데 사이트맵에는 없던 글

이 블로그는 발행할 때 System 이 /api/revalidate 를 부릅니다. 그 안에서 revalidateTag 로 글 캐시 태그를 만료시키면 목록도 RSS 도 함께 갱신됩니다. 지금까지 그렇게 믿고 썼고 실제로 그렇게 동작했습니다.

그래서 처음에는 사이트맵도 같이 갱신되고 있다고 생각했습니다. 확인해 본 적이 없었습니다. 목록에 글이 뜨면 다 된 것으로 여겼습니다.

같은 날 공개 문서 쪽 사이트맵도 열어 봤습니다. 그쪽은 몇 분 전에 고친 문서까지 들어와 있었습니다. 두 프로젝트가 같은 데이터베이스를 보는데 한쪽만 멈춰 있었다는 뜻이라, 여기서 원인이 데이터가 아니라 라우트에 있다는 것이 분명해졌습니다.

age 가 리셋되지 않는다는 사실

응답 헤더가 cache-control: public, max-age=0, must-revalidatex-vercel-cache: HIT 였습니다. 처음에는 엣지에 낡은 사본이 붙들려 있는 줄 알았습니다. 예전에 퍼지 순서 때문에 낡은 캐시가 더 살았던 일이 있어서 같은 쪽을 의심했습니다.

그래서 10초 간격으로 세 번 헤더만 받아 봤습니다.

age: 234   x-vercel-cache: HIT
age: 244   x-vercel-cache: HIT
age: 254   x-vercel-cache: HIT

올라가기만 합니다. 이게 엣지 문제였다면 어딘가에서 리셋되고 새 사본을 받아 왔어야 합니다. 게다가 라우트에 걸린 재검증 주기는 한 시간인데 엿새가 지나 있었습니다. 시간으로 만료되는 길과 태그로 만료되는 길이 둘 다 막혀 있다는 뜻이었습니다.

피드 넷은 동적인데 사이트맵만 ISR 이던 이유

앱 안의 피드 라우트를 전부 열어 봤습니다.

라우트 설정
rss.xml · atom.xml · feed.json dynamic = "force-dynamic"
llms.txt · llms-full.txt dynamic = "force-dynamic"
sitemap.ts revalidate = 3600

혼자만 달랐습니다. 그리고 왜 다른지도 파일 주석에 적혀 있었습니다. 예전에는 사이트맵도 동적이었는데, 응답에 must-revalidate 가 붙어 CDN 이 저장하지 않으니 크롤러가 부를 때마다 113KB 를 새로 만들어 내보내고 있었습니다. 그래서 한 시간짜리 ISR 로 올렸습니다.

문제는 그 뒤였습니다. ISR 로 올리면서 무효화하는 쪽을 같이 손보지 않았습니다. revalidateTag 는 목록 페이지에는 닿지만 사이트맵 같은 메타데이터 라우트의 ISR 항목까지는 닿지 않았습니다. 배포할 때 만들어진 목록이 다음 배포까지 그대로 나가고 있었던 것입니다.

기능을 끄고 켠 것이 아니라 비용을 고치면서 갱신 경로를 하나 잃은 것이라 아무 에러도 나지 않았습니다. 그리고 그 뒤로도 한동안 드러나지 않았는데, 배포를 자주 했기 때문입니다. 배포할 때마다 사이트맵이 새로 만들어지니 결과만 보면 갱신되는 것처럼 보입니다. 이번에는 배포 간격이 엿새로 벌어졌고 그 사이에 글을 발행해서 보였습니다.

쉬운 쪽으로 되돌리지 않은 이유

가장 빠른 수습은 force-dynamic 으로 되돌리는 것입니다. 나머지 피드가 전부 그렇게 돌고 있으니 한 줄이면 끝납니다.

그런데 그건 egress 때문에 일부러 버린 설정이었습니다. 되돌리면 크롤러가 두드릴 때마다 사이트맵을 새로 만들어 보내던 상태로 정확히 돌아갑니다. 고치려다 예전 문제를 되살리는 셈이라, 저는 이 경우 원래 설계를 지키는 쪽이 맞다고 봤습니다.

발행할 때 어차피 부르는 경로가 이미 있었습니다. 거기에 한 줄을 더했습니다.

revalidateTag(POST_CACHE_TAG, "max");
revalidatePath("/sitemap.xml");

발행할 때만 다시 만들어지므로 ISR 로 얻은 이득은 그대로 남고, 새 글은 즉시 들어갑니다.

잘못 센 숫자가 찾아낸 다섯 편

배포한 뒤 숫자를 맞춰 봤습니다. 사이트맵에 글이 345개인데 데이터베이스에서 센 발행 글은 340편이었습니다. 다섯 개가 더 많았습니다.

여기서 제가 틀렸습니다. 블로그는 제가 숫자를 센 데이터베이스를 보지 않습니다. 글의 정본은 사내 워크스페이스에 있고, 발행하면 공개용 데이터베이스로 동기화되며, 블로그는 그 사본을 읽습니다. 저는 정본에서 센 수를 사본으로 만들어진 사이트맵과 맞대고 있었습니다. 앞에서 "몇 편이 빠졌다"고 적은 수치도 그래서 정확하지 않았습니다.

그런데 그 어긋남이 다른 것을 꺼냈습니다. 남는 다섯 편의 주소를 직접 열어 보니 전부 정상으로 응답했는데, 정본에서는 제목으로 찾아도 나오지 않았습니다. 처음에는 정본에서 내린 글이 공개 쪽에 남은 것이라고 생각했습니다. 그것도 틀렸습니다.

공개 쪽 행을 열어 보니 다섯 편 다 동기화 식별자가 비어 있었습니다. 동기화로 들어온 적이 없다는 뜻이고, 정본을 거치지 않고 관리자 화면에서 직접 쓴 글이었습니다. 지금 경로가 자리잡기 전에 쓴 것들이었습니다. 숫자도 정확히 맞았습니다. 공개 346편은 동기화된 341편에 이 다섯 편을 더한 값이었습니다.

그래서 지울 일이 아니라 정본으로 들여올 일이었습니다. 위험한 쪽은 따로 있었습니다. 정본에 새로 만들어 발행하면 식별자가 새로 생겨 같은 글이 공개 쪽에 두 번 뜹니다. 수신구가 그 식별자로 행을 찾기 때문입니다. 그래서 순서를 뒤집어, 정본에 넣어 두고 발행하지 않은 채로 공개 쪽 행에 식별자를 먼저 채운 다음 발행했습니다. 수신구가 그 행을 찾아 제자리에서 갱신했고, 주소도 최초 발행일도 그대로 남았습니다.

원래 찾던 문제와는 아무 상관이 없는 일이었습니다. 숫자가 맞았다면 못 봤을 것입니다.

먹지 않은 한 줄과 두 번째 고침

배포하고 나니 사이트맵에 발행 글이 전부 들어왔습니다. 그런데 이건 증거가 못 됩니다. 배포하면 빌드가 사이트맵을 새로 만들기 때문에, 고치지 않았어도 같은 결과가 나왔을 것입니다. 배포 자체가 증상을 가립니다.

그래서 이 글을 발행하면서 확인했습니다. 배포 없이 새 글이 사이트맵에 들어오면 맞은 것입니다.

안 들어왔습니다.

글수=345  새글=0  age=1985
글수=345  새글=0  age=2005
글수=345  새글=0  age=2025
글수=345  새글=0  age=2046

훅이 안 불린 것도 아니었습니다. 같은 훅을 타는 RSS 는 age: 0 에 새 글이 들어가 있었습니다. 태그로도 경로로도 메타데이터 라우트의 ISR 항목에는 닿지 않습니다. 제가 추가한 한 줄은 아무 일도 하지 않았습니다.

돌이켜 보면 첫 고침이 안이했습니다. 닿지 않는 곳에 같은 계열의 호출을 하나 더 넣은 것이라, 같은 메커니즘을 한 번 더 믿은 셈입니다.

두 번째로는 사이트맵을 메타데이터 라우트에서 빼고 형제 피드 넷과 같은 라우트 핸들러로 옮겼습니다. 그쪽은 응답 헤더를 직접 만들기 때문에 s-maxage 로 CDN 이 저장하고 ETag 로 조건부 요청에 304 를 줍니다. ISR 이 풀려던 비용 문제를 이쪽이 더 잘 풉니다. 만료를 기다리는 대신, 크롤러가 다시 물어봐도 본문을 안 보내기 때문입니다. 그리고 신선도가 무효화에 기대지 않고 만료 시간으로 보장됩니다.

검증 기준도 바꿨습니다. 글 수는 배포가 가려 버리니 응답 헤더를 봤습니다. ETag 가 붙어 있으면 라우트 핸들러가 살아 있다는 뜻이고, 헤더는 빌드가 만드는 것이 아니라 구현이 만드는 것이라 가려지지 않습니다.

cache-control: public, max-age=300
etag: "sRvi6-zuNAuOEzQ-29LZe9_mt0M"
age: 91

age 가 리셋되고 ETag 가 붙었습니다. 사이트맵의 글 수는 346 으로 정본과 정확히 같아졌습니다. 앞서 들여온 다섯 편까지 포함한 수입니다.

이 게시글 공유하기

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.