키모스토리

#34. Data Fetching Pattern 본문

Web Devlopment/NextJs

#34. Data Fetching Pattern

키모형 2025. 12. 31. 11:51
반응형

Data Fetching Pattern 정리

Sequential Data Fetching vs Parallel Data Fetching (Server Component 기준)

Next.js(App Router)의 Server Component 환경에서는 fetch()를 서버에서 실행할 수 있고, 그 결과를 JSX로 바로 렌더링할 수 있습니다.
이때 “여러 데이터를 가져오는 방식”은 크게 두 가지 패턴으로 설명할 수 있습니다.

  • Sequential Data Fetching(순차 요청): A를 받은 후 B를 요청
  • Parallel Data Fetching(병렬 요청): A/B/C를 동시에 요청 후 결과를 모아서 렌더링

아래는 jsonplaceholder API를 활용한 실습 예제를 기준으로 각각을 설명합니다.


1) Sequential Data Fetching (순차 데이터 페칭)

✅ 목적

**“블로그 게시물 목록을 먼저 가져오고, 각 게시물의 작성자 정보를 이후에 가져오는 구조”**를 구현합니다.
즉, Posts → Author 흐름이 “순차적으로” 이어집니다.

✅ 예제 구조

  • posts-sequential/page.tsx
    • posts 목록을 먼저 fetch → 목록 렌더링
    • 각 post 카드 내부에서 <AuthorInfo userId={post.userId} /> 호출
    • <Suspense>로 작성자 영역만 로딩 처리
  • posts-sequential/author.tsx
    • userId로 /users/{id} 호출 → 작성자 이름 출력

✅ 예제 소스

//폴더구조
/app/
  /posts-sequential
    page.tsx
    author.tsx

 

/app/posts-sequential/page.tsx

import { resolve } from "path";
import AuthorInfo from "./author";
import { Suspense } from "react";
type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

export default async function PostsSequential() {
  // await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const posts: Post[] = await response.json();
  const filteredPosts = posts.filter((post) => post.id % 10 === 1);
  // console.log(filteredPosts);
  return (
    <div className="p-4 max-w-7xl mx-auto">
      <h1 className="tet-3xl font-extrabold mb-8">Blog Posts</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        {filteredPosts.map((post) => (
          <div key={post.id} className="bg-white shadow-md rounded-lg p-6">
            <h2 className="text-2xl font-bold mb-3 text-gray-800 leading-tight">
              {post.title}
            </h2>
            <p className="text-gray-600 mb-4 leading-relaxed">{post.body}</p>
            <p className="text-gray-600">
              <Suspense
                fallback={
                  <div className="text-sm text-gray-500">Loading author...</div>
                }
              >
                <AuthorInfo userId={post.userId}></AuthorInfo>
              </Suspense>
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

 

/app/posts-sequential/author.tsx

type Author = {
  id: number;
  name: string;
  username: string;
  email: string;
  phone: string;
};

export default async function AuthorInfo({ userId }: { userId: number }) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
  const user: Author = await response.json();
  console.log(user);
  return (
    <div className="text-sm text-gray-500">
      Written by:{" "}
      <span className="font-semibold text-gray-700 hover:text-gray-900 transition-colors">
        {user.name}
      </span>
    </div>
  );
}

✅ 코드 흐름(실행 순서)

  1. page.tsx에서 /posts를 먼저 호출하여 posts 목록을 가져옵니다.
  2. 화면에 posts 카드들이 렌더링됩니다.
  3. 각 카드 내부에서 AuthorInfo가 실행되며 /users/{userId}를 호출합니다.
  4. 작성자 정보가 도착하면 해당 카드의 “Written by” 영역이 채워집니다.

✅ 이 패턴의 핵심 포인트

1) “렌더링을 먼저 하고, 필요한 부분만 추가로 채운다”

Suspense를 사용했기 때문에, 게시글 본문은 빠르게 보여주고 작성자 영역만 “Loading author…”로 대체되었다가 나중에 채워집니다.

2) 네트워크 요청이 많아질 수 있다 (N+1 문제)

게시글이 10개라면:

  • posts 1번 + users 10번 → 총 11번 요청
    이 구조가 커지면 성능/요청 수가 부담이 될 수 있습니다.

✅ 언제 유리한가?

  • 페이지 전체를 기다리기보다 “일단 콘텐츠를 보여주고” 부분적으로 채우고 싶을 때
  • 각 아이템의 부가 정보(작성자, 댓글, 통계 등)가 독립적으로 로딩되어도 UX가 자연스러울 때
  • 스트리밍/서스펜스 기반 UI를 활용하고 싶을 때

2) Parallel Data Fetching (병렬 데이터 페칭)

✅ 목적

**“사용자 프로필 페이지에서 유저 정보 + 글 목록 + 앨범 목록을 동시에 가져와서 렌더링”**합니다.

주어진 예제는 아래 3개를 병렬로 수행합니다.

  • /users/{id} → 사용자 정보
  • /posts?userId={id} → 사용자 글 목록
  • /albums?userId={id} → 사용자 앨범 목록

✅ 예제 구조

  • user-parallel/[id]/page.tsx
    • getUserInfo(id)
    • getUserPosts(id)
    • getUserAlbums(id)
    • 위 3개를 Promise.all()로 묶어서 한 번에 처리

✅ 예제 소스

//폴더구조
/app/user-parallel/
  /[id]
  	page.tsx
    loading.tsx

 

page.tsx

type User = {
  id: number;
  name: string;
  username: string;
  email: string;
  phone: string;
};

type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

type Album = {
  userId: number;
  id: number;
  title: string;
};

// 유저정보 조회용 fetch
async function getUserInfo(id: string): Promise<User> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  if (!res.ok) throw new Error("Failed to fetch user");
  return (await res.json()) as User;
}

// 유저 POST 조회용 fetch
async function getUserPosts(userId: string) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
  );
  return res.json();
}

// 유저 ALBUM 조회용 fetch
async function getUserAlbums(userId: string) {
  await new Promise((resolve) => setTimeout(resolve, 1000));
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/albums?userId=${userId}`
  );
  return res.json();
}

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

  // 병렬조회요청 (Parallel Data Fetching)
  const [user, posts, albums] = await Promise.all([
    getUserInfo(id),
    getUserPosts(id),
    getUserAlbums(id),
  ]);

  return (
    <div className="p-4 max-w-7xl mx-auto">
      <h1 className="text-3xl font-extrabold mb-8">
        User Profile - {user.name}
      </h1>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div>
          <h2 className="text-2xl font-bold mb-4">Posts</h2>
          <div className="space-y-4">
            {posts.map((post: Post) => (
              <div key={post.id} className="bg-white shadow-md rounded-lg p-6">
                <h2 className="text-2xl font-bold mb-3 text-gray-800 leading-tight">
                  {post.title}
                </h2>
                <p className="text-gray-600 mb-4 leading-relaxed">
                  {post.body}
                </p>
              </div>
            ))}
          </div>
        </div>
        <div>
          <h2 className="text-2xl font-bold mb-4">Album</h2>
          <div className="space-y-4">
            {albums.map((album: Album) => (
              <div key={album.id} className="bg-white shadow-md rounded-lg p-6">
                <p className="text-gray-700">{album.title}</p>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

✅ 코드 흐름(실행 순서)

  1. page.tsx 진입 시점에 3개의 fetch 요청을 “동시에” 시작합니다.
  2. 가장 느린 요청이 끝날 때까지 기다립니다. (Promise.all 특성)
  3. user/posts/albums가 모두 준비되면 한 번에 렌더링합니다.

✅ 이 패턴의 핵심 포인트

1) 총 대기시간이 “합”이 아니라 “최대값”에 가까워진다

예를 들어,

  • user: 200ms
  • posts: 1000ms
  • albums: 1000ms
    이면 병렬에서는 최종 대기 시간이 대략 1000ms 수준이 됩니다.

반대로 순차라면
200 + 1000 + 1000 = 2200ms처럼 “합산”될 가능성이 큽니다.

2) 페이지 완성도를 한 번에 보장한다

병렬 페칭은 보통 페이지 전체의 데이터가 다 모인 뒤 렌더링되므로,
“프로필/글/앨범이 동시에 갖춰진 완성 화면”을 만들기 좋습니다.

✅ 언제 유리한가?

  • 페이지 렌더링에 필요한 데이터들이 서로 의존성이 없을 때
  • “유저 정보 + 글 + 앨범”처럼 각각 독립 API이고, 한 번에 모아서 화면을 구성할 때
  • 서버에서 데이터 수집을 최적화하여 TTFB/전체 로딩 시간을 줄이고 싶을 때

3) 두 패턴 비교 요약

✅ 개념 차이

  • Sequential: A를 받아야 B를 할 수 있거나, 일부 영역만 늦게 채우고 싶을 때
  • Parallel: 서로 독립이면 동시에 요청해서 전체 시간을 줄일 때

✅ UX 관점

  • Sequential + Suspense: “컨텐츠 먼저 → 부가정보 나중” 스트리밍 UX에 강함
  • Parallel: “한 번에 완성된 화면”을 안정적으로 보여주기 좋음

✅ 성능 관점(요청 수/대기시간)

  • Sequential은 리스트에서 자주 N+1 문제가 발생할 수 있습니다.
  • Parallel은 대기 시간이 최대 지연 요청 기준으로 수렴합니다.

4) 실무 팁 (예제에 바로 적용 가능한 포인트)

✅ (1) 리스트 + 작성자 정보는 “병렬 + 배치”도 고려

Sequential 예제는 학습용으로 좋지만, posts 수가 많아지면 작성자 요청이 많아집니다.
실무에서는 다음을 고려합니다.

  • 작성자 정보를 posts와 함께 내려주는 API(서버에서 조인/합치기)
  • userId들을 모아 /users를 한 번 가져온 뒤 매핑해서 쓰기
  • 또는 Next.js Route Handler로 내부 API를 만들어 서버에서 합쳐 반환하기

✅ (2) Promise.all()은 “하나라도 실패하면 전부 실패”

Parallel에서 Promise.all()은 한 요청이 실패하면 전체가 reject 됩니다.
실무에서는 “부분 실패 허용”이 필요하면 Promise.allSettled()를 고려합니다.

 

결론

  • Sequential Data Fetching은 “부분 스트리밍 렌더링”과 “의존성 있는 호출”에 강하고, Suspense와 함께 쓰면 UX가 좋아집니다.
  • Parallel Data Fetching은 “독립 데이터 동시 요청”으로 전체 대기 시간을 줄이고, 완성된 화면을 빠르게 제공하는 데 유리합니다.
반응형