Published on

Next.js 14 RSC 캐시 꼬임? revalidateTag 실전

Authors

서버 컴포넌트(RSC)로 넘어오면서 Next.js의 캐싱은 훨씬 강력해졌지만, 동시에 “왜 이 값이 갱신이 안 되지?” 혹은 “A 유저가 B 유저 데이터 일부를 보는 것 같은데?” 같은 캐시 꼬임 이슈를 더 자주 만나게 됩니다. 특히 fetch 캐시, RSC 렌더 캐시, unstable_cache, 그리고 revalidatePath/revalidateTag가 섞이면 원인 파악이 어렵습니다.

이 글은 Next.js 14(App Router) 기준으로, revalidateTag를 실전에서 안전하게 쓰는 방법을 “재현 가능한 형태”로 정리합니다. 목표는 다음 3가지입니다.

  • 캐시가 꼬이는 대표 패턴을 이해한다
  • 태그 설계를 통해 원하는 범위만 정확히 무효화한다
  • 서버 액션/라우트 핸들러에서 무효화를 일관되게 적용한다

아래 내용은 “트랜잭션 꼬임 디버깅”처럼, 현상을 재현하고 원인을 좁혀가는 방식으로 접근합니다. 비슷한 디버깅 감각이 필요하다면 MSA Saga 보상 트랜잭션 꼬임 디버깅 실전도 함께 참고해보면 좋습니다.

Next.js 14에서 캐시가 꼬이는 흔한 원인

RSC에서 “캐시 꼬임”이라고 부르는 현상은 대개 아래 중 하나입니다.

1) 데이터의 캐시 키가 기대와 다르게 잡힘

fetch는 요청 URL, 메서드, 일부 옵션에 따라 캐시 키가 잡힙니다. 그런데 다음이 빠지면 의도치 않은 공유 캐시가 생깁니다.

  • 쿼리스트링에 사용자 스코프가 없음
  • Authorization 헤더를 쓰지만, 캐시 키/정책을 고려하지 않음
  • 쿠키 기반 인증인데 fetch가 같은 URL을 같은 캐시로 취급

결론: 유저별 데이터는 기본적으로 캐시 공유를 피하는 전략이 필요합니다.

2) RSC 렌더 결과가 캐시되고, 내부 fetch도 캐시됨

RSC 렌더는 “컴포넌트 실행 결과”가 캐시될 수 있고, 내부에서 호출한 fetch는 또 별도로 캐시될 수 있습니다. 둘 다 캐시되면 무효화 포인트를 잘못 잡았을 때 “지웠는데도 안 바뀌는” 느낌이 듭니다.

3) revalidateTag는 “태그가 붙은 캐시”만 지운다

revalidateTag('x')는 마법이 아닙니다. 태그가 연결된 fetch 캐시 혹은 unstable_cache 결과만 무효화합니다.

  • 태그를 안 붙여놓고 revalidateTag를 호출하면 아무 일도 안 일어납니다
  • 태그를 붙였는데도 안 지워지면 “무효화 호출이 실행되지 않았거나” “실행은 됐지만 다른 런타임/리전에 존재”하는 문제일 수 있습니다

실전 시나리오: 게시글 목록이 갱신 안 되는 문제 재현

가정:

  • /posts 페이지는 RSC에서 게시글 목록을 보여줌
  • /posts/new에서 게시글을 생성한 뒤 /posts로 돌아오면 새 글이 보여야 함
  • 하지만 배포 환경에서 가끔 목록이 갱신되지 않음

핵심은 “목록 조회 fetch에 태그를 붙이고, 생성 이후 그 태그를 무효화”입니다.

1) 목록 조회에 태그 붙이기

app/posts/page.tsx

// app/posts/page.tsx
import Link from 'next/link'

type Post = {
  id: string
  title: string
}

async function getPosts(): Promise<Post[]> {
  const res = await fetch(`${process.env.API_BASE_URL}/posts`, {
    // Next.js 데이터 캐시와 연결되는 옵션
    next: { tags: ['posts:list'] },
  })

  if (!res.ok) throw new Error('failed to fetch posts')
  return res.json()
}

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

  return (
    <main>
      <h1>Posts</h1>
      <Link href="/posts/new">New</Link>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>{p.title}</li>
        ))}
      </ul>
    </main>
  )
}

포인트:

  • next: { tags: ['posts:list'] }가 있어야 revalidateTag('posts:list')가 의미가 생깁니다.
  • 이 태그는 “목록”이라는 리소스 의미를 담습니다.

2) 생성 후 태그 무효화

서버 액션을 사용해 생성하고 무효화합니다.

app/posts/new/actions.ts

'use server'

import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'

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

  const res = await fetch(`${process.env.API_BASE_URL}/posts`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title }),
  })

  if (!res.ok) {
    throw new Error('failed to create post')
  }

  // 목록 캐시 무효화
  revalidateTag('posts:list')

  // 사용자 경험상 생성 후 목록으로 이동
  redirect('/posts')
}

이제 /posts는 다음 요청에서 다시 데이터를 받아오게 됩니다.

태그 설계: “너무 넓게”와 “너무 좁게”의 균형

캐시 꼬임의 절반은 태그 설계 문제입니다. 실전에서 추천하는 방식은 다음과 같습니다.

1) 리소스 단위 태그를 먼저 정의

  • 목록: posts:list
  • 상세: posts:detail:${id} 같은 형태

단, MDX 환경에서는 :는 문제 없지만, <> 같은 문자는 본문에 노출되면 빌드 에러가 날 수 있으니 문서화할 때는 항상 백틱으로 감싸는 습관이 안전합니다.

2) 이벤트 단위로 무효화 범위를 결정

  • 글 생성: 목록 무효화는 필수, 작성자 프로필/카운트도 있다면 추가
  • 글 수정: 상세 무효화는 필수, 목록에도 제목이 노출되면 목록도 무효화
  • 글 삭제: 상세, 목록 모두 무효화

예시:

import { revalidateTag } from 'next/cache'

export function invalidatePost(id: string) {
  revalidateTag('posts:list')
  revalidateTag(`posts:detail:${id}`)
}

3) 멀티 테넌트/유저 스코프는 반드시 분리

유저별 목록이라면 posts:list:${userId} 같은 스코프가 필요합니다.

async function getMyPosts(userId: string) {
  const res = await fetch(`${process.env.API_BASE_URL}/me/posts`, {
    next: { tags: [`posts:list:${userId}`] },
  })
  return res.json()
}

// 생성 후
revalidateTag(`posts:list:${userId}`)

이 패턴이 중요한 이유:

  • 유저 A가 글을 생성했는데 posts:list만 쓰면, 유저 B의 목록 캐시까지 불필요하게 무효화될 수 있습니다
  • 반대로 유저별 태그를 안 쓰고 공유 캐시가 생기면 “남의 데이터가 섞여 보이는” 사고로 이어질 수 있습니다

“revalidateTag 했는데도 안 바뀜” 체크리스트

현장에서 가장 많이 시간을 잡아먹는 구간입니다. 아래를 순서대로 확인하면 원인을 빠르게 좁힐 수 있습니다.

1) 정말 태그가 붙은 fetch인가?

  • fetch(..., { next: { tags: [...] } })가 실제로 실행되는 코드 경로에 있는지
  • cache: 'no-store'를 같이 써서 애초에 캐시가 안 생기고 있는 건 아닌지

cache: 'no-store'는 “항상 최신”을 보장하지만, revalidateTag로 제어할 캐시 자체가 없어집니다.

2) 무효화 호출이 서버에서 실행되는가?

revalidateTag는 서버에서만 의미가 있습니다.

  • 서버 액션에 use server가 있는지
  • 라우트 핸들러(app/api/.../route.ts)에서 실행되는지

3) 무효화 이후에 “새 요청”이 발생하는가?

무효화는 다음 요청부터 반영됩니다.

  • 같은 RSC 트리에서 이미 렌더된 결과가 화면에 남아있다면, 클라이언트 전환만으로는 재요청이 안 일어날 수 있습니다
  • 이때는 router.refresh() 같은 갱신 트리거가 필요할 수 있습니다

예시:

'use client'

import { useRouter } from 'next/navigation'

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

4) 배포 환경에서만 문제면 “런타임/리전/인스턴스”를 의심

특히 Edge 런타임, 멀티 리전, 서버리스 환경에서는 캐시/무효화 전파 타이밍이 달라 보일 수 있습니다.

  • API는 A 리전, 프론트는 B 리전
  • 무효화는 발생했는데 사용자는 다른 리전의 캐시를 계속 hit

이때는 태그 기반 무효화만으로 해결이 어려울 수 있고, 데이터 계층(예: Redis)에서 강한 일관성을 확보하거나, 중요한 화면은 no-store로 전환하는 절충이 필요합니다.

Route Handler에서 revalidateTag 적용 패턴

서버 액션 대신 API 라우트에서 처리하고 싶다면 다음처럼 구성합니다.

app/api/posts/route.ts

import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const body = await req.json()

  // DB insert가 있다고 가정
  // await db.post.create({ data: { title: body.title } })

  revalidateTag('posts:list')

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

장점:

  • 외부 시스템(Webhook 등)에서도 호출 가능
  • 서버 액션에 비해 경계가 명확

단점:

  • 인증/인가를 직접 챙겨야 함

인증/인가를 대충 처리하면 캐시보다 더 큰 문제가 됩니다. 관련해서는 JWT alg=none·kid 인젝션 취약점 재현·차단처럼 “공격 관점에서 재현 후 차단” 방식으로 점검하는 습관이 도움이 됩니다.

캐시 꼬임을 줄이는 운영 팁

1) 태그 네이밍 컨벤션을 문서화

예:

  • posts:list
  • posts:detail:${id}
  • users:profile:${userId}

그리고 “어떤 이벤트가 어떤 태그를 무효화하는지” 표로 관리하면, 기능 추가 시 실수 확률이 줄어듭니다.

2) 변경 이벤트에서 무효화를 한 곳으로 모으기

여러 곳에서 revalidateTag를 제각각 호출하면 누락이 생깁니다.

// lib/cache/invalidate.ts
import { revalidateTag } from 'next/cache'

export const tags = {
  postsList: () => 'posts:list',
  postDetail: (id: string) => `posts:detail:${id}`,
}

export function invalidateAfterCreatePost() {
  revalidateTag(tags.postsList())
}

export function invalidateAfterUpdatePost(id: string) {
  revalidateTag(tags.postsList())
  revalidateTag(tags.postDetail(id))
}

3) “정확히 최신”이 필요한 화면은 과감히 no-store

관리자 페이지, 결제 직후 화면, 보안 이벤트 직후 화면 등은 캐시 제어 비용이 더 큽니다.

  • 정합성이 최우선이면 cache: 'no-store'
  • 트래픽이 크고 약간의 지연 허용이면 태그 기반

이 판단은 결국 시스템의 “중복 처리/정합성” 문제와 닮아 있습니다. 이벤트가 중복되거나 순서가 뒤틀릴 때의 대응 관점은 Kafka Exactly-Once 함정 - MSA 중복처리 근절에서 다룬 내용과도 연결됩니다.

정리: revalidateTag는 “설계”가 전부다

  • revalidateTag는 태그가 달린 캐시만 무효화한다
  • 태그는 “리소스 단위 + 스코프(유저/테넌트)”를 반영해야 캐시 꼬임이 줄어든다
  • 무효화는 변경 이벤트 직후, 한 곳에서 일관되게 호출하는 게 안전하다
  • 배포 환경에서만 재현되면 리전/런타임 차이를 의심하고, 필요한 화면은 no-store로 절충한다

RSC 캐시는 성능을 크게 올려주지만, 잘못 쓰면 디버깅 난이도가 급상승합니다. 위 패턴대로 “태그 설계와 무효화 경로”를 정리해두면, 캐시 꼬임은 대부분 재현 가능하고 통제 가능한 문제로 바뀝니다.