운영 중인 Next.js 서비스의 상세 페이지에 revalidate 를 분명히 줬는데, 응답 헤더를 보니 x-vercel-cache: MISS 가 매번 찍혔어요. 캐시가 하나도 안 되고 요청마다 서버에서 다시 그리고 있었던 거예요.
상세 페이지가 수천 장이라, 검색 로봇이 기본 주소만 한 바퀴 돌아도 수천 번 렌더가 돌았어요. Vercel 은 함수가 도는 시간만큼 세기 때문에 무료 한도를 넘긴 원인 중 하나가 이거였어요. 이 글은 왜 ISR 이 안 먹었는지, 그리고 기본 주소는 캐시하고 쿼리가 붙은 요청만 따로 렌더하게 바꾼 방법을 정리한 거예요. Next.js 16 App Router 기준이에요.
왜 ISR 이 안 먹었나 — 아래 중 하나만 있어도 경로 전체가 동적이 된다
Next.js 는 페이지가 요청마다 달라질 수 있는 값을 읽으면, 그 경로 전체를 캐시하지 않고 매번 그려요. revalidate 를 줬어도 마찬가지예요.
| 원인 | 왜 동적이 되나 | 대신 |
|---|---|---|
searchParams 읽기 | 주소의 쿼리(?tab=…)는 요청마다 다르다 | 쿼리 요청만 다른 경로로 보낸다 (이 글의 방법) |
headers() · cookies() | User-Agent · 쿠키는 사람마다 다르다 | 로봇 판별은 하지 않는다. 폰 / PC 는 CSS 로 |
fetch(…, { cache: 'no-store' }) | "캐시하지 마"를 명시했다 | 경로에 export const fetchCache = 'force-cache' |
동적 경로([slug])에 generateStaticParams 없음 | 런타임에 처음 보는 경로를 ISR 대상으로 안 본다 | 빈 generateStaticParams() { return [] } |
제 페이지는 셋이 겹쳐 있었어요. 탭 · 쪽 번호를 searchParams 로 읽었고, 로봇이면 무거운 칸을 빼려고 headers() 로 User-Agent 를 봤고, 일부 조회는 오래된 값이면 캐시 없이 다시 받게 돼 있었어요.
이 중 가장 흔하고 풀기 애매한 게 searchParams 예요. 탭 상태를 주소에 남기는 건 좋은 습관이라(공유 · 뒤로 가기) 쿼리를 없앨 수는 없었거든요.
어떻게 풀었나 — 기본 주소는 캐시, 쿼리가 붙은 요청만 rewrites 로 따로
방문과 로봇의 대부분은 쿼리 없는 기본 주소로 와요. 그래서 기본 주소 페이지는 쿼리를 아예 안 읽게 하고, 쿼리가 붙은 요청만 다른 경로로 보내 매번 그리게 했어요.
/items/abc → app/items/[slug]/page.tsx ● ISR 5분 (쿼리를 안 읽음)
/items/abc?tab=video → (rewrites, 주소창 그대로)
app/items/[slug]/q/page.tsx ƒ 매번 렌더
1. next.config 의 rewrites — 쿼리 키가 있을 때만 ./q 로
// next.config.js
async rewrites() {
const keys = ['tab', 'page']
return {
// beforeFiles 여야 한다 — 기본 rewrites 는 페이지 파일이 먼저 잡아서 안 탄다
beforeFiles: keys.map((key) => ({
source: '/items/:slug',
has: [{ type: 'query', key }],
destination: '/items/:slug/q',
})),
}
},
- 주소창은
/items/abc?tab=video그대로예요. 사용자도 검색엔진도/q를 모르고, 쿼리도 그대로 넘어가요. - 정한 키만 보내요.
?utm_source=…같은 상관없는 쿼리는 캐시된 기본 페이지로 가요. - 라우팅 단계에서 처리돼서 함수가 따로 돌지 않아요.
2. 기본 페이지 — 쿼리를 읽지 않고, 넘겨받을 때만 쓴다
// app/items/[slug]/page.tsx
export const revalidate = 300
export const fetchCache = 'force-cache' // 안쪽 조회가 no-store 여도 캐시로
export async function generateStaticParams() {
return [] // 있어야 런타임 경로가 ISR 된다
}
interface Props {
params: Promise<{ slug: string }>
/** ./q 페이지만 넘긴다 — 여기선 Next 의 searchParams 를 읽지 않는다 */
query?: Record<string, string | string[] | undefined>
}
export default async function Page({ params, query = {} }: Props) {
const { slug } = await params
// query.tab 이 없으면 기본 탭
}
핵심은 Next 가 주는 searchParams 를 받지 않는 것이에요. 대신 직접 만든 query prop 으로만 쿼리를 받아요. 기본 주소로 들어오면 query 는 빈 객체라 기본 탭이 그려지고, 이 결과가 캐시돼요.
3. ./q 페이지 — 기본 페이지를 그대로 쓰고 쿼리만 넘긴다
// app/items/[slug]/q/page.tsx
import BasePage from '../page'
export { generateMetadata } from '../page' // canonical 도 기본 주소 그대로
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
return <BasePage params={params} query={await searchParams} />
}
화면 코드는 한 벌이라 중복이 없어요. generateMetadata 를 그대로 내보내서 canonical 도 기본 주소를 가리키고, 쿼리 주소가 검색에 따로 잡히지 않아요.
force-static 이나 useSearchParams 로는 안 되나 — 캐시는 되지만 쿼리나 HTML 을 잃는다
이 문제를 만나면 보통 먼저 떠올리는 방법이 두 가지 있어요. 저는 아래 이유로 쓰지 않았어요(직접 적용해 본 건 아니고 Next.js 의 동작 기준이에요).
| 방법 | 캐시 | 잃는 것 |
|---|---|---|
export const dynamic = 'force-static' | 된다 | searchParams 가 늘 빈 값이 된다 — 탭 · 쪽 쿼리가 무시돼 늘 기본 화면 |
클라이언트 컴포넌트에서 useSearchParams() | 된다 | 탭 내용이 브라우저에서 그려진다 — 첫 HTML 엔 기본 화면뿐이고, Suspense 로 감싸야 한다 |
| rewrites 로 쿼리 요청만 분리 (이 글) | 기본 주소만 | 설정이 조금 는다 — 쿼리 주소는 매번 렌더 |
force-static은 "요청마다 다른 값은 없는 셈 치고 굳힌다"는 뜻이라, 쿼리를 읽는 코드가 있으면 늘 빈 쿼리로 그려요. 쿼리를 아예 안 쓰는 페이지라면 이게 제일 간단해요.useSearchParams()는 탭이 브라우저에서만 바뀌어도 되는 화면에 좋아요. 다만 쿼리 주소로 바로 들어왔을 때 서버 HTML 에는 기본 탭만 있어서, 검색엔진이나 링크 미리보기가 그 탭 내용을 못 볼 수 있어요.- rewrites 방식은 기본 주소는 캐시하고, 쿼리 주소도 서버에서 완성된 HTML 을 줘요. 탭 주소로 들어와도 그 탭 내용이 처음부터 HTML 에 있길 원해서 이쪽을 골랐어요.
같이 손본 것 — 이름 겹침 · 로봇 판별 · robots.txt
- 목록 페이지는
/q를 위로 뺐어요./items/[slug]/q처럼 동적 경로 아래 두면, 이름이q인 항목과 주소가 겹칠 수 있어요. 목록 · 지도 같은 페이지는/q/items처럼 맨 앞에 두었어요. - 로봇 판별을 지웠어요.
headers()로 로봇이면 가벼운 화면을 주던 코드는, 기본 주소가 캐시되면 필요 없어요. 로봇이 와도 캐시된 페이지를 받으니까요. - 폰 / PC 판별도 CSS 로 바꿨어요. User-Agent 로 폰 화면 · PC 화면을 골라 그리던 곳은, 둘 다 그리고
md:hidden같은 CSS 로 하나만 보이게 했어요. - robots.txt 에서 쿼리 주소를 막았어요. 쿼리 주소는 매번 렌더라, 로봇이 긁으면 다시 비용이 생겨요.
캐시되는지 확인하는 법 — 빌드 출력과 x-vercel-cache
빌드 출력에서 경로 앞 기호를 봐요. ●(ISR) · ○(정적)은 캐시되고, ƒ 는 매번 렌더예요. 바꾸기 전엔 상세 경로가 ƒ 였고, 바꾼 뒤엔 기본 경로가 ●, /q 경로만 ƒ 로 나왔어요.
운영에서는 같은 주소를 몇 초 간격으로 두세 번 요청해 봐요.
curl -sD- -o /dev/null https://example.com/items/abc | grep -iE 'x-vercel-cache|age'
HIT 이고 age 가 늘어나면 캐시되고 있는 거예요. 로컬 운영 빌드(next build && next start)에서 기본 주소를 두 번 요청했더니 첫 번째는 0.2초, 두 번째는 0.004초로 줄었어요.
참고로 이 방법은 Vercel 전용이 아니에요. ISR, generateStaticParams, rewrites 는 모두 Next.js 기능이라 다른 곳에 올려도 그대로 동작해요.
정리
| 무엇 | 내용 |
|---|---|
| 증상 | revalidate 를 줬는데 x-vercel-cache: MISS 가 매번, 빌드 출력이 ƒ |
| 원인 | searchParams · headers() · no-store · generateStaticParams 없음 — 하나만 있어도 경로 전체가 동적 |
| 해결 | 기본 주소 페이지는 쿼리를 안 읽는 ISR, 쿼리 요청만 rewrites(beforeFiles + has: query)로 ./q 에 보내 매번 렌더 |
| 중복 없이 | ./q 는 기본 페이지 컴포넌트에 query 만 넘기고 generateMetadata 를 공유 |
| 같이 | 로봇 판별 제거, 폰 / PC 는 CSS, robots.txt 로 쿼리 주소 막기 |
| 확인 | 빌드 ●, 운영 HIT + age 증가 |
revalidate 만 넣으면 캐시된다고 생각하기 쉬운데, 페이지 어딘가에서 요청마다 다른 값을 하나라도 읽으면 소용이 없어요. 캐시가 이상하면 빌드 출력의 ƒ 부터 확인해 보세요.