오시레이더에는 버튜버 상세 페이지가 5,400장 있습니다. 이 페이지들을 요청마다 서버에서 다시 그리면, 검색 로봇이 한 번 훑고 지나갈 때마다 5,400장이 전부 그려집니다. 실제로 크롤러가 몰려 서버가 죽은 날도 있었습니다.
캐시를 길게 두면 해결되지만, 그러면 데이터가 바뀌어도 화면이 늦게 따라옵니다. 그래서 방향을 이렇게 바꿨습니다.
- 캐시는 하루로 길게 두고,
- 데이터가 바뀌면 API 서버가 웹에 신호를 보내 그 데이터를 쓰는 페이지만 갱신합니다. Next.js 에서는 On-demand ISR(요청형 갱신)이라고 부르고,
revalidateTag로 합니다.
신호 길을 만드는 것까지는 간단했습니다. 까다로웠던 건 그 뒤에 나온 버그 세 가지였습니다. 신호를 받았는데도 옛 화면이 보이거나, 옛 값이 하루 동안 굳는 문제였습니다.
시간 기준 캐시의 한계
처음에는 페이지마다 revalidate 시간을 정해 두었습니다.
| 페이지 | 캐시 | 데이터가 바뀌는 주기 |
|---|---|---|
| 팔로워 지도 · 순위 | 10분 | 하루 한 번 |
| 전체 버튜버 목록 | 1분 | 하루 한 번 |
팔로워 숫자는 하루에 한 번 바뀝니다. 그런데 10분마다 다시 그리면, 하루에 최대 144번 같은 그림을 다시 그릴 수 있습니다. 그렇다고 하루로 늘리면, 데이터가 바뀐 직후부터 최대 하루 동안 옛 숫자가 보입니다.
시간 기준 캐시로는 "자주 갱신"과 "적게 그리기" 중 하나를 골라야 합니다. 데이터가 언제 바뀌는지 아는 쪽이 갱신을 알려 주면 둘 다 가질 수 있습니다. 그리고 데이터가 언제 바뀌는지는 API 서버가 압니다.
캐시 태그로 묶고 바뀐 페이지만 갱신
Next.js 는 fetch 에 캐시 태그(tag)를 달고, 나중에 revalidateTag(태그) 로 그 태그가 달린 캐시만 갱신할 수 있습니다.
// 데이터를 받을 때 태그를 단다
const res = await fetch(url, {
next: { revalidate: 86400, tags: ['all', `streamer:${slug}`] },
})
태그는 "무엇이 바뀌면 어느 페이지가 갱신돼야 하나"를 기준으로 나눴습니다.
| 태그 | 달리는 곳 | 신호가 오는 때 |
|---|---|---|
all | 모든 조회 | 팔로워 숫자가 갱신됐을 때(하루 한 번), 관리자가 정보를 고쳤을 때 |
streamer:{slug} | 한 사람의 상세 · 콘텐츠 | 그 사람에게 새 글이 생겼을 때, 방송이 시작 · 끝났을 때 |
org-contents | 소속사 · 그룹의 콘텐츠 칸 | 멤버 누구든 새 글이 생겼을 때 |
org-broadcasts | 소속사 · 그룹의 방송 칸 | 멤버 방송이 시작 · 끝났을 때 |
all 은 모든 조회에 자동으로 붙입니다. 공통 fetch 함수 한 곳에서 붙이니, 새 페이지를 만들어도 빠뜨리지 않습니다.
이렇게 나눈 덕분에, 한 사람에게 새 영상이 올라오면 5,400장 중 그 사람 페이지와 소속사 페이지 몇 장만 갱신됩니다.
신호를 받는 라우트
웹에는 신호를 받는 라우트 핸들러를 하나 둡니다. 아래는 핵심만 줄인 예시입니다.
import { timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const secret = process.env.REVALIDATE_SECRET
// 비밀값이 없으면 아예 닫는다 — 아무나 캐시를 비우지 못하게
if (!secret) return Response.json({ message: 'disabled' }, { status: 503 })
const given = Buffer.from(req.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '')
const expected = Buffer.from(secret)
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return Response.json({ message: 'unauthorized' }, { status: 401 })
}
const tags = parseTags(await req.json().catch(() => null)) // 정해 둔 모양만, 최대 100개
for (const tag of tags) revalidateTag(tag, tag === 'all' ? 'max' : { expire: 0 })
return Response.json({ ok: true, tags })
}
몇 가지를 지켰습니다.
- 비밀값은 상수 시간 비교(
timingSafeEqual)로 확인합니다. 그냥===로 비교하면 응답 시간 차이로 값을 추측할 여지가 생깁니다. - 태그는 정해 둔 모양만 받습니다. 모르는 태그는 버리고, 한 번에 100개까지만 받습니다.
- 비밀값이 설정되지 않았으면 503 으로 닫습니다. 로컬 개발에서는 신호 없이 시간 기준 캐시만으로 돕니다.
API 서버 쪽에서는 작업이 성공한 뒤에 신호를 보냅니다.
- 신호가 실패해도 작업은 성공으로 둡니다. 로그만 남기고, 하루짜리 시간 기준 캐시가 안전망이 됩니다.
- 관리자가 연달아 여러 번 고치면, 5초 안의 신호를 모아 한 번만 보냅니다.
- 신호가 성공에서 실패로, 실패에서 성공으로 바뀔 때만 관리자에게 알립니다. 매번 알리면 알림이 쌓여서 아무도 안 봅니다.
신호를 받은 뒤 캐시 상태는 로컬 운영 빌드에서 MISS → HIT → (신호) → STALE → HIT 순서로 바뀌는 것을 확인했습니다.
revalidateTag 는 캐시에 "오래됨" 표시만 합니다. 실제로 다시 그리는 건 그 페이지에 다음 방문이 올 때, 한 장씩입니다. all 신호가 와도 5,400장을 한꺼번에 그리지 않습니다.
이 신호가 생긴 뒤 팔로워 지도 · 순위는 10분에서 하루로, 전체 버튜버 목록은 1분에서 하루로 캐시를 늘렸습니다.
버그 1: revalidateTag 'max' 로 첫 방문자가 옛 화면을 봤다
revalidateTag 의 두 번째 인자는 갱신 방식을 정합니다.
| 방식 | 다음 방문자가 받는 것 |
|---|---|
'max' | 옛 화면을 바로 받고, 뒤에서 새로 그린다(stale-while-revalidate) |
{ expire: 0 } | 캐시를 바로 버린다. 기다렸다가 새 화면을 받는다 |
처음엔 모든 태그에 'max' 를 썼습니다. 그랬더니 방송이 시작돼서 신호가 갔는데, 그 뒤 처음 상세 페이지를 연 사람은 방송 시작 전 화면을 받았습니다. 방금 방송을 켰는데 페이지에는 없으니 "방송 안 했나?"로 보였습니다.
그래서 태그마다 방식을 나눴습니다.
- 한 사람 · 소속사 태그는
{ expire: 0 }입니다. 신호 하나에 몇 장뿐이라, 첫 방문자가 조금 기다려도 새 화면을 보는 게 낫습니다. all만'max'입니다. 하루 한 번 전 페이지가 바뀌는데 바로 버리면, 크롤러가 몰릴 때 첫 방문마다 렌더와 API 계산이 겹칩니다. 잠깐 옛 숫자를 주는 편이 안전합니다.
버그 2: 캐시가 두 겹이면 옛 값이 하루 굳는다
API 서버에도 응답 캐시가 있었습니다. 같은 순위를 여러 번 계산하지 않으려고 10분 캐시를 두었습니다.
문제는 신호가 오는 순서였습니다.
API: 팔로워 숫자 갱신 → 웹에 신호
웹: 신호를 받고 페이지를 다시 그리려고 API 를 부름
API: 10분 캐시에 남아 있던 "갱신 전" 순위를 돌려줌
웹: 그 옛 순위로 그린 화면을 하루 동안 캐시
웹 캐시를 하루로 늘린 순간, API 의 10분짜리 캐시가 하루짜리 캐시로 번진 것입니다. 실제로 순위 화면에 소속 변경이 하루 늦게 반영된 적이 있습니다.
같은 일이 한 번 더 있었습니다. 한 사람의 달별 방송 목록은 API 에서 5분 캐시를 거쳤습니다. 방송이 시작되면 웹이 신호를 받고 이 목록을 다시 불렀습니다. 그런데 API 는 5분 캐시에 있던, 그 방송이 빠진 목록을 돌려줬습니다. 그 결과 방송이 빠진 달이 웹의 하루 캐시에 남았습니다.
고친 방법은 두 가지입니다.
- 신호를 보내기 전에 API 캐시를 먼저 비웁니다. 팔로워 갱신이 성공하면 API 응답 캐시를 전부 비우고 나서 신호를 보냅니다.
- 아직 바뀌는 데이터는 API 에서 캐시하지 않습니다. 달별 방송은 이번 달만 매번 계산하고, 이미 끝난 지난 달은 그대로 캐시합니다. 웹이 하루 캐시라 이번 달을 부르는 일은 많지 않습니다.
규칙으로 정리하면 이렇습니다. 신호를 받아 다시 그릴 때, 그 아래 캐시가 신호보다 오래된 값을 주면 안 됩니다. 캐시를 여러 겹 두면 가장 긴 캐시의 기간만큼 옛 값이 남을 수 있습니다.
API 캐시를 비우는 김에 느린 곳도 같이 고쳤습니다. 팔로워 순위는 처음 부를 때 2.7초가 걸렸습니다. 정렬 3가지 × 범위 3가지, 모두 9갈래가 같은 바탕 데이터를 각자 다시 읽고 있었기 때문입니다. 바탕을 한 벌만 만들어 9갈래가 나눠 쓰게 했고, 하루 한 번 바뀌는 데이터라 API 캐시도 10분에서 6시간으로 늘렸습니다. 갱신 때 비우니 6시간이어도 옛 값이 남지 않습니다.
버그 3: 실패 화면도 하루 굳는다
캐시를 하루로 늘리니, 잘못 그린 화면도 하루 동안 남습니다.
API 를 부르다 실패하면 처음엔 오류를 잡아서 빈 목록으로 정상 화면(200)을 그렸습니다. 사용자에게 오류 화면을 보여 주지 않으려던 것이었습니다. 그런데 API 서버가 재시작하는 순간 다시 그려진 페이지는 빈 지도, 빈 순위로 하루 동안 캐시됐습니다.
그래서 캐시되는 렌더에서는 실패를 잡지 않고 그대로 던지게 바꿨습니다.
| 상황 | 결과 |
|---|---|
| 이전 캐시가 있는 페이지를 다시 그리다 실패 | 이전 화면을 그대로 유지하고, 나중에 다시 시도 |
| 이전 캐시가 없는 첫 렌더에서 실패 | 500 + 캐시하지 않음. 사용자는 "다시 시도하기" 화면을 본다 |
Next.js 는 다시 그리다 실패하면 이전 캐시를 유지합니다. 빈 화면으로 덮어쓰는 것보다 하루 전 화면이 훨씬 낫습니다.
Vercel 에 묶이나
묶이지 않습니다. ISR, revalidateTag, 라우트 핸들러는 모두 Next.js 기능입니다. 서버 한 대에서 next start 로 돌려도 그대로 동작하고, 여러 대로 나누면 같은 캐시를 보도록 cacheHandler 를 붙이면 됩니다.
Vercel 에만 있는 건 revalidateTag 때 CDN 캐시까지 같이 비워 주는 동작입니다. 직접 운영하면 CDN 캐시를 짧게 두거나 따로 비워야 합니다.
정리
| 할 일 | 방법 |
|---|---|
| 캐시 길이 | 데이터가 바뀌는 주기보다 길게(하루). 시간 기준은 안전망 |
| 갱신 | 데이터를 바꾼 쪽이 revalidateTag 신호를 보낸다 |
| 태그 | "무엇이 바뀌면 어느 페이지가 갱신돼야 하나"로 나눈다 |
'max' vs { expire: 0 } | 바로 맞아야 하는 몇 장은 expire: 0, 전체 갱신은 'max' |
| 아래 캐시 | 신호 전에 비우거나, 아직 바뀌는 데이터는 캐시하지 않는다 |
| 실패 | 캐시 렌더에서는 잡지 않고 던진다 — 이전 화면이 남는다 |
캐시를 길게 늘리는 건 숫자 하나만 바꾸면 됩니다. 어려운 건 그 뒤입니다. "이 캐시가 갱신될 때 그 아래 캐시는 무엇을 주는가" 를 한 번씩 따라가 보면, 하루짜리 옛 화면을 미리 막을 수 있습니다.
같은 사이트에서 ISR 이 아예 안 걸리던 문제는 searchParams 하나가 페이지 전체를 동적으로 만든 기록에, 공유 카드 캐시는 opengraph-image 캐시를 라우트 핸들러로 옮긴 기록에 정리했습니다.