Next.js fetch 캐시 문제 해결: 데이터가 바뀌었는데 화면이 그대로일 때

2026.05.09·수정 2026.07.20·약 14분

Next.js fetch 캐시 문제를 재현하고 정확히 고치는 순서

Next.js 16 App Router에서 Server Component를 운영하는 개발자를 위한 글입니다. 원본 API의 데이터는 바뀌었는데 배포 화면만 예전 값을 보여 주는 문제를 직접 재현한 뒤, Next.js Data Cache·정적 프리렌더 결과·클라이언트 Router Cache·상위 API/CDN을 분리합니다. 이 과정을 마치면 무조건 no-store를 붙이지 않고 데이터의 최신성 요구에 맞춰 시간 기반 재검증, 태그 무효화, 경로 무효화 중 하나를 선택하고 프로덕션 조건에서 결과까지 검증할 수 있습니다.

최신성 기준: 2026-07-19. Next.js 최신 공식 문서의 16.2.10 App Router 기준이며, cacheComponents 사용 여부에 따른 예외를 별도로 설명합니다.

먼저 stale 상태를 재현하고 요청 위치 확인하기

오류를 고치기 전에 원본 데이터와 화면 데이터를 같은 키로 비교해야 합니다. 예를 들어 게시글 id=42의 제목을 API에서 바꾸고, API 응답의 updatedAt과 페이지에 표시된 값을 기록합니다. API 자체가 예전 값을 주면 Next.js 캐시 문제가 아니므로 원본 서버나 그 앞의 CDN부터 봐야 합니다. 반대로 API는 새 값인데 Server Component만 예전 값을 주면 아래처럼 명시적으로 캐시한 최소 예제로 증상을 고정할 수 있습니다.

async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    cache: 'force-cache',
  })

  if (!response.ok) {
    throw new Error('Failed to load posts')
  }

  return response.json()
}

export default async function PostsPage() {
  const posts = await getPosts()
  return <pre>{JSON.stringify(posts, null, 2)}</pre>
}

이 코드를 배포한 다음 원본 데이터를 바꾸고 같은 URL을 다시 열었을 때 이전 응답이 유지되면 재현에 성공한 것입니다. 실제 프로젝트에서는 호출 위치도 함께 적습니다. Server Component의 확장 fetch, 브라우저에서 실행되는 client-side fetch, Route Handler를 거치는 요청은 같은 URL을 사용해도 캐시 주체가 다릅니다. 브라우저 fetchcache는 HTTP 캐시 지시이고, 서버의 Next.js 확장 fetch에서는 프레임워크의 Data Cache 정책이라는 차이가 핵심입니다.

Next.js fetch 캐시 문제의 원인을 요청 위치와 캐시 계층으로 나누는 흐름
  1. 원본 API를 curl이나 API 도구로 직접 호출해 본문과 갱신 시각을 저장합니다.
  2. 문제가 난 페이지의 응답과 화면에 표시된 동일한 레코드 키를 비교합니다.
  3. 해당 요청이 Server Component, Client Component, Route Handler 중 어디서 실행되는지 찾습니다.
  4. 개발 서버에서만 확인하지 말고 next build 뒤 실행한 결과와 실제 배포 결과를 따로 기록합니다.

서로 다른 캐시 계층을 분리하기

현재 Next.js fetch 공식 문서에서 기본값은 auto no cache입니다. 개발 중에는 원격 리소스를 요청마다 가져오지만, 요청 시점 API가 없는 정적 경로는 next build 때 한 번 가져와 프리렌더될 수 있습니다. cache: 'no-store'는 요청 시점 API 감지 여부와 상관없이 매 요청 원본을 가져오고, cache: 'force-cache'는 일치하는 Data Cache 항목을 재사용합니다. 따라서 “기본 fetch는 항상 캐시된다” 또는 “기본 fetch는 어떤 환경에서도 항상 새로 읽는다”라는 한 문장으로 설명하면 현재 동작을 놓칩니다.

계층 대표 증상 분리 방법 주요 조치
원본 API·상위 CDN API 직접 호출도 예전 값 원본 URL의 본문·ETag·Cache-Control 비교 원본 캐시 정책과 purge 확인
Next.js Data Cache 원본은 새 값, 서버 fetch만 예전 값 cache, next.revalidate, next.tags 검색 최신성에 맞는 요청 정책 지정
정적 프리렌더 결과 개발은 최신인데 빌드·배포만 고정 next build 로그와 요청 시점 API 사용 여부 확인 동적 읽기 또는 재검증 정책 명시
클라이언트 Router Cache 새 탭은 최신인데 기존 탭 이동만 예전 값 전체 새로고침과 클라이언트 내비게이션 비교 서버 무효화 후 필요한 경우 경로 새로고침
브라우저·플랫폼 CDN 특정 브라우저·리전만 오래 유지 응답 헤더와 다른 네트워크·리전 비교 HTTP/CDN 정책을 별도로 수정

next: { revalidate: 300 }은 데이터의 캐시 수명 상한을 300초로 두는 선택이지, 300초마다 모든 브라우저에 새 값을 밀어 넣는 타이머가 아닙니다. 같은 경로의 여러 요청이 서로 다른 재검증 시간을 쓰면 더 낮은 값이 경로 재검증 주기에 영향을 줄 수 있으므로 요청별 정책을 표로 남기는 편이 안전합니다.

no-store와 revalidate를 데이터별로 고르기

정책은 “stale을 몇 초까지 허용할 수 있는가”로 정합니다. 사용자 권한, 결제 직전 상태, 관리자 승인 대기열처럼 매 요청 최신값이 필요하면 no-store가 맞습니다. 공개 게시글 목록이나 상품 카탈로그처럼 짧은 지연을 허용하고 조회량이 큰 데이터는 시간 기반 재검증과 태그를 함께 두면 캐시 효율과 수정 대응을 모두 얻을 수 있습니다.

export async function getAdminQueue() {
  const response = await fetch('https://api.example.com/admin/queue', {
    cache: 'no-store',
  })

  if (!response.ok) throw new Error('Failed to load queue')
  return response.json()
}

export async function getPublicPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 300, tags: ['posts'] },
  })

  if (!response.ok) throw new Error('Failed to load posts')
  return response.json()
}
요구사항 권장 설정 검증 기준
항상 최신이어야 함 cache: 'no-store' 또는 revalidate: 0 새 요청마다 원본 변경이 바로 보임
최대 N초 지연 허용 next: { revalidate: N } N초 전후의 요청에서 새 값으로 전환
수정 이벤트를 알고 있음 next.tags와 태그 무효화 관련 페이지 전체가 같은 태그 기준으로 갱신
특정 경로만 다시 계산 revalidatePath 지정 경로의 다음 방문 또는 Server Action 흐름에서 갱신

{ cache: 'no-store', next: { revalidate: 3600 } }처럼 서로 충돌하는 옵션은 쓰지 않습니다. 공식 문서상 두 옵션이 모두 무시되고 개발 터미널에 경고가 출력될 수 있습니다. 모든 요청에 no-store를 붙이는 방식도 서버 부하와 응답 시간을 늘릴 수 있으므로 진단용 임시 변경과 최종 정책을 구분해야 합니다.

Next.js 데이터 최신성 요구에 따라 no-store와 재검증을 선택하는 체크리스트

수정 직후 태그와 경로를 무효화하기

조회 요청을 잘 설정해도 글 작성·수정 뒤 기존 캐시를 어떻게 바꿀지가 없으면 사용자는 자신의 변경을 바로 보지 못합니다. Server Action에서 “내가 방금 쓴 값”을 즉시 읽어야 할 때는 updateTag가 맞습니다. 이 함수는 해당 태그를 즉시 만료시키고 다음 요청이 새 값을 기다리게 하며 Server Action 안에서만 사용할 수 있습니다.

'use server'

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

export async function publishPost(formData: FormData) {
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: formData,
  })

  if (!response.ok) throw new Error('Failed to publish post')

  updateTag('posts')
  revalidatePath('/posts')
}

revalidatePath는 특정 페이지나 레이아웃 경로를 무효화합니다. 같은 posts 태그를 대시보드와 홈에서도 사용한다면 경로 하나만 무효화해서는 다른 페이지가 계속 stale할 수 있으므로 태그와 경로의 범위를 따로 생각해야 합니다.

Route Handler나 CMS webhook에서 약간의 지연을 허용한다면 revalidateTag('posts', 'max')를 사용합니다. 현재 권장 형태는 두 번째 인수 'max'를 주는 stale-while-revalidate이며, 단일 인수 형태는 deprecated입니다. 외부에서 호출되는 재검증 Route Handler에는 서명이나 공유 비밀 검증을 반드시 추가해야 합니다.

import { revalidateTag } from 'next/cache'

export async function POST() {
  // 실제 서비스에서는 요청 서명을 먼저 검증합니다.
  revalidateTag('posts', 'max')
  return Response.json({ revalidated: true })
}

개발 모드·router.refresh·Cache Components 예외

  • router.refresh()의 한계: useRouter 공식 문서에 따르면 현재 경로의 클라이언트 캐시는 새로 고치지만 서버 Data Cache는 무효화하지 않습니다. mutation 뒤 서버 캐시가 남아 있으면 router.refresh()만 반복해도 같은 값이 돌아옵니다.
  • 개발 HMR 캐시: Server Component의 fetch 응답은 개발 중 HMR 사이에서 재사용될 수 있으며 no-store도 영향을 받을 수 있습니다. 내비게이션이나 전체 페이지 새로고침 뒤 다시 비교하고, 최종 판정은 프로덕션 빌드에서 합니다.
  • 하드 새로고침: 개발 모드 요청에 cache-control: no-cache가 포함되면 fetch의 cache, next.revalidate, next.tags가 무시되고 원본에서 가져옵니다. 이 결과만 보고 배포 캐시가 해결됐다고 판단하면 안 됩니다.
  • Cache Components: Next.js 16에서 cacheComponents: true를 켰다면 기존 route segment의 dynamic, revalidate, fetchCache 중심 설명을 그대로 적용하지 않습니다. 공식 마이그레이션 문서에 따라 'use cache', cacheLife, cacheTag가 캐시 경계를 정하는지 확인합니다.
  • 상위 HTTP 캐시: Next.js 설정을 바꿔도 원본 API나 별도 CDN이 오래된 응답을 주면 화면은 바뀌지 않습니다. Data Cache를 끈 결과와 원본 응답을 항상 함께 저장합니다.

프로덕션 조건에서 수정 결과 검증하기

수정은 한 번에 하나만 적용하고 같은 데이터 변경으로 전후를 비교합니다. 개발 서버의 HMR을 끈 상태를 만들기 위해 먼저 프로덕션 빌드를 실행합니다. 실제 명령은 프로젝트의 패키지 매니저와 스크립트에 맞게 바꾸되, 빌드 성공과 데이터 최신성 검증을 섞지 않습니다.

npm run build
npm run start

# 별도 터미널에서 원본 API와 페이지를 같은 시각에 비교
curl -i https://api.example.com/posts
curl -i http://localhost:3000/posts
검증 시나리오 기대 결과 실패하면 돌아갈 계층
no-store 요청 뒤 원본 수정 다음 새 요청에서 새 값 원본 API·상위 CDN·호출 위치
revalidate: 300 뒤 원본 수정 허용 시간과 실제 갱신 요청을 거쳐 새 값 Data Cache·정적 경로 설정
Server Action 뒤 updateTag 작성자가 다음 읽기에서 새 값 확인 태그 철자·Action 실행 여부
webhook 뒤 revalidateTag(..., 'max') 다음 방문에서 stale-while-revalidate 흐름 작동 webhook 인증·태그 범위
클라이언트 이동과 새 탭 비교 둘 다 같은 최종 값 Router Cache·서버 무효화 누락

검증 기록에는 Next.js 버전, cacheComponents 값, 요청 URL, 실행 위치, 선택한 정책, 원본 변경 시각, 처음 새 값이 보인 시각을 남깁니다. 이 정보가 있어야 캐시 정책 회귀를 테스트로 옮길 수 있습니다.

결론: 최신성 요구부터 정하고 캐시 계층별로 검증합니다

핵심은 stale 화면을 발견하자마자 모든 fetch를 끄는 것이 아닙니다. 먼저 원본 API가 새 값인지 확인하고, 요청이 실행되는 위치와 Next.js 캐시 모델을 찾은 뒤, 항상 최신이면 no-store, 지연을 허용하면 revalidate, 수정 이벤트를 알면 태그·경로 무효화를 선택합니다. 마지막으로 HMR이 없는 프로덕션 빌드에서 동일한 변경을 다시 만들었을 때 기대 시간 안에 화면이 바뀌면 해결된 것입니다.

다음 학습 경로

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기