Published on

Next.js App Router 캐시로 데이터가 안 갱신될 때

Authors

서버에서 데이터는 바뀌었는데 화면은 계속 예전 값이라면, App Router의 캐시 계층을 하나씩 의심해야 합니다. App Router는 성능을 위해 여러 레벨의 캐시를 적극적으로 사용하고, 이들이 겹치면 “내가 갱신했다고 생각한 지점”과 “실제로 무효화된 지점”이 어긋나기 쉽습니다.

이 글은 다음 상황을 해결하는 데 초점을 둡니다.

  • DB 업데이트 후에도 페이지가 그대로인 경우
  • router.refresh()를 했는데도 바뀌지 않는 경우
  • 서버 액션으로 mutate 했는데 리스트/상세가 갱신되지 않는 경우
  • 개발 환경에서는 되는데 배포하면 안 되는 경우

App Router에서 데이터가 안 바뀌는 대표 원인 4가지

1) fetch가 기본적으로 캐시되는 줄 몰랐던 경우

App Router의 서버 컴포넌트에서 실행되는 fetch는 Next.js가 요청을 “데이터 캐시”로 저장할 수 있습니다. 특히 정적 렌더링 경로로 판단되면 결과가 오래 유지됩니다.

다음 같은 코드가 있을 때:

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

배포 환경에서 계속 같은 값이 나올 수 있습니다.

해결 A: 매 요청마다 최신 데이터가 필요하면 no-store

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

해결 B: 주기적 갱신이면 revalidate

const res = await fetch('https://api.example.com/posts', {
  next: { revalidate: 30 },
})
  • no-store: 강제 동적(항상 최신) 성격
  • revalidate: ISR처럼 일정 시간마다 새로고침

2) “페이지가 정적”으로 최적화되어 버린 경우

페이지 안의 모든 데이터가 캐시 가능한 fetch로만 구성되면, Next.js가 해당 경로를 정적으로 판단해 라우트 자체가 캐시될 수 있습니다.

해결 A: 정말 동적이어야 하면 dynamic 강제

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

export default async function Page() {
  const res = await fetch('https://api.example.com/posts', { cache: 'no-store' })
  const posts = await res.json()
  return <pre>{JSON.stringify(posts, null, 2)}</pre>
}

cache: 'no-store'만으로도 충분한 경우가 많지만, “라우트 레벨에서 동적이어야 한다”가 명확하면 dynamic을 같이 두면 의도가 분명해집니다.

해결 B: 특정 헤더/쿠키/세션을 읽으면 자동으로 동적이 되기도 함

예를 들어 서버 컴포넌트에서 cookies()headers()를 읽으면 동적으로 판단되는 경우가 있습니다. 다만 이 방식은 “캐시 무효화 목적”으로 쓰기보다는, 실제 요구사항(인증/개인화)이 있을 때 자연스럽게 따라오는 효과로 보는 게 안전합니다.

3) 서버 액션으로 mutate 했는데, 읽기 쪽 캐시를 안 지운 경우

서버 액션은 DB를 바꿔도 “읽기 캐시”까지 자동으로 무효화해주지 않습니다. 즉, 쓰기(write)와 읽기(read)가 분리되어 있어서, 쓰기 후에 무효화 신호를 명시적으로 줘야 합니다.

해결: revalidatePath 또는 revalidateTag

예시: 글 생성 후 목록 페이지를 갱신하고 싶다면

// app/posts/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = String(formData.get('title') || '')

  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title }),
  })

  // 목록 경로의 라우트/데이터 캐시 무효화
  revalidatePath('/posts')
}

더 정교하게 “이 데이터에 붙은 태그만” 지우고 싶으면 revalidateTag를 씁니다.

// 읽기(fetch)에 태그 부여
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
})

// 쓰기 후 태그 무효화
import { revalidateTag } from 'next/cache'
revalidateTag('posts')
  • revalidatePath: 특정 라우트 세그먼트 기준으로 무효화
  • revalidateTag: 데이터 단위로 무효화(추천, 확장성 좋음)

4) router.refresh()를 과신한 경우

클라이언트 컴포넌트에서 router.refresh()는 “현재 라우트를 다시 렌더”하도록 요청하지만, 서버 쪽에서 여전히 캐시된 fetch 결과를 주면 화면이 그대로일 수 있습니다.

router.refresh()는 만능이 아니라, 서버에서 반환되는 결과가 최신이 되도록(캐시 정책/무효화) 설계를 맞춰야 효과가 있습니다.

'use client'

import { useRouter } from 'next/navigation'

export function RefreshButton() {
  const router = useRouter()
  return (
    <button onClick={() => router.refresh()}>
      refresh
    </button>
  )
}

이 버튼을 눌러도 안 바뀐다면 다음을 점검하세요.

  • 해당 데이터 fetchcache: 'force-cache' 상태로 남아 있지 않은가
  • next: { revalidate: ... }가 너무 길지 않은가
  • mutate 이후 revalidatePath 또는 revalidateTag를 호출했는가

재현 패턴별 빠른 처방전

패턴 A: “관리자에서 글 수정했는데 사용자 페이지가 그대로”

  • 읽기 페이지가 정적/ISR로 캐시됨
  • 해결: 읽기 fetchtags 부여, 관리자 수정 로직에서 revalidateTag
// app/posts/[id]/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const res = await fetch(`https://api.example.com/posts/${params.id}`,
    { next: { tags: [`post:${params.id}`] } }
  )
  const post = await res.json()
  return <pre>{JSON.stringify(post, null, 2)}</pre>
}
// app/admin/posts/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(id: string, title: string) {
  await fetch(`https://api.example.com/posts/${id}`, {
    method: 'PUT',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title }),
  })

  revalidateTag(`post:${id}`)
  revalidateTag('posts')
}

패턴 B: “목록은 바뀌는데 상세는 그대로(또는 반대)”

  • 목록/상세가 서로 다른 캐시 키로 저장됨
  • 해결: 목록은 posts, 상세는 post:${id}처럼 태그를 분리하고, 쓰기에서 둘 다 무효화

패턴 C: “개발에서는 되는데 배포하면 안 됨”

  • 개발 모드는 캐시 동작이 달라서 문제가 숨겨질 수 있음
  • 해결: 배포 환경 기준으로 캐시 정책을 명시(no-store, revalidate, tags)하고, mutate 후 무효화까지 포함해 테스트

이 문제는 캐시가 얽히면 원인 추적이 어려워지는데, 캐시 문제를 체계적으로 디버깅하는 관점은 빌드/배포 캐시를 다룰 때와 유사합니다. 캐시가 안 먹거나(혹은 너무 먹거나) 예상과 다를 때의 사고방식은 GitHub Actions 캐시가 안먹을 때 9가지 원인도 함께 참고하면 도움이 됩니다.

캐시 설계를 안정적으로 가져가는 권장 패턴

1) 읽기 fetch에는 태그를 기본으로 붙인다

처음부터 태그 기반으로 설계하면, 나중에 “어떤 화면을 무효화해야 하지”가 쉬워집니다.

  • 컬렉션: posts
  • 단건: post:${id}
  • 유저별: user:${userId}:posts 같은 네이밍
await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
})

2) 쓰기 로직은 항상 무효화까지 한 트랜잭션으로 본다

서버 액션/라우트 핸들러에서 DB 업데이트만 하고 끝내면, 읽기 캐시가 남아 “유령 데이터”가 됩니다.

'use server'

import { revalidateTag } from 'next/cache'

export async function deletePost(id: string) {
  await fetch(`https://api.example.com/posts/${id}`, { method: 'DELETE' })

  revalidateTag(`post:${id}`)
  revalidateTag('posts')
}

분산 환경에서 “쓰기 이후 읽기 일관성”을 맞추는 문제는 결국 이벤트/보상/재시도 같은 설계와도 닮아 있습니다. 캐시 무효화가 비즈니스 플로우의 일부가 되는 순간이 오면 MSA Saga 패턴 - 보상 트랜잭션 설계·디버깅처럼 일관성 관점에서 접근하는 것도 좋습니다.

3) 정말 최신이 중요한 화면만 no-store로 제한한다

no-store는 강력하지만 비용이 큽니다.

  • 예: 관리자 대시보드, 실시간 재고/가격, 결제 직후 상태
  • 그 외 대부분은 revalidate 또는 태그 기반 무효화로 충분

점검 체크리스트(운영 장애 줄이는 순서)

  1. 서버 컴포넌트의 fetchcache 또는 next.revalidate 또는 next.tags가 명시되어 있는가
  2. mutate(서버 액션/라우트 핸들러) 이후 revalidatePath 또는 revalidateTag가 호출되는가
  3. 목록/상세/필터별로 태그 설계가 일관적인가
  4. router.refresh()에 기대기 전에 서버 반환값이 최신이 되도록 설계했는가
  5. 배포 환경에서도 동일한 조건으로 재현/검증했는가

마무리

App Router에서 “데이터가 갱신되지 않는다”는 문제는 대개 버그가 아니라 캐시 기본값과 무효화 전략의 불일치에서 발생합니다. 해결의 핵심은 단순합니다.

  • 읽기에는 태그를 붙이고
  • 쓰기에는 태그(또는 경로) 무효화를 붙이며
  • 정말 최신이 필요한 곳만 no-store로 제한

이 3가지만 팀 규칙으로 정해도, router.refresh()를 남발하지 않고도 예측 가능한 데이터 갱신을 만들 수 있습니다.