Published on

Next.js ISR 500 - revalidate·캐시 충돌 해결

Authors

서버를 안정화시켰는데도 특정 페이지에서만 간헐적으로 500이 터지고, 로그를 보면 revalidate 타이밍에만 발생한다면 거의 항상 ISR(Incremental Static Regeneration)과 캐시 레이어가 충돌하고 있는 상황입니다. 특히 Next.js 13/14(App Router)에서는 fetch 캐시, Route Cache, Data Cache, CDN 캐시가 동시에 존재할 수 있어 “내가 의도한 revalidate”와 “실제로 재검증되는 범위”가 달라지기 쉽습니다.

이 글에서는 ISR 500의 대표 원인을 동시 재생성(Stampede), 캐시 키 불일치, 잘못된 revalidate 설정, 외부 API의 순간 장애/레이트리밋, 서버리스 환경의 특성으로 나누고, 재현→관측→수정 순으로 정리합니다.

관련 성능/캐시 최적화 관점은 Next.js 14 이미지 LCP 느림 - next/image 설정 7가지도 함께 보면 전체 렌더링 경로 이해에 도움이 됩니다.

ISR에서 500이 나는 전형적인 패턴

1) “평소엔 OK, 특정 시점에만 500”

  • revalidate: 60 같은 설정 이후, 60초 경계 근처에서 트래픽이 몰리면 발생
  • 첫 요청은 정상 HTML을 주다가, 갱신 시점에만 실패

2) 에러가 페이지 단위로만 발생

  • 특정 라우트(예: /posts/[slug])에서만 발생
  • 해당 라우트가 호출하는 외부 API/DB 쿼리가 느리거나 불안정

3) 로컬에선 재현이 어렵고, 배포 환경에서만 재현

  • Vercel/서버리스/엣지 캐시/CDN이 개입
  • 멀티 인스턴스에서 동시 재생성 경합

Next.js(App Router) 캐시 레이어를 먼저 정리

App Router 기준으로 ISR과 얽히는 캐시는 대략 다음처럼 이해하면 실전에서 덜 헷갈립니다.

  • Data Cache: fetch() 결과를 Next가 캐싱(옵션에 따라)
  • Full Route Cache(Route Cache): RSC 결과/HTML에 가까운 라우트 단위 캐시
  • CDN 캐시: 플랫폼(Vercel 등) 또는 앞단 CDN이 캐싱

여기서 중요한 포인트는:

  1. revalidate는 “페이지”만이 아니라 fetch 단위로도 걸 수 있음
  2. 페이지 revalidate와 fetch revalidate가 섞이면, 어떤 것이 실제 갱신 트리거인지가 모호해질 수 있음
  3. no-store/force-cache/revalidate 조합이 꼬이면 캐시 키가 달라져서 “갱신”이 아니라 “새 캐시 생성”처럼 동작하기도 함

ISR 500의 주요 원인 6가지와 해결

1) 동시 재생성(Stampede)로 백엔드가 터짐

증상

  • revalidate 경계 시간에 요청이 몰리면 백엔드 API가 429/5xx
  • Next는 재생성 중 실패 → 최종 사용자에게 500이 노출되거나, 재생성 실패 상태가 반복

해결 전략

  • 재생성 중에는 stale을 서빙하고, 백그라운드에서 갱신(플랫폼/설정에 따라 다름)
  • 백엔드 호출에 타임아웃/재시도/서킷브레이커를 넣어 “갱신 실패”가 “페이지 500”으로 전파되지 않게 함
  • revalidate 값을 트래픽 패턴에 맞게 늘리거나, 온디맨드 revalidate로 전환

코드: fetch 타임아웃 + 재시도(서버 컴포넌트)

// lib/fetchWithTimeout.ts
export async function fetchWithTimeout(
  input: RequestInfo | URL,
  init: RequestInit & { timeoutMs?: number } = {}
) {
  const { timeoutMs = 3000, ...rest } = init
  const controller = new AbortController()
  const id = setTimeout(() => controller.abort(), timeoutMs)

  try {
    const res = await fetch(input, {
      ...rest,
      signal: controller.signal,
    })
    return res
  } finally {
    clearTimeout(id)
  }
}

// lib/fetchRetry.ts
export async function fetchRetry(url: string, init: RequestInit, retries = 2) {
  let lastErr: unknown
  for (let i = 0; i <= retries; i++) {
    try {
      const res = await fetch(url, init)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res
    } catch (e) {
      lastErr = e
      // 간단한 backoff
      await new Promise((r) => setTimeout(r, 200 * (i + 1)))
    }
  }
  throw lastErr
}

코드: revalidate 시점에만 실패해도 페이지는 깨지지 않게

// app/posts/[slug]/page.tsx
import { fetchWithTimeout } from "@/lib/fetchWithTimeout"

export const revalidate = 60

export default async function PostPage({ params }: { params: { slug: string } }) {
  // revalidate는 페이지 단위로 걸되, fetch는 안정성 중심으로
  const res = await fetchWithTimeout(
    `${process.env.API_BASE_URL}/posts/${params.slug}`,
    {
      // Data Cache는 페이지 revalidate에 맡기고 싶으면 fetch는 기본 캐시 정책을 유지
      // 단, 환경에 따라 명시가 안전할 수 있음
      next: { revalidate: 60 },
      timeoutMs: 2500,
    }
  )

  if (!res.ok) {
    // 재생성 실패가 곧바로 500으로 이어지지 않도록 fall back 처리
    // (상황에 따라 notFound() 또는 에러 UI로 유도)
    return (
      <main>
        <h1>임시 오류</h1>
        <p>콘텐츠를 불러오는 중 문제가 발생했습니다. 잠시 후 다시 시도해주세요.</p>
      </main>
    )
  }

  const post = await res.json()
  return (
    <main>
      <h1>{post.title}</h1>
      <article>{post.body}</article>
    </main>
  )
}

2) 페이지 revalidate와 fetch revalidate가 충돌

문제의 핵심

App Router에서 export const revalidate = 60을 걸어도, 내부 fetch()cache: 'no-store'로 되어 있으면 페이지는 사실상 동적이 됩니다. 반대로 페이지는 동적인데 fetch만 revalidate를 걸면 “생각한 만큼” 캐시가 안 먹거나, 일부만 갱신됩니다.

자주 나오는 실수 조합

  • 페이지: revalidate = 60
  • fetch: cache: 'no-store'

→ 결과: ISR 기대했는데 실제로는 매 요청마다 원격 호출, 그리고 특정 순간에 원격이 흔들리면 500.

해결

  • “페이지 ISR”을 원하면 fetch에 no-store를 제거하고 next: { revalidate } 또는 기본 캐시 정책을 사용
  • “항상 최신 데이터”가 원하면 페이지를 동적으로 선언(export const dynamic = 'force-dynamic')하고, ISR을 포기

코드: 의도를 명확히 하는 패턴

// (A) ISR을 확실히 쓰고 싶다
export const revalidate = 120
// fetch는 no-store 금지

// (B) 최신성이 최우선이다 (ISR 사용 안 함)
export const dynamic = "force-dynamic"
// fetch는 cache: 'no-store' 허용

3) 캐시 키 불일치(쿼리스트링/헤더/쿠키)로 “다른 페이지”가 됨

증상

  • 같은 URL이라 생각했는데, 어떤 요청은 캐시 히트/어떤 요청은 미스
  • 특정 사용자/특정 국가/특정 헤더에서만 500

원인

  • fetch()headers에 사용자별 토큰/쿠키를 포함 → 캐시 키가 사용자별로 분기
  • ?utm=... 같은 쿼리가 캐시 키를 분기
  • Vary 헤더가 CDN에서 캐시를 분기

해결

  • ISR 대상 페이지에서 사용자별 데이터를 섞지 말고 분리
  • 마케팅 쿼리는 canonical 처리하거나 미들웨어에서 제거/리다이렉트
  • 서버 컴포넌트에서 인증이 필요하면, 해당 영역만 클라이언트/동적 라우트로 분리

코드: UTM 제거 미들웨어(간단 예시)

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

const TRACKING_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]

export function middleware(req: NextRequest) {
  const url = req.nextUrl
  let changed = false

  for (const p of TRACKING_PARAMS) {
    if (url.searchParams.has(p)) {
      url.searchParams.delete(p)
      changed = true
    }
  }

  if (changed) return NextResponse.redirect(url)
  return NextResponse.next()
}

4) On-demand revalidate 구현이 잘못되어 500 또는 무한 재검증

증상

  • CMS 발행 시 /api/revalidate를 호출하면 500
  • 재검증이 되긴 하는데 일부 페이지만 계속 옛날 데이터

원인

  • revalidatePath/revalidateTag 호출 위치가 부적절
  • 인증(시크릿) 검증 실패
  • 페이지가 태그 기반 캐싱을 안 쓰는데 태그만 revalidate

해결

  • 태그 기반으로 통일: fetch(..., { next: { tags: [...] } }) + revalidateTag()
  • 또는 경로 기반: revalidatePath('/posts/foo')
  • 웹훅 라우트는 반드시 인증/검증

코드: 태그 기반 on-demand revalidate

// app/api/revalidate/route.ts
import { NextResponse } from "next/server"
import { revalidateTag } from "next/cache"

export async function POST(req: Request) {
  const { secret, tag } = await req.json().catch(() => ({}))

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 })
  }

  if (!tag) {
    return NextResponse.json({ ok: false, error: "tag required" }, { status: 400 })
  }

  revalidateTag(tag)
  return NextResponse.json({ ok: true, revalidated: tag })
}
// app/posts/[slug]/page.tsx (또는 data layer)
export async function getPost(slug: string) {
  const res = await fetch(`${process.env.API_BASE_URL}/posts/${slug}`, {
    next: {
      revalidate: 300,
      tags: [`post:${slug}`],
    },
  })
  if (!res.ok) throw new Error(`failed to fetch post: ${slug}`)
  return res.json()
}

5) 빌드/런타임 환경변수 차이로 재생성 시만 실패

증상

  • 평소엔 동작하다가 revalidate 때만 특정 API 호출이 실패
  • 로그에 process.env.API_BASE_URL is undefined

원인

  • 빌드 시점에 주입된 값과 런타임 값이 다른데, ISR 재생성은 런타임에서 실행
  • 서버리스에서 시크릿/환경변수 롤링 중 일부 인스턴스만 값이 다름

해결

  • 서버 런타임에서 필요한 env는 배포 환경에 확실히 설정
  • API base URL은 가능하면 고정, 또는 안전한 기본값/검증 로직 추가

코드: env 검증(서버 시작 시)

// lib/env.ts
function required(name: string) {
  const v = process.env[name]
  if (!v) throw new Error(`Missing env: ${name}`)
  return v
}

export const env = {
  API_BASE_URL: required("API_BASE_URL"),
  REVALIDATE_SECRET: required("REVALIDATE_SECRET"),
}

6) 관측이 부족해서 “ISR 500”으로만 보이는 경우

ISR 500은 원인이 다양해서, 어느 레이어에서 500이 났는지를 먼저 분리해야 합니다.

  • Next 라우트 핸들러에서 500? (코드 예외)
  • 외부 API가 500/429? (재시도/레이트리밋)
  • CDN이 5xx? (원본 연결 문제)

실전 팁

  • 재생성 요청에만 식별자를 심어 로그 상관관계를 만들기
  • fetch 실패 시 status/body 일부를 로깅(민감정보 제외)
  • APM/로그에서 “revalidate 시점”과 “외부 API latency/429”를 같이 보기

성능 병목을 추적하는 방식은 본질적으로 “긴 작업/병목을 찾아 쪼개는 것”이라 Chrome INP 점수 급락? Long Task 추적·해결의 접근법(관측→원인 특정→수정)을 서버 측 캐시 문제에도 그대로 적용할 수 있습니다.

권장 해결 조합(가장 안전한 운영 패턴)

1) ISR은 길게, 최신화는 온디맨드로

  • revalidate: 300~1800처럼 넉넉히
  • CMS 발행/수정 시 웹훅으로 revalidateTag('post:slug')
  • 트래픽 스파이크 시간에 자동 재생성 경합을 줄임

2) 사용자별 데이터는 ISR 페이지에서 분리

  • 페이지 본문은 ISR
  • 사용자별(구독 여부/좋아요 등)은 클라이언트에서 별도 호출 또는 동적 라우트

3) fetch 정책을 통일

  • ISR 페이지: cache: 'no-store' 금지
  • 태그 기반 캐시를 쓰면 revalidate 범위가 명확

체크리스트: “ISR 500”을 빠르게 끝내는 10분 점검

  1. 해당 라우트에 dynamic = 'force-dynamic' 또는 fetch(..., { cache: 'no-store' })가 섞여 있나?
  2. 페이지 revalidate와 fetch next.revalidate가 서로 다른 값으로 혼재되어 있나?
  3. 재생성 시점에 외부 API가 429/5xx를 내지 않나?
  4. 쿼리스트링/헤더/쿠키로 캐시 키가 사용자별로 갈라지지 않나?
  5. on-demand revalidate가 태그/경로와 실제 캐시 전략에 맞게 구현됐나?
  6. 런타임 env가 재생성 시점에 누락/불일치하지 않나?
  7. 타임아웃/재시도 없이 외부 API를 “무한 대기”로 호출하고 있지 않나?

마무리

Next.js ISR의 500은 “ISR이 불안정해서”가 아니라, 대부분 캐시 레이어가 의도와 다르게 겹치거나, 재생성 순간에만 외부 의존성이 흔들리면서 생깁니다. 해결의 핵심은 (1) 캐시 정책을 단일한 철학으로 정리하고(페이지 ISR vs 완전 동적), (2) revalidate를 온디맨드/태그 기반으로 좁히며, (3) 재생성 경합과 외부 API 실패가 사용자 500으로 번지지 않게 방어 코드를 넣는 것입니다.

운영 환경에서 문제를 “충돌”로 느끼게 만드는 건 결국 동시성/경합이 많기 때문인데, 충돌을 줄이는 접근 자체는 버전 관리에서의 충돌 해결과도 닮아 있습니다. 필요하다면 Git rebase 충돌 자동해결 - rerere·autosquash 실전의 ‘반복 충돌을 시스템으로 줄이는’ 관점도 참고해보면, ISR 재생성 실패를 구조적으로 줄이는 데 힌트를 얻을 수 있습니다.