Published on

Next.js Route Cache로 데이터가 갱신 안될 때

Authors

서버에서 데이터는 분명 바뀌었는데, Next.js 화면은 그대로인 경우가 있습니다. 특히 App Router 환경에서는 Route Cache(라우트 단위 캐시), Data Cache(데이터 fetch 캐시), 그리고 클라이언트 라우터 캐시까지 겹치며 “어디가 캐시된 건지”를 놓치기 쉽습니다.

이 글은 “데이터 갱신이 안 된다”는 현상을 캐시 계층별로 분해해서 진단하고, 가장 흔한 실수를 피하는 실전 설정 조합을 제공합니다.

1) 증상부터 분류하기: 무엇이 안 바뀌는가

먼저 관찰 포인트를 세 가지로 나눕니다.

  1. 서버 데이터는 바뀌었는데 페이지 HTML이 안 바뀜
    • 새로고침해도 동일하면 Route Cache 또는 ISR revalidate 문제가 의심됩니다.
  2. 서버 컴포넌트에서 fetch 결과가 안 바뀜
    • Route Cache가 아니라 Data Cache(Next의 fetch 캐시)일 수 있습니다.
  3. 새로고침하면 바뀌는데, 클라이언트 네비게이션만 안 바뀜
    • router.push 이후 화면이 그대로면 클라이언트 라우터 캐시, router.refresh() 누락, 또는 서버 액션 이후 revalidate 미적용을 의심합니다.

이 분류가 중요한 이유는, Next.js는 캐시를 “한 군데”에서만 하지 않기 때문입니다.

2) Route Cache vs Data Cache: 헷갈리는 지점을 한 줄로

  • Route Cache: 특정 라우트(페이지/레이아웃)의 렌더 결과(RSC payload 포함)를 캐시합니다.
  • Data Cache: 서버 컴포넌트에서 호출한 fetch 응답을 캐시합니다(기본은 캐시됨).

즉, 데이터가 바뀌어도 페이지 렌더 결과가 캐시되어 있으면 화면이 안 바뀌고, 페이지가 다시 렌더되더라도 fetch 결과가 캐시되어 있으면 데이터가 안 바뀝니다.

3) 가장 흔한 원인 5가지

원인 A: 서버 컴포넌트 fetch가 기본 캐시로 동작

App Router에서 서버 컴포넌트의 fetch는 기본적으로 캐시될 수 있습니다. 그래서 DB가 바뀌어도 동일 응답이 반복될 수 있습니다.

해결 1: 완전 실시간이 필요하면 cache: 'no-store'

// app/products/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store',
  })
  const products = await res.json()

  return <pre>{JSON.stringify(products, null, 2)}</pre>
}

해결 2: 일정 주기로 갱신이면 next.revalidate

// 30초마다 재검증
const res = await fetch('https://api.example.com/products', {
  next: { revalidate: 30 },
})

여기서 주의할 점은 “페이지가 동적으로 렌더되는지”와 별개로, fetch 레벨에서 캐시가 걸릴 수 있다는 것입니다.

원인 B: 페이지가 정적으로 최적화되어 Route Cache에 고정

특정 페이지가 빌드 시점 또는 첫 요청 시점에 정적으로 생성되고, Route Cache에 의해 계속 재사용될 수 있습니다.

해결: 라우트를 강제 동적 처리

// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'

export default async function Page() {
  const res = await fetch('https://api.example.com/dashboard', {
    cache: 'no-store',
  })
  return <div>{(await res.json()).updatedAt}</div>
}

dynamic = 'force-dynamic'은 “이 라우트는 매 요청마다 서버에서 다시 계산해야 한다”는 의도를 명확히 합니다.

원인 C: 서버 액션이나 API에서 업데이트 후 무효화를 안 함

예를 들어 서버 액션으로 DB를 업데이트했는데, 목록 페이지가 계속 이전 데이터를 보여주는 경우가 많습니다. 이때는 Route Cache 또는 Data Cache 무효화가 필요합니다.

해결: revalidatePath 또는 revalidateTag

// app/actions.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updateProduct(id: string, name: string) {
  // 1) DB 업데이트
  await fetch('https://api.example.com/products/update', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, name }),
  })

  // 2) 무효화
  revalidatePath('/products')
  revalidateTag('products')
}

그리고 데이터를 가져오는 쪽에서 tag를 붙입니다.

// app/products/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  })
  const data = await res.json()
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}
  • revalidatePath('/products'): 해당 경로 렌더 결과(Route Cache)를 무효화하는 성격이 강합니다.
  • revalidateTag('products'): 해당 tag로 묶인 fetch(Data Cache)를 무효화하는 성격이 강합니다.

둘 중 하나만으로 해결되는 경우도 있지만, 실제로는 “페이지 캐시”와 “데이터 캐시”가 동시에 걸려 있는 경우가 있어 둘 다 적용하는 패턴이 안전합니다.

원인 D: 클라이언트 네비게이션 후 화면이 안 바뀜

클라이언트 컴포넌트에서 저장을 누르고 다른 화면으로 이동했는데, 목록이 갱신되지 않는 경우입니다.

해결: 서버 액션 무효화 + router.refresh() 조합

// app/products/ProductEditor.tsx
'use client'

import { useRouter } from 'next/navigation'
import { updateProduct } from '../actions'

export function ProductEditor({ id }: { id: string }) {
  const router = useRouter()

  return (
    <button
      onClick={async () => {
        await updateProduct(id, 'new-name')
        router.refresh()
      }}
    >
      Save
    </button>
  )
}

router.refresh()는 현재 라우트의 서버 컴포넌트를 다시 요청해 RSC payload를 갱신합니다. 서버에서 revalidatePath만 호출하고 클라이언트에서 refresh를 안 하면, “서버는 무효화했는데 내 화면은 그대로”가 될 수 있습니다.

원인 E: CDN 또는 프록시 캐시가 앞단에서 응답을 고정

Vercel/CloudFront/Nginx 같은 레이어가 캐시 헤더를 보고 응답을 캐시하면, Next.js 내부 캐시를 다 꺼도 데이터가 안 바뀌는 것처럼 보일 수 있습니다.

이때는 네트워크 탭에서 응답 헤더를 확인하세요.

  • cache-control이 과도하게 길게 설정되어 있는지
  • x-vercel-cache 같은 힌트 헤더가 HIT인지

API 응답 쪽에서 의도치 않게 캐시되는 경우도 많습니다.

4) 안전한 설정 조합 레시피

레시피 1: 관리자 화면처럼 “항상 최신”이 최우선

  • 페이지: dynamic = 'force-dynamic'
  • 데이터: fetchcache: 'no-store'
export const dynamic = 'force-dynamic'

const res = await fetch('https://api.example.com/admin/stats', {
  cache: 'no-store',
})

장점: 갱신 문제 거의 사라짐 단점: 트래픽 증가, 응답 느려질 수 있음

레시피 2: 목록은 캐시, 수정 직후만 즉시 반영

  • 목록 조회: next: { tags: ['products'], revalidate: 300 }
  • 수정 후: revalidateTag('products') + 필요 시 revalidatePath('/products')
// list
await fetch('https://api.example.com/products', {
  next: { tags: ['products'], revalidate: 300 },
})

// update
revalidateTag('products')
revalidatePath('/products')

장점: 성능과 최신성 균형

레시피 3: 상세는 태그, 목록은 경로 무효화

  • 상세 페이지는 product:ID 태그
  • 목록은 /products 경로 무효화
// detail
await fetch(`https://api.example.com/products/${id}`, {
  next: { tags: [`product:${id}`] },
})

// update action
revalidateTag(`product:${id}`)
revalidatePath('/products')

이 패턴은 “한 상품 수정이 전체 목록/상세에 미치는 영향”을 명확히 분리해 줍니다.

5) 디버깅 체크리스트: 10분 안에 원인 좁히기

  1. 새로고침해도 안 바뀌나, 클라이언트 이동에서만 안 바뀌나
  2. 해당 fetchcache: 'no-store'를 임시로 걸어도 안 바뀌나
  3. 서버 액션/뮤테이션 직후 revalidatePath 또는 revalidateTag를 호출했나
  4. 호출했는데도 안 되면, 데이터를 가져오는 fetchnext: { tags: [...] }가 실제로 붙어 있나
  5. dynamic = 'force-dynamic'이 필요한 페이지인데 정적으로 최적화된 건 아닌가
  6. API 응답 헤더에 캐시가 걸려 있지 않나(특히 cache-control)
  7. 배포 환경에서만 발생한다면 CDN HIT 여부를 확인했나

캐시 문제는 본질적으로 “중복/재처리” 문제와 닮아 있습니다. 이벤트가 한 번 더 처리되거나, 반대로 최신 이벤트가 반영되지 않는 상황은 운영에서 자주 마주칩니다. 데이터 일관성과 재처리 관점은 DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리 글도 함께 보면 사고 정리에 도움이 됩니다.

6) 예제: 업데이트 후 목록이 안 바뀌는 전형적인 케이스

아래는 “수정은 되는데 목록이 그대로”인 케이스를 재현하고, 올바르게 고치는 예시입니다.

(문제) 목록 조회는 캐시되는데 무효화가 없음

// app/products/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/products')
  return <pre>{JSON.stringify(await res.json(), null, 2)}</pre>
}
// app/actions.ts
'use server'

export async function updateProductName(id: string, name: string) {
  await fetch('https://api.example.com/products/update', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, name }),
  })
}

(해결) tag 부여 + tag 무효화 + 필요 시 refresh

// app/products/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'] },
  })
  return <pre>{JSON.stringify(await res.json(), null, 2)}</pre>
}
// app/actions.ts
'use server'

import { revalidateTag, revalidatePath } from 'next/cache'

export async function updateProductName(id: string, name: string) {
  await fetch('https://api.example.com/products/update', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ id, name }),
  })

  revalidateTag('products')
  revalidatePath('/products')
}
// app/products/ProductEditor.tsx
'use client'

import { useRouter } from 'next/navigation'
import { updateProductName } from '../actions'

export function ProductEditor({ id }: { id: string }) {
  const router = useRouter()

  return (
    <button
      onClick={async () => {
        await updateProductName(id, 'new-name')
        router.refresh()
      }}
    >
      Save
    </button>
  )
}

이 조합은 “서버 캐시 무효화”와 “현재 탭 UI 갱신”을 모두 커버합니다.

7) 운영 팁: 캐시 무효화도 결국 분산 시스템 문제다

캐시 무효화는 분산 시스템에서 가장 어려운 문제 중 하나로 자주 언급됩니다. Next.js Route Cache 역시 마찬가지로, “어느 계층이 최신 상태를 보장해야 하는지”를 팀 내에서 합의하지 않으면, 수정 후 갱신이 랜덤하게 실패하는 것처럼 보일 수 있습니다.

특히 다음 상황에서는 캐시 정책이 더 중요해집니다.

  • 결제/주문/재고처럼 최신성이 중요한 화면
  • 여러 서비스에서 동일 데이터를 갱신하는 MSA
  • 비동기 이벤트로 데이터가 늦게 반영되는 구조

이런 맥락에서 중복 이벤트/재처리 패턴을 다룬 Saga 패턴 보상 트랜잭션 중복 실행 방지법도 함께 참고하면, “무효화가 늦거나 중복으로 호출될 때”의 설계를 더 단단히 할 수 있습니다.

마무리

Next.js에서 데이터가 갱신되지 않을 때는 “Route Cache만 의심”하면 해결이 늦어집니다. 다음 순서로 접근하면 빠르게 정리됩니다.

  • fetch가 캐시되는지(Data Cache)
  • 페이지 렌더 결과가 캐시되는지(Route Cache)
  • 업데이트 후 무효화가 실행되는지(revalidatePath, revalidateTag)
  • 클라이언트 화면 갱신이 필요한지(router.refresh())
  • 마지막으로 CDN/프록시 캐시가 앞단에서 고정하는지

이 순서대로 체크하면, 대부분의 “왜 최신 데이터가 안 보이지?” 문제를 재현 가능하게 만들고 안정적으로 해결할 수 있습니다.