goodchuck 블로그

Next.js ISR 인데 매번 렌더된다면 — searchParams 하나가 페이지 전체를 동적으로 만든다

9분 읽기

운영 중인 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 만 넣으면 캐시된다고 생각하기 쉬운데, 페이지 어딘가에서 요청마다 다른 값을 하나라도 읽으면 소용이 없어요. 캐시가 이상하면 빌드 출력의 ƒ 부터 확인해 보세요.