링크를 카카오톡이나 X 에 붙이면 미리보기 그림이 뜹니다. 이 공유 카드(OG 이미지)를 오시레이더에서는 버튜버 · 소속사 · 순위마다 Next.js 로 그려서 내보냅니다.
그런데 운영에서 응답 헤더를 보니, 이 그림이 캐시되지 않고 계속 다시 그려지고 있었습니다. 결론부터 적으면 이렇습니다.
- 주소가 바뀌는 카드는
opengraph-image파일 규칙을 버리고, 라우트 핸들러와Cache-Control헤더로 옮겼습니다. - 주소가 고정된 카드는 잘 캐시되고 있어서 그대로 뒀습니다.
force-static과fetchCache를 차례로 붙여 봤지만 운영의STALE은 사라지지 않았습니다. 왜 그랬는지는 끝내 찾지 못했습니다.
원인을 밝힌 글이 아니라, 원인을 못 찾은 채로 확실히 동작하는 쪽으로 옮긴 기록입니다. 같은 증상을 겪는다면 앞의 두 단계를 건너뛰는 데 도움이 될 수 있습니다.
공유 카드가 왜 비용이 되는가
카드는 서버에서 PNG 로 그려야 합니다. 미리보기를 만드는 로봇은 자바스크립트를 실행하지 않고 그림 주소만 받아 가기 때문입니다.
제 카드는 한 장 그리는 데 CPU 를 약 0.3초 씁니다. 작아 보이지만 버튜버마다 카드가 한 장씩이라 수천 장입니다. 캐시가 없으면 로봇과 미리보기가 올 때마다 함수가 돕니다. 어디에 비용이 나가는지는 vercel usage 로 사용량 보는 글에 적은 방법으로 볼 수 있습니다.
캐시되는지 확인하는 법
Vercel 에서는 응답 헤더로 바로 알 수 있습니다. 몇 초 간격으로 두세 번 불러 봅니다.
curl -sD- -o /dev/null https://example.com/items/abc/og | grep -iE 'x-vercel-cache|^age'
| 값 | 뜻 |
|---|---|
MISS | 캐시에 없어서 방금 그림 |
HIT | 캐시에서 바로 줌, age 가 계속 늘어남 |
STALE | 오래된 캐시를 주고, 뒤에서 다시 그림 |
처음 본 결과는 이랬습니다.
/streamers/{slug}/opengraph-image → MISS (연속 3번, age 0)
/companies/{slug}/opengraph-image → MISS
/opengraph-image → HIT (주소 고정)
/map/opengraph-image → HIT (주소 고정)
주소가 고정된 카드만 캐시되고, [slug] 가 들어간 카드는 매번 새로 그려졌습니다.
1차: force-static — 로컬에서는 해결됐다
revalidate 는 이미 하루로 걸려 있었습니다. 다만 동적 주소는 그것만으로 요청 때 캐시되지 않습니다. Next.js 문서는 런타임 경로를 ISR 하려면 빈 generateStaticParams 나 force-static 을 쓰라고 안내합니다. 그래서 force-static 을 붙였습니다.
export const revalidate = 86400
export const dynamic = 'force-static'
로컬 운영 빌드에서는 깔끔했습니다.
| 항목 | 전 | 후 |
|---|---|---|
| 빌드 표시 | ƒ (동적) | ○ |
| 첫 요청 | 약 0.3초 | 0.29초, MISS |
| 두 번째 요청 | 다시 그림 | 0.004초, HIT |
그런데 배포해 보니 운영에서는 카드가 몇 초 만에 STALE 이 됐고, 순위 카드는 여전히 계속 MISS 였습니다.
2차: fetchCache 와 조회 주기 — 맞는 수정이었지만 부족했다
카드 안에서 부르는 데이터 조회를 의심했습니다. 조회 함수에는 데이터가 오래되면 캐시 없이 다시 받는 로직이 있었고, 조회 주기도 페이지에 맞춘 5분이었습니다. 실제 갱신 주기는 카드의 revalidate 와 안쪽 조회의 revalidate 중 짧은 쪽을 따릅니다. 하루로 적어 놓아도 5분마다 갱신될 수 있다는 뜻입니다.
export const fetchCache = 'force-cache'
// 카드에서 부를 때만 하루로 넘긴다. 페이지는 그대로 5분
const s = await getStreamerDetail(slug, { revalidate: 86400, live: false })
로컬에서는 갱신 주기가 300초에서 86400초로 늘어난 것을 확인했습니다. 일부러 오래된 데이터를 돌려주는 프록시를 세워도 캐시가 유지됐고, 순위 카드도 MISS 에서 HIT 로 바뀌었습니다.
그러나 운영에서는 20초 안팎마다 STALE 이 반복됐습니다. 로컬 운영 빌드에서는 재현되지 않아서, 더 파고들 단서가 없었습니다.
3차: 라우트 핸들러와 Cache-Control
여기서 원인 찾기를 멈추고 이미 동작하는 쪽을 봤습니다. 라우트 핸들러로 만든 다른 카드(/my/og)는 운영에서 HIT 가 나오고 있었습니다. 그래서 같은 방식으로 옮겼습니다.
// app/rankings/[kind]/og/route.tsx
export async function GET(_req: Request, ctx: { params: Promise<{ kind: string }> }) {
return withOgCache(await card(ctx), 'hour')
}
카드 그림과 데이터는 그대로 두고, 파일 위치와 내보내는 방식만 바꿨습니다. 헤더는 한 곳에 모았습니다.
export const OG_CACHE = {
// 팔로워처럼 하루 한 번 바뀌는 카드
day: 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400',
// 순위처럼 장수가 적고 자주 보는 카드
hour: 'public, max-age=600, s-maxage=3600, stale-while-revalidate=3600',
} as const
export function withOgCache(res: Response, policy: keyof typeof OG_CACHE): Response {
res.headers.set('Cache-Control', OG_CACHE[policy])
return res
}
s-maxage: CDN 이 그림을 들고 있는 시간입니다. 이 동안에는 함수가 돌지 않습니다.max-age: 브라우저가 들고 있는 시간입니다.stale-while-revalidate: 시간이 지난 뒤 옛 그림을 주면서 뒤에서 한 번 다시 그릴 수 있는 시간입니다.
캐시 기간은 카드 숫자가 바뀌는 주기에 맞췄습니다. 카드에 기준 날짜가 찍혀 있어서 하루 늦은 숫자여도 괜찮습니다.
파일 규칙을 벗어나면 Next.js 가 og:image 를 자동으로 넣어 주지 않으므로, 페이지 메타데이터에 주소를 직접 적어야 합니다.
return buildMetadata({
title: streamerTitle(streamer),
description: streamerDescription(streamer),
path,
type: 'profile',
image: `${path}/og`, // 파일 규칙이 하던 일
})
buildMetadata 는 제가 만든 함수이고, 결국 openGraph.images 에 이 주소가 들어갑니다.
운영에서 다시 재 본 결과
이 글을 쓰면서 운영에서 5초 간격으로 4번씩 다시 재 봤습니다.
| 카드 | x-vercel-cache | age | 받은 cache-control |
|---|---|---|---|
버튜버 /og | MISS 뒤 HIT | 0 → 5 → 10 → 15 | public, max-age=3600 |
순위 /og | HIT 4번 | 46 → 61 | public, max-age=600 |
사이트 기본 opengraph-image | HIT 4번 | 55 → 70 | public, max-age=0, must-revalidate |
두 가지를 짚어 둘 만합니다.
첫째, 응답에는 s-maxage 가 보이지 않습니다. 코드에서는 s-maxage 와 stale-while-revalidate 를 함께 보내지만, 받은 헤더에는 max-age 만 남아 있습니다. 헤더가 빠졌다고 오해하기 쉬우니, 캐시 여부는 x-vercel-cache 와 age 로 판단하는 편이 정확합니다.
둘째, STALE 이 한 번 보였다고 고장은 아닙니다. 같은 날 앞선 측정에서 고정 주소 카드도 STALE 이 한 번 나왔고, 바로 다음 요청은 HIT 였습니다. 오래된 캐시를 갱신하는 정상 동작입니다. 문제는 age 가 쌓이지 못하고 STALE 이 계속 반복될 때입니다.
옮기면서 치른 대가
공짜는 아니었습니다.
| 대가 | 내용 |
|---|---|
| 옛 주소 | opengraph-image 주소는 404, 새로 긁으면 새 주소로 바뀌지만 이미 만들어진 미리보기는 서비스 쪽 캐시에 따라 다름 |
| 즉시 갱신 | 페이지와 달리 요청형 갱신 신호가 안 닿음, CDN 시간이 지나야 바뀜 |
| 메타데이터 | 자동으로 안 들어가므로 페이지마다 주소를 직접 지정 |
| 없는 주소 | '찾지 못했어요' 카드도 같은 헤더로 캐시됨 |
즉시 갱신이 필요한 카드라면 이 방식이 맞지 않을 수 있습니다. 제 경우에는 하루 늦어도 괜찮아서 받아들였습니다.
정리
| 단계 | 한 일 | 로컬 | 운영 |
|---|---|---|---|
| 1차 | force-static | HIT | 몇 초 만에 STALE, 순위 카드 MISS |
| 2차 | force-cache, 조회 주기 하루 | HIT, 주기 86400초 | 20초 안팎마다 STALE |
| 3차 | 라우트 핸들러 + Cache-Control | 헤더 확인 | HIT, age 증가 |
페이지 쪽에서 비슷한 증상이 난다면 searchParams 때문에 ISR 이 풀리는 글이 더 가까울 수 있습니다.
이번에 남긴 교훈은 두 가지입니다. 캐시는 로컬 운영 빌드에서 HIT 가 나와도 운영 헤더로 다시 봐야 합니다. 그리고 원인을 못 찾을 때는, 같은 서비스 안에서 이미 동작하는 방식으로 옮기는 것도 현실적인 선택입니다. 다만 그때는 원인을 모른다는 사실을 문서에 남겨 둡니다. 저도 카드 문서에 "로컬에서는 재현되지 않았다"고 적어 두었습니다.