본문 바로가기
SPA/Next

Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리

by J4J 2026. 7. 26.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Next.js 16에서 바뀐 캐싱 모델인 Cache Components와, use cache 디렉티브부터 revalidateTag, updateTag, refresh까지 캐시를 다루는 방법에 대해 알아보는 시간을 가져보려고 합니다.

 

 

 

Next.js 16 캐싱 모델이 바뀐 이유, Cache Components란?

 

Next.js 15까지는 무언가를 캐시하려면 상황에 따라 서로 다른 도구를 조합해야 했습니다.

 

fetch 요청 하나를 캐시하려면 cache나 next.revalidate 옵션을 fetch 호출부에 직접 넘겨야 했고, DB 조회처럼 fetch가 아닌 임의의 함수를 캐시하려면 별도로 unstable_cache로 감싸야 했습니다.

 

여기에 정적 셸과 동적 콘텐츠를 한 라우트 안에서 함께 스트리밍하는 Partial Prerendering을 쓰려면 experimental.ppr 플래그까지 별도로 켜야 했습니다.

 

여러 프로젝트의 캐싱 코드를 정리하다 보면 체감할 수 있는 것 중 하나는, fetch 옵션과 unstable_cache가 서로 다른 함수 시그니처를 가지고 있어서 팀 안에서 캐싱 규칙을 하나로 통일하기가 쉽지 않았다는 점입니다.

 

 

 

// ❌ Next.js 15까지 사용하던 방식, 도구마다 시그니처가 다름
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  })
  return res.json()
}

import { unstable_cache } from 'next/cache'

export const getPostsFromDb = unstable_cache(
  async () => db.post.findMany(),
  ['posts'],
  { revalidate: 60, tags: ['posts'] },
)

 

이렇게 fetch 옵션과 unstable_cache를 상황에 따라 나눠 써야 했기 때문에, 어떤 함수가 캐시되고 있는지 코드만 보고 한눈에 파악하기 어려웠습니다.

 

 

 

그러면 Next.js 16은 이 문제를 어떻게 해결했을까요?

 

Next.js 16은 fetch 옵션, unstable_cache, experimental.ppr로 흩어져 있던 캐싱 도구를 use cache 디렉티브 하나와 cacheComponents라는 단일 설정으로 통합했습니다.

 

이 기능은 공식적으로 Cache Components라고 부르며, next.config.ts에 다음과 같이 설정을 추가해야 사용할 수 있습니다.

 

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

 

cacheComponents를 켜면 데이터 페칭은 기본적으로 동적으로 동작하고, 캐시하고 싶은 지점만 use cache 디렉티브로 직접 표시하는 방식으로 바뀝니다.

 

이름 그대로 페이지 전체가 아니라 컴포넌트나 함수 단위로 캐시 여부를 선택할 수 있다는 것이 Cache Components의 핵심입니다.

 

다음으로는 use cache를 실제로 어떻게 선언하는지 살펴보겠습니다.

 

 

반응형

 

 

use cache로 원하는 범위만 캐싱하기

 

use cache는 파일 최상단, 컴포넌트 내부, 함수 내부 세 곳 중 원하는 위치에 선언할 수 있습니다.

 

파일 최상단에 선언하면 그 파일에서 export하는 모든 함수가 캐시 대상이 되고, 함수나 컴포넌트 내부에 선언하면 그 함수의 반환값만 캐시됩니다.

 

다만 파일 최상단에 use cache를 선언하면 그 파일의 모든 export는 반드시 async 함수여야 합니다.

 

// 함수 내부에 선언하는 경우
export async function getData() {
  'use cache'
  const data = await fetch('/api/data')
  return data
}

// 컴포넌트 내부에 선언하는 경우
export async function MyComponent() {
  'use cache'
  return <div>cached</div>
}

 

 

 

캐시 유효기간은 cacheLife로 조정합니다.

 

cacheLife는 use cache가 선언된 함수 내부에서만 호출할 수 있으며, 모듈 최상단에서 호출하면 에러가 발생합니다.

 

기본으로 제공되는 profile은 "default", "seconds", "minutes", "hours", "days", "weeks", "max" 일곱 가지이며, profile 이름이 나타내는 시간 단위가 짧을수록(초, 분, 시간 순으로) 캐시가 더 자주 갱신되고 max로 갈수록 갱신 주기가 길어집니다.

 

예를 들어 seconds profile은 revalidate가 1초, expire가 1분으로 설정되어 있어 실시간에 가까운 데이터에 적합하고, minutes profile은 revalidate가 1분, expire가 1시간으로 설정되어 있어 자주 갱신되는 콘텐츠에 적합합니다.

 

 

 

// src/lib/greeting-cache.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getCachedGreeting() {
  'use cache'
  cacheLife('seconds')
  cacheTag('greeting')

  return {
    message: '안녕하세요, Cache Components 데모입니다.',
    generatedAt: new Date().toISOString(),
  }
}

 

seconds처럼 캐시 주기가 아주 짧은 profile을 쓰는 caching 함수는 Suspense 경계 없이 페이지에 바로 두면 안 됩니다.

 

이렇게 캐시 주기가 짧은 함수를 Suspense 없이 페이지에 직접 두면, 그 페이지는 정적으로 미리 그릴 수 없는 상태로 처리되어 Runtime data such as cookies(), headers(), params, or searchParams was accessed outside of Suspense라는 경고와 함께 페이지 전체가 blocking 상태로 렌더링됩니다.

 

그러므로 caching 데이터를 읽는 부분만 별도 컴포넌트로 분리하고 Suspense로 감싸는 것이 좋습니다.

 

 

 

// app/use-cache/page.tsx
import { Suspense } from 'react'
import { getCachedGreeting } from '@/lib/greeting-cache'

async function Greeting() {
  const { message, generatedAt } = await getCachedGreeting()

  return (
    <>
      <p>{message}</p>
      <p>caching 시각: {generatedAt}</p>
    </>
  )
}

export default function UseCachePage() {
  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">use cache 데모</h1>
      <Suspense fallback={<p>불러오는 중입니다...</p>}>
        <Greeting />
      </Suspense>
    </main>
  )
}

 

이 페이지를 새로고침하면 caching 시각이 1초 동안은 그대로 유지되다가, 1초가 지난 뒤 처음 요청하면 그 요청에서는 기존 caching 시각이 먼저 그대로 보이고, 그 사이 백그라운드에서 채워진 새 값은 그다음 새로고침에서 확인할 수 있습니다.

 

그러면 이렇게 붙인 cacheTag를 이용해서 원하는 시점에 직접 캐시를 무효화하는 방법을 살펴보겠습니다.

 

 

 

 

cacheTag와 revalidateTag로 태그 기반 캐시 무효화하기

 

cacheTag는 use cache가 선언된 함수 안에서 캐시 결과에 하나 이상의 태그를 붙이는 함수입니다.

 

앞서 작성한 getCachedGreeting에는 이미 cacheTag('greeting')으로 태그를 붙여두었으니, 이 태그를 대상으로 revalidateTag를 호출해 보겠습니다.

 

Next.js 16부터 revalidateTag는 태그 문자열 하나만 넘기던 기존 방식과 달리, 두 번째 인자로 무효화 방식을 함께 넘겨야 합니다.

 

 

 

// ❌ 두 번째 인자 없이 태그만 넘기는 기존 방식
'use server'
import { revalidateTag } from 'next/cache'

export async function revalidateGreeting() {
  revalidateTag('greeting')
}

 

이렇게 두 번째 인자 없이 revalidateTag를 호출하면 TypeScript 타입 에러가 발생하고, 타입 에러를 억지로 무시하고 그대로 두더라도 향후 버전에서 제거될 수 있는 deprecated 동작에 의존하게 됩니다.

 

 

 

// ✅ 두 번째 인자로 무효화 방식을 명시하는 방식
'use server'
import { revalidateTag } from 'next/cache'

export async function revalidateGreeting() {
  revalidateTag('greeting', 'max')
}

 

그러므로 revalidateTag를 호출할 때는 두 번째 인자로 'max'를 넘겨 stale-while-revalidate 방식으로 무효화하는 것이 좋습니다.

 

profile을 'max'로 넘기면 태그가 stale 상태로만 표시되고, 그 태그를 가진 캐시를 다음에 요청하는 시점에 일단 기존 값을 그대로 보여준 뒤 백그라운드에서 새 값으로 갱신합니다.

 

 

 

// app/revalidate-tag/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function revalidateGreeting() {
  revalidateTag('greeting', 'max')
}

 

// app/revalidate-tag/page.tsx
import { Suspense } from 'react'
import { getCachedGreeting } from '@/lib/greeting-cache'
import { revalidateGreeting } from './actions'

async function Greeting() {
  const { message, generatedAt } = await getCachedGreeting()

  return (
    <>
      <p>{message}</p>
      <p>caching 시각: {generatedAt}</p>
    </>
  )
}

export default function RevalidateTagPage() {
  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">revalidateTag 데모</h1>
      <Suspense fallback={<p>불러오는 중입니다...</p>}>
        <Greeting />
      </Suspense>
      <form action={revalidateGreeting}>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          type="submit"
        >
          태그 재검증하기
        </button>
      </form>
    </main>
  )
}

 

태그 재검증하기 버튼을 눌러도 이번 응답은 여전히 기존 caching 시각을 그대로 보여주고, 다음 새로고침에서야 갱신된 시각을 확인할 수 있습니다.

 

이런 지연이 stale-while-revalidate 방식의 정상 동작이지만, 사용자가 방금 만든 데이터만큼은 그 자리에서 바로 최신 상태로 보여주고 싶은 경우도 있습니다.

 

다음으로는 이런 상황을 위한 updateTag를 살펴보겠습니다.

 

 

 

 

updateTag로 방금 만든 데이터 즉시 반영하기

 

updateTag는 Next.js 16에서 새로 추가된 함수로, 사용자가 데이터를 만든 직후 그 변경 사항을 화면에 곧바로 반영해야 하는 read-your-own-writes 상황을 위해 만들어졌습니다.

 

updateTag를 호출하면 해당 태그의 캐시가 즉시 만료되고, 그다음 요청은 캐시된 값을 서빙하지 않고 새 데이터를 기다립니다.

 

revalidateTag(tag, 'max')가 일단 기존 값을 보여준 뒤 백그라운드로 갱신한다면, updateTag는 다음 요청부터 곧바로 최신 값을 기다린다는 점에서 서로 다른 상황을 위한 함수입니다.

 

updateTag는 Server Action 내부에서만 호출할 수 있습니다.

 

 

 

// ❌ Route Handler 내부에서 updateTag를 호출하는 경우
// app/api/posts/route.ts
import { updateTag } from 'next/cache'

export async function POST() {
  updateTag('posts')
  return Response.json({ ok: true })
}

 

이렇게 Route Handler에서 updateTag를 호출하면 updateTag can only be called from within a Server Action이라는 에러가 발생합니다.

 

 

 

// ✅ Server Action 내부에서 updateTag를 호출하는 경우
'use server'
import { updateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  // 게시글 저장 로직
  updateTag('posts')
}

 

그러므로 updateTag는 반드시 'use server'가 선언된 Server Action 내부에서만 호출하는 것이 좋습니다.

 

Server Action 자체를 처음 다루신다면 이전에 작성한 React Server Action, API Route 없이 서버 함수를 호출하는 방법을 먼저 참고하셔도 좋습니다.

 

React Server Action, API Route 없이 서버 함수를 호출하는 방법

안녕하세요. J4J입니다. 이번 포스팅은 React Server Action이 무엇이고, 실무에서 어떻게 활용할 수 있는지에 대해 알아보는 시간을 가져보려고 합니다. Server Action이란? Server Action은 서버에서 실행되

jforj.tistory.com

 

그러면 게시글을 새로 등록하는 예시로 updateTag가 실제로 어떻게 동작하는지 확인해 보겠습니다.

 

 

 

// src/lib/posts-store.ts
// 데모용 인메모리 저장소
export type Post = { id: number; title: string }

const posts: Post[] = [{ id: 1, title: '첫 번째 게시글' }]

export function getPosts() {
  return posts
}

export function addPost(title: string) {
  const post: Post = { id: posts.length + 1, title }
  posts.push(post)
  return post
}

 

// app/update-tag/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { addPost } from '@/lib/posts-store'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  addPost(title)
  updateTag('posts')
}

 

// app/update-tag/page.tsx
import { cacheLife, cacheTag } from 'next/cache'
import { getPosts } from '@/lib/posts-store'
import { createPost } from './actions'

async function getCachedPosts() {
  'use cache'
  cacheLife('minutes')
  cacheTag('posts')
  return getPosts()
}

export default async function UpdateTagPage() {
  const posts = await getCachedPosts()

  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">updateTag 데모</h1>
      <ul className="flex flex-col gap-1">
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <form action={createPost} className="flex gap-2">
        <input
          name="title"
          placeholder="게시글 제목"
          className="rounded border border-gray-300 px-2 py-1 text-sm"
          required
        />
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          type="submit"
        >
          게시글 등록
        </button>
      </form>
    </main>
  )
}

 

getCachedPosts는 minutes profile로 캐시되어 있어 원래대로라면 최대 1분 동안 새 게시글이 반영되지 않아야 하지만, createPost 안에서 updateTag('posts')를 호출했기 때문에 폼을 제출하는 즉시 목록에 새 게시글이 나타납니다.

 

다음으로는 캐시 무효화와는 무관하게, 화면만 새로 그리고 싶을 때 사용하는 refresh를 살펴보겠습니다.

 

 

 

 

refresh로 캐시 갱신 없이 화면만 새로고침하기

 

refresh는 Next.js 16에서 새로 추가된 함수로, Server Action 내부에서 클라이언트 라우터를 새로고침할 때 사용합니다.

 

updateTag나 revalidateTag가 특정 캐시 태그를 대상으로 동작하는 것과 달리, refresh는 태그와 무관하게 현재 라우터를 새로고침하고 싶을 때 사용하는 함수입니다.

 

refresh 역시 Server Action 내부에서만 호출할 수 있으며, Route Handler나 Client Component에서는 사용할 수 없습니다.

 

 

 

// src/lib/counter-store.ts
// 데모용 인메모리 저장소
let count = 0

export function getCount() {
  return count
}

export function incrementCount() {
  count += 1
  return count
}

 

// app/refresh/actions.ts
'use server'
import { refresh } from 'next/cache'
import { incrementCount } from '@/lib/counter-store'

export async function increment() {
  incrementCount()
  refresh()
}

 

// app/refresh/page.tsx
import { getCount } from '@/lib/counter-store'
import { increment } from './actions'

export default function RefreshPage() {
  const count = getCount()

  return (
    <main className="flex flex-col gap-4 p-6">
      <h1 className="text-xl font-bold">refresh 데모</h1>
      <p>현재 카운트: {count}</p>
      <form action={increment}>
        <button
          className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          type="submit"
        >
          카운트 증가하고 새로고침
        </button>
      </form>
    </main>
  )
}

 

getCount는 use cache로 캐시되지 않은 일반 함수이므로, increment 액션이 끝난 뒤 refresh를 호출하면 현재 화면이 다시 그려지면서 늘어난 카운트를 곧바로 확인할 수 있습니다.

 

캐시 태그로 묶기 애매한 값을 다루거나, 그저 지금 보고 있는 화면을 최신 상태로 유지하고 싶을 때 이렇게 refresh를 사용할 수 있습니다.

 

 

 

 

 

 

 

이상으로 Next.js 16에서 바뀐 캐싱 모델인 Cache Components와, use cache 디렉티브부터 revalidateTag, updateTag, refresh까지 캐시를 다루는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글