Next.js Server Component 데이터 조회와 Streaming

··설명 약 7분·작성: 해비·블로그 소개

Next.js에서 느린 Server Component 때문에 페이지 전체가 기다리지 않게 하려면 어떻게 하나요?

느린 통계 때문에 페이지 전체를 기다리게 할 필요는 없습니다. Server Component에서 데이터를 읽되 느린 부분만 Suspense로 감싸면 먼저 준비된 제목과 안내 UI를 보낼 수 있습니다.

적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

본문 근거: Next.js 서버 조회·병렬 요청·Streaming · Streaming

독립 실습: 느린 부분만 Suspense로 기다리기

React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.

npm run dev 후 /practice를 직접 열고 Network 응답을 확인합니다. force-dynamic은 이 지연 관찰 예제에서 요청마다 실행시키기 위한 설정이며 모든 페이지에 적용하는 해결책이 아닙니다.

app/practice/page.tsx 전체 코드

import { Suspense } from 'react';
export const dynamic = 'force-dynamic';
async function SlowStats() {
  await new Promise(resolve => setTimeout(resolve, 1200));
  return <p>통계: 3개</p>;
}
export default function Page() {
  return (
    <main>
      <h1>먼저 읽는 제목</h1>
      <Suspense fallback={<p>통계 준비 중</p>}>
        <SlowStats />
      </Suspense>
    </main>
  );
}

기대 결과: 먼저 제목과 fallback이 준비되고 1200ms 지연 뒤 통계가 준비됩니다. 브라우저·프록시 버퍼링에 따라 중간 화면이 보이지 않을 수 있습니다.

자가 확인: SlowStats를 Suspense 바깥에서 먼저 await하면 어느 화면까지 기다리게 되는지 설명합니다.

Server Component 데이터 조회

async Server Component는 서버에서 조회 함수를 await한 뒤 결과를 JSX로 렌더링합니다. 서버 코드의 비밀 설정이나 DB 연결을 Client import에 넣지 않고 필요한 데이터만 화면에 사용합니다. 아래 지연 함수 예제는 외부 서버 없이 실행되며 하단 프로젝트 적용은 목록 조회로 연결합니다.

병렬 요청

독립적인 요청은 동시에 시작할 수 있습니다.

두 요청이 서로의 결과를 필요로 하지 않으면 먼저 둘 다 시작하고 Promise.all로 기다립니다. 두 번째 요청에 첫 번째 결과가 필요하면 순차 await가 맞습니다. 하단 프로젝트 적용의 느린 통계 분리는 요청 병렬화와 별개로 Suspense가 기다릴 화면의 범위를 정합니다.

Suspense와 Streaming

느린 영역을 별도 컴포넌트로 나누면 전체 화면이 준비될 때까지 기다리지 않고 먼저 준비된 UI를 보낼 수 있습니다.

import { Suspense } from 'react';

<Suspense fallback={<p>통계 로딩 중...</p>}>
  <TaskStats />
</Suspense>

자주 발생하는 오류

  • 모든 데이터 패칭을 useEffect로 이동
  • 서버에서 직접 읽을 수 있는데 불필요한 내부 API 호출
  • 독립 요청을 순차 await
  • 느린 한 영역 때문에 페이지 전체를 기다리게 함

병렬 요청은 서로 의존하지 않는 작업을 함께 시작해 대기 시간을 줄이는 방법이고, Streaming은 준비된 UI부터 전달하는 방법입니다. Promise.all로 모든 값을 기다리면 요청은 병렬이어도 그 결과를 쓰는 화면은 가장 늦은 값까지 기다릴 수 있습니다. 기다릴 범위를 Suspense로 나누는 판단이 별도로 필요합니다.

원리 확인: Next.js 서버 조회·병렬 요청·Streaming (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.

목록과 느린 통계를 분리합니다. 통계 컴포넌트의 학습용 지연으로 Suspense fallback을 관찰하며 기존 상세 Link와 검색 입력을 유지합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow Next.js 프로젝트 적용

시작 체크포인트: 이전: Next.js hydration failed 오류 해결의 완료 상태에서 이어갑니다.

이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.

이전 완료본과 달라진 파일: app/(app)/tasks/page.tsx, app/ui/task-stats.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.

개념 설명에서 사용한 프로젝트 부분 코드

다음은 조회와 병렬 요청을 설명하는 부분 조각입니다. getStats라는 이름은 비교용이며 완료본의 실제 통계 조회는 getTaskStats()와 TaskStats가 담당합니다. 독립 실행은 상단 한 파일 예제를, 프로젝트 수정은 아래 전체 파일을 사용합니다.

export default async function TasksPage() {
  const tasks = await getTasks();

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task.id}>
          {task.title}
        </li>
      ))}
    </ul>
  );
}
const tasksPromise = getTasks();
const statsPromise = getStats();

const [tasks, stats] = await Promise.all([
  tasksPromise,
  statsPromise,
]);

직접 확인: 목록과 통계의 도착 순서

독자 질문: 통계가 느리면 작업 목록도 기다려야 할까요? 완료 코드의 app/(app)/tasks/page.tsx는 목록을 읽고 TaskStats만 Suspense로 감쌉니다.

  1. app/lib/data.ts의 두 로컬 작업과 getTaskStats()를 확인합니다. app/ui/task-stats.tsx의 학습용 600ms 지연 뒤 예상 최종 화면은 ‘작업 목록’·두 작업·’전체: 2 · 완료: 1’입니다. 외부 API나 DB 결과가 아닙니다.
  2. 개발 서버의 /tasks에서 목록과 ‘통계 로딩 중…’ fallback이 먼저 보이고 이후 통계가 나타나는지 관찰합니다. 렌더가 빠르거나 응답이 버퍼링되면 fallback을 눈으로 보지 못할 수 있으므로 화면과 Network 응답을 구분합니다.
  3. 상단 Promise.all은 독립적인 병렬 요청 예제입니다. TaskFlow 완료 코드에서는 목록을 await한 뒤 별도 Suspense 안에 통계를 둡니다.
  4. npm run typecheck, npm run build, npm run start의 결과와 경로 응답을 각각 기록합니다.

오류 점검

목록 전체가 늦다면 통계를 Suspense 밖에서 먼저 await했는지 확인합니다. 통계가 표시되지 않으면 getTaskStats()의 데이터와 경계 안의 오류를 확인합니다.

검증 상태: 이번 편집에서는 빌드·서버·브라우저·네트워크를 실행하지 않아 스트리밍 순서와 실제 소요 시간은 미확인입니다.

완료 코드 ZIP 다운로드 — next-step-14-server-data

이전 체크포인트: next-step-13-hydration-fix

이번 회차의 변경

목록과 느린 통계를 분리합니다. 통계 컴포넌트의 학습용 지연으로 Suspense fallback을 관찰하며 기존 상세 Link와 검색 입력을 유지합니다.

변경·추가 파일:

  • app/(app)/tasks/page.tsx
  • app/ui/task-stats.tsx
전체 파일·코드 보기

실행과 검증

Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.

npm install
npm run dev
npm run build
npm run start

전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.

완료 체크포인트: next-step-14-server-data

실습 프로젝트 전체 코드

.gitignore

node_modules
.next
.env*
!.env.example
*.tsbuildinfo

app/(app)/@modal/(.)tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <Modal>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
    </Modal>
  );
}

app/(app)/@modal/[…catchAll]/page.tsx

export default function ResetModal() {
  return null;
}

app/(app)/@modal/default.tsx

export default function Default() {
  return null;
}

app/(app)/layout.tsx

import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
  children,
  modal,
}: {
  children: ReactNode;
  modal: ReactNode;
}) {
  return (
    <>
      <SiteHeader />
      {children}
      {modal}
    </>
  );
}

app/(app)/settings/page.tsx

import ThemeSetting from "@/app/ui/theme-setting";
export default function Settings() {
  return (
    <main>
      <h1>설정</h1>
      <ThemeSetting />
    </main>
  );
}

app/(app)/tasks/[id]/page.tsx

import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const task = await getTask(id);
  if (!task) notFound();
  return (
    <main>
      <h1>{task.title}</h1>
      <p>{task.description}</p>
      <p>상태: {task.completed ? "완료" : "진행 중"}</p>
      <Link href="/tasks">목록</Link>
    </main>
  );
}

app/(app)/tasks/error.tsx

"use client";
export default function ErrorPage({
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <main>
      <h1>작업을 불러오지 못했습니다.</h1>
      <button onClick={reset}>다시 시도</button>
    </main>
  );
}

app/(app)/tasks/loading.tsx

export default function Loading() {
  return (
    <main>
      <p>작업을 불러오는 중...</p>
    </main>
  );
}

app/(app)/tasks/not-found.tsx

export default function NotFound() {
  return (
    <main>
      <h1>작업을 찾을 수 없습니다.</h1>
    </main>
  );
}

app/(app)/tasks/page.tsx

import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
import { Suspense } from "react";
import TaskStats from "@/app/ui/task-stats";
export default async function TasksPage() {
  const tasks = await getTasks();
  return (
    <main>
      <h1>작업 목록</h1>
      <TaskSearch />
      <ul>
        {tasks.map((task) => (
          <li key={task.id}>
            <Link href={"/tasks/" + task.id}>{task.title}</Link>
            <span> · {task.completed ? "완료" : "진행 중"}</span>
          </li>
        ))}
      </ul>
      <Suspense fallback={<p>통계 로딩 중...</p>}>
        <TaskStats />
      </Suspense>
    </main>
  );
}

app/(app)/template.tsx

import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
  return <>{children}</>;
}

app/(public)/page.tsx

import Link from "next/link";
export default function HomePage() {
  return (
    <main>
      <h1>TaskFlow Next.js</h1>
      <p>App Router 기반 작업 관리 서비스입니다.</p>
      <Link href="/tasks">작업 목록 보기</Link>
    </main>
  );
}

app/_components/SiteHeader.tsx

import Link from "next/link";
export default function SiteHeader() {
  return (
    <header>
      <nav aria-label="주 메뉴">
        <Link href="/">홈</Link>
        <Link href="/tasks">작업</Link>
        <Link href="/settings">설정</Link>
      </nav>
    </header>
  );
}

app/globals.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  color: #171717;
  background: #fafafa;
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
header,
main {
  width: min(880px, calc(100% - 32px));
  margin: 24px auto;
}
nav {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
}
a {
  color: inherit;
  text-underline-offset: 3px;
}
button,
input,
select {
  min-height: 44px;
  padding: 8px 12px;
  font: inherit;
  border: 1px solid #aaa;
  background: #fff;
  color: #171717;
  max-width: 100%;
}
button {
  cursor: pointer;
}
button:disabled {
  opacity: 0.6;
  cursor: wait;
}
input {
  display: block;
  width: 100%;
}
label {
  display: block;
}
form,
section,
aside {
  margin: 20px 0;
}
ul {
  padding: 0;
  list-style: none;
}
li {
  padding: 12px;
  margin: 12px 0;
  border: 1px solid #ccc;
  overflow-wrap: anywhere;
}
.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}
.controls form {
  margin: 0;
}
.modal {
  width: min(640px, calc(100% - 32px));
  border: 1px solid #777;
  padding: 24px;
  background: #fff;
  color: #171717;
}
.modal::backdrop {
  background: rgb(0 0 0 / 55%);
}
:focus-visible {
  outline: 3px solid #555;
  outline-offset: 3px;
}
[data-theme="dark"] {
  background: #202020;
  color: #fafafa;
  padding: 16px;
}
[data-density="compact"] li {
  padding: 8px;
  margin: 8px 0;
}
img {
  max-width: 100%;
  height: auto;
}

app/layout.tsx

import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
  title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
  description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

app/lib/data.ts

import type { Task } from "./types";
const tasks: Task[] = [
  {
    id: "task-1",
    title: "App Router 공부",
    description: "서버와 브라우저의 역할을 구분합니다.",
    completed: false,
    ownerId: "demo",
  },
  {
    id: "task-2",
    title: "TaskFlow 만들기",
    description: "누적 실습을 완성합니다.",
    completed: true,
    ownerId: "demo",
  },
];
export async function getTasks() {
  return tasks;
}
export async function getTask(id: string) {
  return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
  const ts = await getTasks();
  return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}

app/lib/types.ts

export type Task = {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  ownerId: string;
};

app/ui/modal.tsx

"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
  const ref = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => {
    const dialog = ref.current;
    if (dialog && !dialog.open) dialog.showModal();
    return () => {
      dialog?.close();
    };
  }, []);
  return (
    <dialog
      className="modal"
      ref={ref}
      aria-label="작업 상세"
      onCancel={(event) => {
        event.preventDefault();
        router.back();
      }}
    >
      <button type="button" onClick={() => router.back()}>
        닫기
      </button>
      {children}
    </dialog>
  );
}

app/ui/task-search.tsx

"use client";
import { useState } from "react";
export default function TaskSearch() {
  const [keyword, setKeyword] = useState("");
  return (
    <label>
      작업 검색
      <input
        value={keyword}
        onChange={(event) => setKeyword(event.target.value)}
        placeholder="검색어 입력"
      />
    </label>
  );
}

app/ui/task-stats.tsx

import { getTaskStats } from "@/app/lib/data";
export default async function TaskStats() {
  await new Promise((resolve) => setTimeout(resolve, 600));
  const s = await getTaskStats();
  return (
    <aside aria-label="작업 통계">
      <p>
        전체: {s.total} · 완료: {s.completed}
      </p>
    </aside>
  );
}

app/ui/theme-setting.tsx

"use client";
import { useState, useEffect } from "react";
export default function ThemeSetting() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  useEffect(() => {
    try {
      const saved = localStorage.getItem("taskflow-theme");
      if (saved === "light" || saved === "dark") setTheme(saved);
    } catch {}
  }, []);
  function change(value: "light" | "dark") {
    setTheme(value);
    try {
      localStorage.setItem("taskflow-theme", value);
    } catch {}
  }
  return (
    <section data-theme={theme}>
      <p>현재 테마: {theme}</p>
      <div className="controls">
        <button onClick={() => change("light")}>밝게</button>
        <button onClick={() => change("dark")}>어둡게</button>
      </div>
    </section>
  );
}

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";


// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

next.config.ts

import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;

package.json

{
  "name": "taskflow-next",
  "version": "1.0.14",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "next": "16.3.5",
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "typescript": "5.9.3",
    "@types/node": "24.13.6",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": [
        "./*"
      ]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts"
  ],
  "exclude": [
    "node_modules"
  ]
}

글 소개·학습 준비·전체 목차

Next.js Server Component 데이터 조회와 Streaming 학습 표지

Next.js · 14/38 · TaskFlow 누적 실습

Next.js Server Component 데이터 조회와 Streaming

Next.js · 14 / 38 · 필수 학습 · Server Data·Streaming

통계가 느리면 페이지 전체가 기다려야 할까요? Server Component가 서버에서 데이터를 읽고, 느린 부분을 Suspense 안에 분리하면 먼저 준비된 UI를 보낼 수 있습니다. 한 파일의 지연 예제로 제목·fallback과 통계의 준비 순서를 구분하고, 병렬 요청과 Streaming의 차이를 설명합니다.

이번 글에서 배우는 내용

  • async Server Component
  • 서버 데이터 조회
  • 비밀정보를 브라우저로 보내지 않는 구조
  • 순차 요청과 병렬 요청
  • Suspense
  • Streaming

목차

다음 학습

다음 글에서는 백엔드 API가 아직 없다는 실무 상황을 가정하고 MSW 2로 Mock API를 구성합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 39개 · 필수 38개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기