Post

Next.js (8. Caching) 정리

Next.js (8. Caching) 정리

0. 캐싱


캐싱은 데이터 페칭과 연산 결과를 저장해 동일한 요청을 더 빠르게 처리하는 기법이다.

1. Cache Components 설정


1-1. Cache Components 활성화


next.config.tscacheComponents: true를 추가해 Cache Components를 활성화한다.

1
2
3
4
5
6
7
8
9
// next.config.ts

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Cache Components를 활성화하면 GET Route Handler도 페이지와 동일한 프리렌더링 모델을 따른다.

2. use cache 지시문


"use cache"는 비동기 함수와 컴포넌트의 반환값을 캐시한다.
데이터 레벨과 UI 레벨 두 가지 방식으로 적용할 수 있다.

레벨적용 대상예시
데이터 레벨데이터 페칭 또는 연산 함수getProducts(), getUser(id)
UI 레벨컴포넌트, 페이지, 레이아웃 전체async function BlogPosts()

함수에 전달된 인자와 외부 스코프의 값은 자동으로 캐시 키의 일부가 된다. 입력이 다르면 별도의 캐시 항목이 생성된다.

2-1. 데이터 레벨 캐싱 (Data-level caching)


데이터를 페칭하는 비동기 함수 본문 최상단에 "use cache"를 추가해 결과를 캐시한다.
여러 컴포넌트에서 동일한 데이터를 사용하거나 UI와 독립적으로 데이터를 캐시하고 싶을 때 유용하다.

1
2
3
4
5
6
7
8
9
// app/lib/data.ts

import { cacheLife } from 'next/cache'

export async function getUsers() {
  'use cache'
  cacheLife('hours')
  return db.query('SELECT * FROM users')
}

2-2. UI 레벨 캐싱 (UI-level caching)


컴포넌트, 페이지, 레이아웃 전체를 캐시하려면 함수 본문 최상단에 "use cache"를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/page.tsx

import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')

  const users = await db.query('SELECT * FROM users')

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

파일 최상단에 "use cache"를 선언하면 해당 파일의 모든 export 함수가 캐시된다.

2-3. 캐시되지 않은 데이터 스트리밍 (Streaming uncached data)


매 요청마다 최신 데이터가 필요한 컴포넌트는 "use cache" 대신 <Suspense>로 감싼다.
React는 폴백 UI를 먼저 렌더링한 뒤, 비동기 작업이 완료되면 실제 콘텐츠를 스트리밍한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// page.tsx

import { Suspense } from 'react'

async function LatestPosts() {
  const data = await fetch('https://api.example.com/posts')
  const posts = await data.json()
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

export default function Page() {
  return (
    <>
      <h1>My Blog</h1>
      <Suspense fallback={<p>Loading posts...</p>}>
        <LatestPosts />
      </Suspense>
    </>
  )
}

폴백(<p>Loading posts...</p>)은 정적 셸에 포함되고, 컴포넌트 콘텐츠는 요청 시점에 스트리밍된다.

3. 런타임 API 다루기


런타임 API는 사용자 요청 시점에만 사용 가능한 정보에 접근한다. 런타임 API를 사용하는 컴포넌트는 반드시 <Suspense>로 감싸야 한다.

API설명
cookies사용자의 쿠키 데이터
headers요청 헤더
searchParamsURL 쿼리 파라미터
params동적 라우트 파라미터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// page.tsx

import { cookies } from 'next/headers'
import { Suspense } from 'react'

async function UserGreeting() {
  const cookieStore = await cookies()
  const theme = cookieStore.get('theme')?.value || 'light'
  return <p>테마: {theme}</p>
}

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>불러오는 중...</p>}>
        <UserGreeting />
      </Suspense>
    </>
  )
}

3-1. 런타임 값을 캐시 함수에 전달


런타임 API에서 값을 추출해 캐시 함수의 인자로 전달하면, 해당 값이 캐시 키의 일부가 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// app/profile/page.tsx

import { cookies } from 'next/headers'
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<div>불러오는 중...</div>}>
      <ProfileContent />
    </Suspense>
  )
}

// 캐시되지 않는 컴포넌트 — 런타임 데이터를 읽는다
async function ProfileContent() {
  const session = (await cookies()).get('session')?.value
  return <CachedContent sessionId={session} />
}

// 캐시된 컴포넌트 — 추출된 값을 prop으로 받는다
async function CachedContent({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId가 캐시 키의 일부가 된다
  const data = await fetchUserData(sessionId)
  return <div>{data}</div>
}

요청 시 동일한 sessionId의 캐시 항목이 없으면 CachedContent가 실행되고 결과를 저장한다. 이후 동일한 sessionId로 요청이 오면 캐시된 결과를 반환한다.

4. 연산 처리 방식


4-1. 비결정적 연산 (Non-deterministic operations)


Math.random(), Date.now(), crypto.randomUUID() 같은 비결정적 연산은 Cache Components에서 명시적으로 처리해야 한다.

요청마다 고유한 값이 필요하다면 connection()을 먼저 호출해 실행을 요청 시점으로 미루고, 컴포넌트를 <Suspense>로 감싼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// page.tsx

import { connection } from 'next/server'
import { Suspense } from 'react'

async function UniqueContent() {
  await connection()
  const uuid = crypto.randomUUID()
  return <p>Request ID: {uuid}</p>
}

export default function Page() {
  return (
    <Suspense fallback={<p>불러오는 중...</p>}>
      <UniqueContent />
    </Suspense>
  )
}

모든 사용자에게 재검증 전까지 동일한 값을 보여주려면 결과를 캐시한다.

1
2
3
4
5
6
7
// page.tsx

export default async function Page() {
  'use cache'
  const buildId = crypto.randomUUID()
  return <p>Build ID: {buildId}</p>
}

4-2. 결정적 연산 (Deterministic operations)


동기 I/O, 모듈 import, 순수 연산처럼 프리렌더링 중 완료 가능한 연산은 자동으로 정적 HTML 셸에 포함된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// page.tsx

import fs from 'node:fs'

export default async function Page() {
  const content = fs.readFileSync('./config.json', 'utf-8')
  const constants = await import('./constants.json')
  const processed = JSON.parse(content).items.map((item) => item.value * 2)

  return (
    <div>
      <h1>{constants.appName}</h1>
      <ul>
        {processed.map((value, i) => (
          <li key={i}>{value}</li>
        ))}
      </ul>
    </div>
  )
}

5. 렌더링 동작 원리


5-1. Partial Prerendering (PPR)


Cache Components의 기본 렌더링 방식은 Partial Prerendering(PPR)이다. 빌드 타임에 Next.js는 컴포넌트 트리를 렌더링하고, 각 컴포넌트가 사용하는 API에 따라 처리 방식이 결정된다.

처리 방식설명
use cache결과가 캐시되어 정적 셸에 포함됨
<Suspense>폴백 UI가 정적 셸에 포함되고 콘텐츠는 요청 시 스트리밍
결정적 연산자동으로 정적 셸에 포함

Partially re-rendered Product Page showing static nav and product information, and dynamic cart and recommended products

Diagram showing partially rendered page on the client, with loading UI for chunks that are being streamed.

프리렌더링 중 완료할 수 없는 컴포넌트를 <Suspense>use cache로 처리하지 않으면, 개발 및 빌드 타임에 Uncached data was accessed outside of <Suspense> 에러가 발생한다.

5-2. Static Shell 비활성화


루트 레이아웃의 <body> 위에 빈 폴백을 가진 <Suspense>를 배치하면 앱 전체가 요청 시점으로 미뤄진다. 정적 셸이 없으므로 모든 요청이 페이지 전체 렌더링을 완료할 때까지 대기한다. 특정 라우트에만 제한하려면 다중 루트 레이아웃을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/layout.tsx

import { Suspense } from 'react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <Suspense fallback={null}>
        <body>{children}</body>
      </Suspense>
    </html>
  )
}

5-3. 종합 예시


정적 콘텐츠, 캐시된 동적 콘텐츠, 스트리밍 동적 콘텐츠를 단일 페이지에서 함께 사용하는 예시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// app/blog/page.tsx

import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag, updateTag } from 'next/cache'
import Link from 'next/link'

export default function BlogPage() {
  return (
    <>
      {/* 정적 콘텐츠 — 자동으로 프리렌더링 */}
      <header>
        <h1>Our Blog</h1>
        <nav>
          <Link href="/">Home</Link> | <Link href="/about">About</Link>
        </nav>
      </header>

      {/* 캐시된 동적 콘텐츠 — 정적 셸에 포함됨 */}
      <BlogPosts />

      {/* 런타임 동적 콘텐츠 — 요청 시 스트리밍 */}
      <Suspense fallback={<p>환경설정을 불러오는 중...</p>}>
        <UserPreferences />
      </Suspense>

      {/* 데이터 변경 — 캐시를 재검증하는 Server Action */}
      <Suspense fallback={<p>불러오는 중...</p>}>
        <CreatePost />
      </Suspense>
    </>
  )
}

// 모든 사용자가 동일한 포스트를 봄 (1시간마다 재검증)
async function BlogPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  const res = await fetch('https://api.vercel.app/blog')
  const posts = await res.json()

  return (
    <section>
      <h2>Latest Posts</h2>
      <ul>
        {posts.slice(0, 5).map((post: any) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>
              By {post.author} on {post.date}
            </p>
          </li>
        ))}
      </ul>
    </section>
  )
}

// 쿠키를 기반으로 사용자별 개인화
async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value || 'light'
  const favoriteCategory = (await cookies()).get('category')?.value

  return (
    <aside>
      <p>테마: {theme}</p>
      {favoriteCategory && <p>즐겨찾는 카테고리: {favoriteCategory}</p>}
    </aside>
  )
}

// 관리자 전용 폼 — 포스트를 생성하고 캐시를 재검증
async function CreatePost() {
  const isAdmin = (await cookies()).get('role')?.value === 'admin'
  if (!isAdmin) return null

  async function createPost(formData: FormData) {
    'use server'
    await db.post.create({ data: { title: formData.get('title') } })
    updateTag('posts')
  }

  return (
    <form action={createPost}>
      <input name="title" placeholder="포스트 제목" required />
      <button type="submit">발행</button>
    </form>
  )
}

프리렌더링 시 헤더(정적)와 블로그 포스트(use cache)가 정적 셸에 포함된다.
사용자 환경설정은 요청 시점에 스트리밍된다.
관리자가 새 포스트를 발행하면 updateTag('posts') 호출로 캐시가 즉시 만료되어 다음 방문자는 최신 포스트를 보게 된다.

This post is licensed under CC BY 4.0 by the author.