Next.js (6. Fetching Data) 정리
0. 데이터 페칭
Next.js에서 데이터를 가져오는 방법은 어느 컴포넌트에서 페칭하느냐에 따라 달라진다.
Server Component에서는 fetch API 또는 ORM을, Client Component에서는 use API나 외부 라이브러리를 사용한다.
느린 데이터 요청이 있을 때는 스트리밍으로 초기 로드 시간과 UX를 개선할 수 있다.
1. Server Component에서 데이터 페칭
1-1. fetch API 사용
Server Component를 async 함수로 만들고 fetch를 await하면 데이터를 가져올 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/blog/page.tsx
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>
)
}
- 동일한
fetch요청은 React 컴포넌트 트리 내에서 메모이제이션되어 중복 요청이 발생하지 않는다.fetch요청은 기본적으로 캐시되지 않으며, 요청이 완료될 때까지 페이지 렌더링을 블로킹한다.- 결과를 캐시하려면
use cache디렉티브를, 최신 데이터를 스트리밍하려면<Suspense>로 감싼다.
1-2. ORM / 데이터베이스 사용
Server Component는 서버에서만 실행되므로 ORM이나 DB 클라이언트를 안전하게 사용할 수 있다.
인증 정보와 쿼리 로직이 클라이언트 번들에 포함되지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/blog/page.tsx
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>
)
}
서버 사이드 데이터 접근에는 적절한 인증과 인가(authorization)를 반드시 적용한다.
1-3. 스트리밍 (Streaming)
느린 데이터 요청이 있으면 해당 요청이 완료될 때까지 라우트 전체가 블로킹된다.
스트리밍을 사용하면 페이지를 작은 청크(chunk)로 나눠 준비된 것부터 순차적으로 클라이언트에 전송할 수 있다.
스트리밍을 적용하는 방법은 두 가지다.
loading.js로 페이지 전체 스트리밍페이지와 같은 폴더에
loading.js를 만들면 페이지 전체를 스트리밍할 수 있다.
내비게이션 시 레이아웃과 로딩 상태를 즉시 보여주고, 렌더링이 완료되면 새 콘텐츠로 자동 교체된다.1 2 3 4 5
// app/blog/loading.tsx export default function Loading() { return <div>Loading...</div> }
loading.js는 내부적으로layout.js안에 중첩되며,page.js와 하위 컴포넌트를<Suspense>바운더리로 자동으로 감싼다.레이아웃에서 캐시되지 않은 데이터(
cookies(),headers(), 미캐시 fetch 등)에 접근하면loading.js가 적용되지 않고 레이아웃 렌더링이 완료될 때까지 내비게이션이 블로킹된다.
이 경우 해당 접근을 별도의<Suspense>로 감싸거나, 데이터 페칭을page.js로 이동한다.<Suspense>로 특정 컴포넌트 스트리밍<Suspense>는 페이지의 특정 부분만 스트리밍할 때 사용한다.
바운더리 밖의 콘텐츠는 즉시 전송하고, 바운더리 안의 느린 컴포넌트만 스트리밍할 수 있다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// app/blog/page.tsx import { Suspense } from 'react' import BlogList from '@/components/BlogList' import BlogListSkeleton from '@/components/BlogListSkeleton' export default function BlogPage() { return ( <div> {/* 즉시 클라이언트로 전송 */} <header> <h1>Welcome to the Blog</h1> <p>Read the latest posts below.</p> </header> <main> {/* BlogList가 준비되면 스트리밍 */} <Suspense fallback={<BlogListSkeleton />}> <BlogList /> </Suspense> </main> </div> ) }
loading.js는 라우트 세그먼트 전체를 스트리밍할 때 적합하고,
<Suspense>는 런타임 데이터나 캐시되지 않은 데이터에 더 가까이 배치하는 것이 권장된다.구분 loading.js<Suspense>범위 페이지 전체 컴포넌트 일부 타이밍 페이지 진입 전 렌더링 중 목적 초기 진입 UX 부분 로딩 UX 위치 파일 기반 JSX 내부
2. Client Component에서 데이터 페칭
2-1. use API로 서버에서 스트리밍
서버에서 시작한 Promise를 Client Component에 prop으로 전달하고, use API로 읽으면 서버에서 클라이언트로 데이터를 스트리밍할 수 있다.
Server Component에서 데이터 페칭 함수를 await 없이 호출해 Promise를 Client Component로 전달한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/blog/page.tsx — Server Component
import Posts from '@/app/ui/posts'
import { Suspense } from 'react'
export default function Page() {
const posts = getPosts() // await하지 않음
return (
<Suspense fallback={<div>Loading...</div>}>
<Posts posts={posts} />
</Suspense>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/ui/posts.tsx — Client Component
'use client'
import { use } from 'react'
export default function Posts({
posts,
}: {
posts: Promise<{ id: string; title: string }[]>
}) {
const allPosts = use(posts) // Promise를 읽음
return (
<ul>
{allPosts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
<Posts>는<Suspense>로 감싸져 있으므로 Promise가 resolve되는 동안 fallback UI가 표시된다.
2-2. 커뮤니티 라이브러리 (SWR, React Query)
SWR이나 React Query 같은 라이브러리를 사용하면 캐싱, 재검증, 에러 처리 등을 쉽게 관리할 수 있다.
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
// app/blog/page.tsx — Client Component
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export default function BlogPage() {
const { data, error, isLoading } = useSWR(
'https://api.vercel.app/blog',
fetcher
)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return (
<ul>
{data.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
3. 데이터 페칭 패턴
3-1. 순차적 데이터 페칭 (Sequential)
한 요청의 결과가 다음 요청에 필요할 때 순차적으로 페칭한다.
예를 들어, <Playlists>는 artistID가 있어야 하므로 <Artist> 데이터 페칭이 완료된 후에 실행된다. <Suspense>로 <Playlists>를 감싸면 아티스트 데이터가 로드되는 동안 폴백 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
27
28
29
30
31
32
33
34
35
// app/artist/[username]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
// 아티스트 정보 가져오기
const artist = await getArtist(username)
return (
<>
<h1>{artist.name}</h1>
{/* Playlists 컴포넌트가 로딩되는 동안 폴백 UI 표시 */}
<Suspense fallback={<div>Loading...</div>}>
{/* Playlists 컴포넌트에 아티스트 ID 전달 */}
<Playlists artistID={artist.id} />
</Suspense>
</>
)
}
async function Playlists({ artistID }: { artistID: string }) {
// 아티스트 ID를 사용하여 플레이리스트 가져오기
const playlists = await getArtistPlaylists(artistID)
return (
<ul>
{playlists.map((playlist) => (
<li key={playlist.id}>{playlist.name}</li>
))}
</ul>
)
}
첫 번째 요청이 느리면 이후 모든 요청이 지연된다.
데이터 변경이 적다면 결과를 캐시하는 것을 고려한다.
3-2. 병렬 데이터 페칭 (Parallel)
서로 의존하지 않는 요청은 동시에 시작해 전체 대기 시간을 줄인다.
같은 컴포넌트 안에서 await을 순서대로 사용하면 요청이 순차적으로 실행된다.
1
2
3
// 순차 실행 — getAlbums는 getArtist 완료 후 시작
const artist = await getArtist(username)
const albums = await getAlbums(username)
fetch를 먼저 호출해 두고 Promise.all로 동시에 기다리면 두 요청이 병렬로 실행된다.
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
// app/artist/[username]/page.tsx
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
// 두 요청을 동시에 시작
const artistData = getArtist(username)
const albumsData = getAlbums(username)
const [artist, albums] = await Promise.all([artistData, albumsData])
return (
<>
<h1>{artist.name}</h1>
<Albums list={albums} />
</>
)
}
Promise.all을 사용하면 하나라도 실패 시 전체가 실패한다.
개별 실패를 허용하려면Promise.allSettled를 사용한다.
3-3. context와 React.cache로 데이터 공유
React.cache로 감싼 함수는 같은 요청 내에서 여러 번 호출해도 중복 페칭 없이 동일한 결과를 반환한다.
이를 context와 결합하면 Server Component와 Client Component 양쪽에서 같은 데이터를 공유할 수 있다.
캐시된 페칭 함수 생성
1 2 3 4 5 6 7 8
// 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() })
Promise를 저장하는 Context Provider 생성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// app/user-provider.tsx 'use client' import { createContext } from 'react' type User = { id: string; name: string } export const UserContext = createContext<Promise<User> | null>(null) export default function UserProvider({ children, userPromise, }: { children: React.ReactNode userPromise: Promise<User> }) { return <UserContext value={userPromise}>{children}</UserContext> }
레이아웃에서 Promise를 Provider에 전달 (await 없이)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// app/layout.tsx import UserProvider from './user-provider' import { getUser } from './lib/user' export default function RootLayout({ children, }: { children: React.ReactNode }) { const userPromise = getUser() // await하지 않음 return ( <html> <body> <UserProvider userPromise={userPromise}>{children}</UserProvider> </body> </html> ) }
Client Component에서
use()로 데이터 읽기1 2 3 4 5 6 7 8 9 10 11 12 13 14
// app/ui/profile.tsx 'use client' import { use, useContext } from 'react' import { UserContext } from '../user-provider' export function Profile() { const userPromise = useContext(UserContext) if (!userPromise) throw new Error('UserProvider 안에서 사용해야 합니다.') const user = use(userPromise) return <p>Welcome, {user.name}</p> }
1 2 3 4 5 6 7 8 9 10 11 12
// app/page.tsx import { Suspense } from 'react' import { Profile } from './ui/profile' export default function Page() { return ( <Suspense fallback={<div>Loading profile...</div>}> <Profile /> </Suspense> ) }
Server Component는
getUser()를 직접 호출해도 중복 페칭이 발생하지 않는다.1 2 3 4 5 6 7 8
// app/dashboard/page.tsx import { getUser } from '../lib/user' export default async function DashboardPage() { const user = await getUser() // 캐시됨 — 동일 요청, 중복 페칭 없음 return <h1>Dashboard for {user.name}</h1> }
React.cache는 현재 요청 범위에만 적용된다.
요청마다 새로운 메모이제이션 스코프가 생성되며, 요청 간에는 공유되지 않는다.




