문서
App Router 사용하기
데이터 가져오기와 캐싱

데이터 가져오기와 캐싱

예시

이 가이드는 Next.js에서 데이터 가져오기와 캐싱의 기본 사항을 안내하며, 실용적인 예시와 모범 사례를 제공합니다.

다음은 Next.js에서 데이터 가져오기의 최소한의 예시입니다:

app/page.tsx
export default async function Page() {
  let data = await fetch("https://api.vercel.app/blog");
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
app/page.js
export default async function Page() {
  let data = await fetch("https://api.vercel.app/blog");
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 예시는 비동기 React 서버 컴포넌트에서 fetch API를 사용한 기본적인 서버 측 데이터 가져오기를 보여줍니다.

참조

예시

fetch API를 사용하여 서버에서 데이터 가져오기

이 컴포넌트는 블로그 게시물 목록을 가져와 표시합니다. fetch에서의 응답은 자동으로 캐시됩니다.

app/page.tsx
export default async function Page() {
  let data = await fetch("https://api.vercel.app/blog");
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
app/page.js
export default async function Page() {
  let data = await fetch("https://api.vercel.app/blog");
  let posts = await data.json();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

애플리케이션의 다른 곳에서 동적 함수를 사용하지 않는 경우, 이 페이지는 next build 동안 정적 페이지로 사전 렌더링됩니다. 그런 다음 점진적 정적 재생성을 사용하여 데이터를 업데이트할 수 있습니다.

fetch의 응답을 캐시하지 않으려면 다음과 같이 할 수 있습니다:

let data = await fetch("https://api.vercel.app/blog", { cache: "no-store" });

ORM 또는 데이터베이스를 사용하여 서버에서 데이터 가져오기

이 컴포넌트는 항상 동적이고 최신 블로그 게시물 목록을 가져와 표시합니다.

app/page.tsx
import { db, posts } from "@/lib/db";
 
export default async function Page() {
  let allPosts = await db.select().from(posts);
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
app/page.js
import { db, posts } from "@/lib/db";
 
export default async function Page() {
  let allPosts = await db.select().from(posts);
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

데이터베이스 호출은 캐시되지 않습니다. 이 예시는 Next.js 애플리케이션을 서버 사이드 렌더링으로 전환합니다. 응답을 캐시하고 페이지가 사전 렌더링되도록 하려면 이 예시를 참조하세요.

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

먼저 서버 측에서 데이터를 가져오는 것을 권장합니다.

그러나 클라이언트 측 데이터 가져오기가 합리적인 경우도 있습니다. 이러한 시나리오에서는 useEffect에서 수동으로 fetch를 호출하거나(권장하지 않음), 커뮤니티의 인기 있는 React 라이브러리(예: SWR 또는 React Query)를 활용하여 클라이언트 가져오기를 할 수 있습니다.

app/page.tsx
"use client";
 
import { useState, useEffect } from "react";
 
export function Posts() {
  const [posts, setPosts] = useState(null);
 
  useEffect(() => {
    async function fetchPosts() {
      let res = await fetch("https://api.vercel.app/blog");
      let data = await res.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);
 
  if (!posts) return <div>로딩 중...</div>;
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
app/page.js
"use client";
 
import { useState, useEffect } from "react";
 
export function Posts() {
  const [posts, setPosts] = useState(null);
 
  useEffect(() => {
    async function fetchPosts() {
      let res = await fetch("https://api.vercel.app/blog");
      let data = await res.json();
      setPosts(data);
    }
    fetchPosts();
  }, []);
 
  if (!posts) return <div>로딩 중...</div>;
 
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

ORM 또는 데이터베이스를 사용한 데이터 캐싱

unstable_cache API를 사용하여 응답을 캐시하여 next build를 실행할 때 페이지가 사전 렌더링되도록 할 수 있습니다.

app/page.tsx
import { unstable_cache } from "next/cache";
import { db, posts } from "@/lib/db";
 
const getPosts = unstable_cache(
  async () => {
    return await db.select().from(posts);
  },
  ["posts"],
  { revalidate: 3600, tags: ["posts"] }
);
 
export default async function Page() {
  const allPosts = await getPosts();
 
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
app/page.js
import { unstable_cache } from "next/cache";
import { db, posts } from "@/lib/db";
 
const getPosts = unstable_cache(
  async () => {
    return await db.select().from(posts);
  },
  ["posts"],
  { revalidate: 3600, tags: ["posts"] }
);
 
export default async function Page() {
  const allPosts = await getPosts();
 
  return (
    <ul>
      {allPosts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 예시는 데이터베이스 쿼리 결과를 1시간(3600초) 동안 캐시합니다. 또한 posts 캐시 태그를 추가하여 점진적 정적 재생성으로 무효화할 수 있습니다.

여러 함수에서 데이터 재사용

Next.js는 generateMetadatagenerateStaticParams와 같은 API를 사용하여 page에서 가져온 동일한 데이터를 사용해야 할 수 있습니다.

fetch를 사용하는 경우, 요청은 자동으로 메모이제이션됩니다. 이는 동일한 URL과 옵션으로 안전하게 여러 번 호출할 수 있고 하나의 요청만 이루어진다는 것을 의미합니다.

app/page.tsx
import { notFound } from "next/navigation";
 
interface Post {
  id: string;
  title: string;
  content: string;
}
 
async function getPost(id: string) {
  let res = await fetch(`https://api.example.com/posts/${id}`);
  let post: Post = await res.json();
  if (!post) notFound();
  return post;
}
 
export async function generateStaticParams() {
  let posts = await fetch("https://api.example.com/posts").then((res) =>
    res.json()
  );
 
  return posts.map((post: Post) => ({
    id: post.id,
  }));
}
 
export async function generateMetadata({ params }: { params: { id: string } }) {
  let post = await getPost(params.id);
 
  return {
    title: post.title,
  };
}
 
export default async function Page({ params }: { params: { id: string } }) {
  let post = await getPost(params.id);
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}
app/page.js
import { notFound } from "next/navigation";
 
async function getPost(id) {
  let res = await fetch(`https://api.example.com/posts/${id}`);
  let post = await res.json();
  if (!post) notFound();
  return post;
}
 
export async function generateStaticParams() {
  let posts = await fetch("https://api.example.com/posts").then((res) =>
    res.json()
  );
 
  return posts.map((post) => ({
    id: post.id,
  }));
}
 
export async function generateMetadata({ params }) {
  let post = await getPost(params.id);
 
  return {
    title: post.title,
  };
}
 
export default async function Page({ params }) {
  let post = await getPost(params.id);
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

fetch를 사용하지 않고 ORM이나 데이터베이스를 직접 사용하는 경우, React cache 함수로 데이터 가져오기를 래핑할 수 있습니다. 이렇게 하면 중복을 제거하고 하나의 쿼리만 수행됩니다.

import { cache } from "react";
import { db, posts, eq } from "@/lib/db"; // Drizzle ORM 예시
import { notFound } from "next/navigation";
 
export const getPost = cache(async (id) => {
  const post = await db.query.posts.findFirst({
    where: eq(posts.id, parseInt(id)),
  });
 
  if (!post) notFound();
  return post;
});

캐시된 데이터 재검증

점진적 정적 재생성을 통해 캐시된 데이터를 재검증하는 방법에 대해 자세히 알아보세요.

패턴

병렬 및 순차적 데이터 가져오기

컴포넌트 내에서 데이터를 가져올 때는 두 가지 데이터 가져오기 패턴을 알아야 합니다: 패턴에는 '병렬(Parallel)'과 '순차적(Sequential)'이 있습니다.

순차적 및 병렬 데이터 가져오기
  • 순차적: 컴포넌트 트리의 요청이 서로 의존적입니다. 이는 더 긴 로딩 시간으로 이어질 수 있습니다.
  • 병렬: 라우트의 요청이 즉시 시작되어 동시에 데이터를 로드합니다. 이는 데이터를 로드하는 데 걸리는 총 시간을 줄입니다.

순차적 데이터 가져오기

중첩된 컴포넌트가 있고 각 컴포넌트가 자체 데이터를 가져오는 경우, 해당 데이터 요청이 메모이제이션되지 않으면 데이터 가져오기가 순차적으로 발생합니다.

한 가져오기가 다른 가져오기의 결과에 의존하는 경우 이 패턴이 필요할 수 있습니다. 예를 들어, Playlists 컴포넌트는 Artist 컴포넌트가 데이터 가져오기를 완료한 후에만 데이터 가져오기를 시작합니다. 왜냐하면 PlaylistsartistID prop에 의존하기 때문입니다:

app/artist/[username]/page.tsx
export default async function Page({
  params: { username },
}: {
  params: { username: string };
}) {
  // 아티스트 정보 가져오기
  const artist = await getArtist(username);
 
  return (
    <>
      <h1>{artist.name}</h1>
      {/* Playlists 컴포넌트가 로딩되는 동안 폴백 UI 표시 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        {/* 아티스트 ID를 Playlists 컴포넌트에 전달 */}
        <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>
  );
}
app/artist/[username]/page.js
export default async function Page({ params: { username } }) {
  // 아티스트 정보 가져오기
  const artist = await getArtist(username);
 
  return (
    <>
      <h1>{artist.name}</h1>
      {/* Playlists 컴포넌트가 로딩되는 동안 폴백 UI 표시 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        {/* 아티스트 ID를 Playlists 컴포넌트에 전달 */}
        <Playlists artistID={artist.id} />
      </Suspense>
    </>
  );
}
 
async function Playlists({ artistID }) {
  // 아티스트 ID를 사용하여 플레이리스트 가져오기
  const playlists = await getArtistPlaylists(artistID);
 
  return (
    <ul>
      {playlists.map((playlist) => (
        <li key={playlist.id}>{playlist.name}</li>
      ))}
    </ul>
  );
}

loading.js (라우트 세그먼트용) 또는 React <Suspense> (중첩된 컴포넌트용)를 사용하여 React가 결과를 스트리밍하는 동안 즉각적인 로딩 상태를 표시할 수 있습니다.

이렇게 하면 전체 라우트가 데이터 요청에 의해 차단되는 것을 방지하고, 사용자는 준비된 페이지의 부분과 상호 작용할 수 있게 됩니다.

병렬 데이터 가져오기

기본적으로 레이아웃과 페이지 세그먼트는 병렬로 렌더링됩니다. 이는 요청이 병렬로 시작된다는 것을 의미합니다.

그러나 async/await의 특성상, 동일한 세그먼트나 컴포넌트 내의 대기된 요청은 그 아래의 모든 요청을 차단합니다.

병렬로 데이터를 가져오려면 데이터를 사용하는 컴포넌트 외부에서 요청을 정의하여 즉시 시작할 수 있습니다. 이렇게 하면 두 요청을 병렬로 시작하여 시간을 절약할 수 있지만, 사용자는 두 프로미스가 모두 해결될 때까지 렌더링된 결과를 볼 수 없습니다.

아래 예시에서 getArtistgetAlbums 함수는 Page 컴포넌트 외부에서 정의되고 Promise.all을 사용하여 컴포넌트 내에서 시작됩니다:

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: { username },
}: {
  params: { username: string };
}) {
  const artistData = getArtist(username);
  const albumsData = getAlbums(username);
 
  // 두 요청을 병렬로 시작
  const [artist, albums] = await Promise.all([artistData, albumsData]);
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  );
}
app/artist/[username]/page.js
import Albums from "./albums";
 
async function getArtist(username) {
  const res = await fetch(`https://api.example.com/artist/${username}`);
  return res.json();
}
 
async function getAlbums(username) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`);
  return res.json();
}
 
export default async function Page({ params: { username } }) {
  const artistData = getArtist(username);
  const albumsData = getAlbums(username);
 
  // 두 요청을 병렬로 시작
  const [artist, albums] = await Promise.all([artistData, albumsData]);
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums} />
    </>
  );
}

또한 Suspense 경계를 추가하여 렌더링 작업을 나누고 결과의 일부를 최대한 빨리 표시할 수 있습니다.

데이터 프리로딩

요청이 순차적으로 처리되는 문제(waterfalls)를 방지하는 또 다른 방법은 차단 요청 위에서 즉시 호출하는 프리로드 패턴을 사용하여 유틸리티 함수를 만드는 것입니다. 예를 들어, checkIsAvailable()<Item/>의 렌더링을 차단하므로 그 전에 preload()를 호출하여 <Item/>의 데이터 의존성을 즉시 시작할 수 있습니다. <Item/>이 렌더링될 때쯤이면 이미 데이터가 가져와져 있을 것입니다.

preload 함수는 checkIsAvailable()의 실행을 차단하지 않습니다.

components/Item.tsx
import { getItem } from "@/utils/get-item";
 
export const preload = (id: string) => {
  // void는 주어진 표현식을 평가하고 undefined를 반환합니다
  // https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void
  void getItem(id);
};
export default async function Item({ id }: { id: string }) {
  const result = await getItem(id);
  // ...
}
components/Item.js
import { getItem } from "@/utils/get-item";
 
export const preload = (id) => {
  // void는 주어진 표현식을 평가하고 undefined를 반환합니다
  // https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/void
  void getItem(id);
};
export default async function Item({ id }) {
  const result = await getItem(id);
  // ...
}
app/item/[id]/page.tsx
import Item, { preload, checkIsAvailable } from "@/components/Item";
 
export default async function Page({
  params: { id },
}: {
  params: { id: string };
}) {
  // 아이템 데이터 로딩 시작
  preload(id);
  // 다른 비동기 작업 수행
  const isAvailable = await checkIsAvailable();
 
  return isAvailable ? <Item id={id} /> : null;
}
app/item/[id]/page.js
import Item, { preload, checkIsAvailable } from "@/components/Item";
 
export default async function Page({ params: { id } }) {
  // 아이템 데이터 로딩 시작
  preload(id);
  // 다른 비동기 작업 수행
  const isAvailable = await checkIsAvailable();
 
  return isAvailable ? <Item id={id} /> : null;
}

알아두면 좋은 점: "프리로드" 함수는 API가 아닌 패턴이므로 어떤 이름이든 가질 수 있습니다.

프리로드 패턴에 React cacheserver-only 사용하기

cache 함수, preload 패턴, server-only 패키지를 결합하여 앱 전체에서 사용할 수 있는 데이터 가져오기 유틸리티를 만들 수 있습니다.

utils/get-item.ts
import { cache } from "react";
import "server-only";
 
export const preload = (id: string) => {
  void getItem(id);
};
 
export const getItem = cache(async (id: string) => {
  // ...
});
utils/get-item.js
import { cache } from "react";
import "server-only";
 
export const preload = (id) => {
  void getItem(id);
};
 
export const getItem = cache(async (id) => {
  // ...
});

이 접근 방식을 사용하면 데이터를 미리 가져오고, 응답을 캐시하며, 이 데이터 가져오기가 서버에서만 발생하도록 보장할 수 있습니다.

utils/get-item 내보내기는 레이아웃, 페이지 또는 다른 컴포넌트에서 사용하여 아이템의 데이터가 언제 가져와지는지 제어할 수 있습니다.

알아두면 좋은 점:

  • 서버 데이터 가져오기 함수가 클라이언트에서 절대 사용되지 않도록 하기 위해 server-only 패키지를 사용하는 것을 권장합니다.

중요한 데이터가 클라이언트에 노출되는 것을 방지하기

React의 taint API인 taintObjectReferencetaintUniqueValue를 사용하여 전체 객체 인스턴스나 중요한 값이 클라이언트에 전달되는 것을 방지하는 것을 권장합니다.

애플리케이션에서 tainting을 활성화하려면 Next.js 구성의 experimental.taint 옵션을 true로 설정하세요:

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

그런 다음 taint하려는 객체나 값을 experimental_taintObjectReference 또는 experimental_taintUniqueValue 함수에 전달하세요:

app/utils.ts
import { queryDataFromDB } from "./api";
import {
  experimental_taintObjectReference,
  experimental_taintUniqueValue,
} from "react";
 
export async function getUserData() {
  const data = await queryDataFromDB();
  experimental_taintObjectReference(
    "전체 사용자 객체를 클라이언트에 전달하지 마세요",
    data
  );
  experimental_taintUniqueValue(
    "사용자의 주소를 클라이언트에 전달하지 마세요",
    data,
    data.address
  );
  return data;
}
app/utils.js
import { queryDataFromDB } from "./api";
import {
  experimental_taintObjectReference,
  experimental_taintUniqueValue,
} from "react";
 
export async function getUserData() {
  const data = await queryDataFromDB();
  experimental_taintObjectReference(
    "전체 사용자 객체를 클라이언트에 전달하지 마세요",
    data
  );
  experimental_taintUniqueValue(
    "사용자의 주소를 클라이언트에 전달하지 마세요",
    data,
    data.address
  );
  return data;
}
app/page.tsx
import { getUserData } from "./data";
 
export async function Page() {
  const userData = getUserData();
  return (
    <ClientComponent
      user={userData} // 이는 taintObjectReference로 인해 오류를 발생시킬 것입니다
      address={userData.address} // 이는 taintUniqueValue로 인해 오류를 발생시킬 것입니다
    />
  );
}
app/page.js
import { getUserData } from "./data";
 
export async function Page() {
  const userData = await getUserData();
  return (
    <ClientComponent
      user={userData} // 이는 taintObjectReference로 인해 오류를 발생시킬 것입니다
      address={userData.address} // 이는 taintUniqueValue로 인해 오류를 발생시킬 것입니다
    />
  );
}