Published on

Next.js 14 RSC 캐시 꼬임, revalidateTag로 푸는 법

Authors

서버 컴포넌트(React Server Components, RSC) 기반으로 앱을 만들다 보면, 분명 DB는 업데이트됐는데 화면은 계속 예전 데이터를 보여주는 순간이 있습니다. 특히 Next.js 14의 App Router에서 fetch 캐시, 라우트 세그먼트 캐시, 그리고 RSC 스트리밍이 섞이면서 “캐시가 꼬인 것처럼 보이는” 현상이 자주 발생합니다.

이 글은 그 현상을 재현 가능한 형태로 쪼개서 설명하고, 가장 실전적인 해법인 revalidateTag정확히 필요한 데이터만 무효화하는 패턴을 정리합니다.

참고로 캐시가 꼬였을 때 무작정 no-store로 도망치면 문제는 사라지지만, 성능 이점을 전부 포기하게 됩니다. 목표는 “캐시를 쓰되, 바뀐 것만 정확히 갱신”입니다.

왜 RSC에서 캐시가 꼬인 것처럼 보일까

Next.js App Router에서 데이터가 오래 남는 대표 원인은 보통 아래 셋 중 하나입니다.

1) fetch가 기본으로 캐시된다

서버 컴포넌트에서 호출한 fetch는 Next.js가 요청을 최적화하기 위해 캐싱할 수 있습니다. 개발자는 “서버에서 매번 실행되니까 매번 최신이겠지”라고 생각하지만, 실제론 같은 요청이 캐시에서 재사용될 수 있습니다.

2) 라우트 세그먼트 캐시와 결합된다

페이지가 RSC로 렌더링될 때, 결과가 세그먼트 단위로 캐시되며, 이 캐시가 다음 요청에서 재사용될 수 있습니다. 즉, 데이터만 바뀌어도 “세그먼트가 재생성되지 않으면” 화면은 그대로일 수 있습니다.

3) 갱신 트리거가 없다

DB 업데이트 후에 “이 태그를 가진 캐시를 무효화하라”는 신호가 없으면, 캐시는 TTL이 끝나기 전까지 남습니다. 이때 사용자는 “갱신이 안 된다”라고 느끼고, 개발자는 “캐시가 꼬였다”라고 느낍니다.

해결 전략: revalidateTag를 중심으로 설계하기

핵심은 단순합니다.

  1. 읽기 요청(fetch)에 태그를 붙인다
  2. 쓰기 요청(POST/PUT/DELETE, 서버 액션 등)에서 그 태그를 무효화한다

이 방식은 페이지 전체를 날리는 revalidatePath보다 더 정밀합니다. 목록/상세/요약 등 여러 화면이 같은 데이터 소스를 공유할 때 특히 효과가 큽니다.

예제 시나리오: 게시글 목록과 상세

  • /posts에서 게시글 목록을 보여준다
  • /posts/[id]에서 상세를 보여준다
  • 게시글을 수정하면 목록과 상세가 모두 최신으로 바뀌어야 한다

1) 서버 컴포넌트에서 fetch에 태그 달기

아래 예시는 목록과 상세에 각각 태그를 부여하는 패턴입니다.

  • 목록: posts:list
  • 상세: posts:detail:{id}
// app/posts/page.tsx

async function getPosts() {
  const res = await fetch(`${process.env.API_BASE_URL}/api/posts`, {
    next: { tags: ['posts:list'] },
  })

  if (!res.ok) throw new Error('Failed to fetch posts')
  return res.json() as Promise<Array<{ id: string; title: string }>>
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </main>
  )
}
// app/posts/[id]/page.tsx

async function getPost(id: string) {
  const res = await fetch(`${process.env.API_BASE_URL}/api/posts/${id}`, {
    next: { tags: [`posts:detail:${id}`] },
  })

  if (!res.ok) throw new Error('Failed to fetch post')
  return res.json() as Promise<{ id: string; title: string; body: string }>
}

export default async function PostDetailPage({
  params,
}: {
  params: { id: string }
}) {
  const post = await getPost(params.id)

  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </main>
  )
}

여기까지는 “캐시를 잘 쓰는 읽기”입니다. 이제 “쓰기”에서 무효화를 걸어야 합니다.

2) 라우트 핸들러에서 업데이트 후 revalidateTag

게시글 수정 API에서 DB 업데이트를 한 다음, 관련 태그를 무효화합니다.

// app/api/posts/[id]/route.ts

import { revalidateTag } from 'next/cache'

export async function PUT(
  req: Request,
  { params }: { params: { id: string } }
) {
  const body = (await req.json()) as { title?: string; body?: string }

  // 1) DB 업데이트 (예시)
  // await db.post.update({ where: { id: params.id }, data: body })

  // 2) 캐시 무효화
  revalidateTag('posts:list')
  revalidateTag(`posts:detail:${params.id}`)

  return Response.json({ ok: true })
}

이제 /posts 목록과 /posts/[id] 상세는 다음 요청에서 최신 데이터로 재생성됩니다.

3) 서버 액션에서 revalidateTag 쓰기

폼 기반으로 수정하는 UI를 만들면 서버 액션이 더 자연스러운 경우가 많습니다.

// app/posts/[id]/actions.ts

'use server'

import { revalidateTag } from 'next/cache'

export async function updatePostAction(id: string, formData: FormData) {
  const title = String(formData.get('title') ?? '')
  const body = String(formData.get('body') ?? '')

  // 1) DB 업데이트
  // await db.post.update({ where: { id }, data: { title, body } })

  // 2) 캐시 무효화
  revalidateTag('posts:list')
  revalidateTag(`posts:detail:${id}`)

  return { ok: true }
}
// app/posts/[id]/edit/page.tsx

import { updatePostAction } from '../actions'

export default function EditPostPage({ params }: { params: { id: string } }) {
  return (
    <form action={updatePostAction.bind(null, params.id)}>
      <label>
        Title
        <input name="title" />
      </label>
      <label>
        Body
        <textarea name="body" />
      </label>
      <button type="submit">Save</button>
    </form>
  )
}

서버 액션 기반이면 “업데이트 API 호출 후 다시 라우팅/리프레시” 같은 클라이언트 로직이 줄고, 캐시 무효화도 서버 내부에서 일관되게 처리할 수 있습니다.

revalidateTag가 안 먹는 것처럼 보일 때 체크리스트

현장에서 “태그 무효화했는데도 그대로인데요?”가 가장 흔한 질문입니다. 대부분 아래 함정 중 하나입니다.

1) 태그를 단 fetch가 실제로 실행되는가

태그는 fetch 캐시 엔트리에 붙습니다. 즉, 해당 데이터가 fetch로 만들어진 캐시를 사용하지 않으면 태그 무효화가 체감되지 않습니다.

  • ORM 직접 호출(예: Prisma)을 서버 컴포넌트에서 바로 하면 revalidateTag가 아니라 다른 전략이 필요합니다.
  • 가능하면 “읽기 경로는 fetch로 통일”하거나, 최소한 캐싱 전략을 한곳에 모으세요.

2) 태그 문자열이 정확히 일치하는가

posts:detail:1을 달아놓고 posts:details:1을 무효화하면 당연히 안 됩니다. 태그 네이밍은 규칙을 정해두는 게 좋습니다.

권장 규칙 예시:

  • 컬렉션: posts:list
  • 엔티티: posts:detail:{id}
  • 파생 뷰(예: 인기글): posts:popular

3) revalidateTag 호출 위치가 서버인가

revalidateTag는 서버에서만 실행됩니다. 클라이언트 컴포넌트에서 직접 호출할 수 없습니다.

  • 라우트 핸들러
  • 서버 액션
  • 서버 전용 유틸

이 중 하나에서 호출해야 합니다.

4) 결국 페이지가 새로 렌더링될 기회가 있는가

태그를 무효화해도 “현재 열려 있는 화면”이 자동으로 바뀌는 건 아닙니다. 다음 중 하나가 필요할 수 있습니다.

  • 사용자가 다른 페이지로 이동했다가 돌아옴
  • router.refresh()로 RSC를 다시 가져옴
  • 서버 액션의 후속 동작으로 리다이렉트

즉, revalidateTag는 “다음 요청부터 최신을 보장”하는 장치이고, “현재 화면을 즉시 푸시 업데이트”하는 장치는 아닙니다.

revalidatePath vs revalidateTag 선택 기준

  • revalidatePath: 특정 경로(세그먼트)를 통째로 무효화. 구현은 단순하지만 영향 범위가 넓음.
  • revalidateTag: 데이터 단위로 무효화. 여러 화면이 같은 데이터를 공유할 때 강력.

실무에서는 보통 이렇게 갑니다.

  • 초기에 빠르게 고칠 때: revalidatePath('/posts')
  • 안정화/최적화 단계: revalidateTag('posts:list')revalidateTag('posts:detail:{id}')

운영에서 “캐시 꼬임”을 줄이는 설계 팁

1) 읽기/쓰기 경로를 분리하고, 쓰기에서만 무효화하기

쓰기 로직이 여러 군데 흩어져 있으면 무효화 누락이 생깁니다. “게시글 변경”은 반드시 한 군데 서비스 레이어(서버 액션 또는 API)로 모으고, 그곳에서 태그를 무효화하세요.

2) 태그 네이밍을 문서화하기

태그는 사실상 캐시 키 설계입니다. 팀이 커질수록 규칙이 없으면 충돌하거나 누락됩니다.

3) 캐시 문제는 종종 ‘캐시’가 아니라 ‘배포/빌드 캐시’이기도 하다

로컬에서는 잘 되는데 CI에서만 이상하거나, 특정 환경에서만 오래된 결과가 보이면 빌드 캐시나 이미지 레이어 캐시가 원인일 때가 있습니다. 이런 경우는 Docker/CI 캐시까지 함께 의심해야 합니다.

결론: no-store는 최후의 수단, revalidateTag가 정답에 가깝다

Next.js 14 RSC에서 “캐시가 꼬였다”는 느낌은 대부분 무효화 설계가 없거나, 태그가 데이터 읽기 경로에 제대로 연결되지 않았거나, 화면 갱신 트리거가 없는 문제로 귀결됩니다.

정리하면 아래 3가지만 지키면 체감 문제가 크게 줄어듭니다.

  1. 서버 컴포넌트의 읽기 fetchnext.tags를 붙인다
  2. 쓰기(서버 액션/라우트 핸들러)에서 revalidateTag로 관련 태그를 무효화한다
  3. 필요하면 router.refresh() 또는 리다이렉트로 “다음 RSC 요청”을 유도한다

이 패턴으로 가면 캐시를 포기하지 않고도, 사용자에게는 “항상 최신 데이터”처럼 보이게 만들 수 있습니다.