THE DEVLOG

scribbly.

Next JS 치트 시트

2026.02.08 01:21:36

Next.js 16 버전에서 기존 실험적 기능이었던 Partial Prerendering'use cache''cacheComponents' 라는 공식 기능으로 추가되었다.

해당 기능을 커면 'dynamic' 하게 페이지를 생성하는 것이 제한되지만, 컴포넌트 단위로 프리렌더링하여 Partial Rendering으로 성능상 이점이 생긴다.

예시

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
// app/lib/products.ts
import "server-only"
import { cacheTag, cacheLife } from 'next/cache'

export const getProductsCached = async () => {
  'use cache'
  cacheTag('products:list')
  cacheLife('hours')

  return db.query('SELECT * FROM products ORDER BY updated_at DESC')
}

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { getProductsCached } from '@/app/lib/products'
import { RuntimeUserPanel } from './runtime-user-panel'

export default async function Page() {
  // 캐시된 데이터를 불러옴으로써 정적 컴포넌트가 생성된다
  const products = await getProductsCached()

  return (
    <>
      <h1>Dashboard</h1>
      <Products initial={products} />
      {/* 캐시하지 못하는 데이터는 서스펜스로 감싸 불러온다 */}
      <Suspense fallback={<p>Loading user...</p>}>
        <RuntimeUserPanel />
      </Suspense>
    </>
  )
}

// app/dashboard/runtime-user-panel.tsx
import { cookies } from 'next/headers'

export async function RuntimeUserPanel() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value
  if (!token) return <p>로그인이 필요합니다.</p>

  const userData = await fetchUserWithToken(token)
  return <p>{userData["주민등록번호"]}</p>
}

// app/actions/products.ts
'use server'
import { updateTag } from 'next/cache'

export const renameProductAction = async (input: { id: string; name: string }) => {
  await db.query('UPDATE products SET name=? WHERE id=?', [input.name, input.id])

  // updateTag로 stale없이 revalidate 할 수 있다.
  updateTag('products:list')
}


1) Partial Prerendering

cacheComponents는 next.js 15의 실험적 기능이었던 Partial Prerendering
을 정식지원하는 버전이다.

cacheComponents가 켜진 App Router에서 라우트는 크게 3종류의 출력이 섞여 만들어진다.

 

1) 정적 (자동 prerender)

페이지, 모듈 등 렌더링 시 요청에 관한 정보가 필요 없는 컴포넌트는 프리렌더 단계에서 정적 shell에 포함된다.

2) 캐시된 결과(use cache)

네트워크 결과의 캐싱, DB 조회 결과, 컴포넌트 렌더링 결과 등은 use cache를 통해 캐싱하여 점진적으로 정적 shell에 포함시킬 수 있다. use cache는 서버 함수 뿐만 아니라 서버 컴포넌트에도 적용할 수 있다.

3) 런타임

cookies(), headers(), searchParams처럼 요청 시점 데이터에 의존하는 캐싱할 수 없다.

또한 cacheComponents 설정을 켜는 경우에는 반드시<Suspense> 경계를 통해 분리해야 하며, 해당 suspense 내부는 요청 시점에 스트리밍된다.


2) 설정: cacheComponents 활성화

use cache, cacheTag, cacheLife 등 Cache Components API는 cacheComponents: true가 전제다.

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig


3) use cache 적용 위치와 캐시 키

3-1) 적용 위치: 파일 / 컴포넌트 / 함수

use cache는 파일/컴포넌트/함수 레벨로 적용할 수 있다.

// (1) 파일 레벨
'use cache'
export default async function Page() { /* ... */ }

// (2) 컴포넌트 레벨
export async function ProductList() {
  'use cache'
  /* ... */
}

// (3) 함수 레벨
export async function getProducts() {
  'use cache'
  /* ... */
}

파일 레벨 적용은 편하지만, export 함수가 async여야 하는 등 제약이 발생할 수 있다.

3-2) 캐시 키: “인자 + 바깥 스코프 캡처 값”

use cache의 캐시 엔트리는 단순히 함수명으로만 구분되지 않는다. 기본적으로 다음이 직렬화되어 키를 구성한다.

  • (a) 함수/컴포넌트 인자

  • (b) 바깥 스코프에서 캡처한 값(클로저)

따라서 입력(인자) 설계가 캐싱 전략의 핵심이 된다. 특히 “의도치 않은 캡처값”은 캐시 분기를 늘리거나 캐시 폭발을 유발할 수 있으므로 구조를 단순화하는 편이 낫다.


4) 선택적 무효화: cacheTag / 수명: cacheLife

4-1) 태그 달기: cacheTag

cacheTag()use cache 범위 안에서 호출되어, “이 결과가 어떤 논리 태그에 속하는지”를 지정한다.

import { cacheTag, cacheLife } from 'next/cache'

export async function getProducts() {
  'use cache'
  cacheTag('products')
  cacheLife('hours')

  const rows = await db.query('SELECT * FROM products')
  return rows
}

태그는 다수 지정 가능하며, 동일 태그를 반복 지정해도 의미는 같다. 태그 길이/개수 제한 또한 고려 대상이다.

4-2) 수명 프로필: cacheLife

cacheLife()는 캐시의 유지 기간(프로필)을 지정한다.

profile권장 용도stale (클라이언트 라우터)revalidate (백그라운드 갱신 트리거)expire (트래픽 없을 때 만료)
defaultStandard content5 minutes15 minutes1 year
secondsReal-time data30 seconds1 second1 minute
minutesFrequently updated content5 minutes1 minute1 hour
hoursContent updated multiple times per day5 minutes1 hour1 day
daysContent updated daily5 minutes1 day1 week
weeksContent updated weekly5 minutes1 week30 days
maxStable content that rarely changes5 minutes30 days1 year

stale은 HTTP 캐시 헤더(Cache-Control)가 아니라 클라이언트 라우터 캐시 동작을 제어하는 값이며, 프리페치된 링크가 너무 빨리 무효화되는 것을 방지하기 위해 최소 30초가 강제된다.
stale에서 revalidate 시점까지는 요청이 들어오면 항상 캐시된 데이터를 보낸다.
revalidate에서 expire 시점까지는 요청이 들어오면, 기존에 캐시된 데이터를 보내면서, 새로 캐싱을 시도한다.
expire 이후 시점에는 캐시된 데이터가 없어 새로운 데이터를 캐싱한 뒤 보낸다.

Functions: cacheLife


5) 캐시 무효화

5-1) updateTag(tag)

  • Server Action에서만 호출 가능

  • 태그를 즉시 expire 처리

  • 이후 해당 태그를 사용하는 요청은 stale을 제공하지 않고 fresh가 생성될 때까지 대기하는 형태로 동작한다.

  • “read-your-own-writes(내가 방금 바꾼 값은 즉시 최신이어야 함)”

5-2) revalidateTag(tag, 'max') — SWR(점진 반영)

  • Server Action과 Route Handler에서 호출 가능

  • 'max' 프로필은 stale-while-revalidate에 가깝게 동작한다. 즉, stale을 먼저 응답하고 백그라운드에서 갱신한다.

  • 호출 시점에 “즉시 대량 재검증”을 강제하지 않고, 다음 방문/요청 흐름에서 갱신이 자연스럽게 발생하도록 설계되는 경우가 많다.

5-3) revalidateTag(tag, { expire: 0 })

서버 액션이 아닌 Route Handler로 즉시 만료를 시키고 싶은 경우에는 두번째 인자에 expire가 0인 객체를 넘기는 방식으로 우회할 수 있다.

6) 쿠키/헤더/인증이 포함된 런타임 데이터의 캐싱 전략

"use cache" 지시자가 있는 함수/컴포넌트에서 쿠키나 헤더를 읽는 경우 에러가 발생하여 캐싱 자체가 불가능하다.

이때 DB에 있는 데이터를 별도로 분리하여 캐싱해두고 DB에 요청을 보내지 않는 방식으로 성능 최적화가 가능하다.

단, 해당 함수를 다른 모듈로 export 하지 않도록 주의하고, 파일엔 항상 import "server-only" 지시어를 통해 서버 컴포넌트에서만 읽도록 한다.

한편 캐시키에는 반드시 쿠키/헤더에서 읽은 고유한 값이 포함되어야 한다.

  • 런타임 API(cookies(), headers())는 cached scope 밖에서 읽는다
import "server-only"

// runtime scope (cokies/header를 읽어 'use cache'가 불가능)
export const getUserData = async () => {
  const userId = await getUserId()   // cookies/headers 사용 가능
  return getUserDataCached(userId)   // cached scope 호출
}

// cached scope (외부 export 금지)
const getUserDataCached = async (userId: string) => {
  'use cache'
  cacheTag(`user-data:${userId}`)
  return fetchUserDataFromDB(userId)
}


그 밖에 컴포넌트 작성 방식

7) 캐시되지 않은 데이터는 <Suspense> 경계 아래로

Cache Components 모드에서는 “프리렌더 단계에서 끝나지 않는 작업”을 가진 컴포넌트는 <Suspense>로 요청 시점까지 렌더링을 지연시켜야 한다.

이때 <Suspense> 경계는 데이터를 실제로 사용하는 지점과 최대한 가깝고 좁게 설정하여야 사용자 경험이 좋다.

8) 서버에서 Promise를 넘기고, 클라이언트에서 use()로 resolve하기

상위 컴포넌트를 캐시 상태로 유지하기 위해 params를 resolve하지 않는 전략이다.

React 19의 react.use()를 이용하면 서스펜스 경계 안에서 promise를 resolve할 수 있다.

promise를 자식 컴포넌트로 그대로 넘겨주고, 자식 컴포넌트에서는 use()나 await로 resolve하여 데이터를 사용한다.

// app/[주민등록번호]/page.tsx (Server Component)
import { Suspense } from 'react'
import { fetchPosts } from './queries'

export default function UserPage({params}:PageProps<"/[주민등록번호]") {
  return (
    <Suspense fallback={<p>Loading posts...</p>}>
      <UserProfileClient params={params} />
    </Suspense>
  )
}

export default async function UserProfileClient({
  params,
}: {
  params: Promise<{ 주민등록번호: string; }>>
}) {
  const { 주민등록번호 } = await params
  return (
    <ul>
      {`해킹된 주민등록번호는 ${주민등록번호} 입니다.`}
    </ul>
  )
}

 


9) refresh() / router.refresh()

refresh 는 현재 요청이 들어온 주소를 갱신한다.

Next.js 16에서 서버 에서도 사용할 수 있도록 'next/cache'에 refresh 함수가 추가되었다.

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

export async function renamePostAction(input: { id: string; title: string }) {
  "use server"
  await db.query('UPDATE posts SET title=? WHERE id=?', [input.title, input.id])
  
  updateTag(`post:${input.id}`)
  refresh()
}

참고 문서

Next.js Cache Components (Getting Started) - Next.js 공식문서

next.config cacheComponents - Next.js 공식문서

use cache directive - Next.js 공식문서

cacheTag - Next.js 공식문서

cacheLife - Next.js 공식문서

updateTag - Next.js 공식문서

revalidateTag - Next.js 공식문서

refresh - Next.js 공식문서

useRouter / router.refresh - Next.js 공식문서

React use() - 리액트 공식문서
React useOptimistic - 리액트 공식문서