Next.js (8. Caching) 정리
0. 캐싱
캐싱은 데이터 페칭과 연산 결과를 저장해 동일한 요청을 더 빠르게 처리하는 기법이다.
1. Cache Components 설정
1-1. Cache Components 활성화
next.config.ts에 cacheComponents: 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를 활성화하면
GETRoute 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 | 요청 헤더 |
searchParams | URL 쿼리 파라미터 |
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가 정적 셸에 포함되고 콘텐츠는 요청 시 스트리밍 |
| 결정적 연산 | 자동으로 정적 셸에 포함 |
프리렌더링 중 완료할 수 없는 컴포넌트를
<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')호출로 캐시가 즉시 만료되어 다음 방문자는 최신 포스트를 보게 된다.


