데이터 페칭

데이터 페칭 (Fetching Data)

Next.js에서는 서버 컴포넌트에서 비동기 I/O로 데이터를 가져올 수 있고, 클라이언트 컴포넌트에서는 use API나 커뮤니티 라이브러리로 가져올 수 있어요. 또 캐시되지 않은 데이터에 의존하는 컴포넌트는 스트리밍으로 점진적으로 보낼 수 있죠. 이 글에서는 서버·클라이언트에서 데이터를 가져오는 방법과, 스트리밍으로 초기 로딩을 개선하는 방법을 살펴볼게요.

출처: Next.js 공식 문서 - Fetching Data

서버 컴포넌트에서 데이터 가져오기

서버 컴포넌트에서는 fetch API나 ORM/데이터베이스 같은 어떤 비동기 I/O든 사용해 데이터를 가져올 수 있어요.

fetch API로

fetch API로 데이터를 가져오려면 컴포넌트를 비동기 함수로 만들고 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>
  )
}

알아두면 좋은 점이 몇 가지 있어요.

  • React 컴포넌트 트리 안의 동일한 fetch 요청은 기본적으로 메모이제이션돼요. 그래서 props를 일일이 내려주는 대신 데이터가 필요한 컴포넌트에서 바로 가져올 수 있어요.
  • fetch 요청은 기본적으로 캐시되지 않아, 요청이 끝날 때까지 페이지 렌더링을 막아요. 결과를 캐시하려면 use cache 지시어를 쓰거나, 가져오는 컴포넌트를 <Suspense>로 감싸 요청 시점에 최신 데이터를 스트리밍하면 돼요.

ORM 또는 데이터베이스로

서버 컴포넌트는 서버에서 렌더링되므로, 자격증명(credential)과 쿼리 로직이 클라이언트 번들에 포함되지 않아요. 따라서 ORM이나 DB 클라이언트로 안전하게 데이터베이스 쿼리를 할 수 있어요.

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>
  )
}

다만 요청이 제대로 인증·인가되는지는 여전히 확실히 해야 해요. 서버 측 데이터 접근을 안전하게 하는 모범 사례는 데이터 보안 가이드를 참고하세요.

스트리밍 (Streaming)

서버 컴포넌트에서 데이터를 가져오면 요청마다 서버에서 데이터를 가져와 렌더링해요. 느린 데이터 요청이 하나라도 있으면 모든 데이터를 가져올 때까지 전체 라우트 렌더링이 막히죠.

초기 로드 시간과 사용자 경험을 개선하려면 페이지를 더 작은 청크로 나누고, 준비되는 대로 서버에서 클라이언트로 점진적으로 보내면 돼요. 이걸 스트리밍이라고 해요. 스트리밍을 쓰는 방법은 두 가지예요.

  1. 페이지를 loading.js 파일로 감싸기
  2. 컴포넌트를 <Suspense>로 감싸기

알아두면 좋아요: 봇(bot)과 크롤러는 브라우저와 다르게 서빙돼요. Next.js는 데이터 페칭이 끝날 때까지 기다렸다가 점진적 스트리밍 대신 완전히 렌더링된 페이지를 보내요.

loading.js

페이지와 같은 폴더에 loading.js 파일을 만들면 데이터를 가져오는 동안 전체 페이지를 스트리밍할 수 있어요. 네비게이션하면 사용자는 페이지가 렌더링되는 동안 레이아웃과 로딩 상태를 즉시 보고, 렌더링이 끝나면 새 콘텐츠로 자동 교체돼요.

export default function Loading() {
  // Loading UI를 여기서 정의
  return <div>Loading...</div>
}

내부적으로 loading.jslayout.js 안에 중첩되고, page.js와 그 아래 자식들을 <Suspense> 경계로 자동 감싸요. 그래서 cookies(), headers() 같은 캐시되지 않거나 런타임 데이터에 접근하는 레이아웃은 같은 세그먼트의 loading.js로 폴백하지 않아요. 런타임·캐시되지 않은 데이터 접근에 가까운 곳에서는 <Suspense>를 쓰는 편이 권장돼요.

<Suspense>

<Suspense>를 쓰면 페이지에서 어떤 부분을 스트리밍할지 더 세밀하게 조절할 수 있어요. 예를 들어 <Suspense> 경계 밖의 페이지 콘텐츠는 즉시 보여주고, 경계 안의 블로그 글 목록만 스트리밍할 수 있죠.

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>
        {/* 이 경계 안에 동적 콘텐츠가 있으면 스트리밍됨 */}
        <Suspense fallback={<BlogListSkeleton />}>
          <BlogList />
        </Suspense>
      </main>
    </div>
  )
}

의미 있는 로딩 상태 만들기

인스턴트 로딩 상태(instant loading state)는 네비게이션 직후 사용자에게 즉시 보여주는 폴백 UI예요. 최상의 경험을 위해, 사용자가 앱이 응답 중임을 이해하도록 돕는 의미 있는 로딩 상태를 설계하길 권해요. 스켈레톤·스피너, 또는 커버 사진·제목처럼 다음 화면에서 쓰일 작지만 의미 있는 부분을 쓰는 식이에요.

클라이언트 컴포넌트에서 데이터 가져오기

클라이언트 컴포넌트에서 데이터를 가져오는 방법은 두 가지예요.

  1. React의 use API
  2. SWR, React Query 같은 커뮤니티 라이브러리

use API로 데이터 스트리밍하기

React의 use API로 서버에서 클라이언트로 데이터를 스트리밍할 수 있어요. 서버 컴포넌트에서 데이터를 가져오고, 그 프로미스(promise)를 클라이언트 컴포넌트에 prop으로 넘기면 돼요. 가져오는 함수는 await하지 않고 넘겨요.

import Posts from '@/app/ui/posts'
import { Suspense } from 'react'

export default function Page() {
  // 데이터 페칭 함수를 await하지 않음
  const posts = getPosts()

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Posts posts={posts} />
    </Suspense>
  )
}

클라이언트 컴포넌트에서는 use API로 프로미스를 읽어요.

'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> 경계로 감싸져 있어요. 프로미스가 해결되는 동안 폴백이 보여지죠. 프로미스는 서버에서 await로, 클라이언트 컴포넌트에서는 use()로 해결할 수 있어요.

커뮤니티 라이브러리

클라이언트 컴포넌트에서 데이터를 가져올 때 SWR이나 React Query 같은 커뮤니티 라이브러리를 쓸 수 있어요. 이 라이브러리들은 캐싱·스트리밍 등에서 각자의 의미 체계를 갖고 있어요. 예를 들어 SWR을 쓰면 이렇게 돼요.

'use client'
import useSWR from 'swr'

const fetcher = (url) => 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>
  )
}

예시

순차적 데이터 페칭 (Sequential data fetching)

순차적 데이터 페칭은 한 요청이 다른 요청의 데이터에 의존할 때 일어나요. 예를 들어 <Playlists>artistID가 필요하므로 getArtist()가 해결된 뒤에야 데이터를 가져올 수 있어요.

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>
  )
}

이 예시에서 <Suspense> 덕분에 아티스트 데이터가 로드된 후 플레이리스트가 스트리밍돼요. 다만 페이지는 아티스트 데이터 오기 전까지 아무것도 보여주지 않아요. 이것까지 방지하려면 페이지 전체 컴포넌트를 <Suspense> 경계(예: loading.js)로 감싸 로딩 상태를 즉시 보여주면 돼요.

병렬 데이터 페칭 (Parallel data fetching)

병렬 데이터 페칭은 라우트 안의 데이터 요청들이 동시에 시작될 때 일어나요. 레이아웃과 페이지는 기본적으로 병렬로 렌더링되므로, 각 세그먼트는 가능한 한 빨리 데이터 페칭을 시작해요.

다만 어떤 컴포넌트 안에서도 여러 async/await 요청은 차례로 놓여 있으면 순차적으로 실행될 수 있어요. 여러 요청을 시작하려면 fetch를 호출한 뒤 Promise.all로 모아 await하면 돼요. 요청은 fetch가 호출되는 즉시 시작돼요.

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 메서드를 대신 쓸 수 있어요.

React.cache로 데이터 재사용하기

fetch를 쓰지 않는 데이터 접근(ORM·DB 쿼리)은 함수를 React.cache로 감싸면, 같은 요청 안에서 여러 컴포넌트가 하나의 결과를 공유하며 함수를 호출할 수 있어요.

import { cache } from 'react'
import { db, eq, users } from '@/lib/db'

export const getUser = cache(async (id: string) => {
  return db.query.users.findFirst({
    where: eq(users.id, id),
  })
})

React.cache로 감쌌으니, 한 요청 안에서 같은 id로 호출하면 같은 메모이제이션 결과를 돌려받아요. 참고로 React.cache현재 요청으로 한정돼요. 요청마다 각자의 메모이제이션 스코프를 갖고, 요청 간 공유는 없어요.

데이터 프리로딩 (Preloading)

컴포넌트가 다른 블로킹 작업 뒤에 렌더링되면, 요청 입력값이 이미 준비돼 있어도 데이터 요청이 늦게 시작돼요. 프리로딩은 요청을 더 일찍 시작해서 그 작업과 병렬로 실행하고, 요청 폭포(waterfall)를 피하게 해 줘요.

데이터를 프리로드하려면 블로킹 작업 전에 데이터 페칭 함수를 await 없이 호출하고, 결과를 소비하는 컴포넌트에서 같은 함수를 다시 호출하면 돼요. 이때 프리로딩 중 시작한 요청을 컴포넌트가 재사용할 수 있도록, 데이터 페칭 함수는 일치하는 호출을 디듀플리케이션(deduplicate)해야 해요.

  • fetch동일한 요청이 자동으로 메모이제이션돼요.
  • ORM·DB는 데이터 페칭 함수를 React.cache로 감싸요.
  • Cache Components에서는 데이터 페칭 함수에 'use cache'를 추가해요. 함수가 cookies()·headers() 같은 요청 API를 읽으면 'use cache: private'를 써요.

프리로드 함수는 데이터를 소비하는 컴포넌트 옆에 두는 게 좋아요. 그래야 컴포넌트를 옮기거나 지울 때 의존성을 찾기 쉬워요.

async function getItem(id: string) {
  const res = await fetch(`https://api.example.com/items/${id}`)
  return res.json()
}

export const preload = (id: string) => {
  void getItem(id)
}

export default async function Item({ id }: { id: string }) {
  const item = await getItem(id)
  return <div>{item.name}</div>
}

다른 블로킹 요청 앞에서 preload()를 호출하면 항목 로딩을 더 일찍 시작할 수 있어요.

import Item, { preload } from './item'
import { checkIsAvailable } from '@/app/lib/data'

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  preload(id)
  const isAvailable = await checkIsAvailable(id)

  return isAvailable ? <Item id={id} /> : null
}

항목 요청은 checkIsAvailable()이 실행되는 동안 계속 진행돼요. 페이지가 <Item>을 렌더링하면, 동일한 fetch 호출이 preload()가 시작한 요청을 재사용해요.

더 알아보기 (Learn more)

  • fetch — 확장된 fetch 함수 API 레퍼런스
  • loading.js — loading 파일 API 레퍼런스
  • Data Security — Next.js 내장 데이터 보안 기능과 앱 데이터 보호 모범 사례
  • Client-side data fetching — 서버 컴포넌트 초기 데이터 제공, 라이브러리 캐시와 Next.js 캐시 조율