goodchuck 블로그

Next.js 공유 카드가 계속 다시 그려진다면 — opengraph-image 캐시를 라우트 핸들러로 옮긴 기록

링크를 카카오톡이나 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-cacheage받은 cache-control
버튜버 /ogMISS 뒤 HIT0 → 5 → 10 → 15public, max-age=3600
순위 /ogHIT 4번46 → 61public, max-age=600
사이트 기본 opengraph-imageHIT 4번55 → 70public, 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-staticHIT몇 초 만에 STALE, 순위 카드 MISS
2차force-cache, 조회 주기 하루HIT, 주기 86400초20초 안팎마다 STALE
3차라우트 핸들러 + Cache-Control헤더 확인HIT, age 증가

페이지 쪽에서 비슷한 증상이 난다면 searchParams 때문에 ISR 이 풀리는 글이 더 가까울 수 있습니다.

이번에 남긴 교훈은 두 가지입니다. 캐시는 로컬 운영 빌드에서 HIT 가 나와도 운영 헤더로 다시 봐야 합니다. 그리고 원인을 못 찾을 때는, 같은 서비스 안에서 이미 동작하는 방식으로 옮기는 것도 현실적인 선택입니다. 다만 그때는 원인을 모른다는 사실을 문서에 남겨 둡니다. 저도 카드 문서에 "로컬에서는 재현되지 않았다"고 적어 두었습니다.