fetch API를 사용하는 방법컴포넌트를 async 함수로 변경하고 fetch 함수를 await 하기
export default async function Page() {
const data = await fetch('https://api.vercel.app/blog')
const posts = await data.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
💡 참고
Request Memoization
- 한 번의 렌더링 요청 동안 서로 다른 여러 컴포넌트에서 동일한 URL과 옵션으로
fetch를 호출하면 실제 네트워크 요청은 단 1번만 발생한다.- 따라서 데이터 중복 요청을 걱정해서 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달(Prop drilling)할 필요 없이, 데이터가 필요한 각 컴포넌트에서 직접
fetch를 사용하면 된다.캐싱
- Next.js에서
fetch요청은 기본적으로 캐시되지 않으며, 해당 fetch가 완료될 때까지 전체 페이지 렌더링이 차단(block)된다.- 데이터 캐싱이 필요하다면
use cache지시어를 사용하고, 빠른 화면을 보여주기를 원한다면<Suspense>로 컴포넌트를 감싸서 스트리밍을 적용한다.개발 환경 디버깅
- 개발 환경에서는 Next.js에서 제공하는 logging API를 사용하여 어떤
fetch요청이 발생했는지를 쉽게 확인할 수 있다.
서버 컴포넌트는 서버에서 렌더링되어 클라이언트 자바스크립트 번들에 포함되지 않으므로, 별도 API 생성 없이 컴포넌트 내부에서 직접 쿼리나 ORM을 안전하게 사용할 수 있다.
import { db, posts } from '@/lib/db'
export default async function Page() {
const allPosts = await db.select().from(posts)
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
❓ ORM이란?
Object-Relational Mapping의 약자로 프로그래밍 언어의 객체(Object)와 RDBMS의 테이블(Table)을 자동으로 매핑해주는 기술이다.
예시)
- SQL:
SELECT * FROM posts WHERE id = 1;- ORM:
const post = await db.post.findUnique({ where: { id: 1 } });
서버 컴포넌트에서 데이터를 가져올 때, 느린 API 요청이 하나라도 있으면 데이터 준비가 다 끝날 때까지 전체 페이지 렌더링이 블로킹되어 사용자는 빈 화면을 보게 된다.
첫 로드 시간과 사용자 경험을 개선하기 위해서 페이지를 여러 개의 작은 조각(Chunk)로 나누어 빠르게 준비되는 부분부터 로드하는 방법을 Streaming이라고 한다.
loading.js 파일을 사용하는 방법
page.js와 같은 폴더에 loading.js를 추가하여 페이지 전체가 로드되는 동안 로딩 스켈레톤 UI를 보여준다.
export default function Loading() {
// Define the Loading UI here
return <div>Loading...</div>
}

loading.js에 정의된 로딩 UI가 즉시 표시된다.loading.js를 작성하면 개발자가 직접 <Suspense>를 작성하지 않아도 Next.js가 내부적으로 page.js를 <Suspense fallback="{<Loading"/>}>로 감싸주도록 처리한다.layout.js 파일 내부에서 캐시되지 않은 동적 데이터(예: cookies(), headers() 호출)를 직접 접근할 경우, 해당 라우트의 loading.js가 작동하지 않는다. 이를 해결하기 위해서는 동적 데이터 접근 로직을 layout.js가 아닌 page.js 내부로 이동시키거나, layout.js 내부의 동적 데이터 사용 부분을 별도의 <Suspense> boundary로 직접 감싸주어야 한다.<Suspense>를 사용하는 방법<Suspense>를 사용하면 전체 페이지를 스트리밍하는 loading.js보다 좀 더 세밀한 스트리밍이 가능하다.
<Suspense> 외부에 있는 UI는 브라우저에 즉시 표시하고, 내부에 있는 UI는 데이터 요청이 완료되는 대로 표시할 수 있어서 페이지의 작은 영역마다 별도로 스트리밍을 적용할 수 있다.
import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
import BlogListSkeleton from '@/components/BlogListSkeleton'
export default function BlogPage() {
return (
<div>
{/* This content will be sent to the client immediately */}
<header>
<h1>Welcome to the Blog</h1>
<p>Read the latest posts below.</p>
</header>
<main>
{/* If there's any dynamic content inside this boundary, it will be streamed in */}
<Suspense fallback={<BlogListSkeleton />}>
<BlogList />
</Suspense>
</main>
</div>
)
}
💡 의미 있는 로딩 상태 만들기
- 스켈레톤: 실제 UI의 레이아웃 형태를 본뜬 반짝이는 뼈대 화면
- 로딩 스피너: 빠른 처리를 나타내는 회전 아이콘
- 커버 사진/제목 등 일부 완성된 UI: 데이터가 덜 필요해 먼저 그릴 수 있는 화면 요소들
use API를 사용하는 방법서버 컴포넌트에서 데이터를 요청한 promise를 클라이언트 컴포넌트에 prop으로 전달하고, 클라이언트 컴포넌트에서 use API를 사용하여 promise를 읽는다.
// app/blog/page.tsx
import Posts from '@/app/ui/posts'
import { Suspense } from 'react'
export default function Page() {
// Don't await the data fetching function
const posts = getPosts()
return (
<Suspense fallback={<div>Loading...</div>}>
<Posts posts={posts} />
</Suspense>
)
}
// app/ui/posts.tsx
'use client'
import { use } from 'react'
export default function Posts({
posts,
}: {
posts: Promise<{ id: string; title: string }[]>
}) {
const allPosts = use(posts)
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
위 예제에서는 <Posts> 컴포넌트가 <Suspense> 바운더리로 감싸져 있어 promise를 resolve하는 동안 화면에 fallback 컴포넌트가 노출된다.
이런 라이브러리들에는 캐싱, 스트리밍 등을 위한 다양한 기능이 존재하기 때문에 클라이언트 상호작용이 많은 데이터 처리가 필요할 경우에 자유롭게 활용할 수 있다.
필요에 따라 서버 컴포넌트에서 초기 데이터를 먼저 받아온 뒤, 이를 클라이언트 라이브러리(SWR/React Query)의 초기 데이터로 전달하여 서버 캐시와 클라이언트 캐시를 동기화하는 패턴으로 확장할 수도 있다.
데이터를 병렬로 요청할 때 async/await 를 연속으로 사용하기보다는, Promise.all을 사용하는 것이 좋다.
import Albums from './albums'
async function getArtist(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}`)
return res.json()
}
async function getAlbums(username: string) {
const res = await fetch(`https://api.example.com/artist/${username}/albums`)
return res.json()
}
export default async function Page({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
// Initiate requests
const artistData = getArtist(username)
const albumsData = getAlbums(username)
const [artist, albums] = await Promise.all([artistData, albumsData])
return (
<>
<h1>{artist.name}</h1>
<Albums list={albums} />
</>
)
}
React.cacheReact의 cache 함수로 데이터 요청 함수를 감싸서 캐싱을 할 수 있다.
// app/lib/user.ts
import { cache } from 'react'
export const getUser = cache(async () => {
const res = await fetch('https://api.example.com/user')
return res.json()
})
// app/dashboard/page.tsx
import { getUser } from '../lib/user'
export default async function DashboardPage() {
const user = await getUser() // Cached - same request, no duplicate fetch
return <h1>Dashboard for {user.name}</h1>
}
위 예제에서 getUser 함수는 캐시되었으므로 이 함수를 여러 번 반복하더라도 실제로 네트워크 요청은 한 번만 하게 된다.
Next.js에서는 React Server Function을 활용하여 데이터를 변경(mutate) 할 수 있다.
서버 함수는 기본적으로 startTransition과 함께 비동기로 실행하며, Next.js에서는 아래 두 가지 경우에 이를 자동으로 처리해준다.
<form>에 action prop으로 전달할 때: <form action={serverAction}><button>에 formAction prop으로 전달할 때: <button formAction={serverAction}>⚠️ 주의할 점
서버 함수는 UI를 통해서 실행하는 것 뿐만 아니라, 외부에서 직접적인 HTTP POST 요청을 보내도 실행이 가능하다. 따라서 모든 서버 함수 내부에는 유저 인증 및 권한 검증 로직을 작성해야 데이터가 오남용되는 것을 막을 수 있다.
use server 지시어를 선언하면 해당 함수가 서버 함수가 된다.use server 지시어를 선언하면 해당 파일에서 export하는 모든 비동기 함수가 서버 함수가 된다.// createPost만 서버 함수로 지정
export async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
await db.post.create({ data: { title } })
}
// createPost, deletePost 전부 서버 함수로 지정
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
await db.post.create({ data: { title } })
}
export async function deletePost(formData: FormData) {
const id = formData.get('id')
await db.post.delete({ where: { id } })
}
use server 지시어를 사용하여 인라인으로 서버 함수를 선언한다.export default function Page() {
// Server Action
async function createPost(formData: FormData) {
'use server'
// ...
}
return <></>
}
use server가 선언된 별도 파일에서 서버 함수를 작성한 뒤 import해서 사용해야 한다.'use server'
export async function createPost() {}
'use client'
import { createPost } from '@/app/actions'
export function Button() {
return <button formAction={createPost}>Create</button>
}
🤔 다른 파일에서 함수 내부에
use server를 사용해서 특정 함수만 서버함수가 되게 만든 다음에 그걸 클라이언트 컴포넌트에서 호출할 수는 없을까? 무조건 파일 최상단에 선언해야 하는걸까?
서버 컴포넌트와 클라이언트 컴포넌트 둘 다에서 <form>의 action 속성을 통해 서버 함수를 호출할 수 있다.
import { createPost } from '@/app/actions'
export function Form() {
return (
<form action={createPost}>
<input type="text" name="title" />
<input type="text" name="content" />
<button type="submit">Create</button>
</form>
)
}
useEffect를 통해 서버 함수를 실행하는 방법클라이언트 컴포넌트에서 onClick, onChange 같은 이벤트 핸들러나 useEffect 내부에서 직접 호출할 수 있다.
'use client'
import { incrementLike } from './actions'
import { useState } from 'react'
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<>
<p>Total Likes: {likes}</p>
<button
onClick={async () => {
const updatedLikes = await incrementLike()
setLikes(updatedLikes)
}}
>
Like
</button>
</>
)
}
💡 서버 함수는 기본적으로 데이터 변경(mutation)을 목적으로 설계되었기 때문에, 클라이언트에서 여러 개의 서버 함수들을 호출하게 되면 한 번에 하나씩 순차적으로 호출한 후 대기하게 된다.
useActionState 훅을 사용하면 특정 action의 pending 상태를 알 수 있다.
'use client'
import { useActionState, startTransition } from 'react'
import { createPost } from '@/app/actions'
import { LoadingSpinner } from '@/app/ui/loading-spinner'
export function Button() {
const [state, action, pending] = useActionState(createPost, false)
return (
<button onClick={() => startTransition(action)}>
{pending ? <LoadingSpinner /> : 'Create Post'}
</button>
)
}
mutation 이후 변경된 최신 데이터를 화면에서 보여주게 하고 싶다면 next/cache의 refresh 함수를 사용한다.
refresh는 클라이언트 라우터의 캐시를 새로고침하여 화면이 최신 서버 데이터를 반영하도록 업데이트하며, 이 때 서버 컴포넌트들을 서버에서 다시 렌더링하여 생성된 RSC Payload를 사용하기 때문에 화면 전체 새로고침 없이 바뀐 부분의 UI만 갱신한다
'use server'
import { auth } from '@/lib/auth'
import { refresh } from 'next/cache'
export async function updatePost(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Mutate data
// ...
refresh()
}
mutation 이후 revalidatePath 또는 revalidateTag를 호출하면 캐시된 데이터를 파기하고 서버에서 최신 데이터를 다시 불러오도록 할 수 있다.
revalidatePath: 특정 URL 경로에 해당하는 페이지 전체의 캐시를 무효화하고 다시 렌더링revalidateTag: fetch 요청 시 지정했던 특정 캐시 태그 단위로 해당 데이터 캐시만 핀포인트로 무효화import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Mutate data
// ...
revalidatePath('/posts')
}
❓ refresh와 revalidate의 차이는?
- refresh: 클라이언트 라우터 캐시를 초기화함. 현재 화면과 서버 캐시가 일치하지 않을 때 사용
- revalidate: 서버 캐시를 초기화함.
예시) A가 특정 상품에 좋아요를 눌러서 카운트가 증가했을 때(mutation), A의 화면을 업데이트 하기 위해서는 revalidate를 해야 하고 B의 화면을 업데이트 하기 위해서는 refresh를 해야 한다.
즉, 내가 직접 mutation을 했다 → revalidate, 내가 mutation을 하지는 않았지만 서버 캐시가 업데이트 되었을 것으로 예상된다 → refresh를 실행하면 된다.
서버 함수 내부에서 redirect 함수를 호출한다.
'use server'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// Mutate data
// ...
revalidatePath('/posts')
redirect('/posts')
}
서버 함수 내부에서 cookies API를 사용하여 쿠키를 get, set, delete할 수 있다.
'use server'
import { cookies } from 'next/headers'
export async function exampleAction() {
const cookieStore = await cookies()
// Get cookie
cookieStore.get('name')?.value
// Set cookie
cookieStore.set('name', 'Delba')
// Delete cookie
cookieStore.delete('name')
}
useEffect 내부에서 서버 액션을 호출해야 할 경우 startTransition과 함께 사용한다.
'use client'
import { incrementViews } from './actions'
import { useState, useEffect, useTransition } from 'react'
export default function ViewCount({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews)
const [isPending, startTransition] = useTransition()
useEffect(() => {
startTransition(async () => {
const updatedViews = await incrementViews()
setViews(updatedViews)
})
}, [])
// You can use `isPending` to give users feedback
return <p>Total Views: {views}</p>
}
캐시 컴포넌트를 활성화하기 위해서는 설정에서 cacheComponents 옵션을 켜주어야 한다.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
❓ Cache Component란?
기본적으로는 dynamic(캐시 없음)으로 처리하되, 필요한 컴포넌트만 캐싱하는 Next.js의 캐싱 모델
cacheComponents옵션을 사용하지 않을 때
fetch요청의 캐시 기본값이 동적(Dynamic, 캐시 안 함)으로 처리됨fetch('URL', { cache: 'force-cache' })처럼 매 요청마다 캐시 옵션을 전달export const revalidate = 60처럼 페이지 단위 설정- DB 직접 조회 시
unstable_cache함수 사용'use cache'지시어나 컴포넌트 단위의 유연한 캐싱 제어 기능을 사용할 수 없음
cacheComponents옵션을 사용할 때
- 페이지, 컴포넌트,
fetch요청, DB 조회 등 모든 레이어가 기본적으로 캐시 없이 매 요청마다 새로 실행됨- 캐싱 선언: 원하는 컴포넌트, 함수, 또는 파일에
'use cache'를 작성할 때만 서버 메모리/CDN에 정적으로 캐시됨
use cache 지시어는 async 함수와 컴포넌트의 리턴값을 캐시한다. Data-level, UI-level의 수준으로 적용할 수 있다.
getProducts(), getUser(id))async function BlogPosts())💡 함수의 인자나 부모 스코프에서 가져온 값들은 자동으로 캐시 키에 포함된다.
💡
use cache를 선언할 때 미리 정의된 단어를 통해 캐시의 유효기간과 재검증 주기를 지정할 수 있는cacheLife()를 함께 사용하는 것이 권장된다.
import { cacheLife } from 'next/cache'
export async function getUsers() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM users')
}
여러 컴포넌트에서 같은 데이터를 사용하거나 UI와 독립적으로 데이터를 캐싱하고 싶을 때 사용하면 좋다.
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>
)
}
❓ UI-level 캐싱은 정확히 어떤 걸 캐싱하는 걸까?
- 컴포넌트 내부의 모든 데이터 요청 결과: 내부에서 호출한 DB 쿼리나 API 결과
- UI 구조 데이터 (RSC Payload): 클라이언트 리액트가 화면을 그리는데 필요한 가상 DOM 구조
- 최종 HTML 조각: 브라우저에 바로 전달할 수 있는 렌더링 결과
❓ UI-level caching이랑 Full route cache랑 같은 걸까?
그렇지 않다. UI-level caching은 개발자가 지정한 특정 컴포넌트 단위로 캐싱하는 것이고 Full route cache는 특정 URL 경로에 해당하는 전체 페이지를 캐싱하는 것이다.
참고: https://dani1552.tistory.com/53
매 요청마다 최신 데이터가 필요한 비동기 컴포넌트에는 use cache를 사용하면 안 된다. 대신 이 컴포넌트를 <Suspense>로 감싸고 fallback UI를 제공하는 것이 좋다.
Runtime API는 사용자가 페이지를 요청하는 시점에만 알 수 있는 정보를 다루는 API들이다.
종류: cookies, headers, searchPararms, params
Runtime API를 사용하는 컴포넌트는 반드시 <Suspense>로 감싸져야 한다. 그렇지 않으면 개발 도구에 blocking-route 경고가 발생한다.
일반적으로 Runtime API는 사용자(요청)마다 값이 다르기 때문에 캐시에 보관할 수 없지만, use cache 대신 use cache:private를 사용하면 개인화된 캐시에 유효기간(cache lifetime)을 부여하여 저장할 수 있다. 이렇게 하면 prefetch가 가능해져 페이지 전환 속도가 빨라진다는 장점이 있다.
캐시되는 컴포넌트에 Runtime API의 값을 prop으로 잔달할 수 있다.
import { cookies } from 'next/headers'
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProfileContent />
</Suspense>
)
}
// Component (not cached) reads runtime data
async function ProfileContent() {
const session = (await cookies()).get('session')?.value
return <CachedContent sessionId={session} />
}
// Cached component receives extracted value as a prop
async function CachedContent({ sessionId }: { sessionId: string }) {
'use cache'
// sessionId becomes part of the cache key
const data = await fetchUserData(sessionId)
return <div>{data}</div>
}
CachedContent는 전달받은 seesionId를 캐시 키로 사용하여 sessionId 별로 데이터를 캐싱하게 된다.
이 패턴을 사용하면 클라이언트에서 페이지를 이동할 때, 사용자의 실제 seesionId를 기반으로 CachedContent를 사전 렌더링(prefetch)해둘 수 있다.
❓ Runtime API를 사용하는 서버 컴포넌트는 서버 렌더링이 가능한가?
헷갈리지 말 것! 서버 렌더링은 사용자 요청 이후에 진행되므로 당연히 가능하다. 안 되는 것은 사전 렌더링뿐! 단, 사전 렌더링도
use cache:private를 사용하면 가능하게 된다.
Math.random(), Date.now(), crypto.randomUUID()와 같은 함수들은 실행할 때마다 매번 다른 값을 생성한다. prerendering 단계에서 이런 함수가 있으면 렌더링 시점의 값과 실제 요청 시점의 값이 달라질 수 있으므로 이를 적절하게 대응해야 한다.
await connection()을 호출하고 컴포넌트를 <Suspense>로 감싸준다.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>Loading...</p>}>
<UniqueContent />
</Suspense>
)
}
use cache를 선언한다.export default async function Page() {
'use cache'
const buildId = crypto.randomUUID()
return <p>Build ID: {buildId}</p>
}
⚠️ 난수/시간 관련 함수를 이러한 명시적 처리 없이 사용하면 Next.js가
blocking-prerender-random,blocking-prerender-current-time,blocking-prerender-crypto와 같은 warning을 띄워준다.
언제 실행하더라도 항상 동일한 결과를 반환하는 작업들은 빌드 시점에 자동으로 사전 렌더링되어 정적 HTML로 만들어진다. 따라서 별도의 지시어나 <Suspense> 사용이 필요하지 않다.
예: 모듈 import, 동기식I/O(fs.readFileSync), 순수 연산/계산 등
또한 모든 요청에서 값이 변하지 않는 설정 파일, 폰트, 로컬 데이터 등은 컴포넌트 외부에서 한 번만 읽어오는 것이 좋다.
import { readFile } from 'node:fs/promises'
// 💡 컴포넌트 밖(모듈 스코프)에서 1번만 실행
const content = await readFile('./config.json', 'utf-8')
const items = JSON.parse(content).items ?? []
export default function Page() {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.value}</li>
))}
</ul>
)
}
컴포넌트에서 어떤 API나 로직을 사용하느냐에 따라 사전 렌더링이 다른 방식으로 동작한다.
use cache 사용: 캐시 수명이 너무 짧지 않다면 Static HTML에 그대로 포함됨<Suspense> 사용: fallback UI가 Static HTML에 포함됨connection() + <Susepense>를 조합하고, 모든 사용자가 공유하게 하려면 use cache 사용Prerendering 과정을 통해 HTML과 RSC Payload로 구성된 Static Shell이 만들어지며, 동적 UI만 요청 시점에 스트리밍으로 채워 넣는 방법을 Partial Prerendering이라고 한다. Cache Components 모델에서는 이 방식이 기본 동작이다.

❓ Prerendering과 PPR의 차이가 뭘까?
Prerendering
- 빌드 시점에 HTML을 미리 그려두는 행위 그 자체를 의미함
- 페이지 단위로 적용
- 페이지 내부에 동적 데이터가 하나라도 있으면 사전 렌더링이 취소되고 Dynamic SSR로 전환됨
Partial Prerendering
- 페이지를 정적 영역 + 동적 영역으로 분리하여 사전 렌더링하는 기술
- 컴포넌트 단위로 적용
- 정적 껍데기만 사전 렌더링하고, 동적 데이터만 요청 시점에 스트리밍
지금까지 Prerendering을 PPR로 생각하고 있던듯!!
비동기 작업이나 런타임 API 접근을 컴포넌트 트리의 최하단으로 밀어낼수록 사전 렌더링되는 정적 영역이 넓어진다.
// app/shop/[slug]/layout.tsx (개선 전)
export default async function Layout({ children, params }) {
const { slug } = await params // 👈 최상위(Layout)에서 await 호출!
return (
<div>
<Sidebar />
<h1>{slug}</h1>
{children}
</div>
)
}
// app/shop/[slug]/layout.tsx (개선 후)
import { Suspense } from 'react'
// 💡 Not async: 레이아웃 자체는 비동기 함수가 아님
export default function Layout({ children, params }) {
return (
<div>
<Sidebar /> {/* 👈 Static Shell에 완벽 포함! */}
<Suspense fallback={<h1>Loading...</h1>}>
{/* await를 Suspense 경계 내부의 말단 컴포넌트로 지연 */}
{params.then(({ slug }) => (
<SlugHeading slug={slug} />
))}
</Suspense>
{children}
</div>
)
}
<Link>의 기본 동작
cookies(), headers() 기반의 세션 데이터가 포함된 App Shell만 미리 가져옴<Link prefetch={true}>의 동작
use cache: URL에서 추출한 값을 인자로 받는 캐시 함수도 prefetch에 포함되어 데이터가 미리 준비됨use cache: private: 서버에서 실행되어 런타임 데이터를 읽은 후, 브라우저에 해당 결과를 링크별 prefetch로 캐싱<Susepnse>: 캐시되지 않은 동적 영역은 fallback UI 상태로 유지되고, 실제 요청 시점에 스트리밍⚠️ 링크의 전체 dynamic URL 구조를 해석해서 캐시 데이터를 미리 끌어오므로 prefetch가 가능한 링크당 1회의 서버 호출 비용이 발생한다.
import { Suspense } from 'react'
export default function SearchPage(props: PageProps<'/search'>) {
return (
<Suspense fallback={<p>Loading results...</p>}>
<Results searchParams={props.searchParams} />
</Suspense>
)
}
async function Results({
searchParams,
}: Pick<PageProps<'/search'>, 'searchParams'>) {
const { q } = await searchParams
const results = await search(q)
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)
}
async function search(query: string | string[] | undefined) {
'use cache'
return db.search(query)
}
위의 예제에서
/search?q=shoes에 직접 방문할 경우: fallback UI가 먼저 보이고 <Results> 컴포넌트는 스트리밍으로 렌더링<Link href="/search?q=shoes" prefetch={true}>가 prefetch될 경우: search(’shoes’)를 미리 실행하여 <Results> 컴포넌트 전체가 prefetch됨. 사용자가 링크를 누르면 미리 캐시된 결과가 화면에 즉시 출력Revalidation은 캐시된 데이터를 업데이트하는 과정이다. Revalidation을 하는 데는 두 가지 방법이 있다.
cacheLife를 사용하여 일정 시간이 지난 후 자동으로 refresh되게 하는 방법revalidateTag, updateTag, revalidatePath 중 하나를 사용하여 직접 업데이트 하는 방법cacheLifeuse cache 범위 내부에서 사용할 수 있으며 특정 프로필 또는 객체를 전달하여 데이터가 얼마 동안 유효한지 지정할 수 있다.
프로필
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5m | 15m | never |
seconds | 30s | 1s | 60s |
minutes | 5m | 1m | 1h |
hours | 5m | 1h | 1d |
days | 5m | 1d | 1w |
weeks | 5m | 1w | 30d |
max | 5m | 30d | 1y |
stale: 데이터가 fresh → stale 상태로 변하는 시간revalidate: 백그라운드에서 데이터 재검증(캐시 갱신)을 시작하는 시간expire: 이 시간이 지나도록 요청이 없으면 캐시를 서버 메모리에서 완전히 삭제(파기)🤔 Tanstack Query에서는 stale 상태에서 요청이 들어오면 무조건 revalidation이 발생했는데, 여기서는
stale,revalidate가 나뉘어져 있는 이유가 뭘까?stale~revalidate사이의 시간에서는 stale한 상태이지만 revalidation을 수행하지 않는 건가? 그리고stale보다revalidate가 짧은 경우에는 어떻게 동작하는걸까?
객체
'use cache'
cacheLife({
stale: 3600, // 1 hour until considered stale
revalidate: 7200, // 2 hours until revalidated
expire: 86400, // 1 day until expired
})
💡
seconds프로필을 사용하거나revalidate: 0또는expire가 5분 미만일 경우는 short-lived 캐시로 간주된다. 이러한 짧은 수명의 캐시는 사전 렌더링 대상에서 자동으로 제외되며, 해당 영역은 요청 시점에 스트리밍되는 동적 구멍(dynamic holes)로 처리된다.
cacheTaguse cache 범위 내부에서 사용할 수 있으며 특정 캐시에 태그를 추가하는 역할을 한다. 추후에 revalidateTag 또는 updateTag에서 이 태그를 사용한다.
import { cacheTag } from 'next/cache'
export async function getProducts() {
'use cache'
cacheTag('products')
return db.query('SELECT * FROM products')
}
revalidateTagrevalidateTag는 먼저 stale 데이터를 즉시 응답하고 백그라운드에서 새로운 데이터를 불러와 캐시를 최신화하는 stale-while-revalidate 방식으로 동작한다.
import { revalidateTag } from 'next/cache'
export async function updateUser(id: string) {
// Mutate data
revalidateTag('user', 'max') // Recommended: stale-while-revalidate
}
revalidateTag의 두 번째 인자: 'max' 또는 시간을 전달하며, stale 데이터를 보여줄 허용 시간을 지정한다. 이 시간 이후로 들어오는 요청에서는 stale 데이터를 응답하지 않고 새 데이터가 준비될 때까지 대기한다. 'max'로 설정되었을 경우 기존 stale 데이터를 제한 없이 보여준다.updateTag이전 stale한 캐시 데이터를 사용자에게 보여주지 않고 캐시를 즉시 만료시킨 후 새로운 데이터를 불러오는 read-your-own-writes 방식으로 동작한다. 사용자가 방금 수정/생성한 결과를 본인이 즉시 확인해야 할 때 사용할 수 있다.
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: formData.get('title'),
content: formData.get('content'),
},
})
updateTag('posts')
redirect(`/posts/${post.id}`)
}
revalidatePath특정 라우트 경로에 있는 모든 캐시를 만료시킨다.
import { revalidatePath } from 'next/cache'
export async function updateUser(id: string) {
// Mutate data
revalidatePath('/profile')
}
에러는 Expected error와 Uncaught exception으로 구분할 수 있다.
throw new Error()로 던져지며, Next.js에서는 주로 error.js 파일이나 React Error Boundary를 통해 포착하여 에러 화면을 렌더링한다.서버 함수에서 에러를 반환하면 useActionState를 사용하여 UI로 표시한다. try/catch나 throw를 사용하는 것을 지양한다.
// app/actions.ts
'use server'
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
const res = await fetch('https://api.vercel.app/posts', {
method: 'POST',
body: { title, content },
})
const json = await res.json()
if (!res.ok) {
return { message: 'Failed to create post' }
}
}
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
const initialState = {
message: '',
}
export function Form() {
const [state, formAction, pending] = useActionState(createPost, initialState)
return (
<form action={formAction}>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state?.message && <p aria-live="polite">{state.message}</p>}
<button disabled={pending}>Create Post</button>
</form>
)
}
서버 컴포넌트 내부에서 fetch하는 API가 실패하였을 경우 에러 메세지를 반환하거나 redirect를 사용한다.
export default async function Page() {
const res = await fetch(`https://...`)
const data = await res.json()
if (!res.ok) {
return 'There was an error.'
}
return '...'
}
not-found.js를 생성하고 route segment 내부에서 notFound 함수를 사용하면 404 UI를 보여줄 수 있다.
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) {
notFound()
}
return <div>{post.title}</div>
}
error.js 파일을 생성하면 자동으로 ErrorBoundary로 page.js를 감싸주게 된다.
ErrorBoundary는 클라이언트 컴포넌트에서만 사용 가능하다.ErrorBoundary는 중첩될 수 있으며 에러는 가장 가까운 ErrorBoundary를 만날 때까지 부모를 타고 올라간다.startTransition 또는 useTransition 내부에서 발생한 에러들은 ErrorBoundary가 처리할 수 있다.// error.tsx
'use client' // Error boundaries must be Client Components
import { useEffect } from 'react'
export default function ErrorPage({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by re-fetching and re-rendering the segment
() => retry()
}
>
Try again
</button>
</div>
)
}

컴포넌트 레벨의 에러를 처리하기 위해서 catchError 함수를 사용하여 컴포넌트를 감싸는 error boundary를 생성할 수 있다.
'use client'
import { catchError, type ErrorInfo } from 'next/error'
function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
return (
<div>
<h2>{props.title}</h2>
<p>{error.message}</p>
<button onClick={() => retry()}>Try again</button>
</div>
)
}
export default catchError(ErrorFallback)
import ErrorBoundary from './custom-error-boundary'
export default function Component({ children }: { children: React.ReactNode }) {
return <ErrorBoundary title="Dashboard Error">{children}</ErrorBoundary>
}
ErrorBoundary는 렌더링 과정 동안 발생하는 에러들을 감지하는 목적으로 생성되었으므로 이벤트 핸들러 내부에서 발생하는 에러들은 처리하지 못한다. 따라서 이벤트 핸들러에서 발생하는 에러들을 처리하기 위해서는 useState 또는 useReducer를 사용하여 직접 UI를 업데이트 해야 한다.
'use client'
import { useState } from 'react'
export function Button() {
const [error, setError] = useState(null)
const handleClick = () => {
try {
// do some work that might fail
throw new Error('Exception')
} catch (reason) {
setError(reason)
}
}
if (error) {
/* render fallback UI */
}
return (
<button type="button" onClick={handleClick}>
Click me
</button>
)
}
root layout(app/layout.tsx)에서 발생한 에러를 잡기 위해서는 global-error.js 파일을 사용해야 한다. global-error.js 파일은 최상위 app 디렉토리 바로 아래에 작성하며 root layout 자체를 대체하는 역할을 하므로 <html>과 <body> 태그를 직접 가지고 있어야 한다.
'use client' // Error boundaries must be Client Components
export default function GlobalError({
error,
retry,
}: {
error: Error & { digest?: string }
retry: () => void
}) {
return (
// global-error must include html and body tags
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => retry()}>Try again</button>
</body>
</html>
)
}