Frontend/Next.js

[Next.js/App Router] 9. 스트리밍과 에러 핸들링

ayeongjin 2025. 2. 10. 00:18

스트리밍이란?

데이터가 너무 커서 빠르게 전송하기 어려울 때, 데이터를 작은 단위로 나누어 전송하는 방식

 

1) Next.js에서 제공하는 HTML 스트리밍

  • 사용자가 페이지에 접속하면 먼저 렌더링이 빠른 단순한 컴포넌트를 표시
  • 데이터 페칭 등 렌더링이 오래 걸릴 것으로 예상되는 컴포넌트는 대체 UI를 표시했다가 나중에 렌더링
  • 장점:
    • 사용자가 빠르게 콘텐츠를 볼 수 있도록 UX 개선
    • 로딩 바 등 대체 UI를 보여줄 수 있어 더 좋은 환경에서 대기 가능

✅ Next.js의 스트리밍은 Dynamic Page에서 주로 사용된다.

 


 

스트리밍 적용하기

1) 페이지 - 자동 스트리밍

  • loading.tsx 파일을 생성하면 자동으로 스트리밍 적용됨
// src/app/(with-searchbar)/search/loading.tsx

export default function Loading() {
  return <div>Loading ...</div>;
}
// src/app/(with-searchbar)/search/page.tsx

import BookItem from "@/components/book-item";
import { BookData } from "@/types";
import { delay } from "@/util/delay";

export default async function Page({ searchParams }: { searchParams: { q?: string; }; }) {
  await delay(1500);
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${searchParams.q}`,
    { cache: "force-cache" }
  );
  const books: BookData[] = await response.json();
  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

 

주의할 점

  • search 폴더 내에 새로운 하위 폴더 (예: settings/)를 추가하면 자동으로 loading.tsx 적용됨
  • loading.tsx는 async가 적용된 비동기 컴포넌트에서만 적용 가능
  • loading.tsx는 반드시 page.tsx에만 적용 가능 (다른 컴포넌트에는 Suspense 사용 필요)
  • 쿼리스트링이 변경될 때는 적용되지 않음 → 해결 방법: Suspense 사용

 

2) 컴포넌트 - Suspense 적용

  • Suspense 컴포넌트로 감싸면 자동으로 스트리밍 적용된다.
import BookItem from "@/components/book-item";
import { BookData } from "@/types";
import { delay } from "@/util/delay";
import { Suspense } from "react";

// 비동기작업 컴포넌트 분리
async function SearchResult({ q }: { q: string }) {
  await delay(1500);
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
    { cache: "force-cache" }
  );
  const books: BookData[] = await response.json();
  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

export default function Page({ searchParams }: { searchParams: { q?: string; }; }) {
  return (
    <Suspense
	    key={searchParams.q || ""}
	    fallback={<div>Loading ...</div>}
	   >
      <SearchResult q={searchParams.q || ""} />
    </Suspense>
  );
}

  • 비동기 작업을 하는 모든 컴포넌트를 분리하여 Suspense로 감싸면 스트리밍 적용됨
  • fallback 속성을 사용해 대체 UI 제공 가능
  • key 값을 설정하면 매번 새로운 검색어로 변경될 때 새로운 요청을 보냄

 


 

스켈레톤 UI 적용하기

대략적인 UI 구조를 미리 보여주고 데이터를 불러오는 동안 사용자 경험을 개선하는 기법

// src/components/skeleton/book-item-skeleton.tsx

import style from "./book-item-skeleton.module.css";

export default function BookItemSkeleton() {
  return (
    <div className={style.container}>
      <div className={style.cover_img}></div>
      <div className={style.info_container}>
        <div className={style.title}></div>
        <div className={style.subtitle}></div>
        <div className={style.author}></div>
      </div>
    </div>
  );
}
// src/components/skeleton/book-item-skeleton.tsx

import BookItemSkeleton from "./book-item-skeleton";

export default function BookListSkeleton({
  count,
}: {
  count: number;
}) {
	// count개만큼 배열 생성, 
  return new Array(count)
    .fill(0)
    .map((_, idx) => (
      <BookItemSkeleton key={`book-item-skeleton-${idx}`} />
    ));
}
// 적용
        <Suspense fallback={<BookListSkeleton count={10} />}>
          <AllBooks />
        </Suspense>
// src/components/skeleton/book-item-skeleton.module.css

.container {
  display: flex;
  gap: 15px;
  padding: 20px 10px;
  border-bottom: 1px solid rgb(220, 220, 220);
}

.cover_img {
  width: 80px;
  height: 105px;
  background-color: rgb(230, 230, 230);
}

.info_container {
  flex: 1;
}

.title,
.subtitle,
.author {
  width: 100%;
  height: 20px;
  background-color: rgb(230, 230, 230);
}
  • 로딩 중에도 사용자가 UI 구조를 인지할 수 있도록 Skeleton UI 적용 가능

 

✅ react-loading-skeleton 라이브러리도 사용하면 자동으로 스켈레톤 UI 생성 가능

 


 

에러 핸들링 (Error Handling)

Next.js에서는 에러가 발생했을 때 전용 컴포넌트를 활용해 대체 UI 제공 가능

// error.tsx

// 서버 또는 클라이언트 중 어떤 컴포넌트에서 발생하는 오류든 다 적용할 수 있도록 클라이언트 컴포넌트로 설정
"use client";

import { useRouter } from "next/navigation";
import { startTransition, useEffect } from "react";

export default function Error({
  error, // 현재 발생한 에러의 원인 또는 메세지 -> 전달된 props에서 사용 가능
         // 자바스크립트 에러 타입
  reset, // 에러가 발생하는 페이지를 복구하기 위한 함수, void 타입
}: {
  error: Error;
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    console.error(error.message);
  }, [error]);

  return (
    <div>
      <h3>오류가 발생했습니다</h3>
      <button
        onClick={() => {
	        // 함수 하나를 인수로받아서, 해당 함수 내부의 코드를 동기적으로 실행
          startTransition(() => {
            router.refresh(); // 현재 페이지에 필요한 서버컴포넌트들을 다시 불러옴
            reset();          // 에러 상태를 초기화, 컴포넌트들을 다시 렌더링
          });
        }}
      >
        다시 시도
      </button>
    </div>
  );
}
  • error.tsx 파일을 특정 폴더에 두면 해당 경로의 모든 하위 페이지에서 적용 가능
  • reset() 함수를 사용하여 에러 상태를 초기화하고 다시 요청 가능
  • Next.js의 자동 에러 핸들링을 활용하여 UX 개선 가능

 


 

🎯 배운점

Next.js의 스트리밍 기능을 활용하면 더 빠르고 유연한 사용자 경험 제공 가능

페이지 로딩을 최적화하고, Suspense 및 loading.tsx를 사용하여 성능 개선 가능

스켈레톤 UI, 에러 핸들링 등을 적용하여 더 나은 UX 제공 가능