본문 바로가기
기술, 개발/nextjs

8차 강의 노트 (스트리밍)

by Jaejin Sim 2025. 8. 28.
반응형

Next.js 스트리밍, 스켈레톤, 에러핸들링 완벽 가이드

1. 스트리밍 (Streaming)

개념

  • 서버에서 클라이언트로 데이터를 여러 조각으로 나누어 전송하는 기술
  • 큰 데이터나 시간이 오래 걸리는 렌더링을 처리할 때 사용
  • 사용자에게 긴 로딩 없이 점진적으로 콘텐츠를 제공

언제 사용되는가?

  • Dynamic Pages에서 주로 활용
  • 무거운 데이터 페칭이나 복잡한 연산이 필요한 컴포넌트
  • 사용자 경험 개선이 필요한 페이지

동작 원리

일반적인 렌더링: [로딩...] → [전체 페이지 완성]
스트리밍 렌더링: [기본 UI] → [일부 로딩] → [점진적 완성]

2. 페이지 레벨 스트리밍

loading.tsx 파일 사용

// app/search/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center p-8">
      <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
      <span className="ml-2">검색 중...</span>
    </div>
  );
}

loading.tsx 적용 규칙

  1. 적용 범위: 동일 폴더 + 모든 하위 폴더의 page.tsx
  2. 레이아웃과 유사한 동작: 상위에서 하위로 상속됨
  3. 비동기 컴포넌트만 해당: async 함수 컴포넌트에서만 작동
  4. page.tsx 전용: 레이아웃이나 일반 컴포넌트에는 적용 안됨

loading.tsx 제한사항

❌ 적용되지 않는 경우

  • 쿼리스트링만 변경: /search?q=react → /search?q=nextjs
  • 레이아웃 컴포넌트: layout.tsx에서는 작동 안함
  • 일반 컴포넌트: 별도 컴포넌트에서는 Suspense 필요

✅ 해결책: Suspense 사용

3. 컴포넌트 레벨 스트리밍 (Suspense)

기본 구현

import { Suspense } from 'react';

// 무거운 비동기 컴포넌트
async function SearchResult({ q }: { q: string }) {
  // 의도적인 지연 (실제로는 DB 쿼리, API 호출 등)
  await new Promise(resolve => setTimeout(resolve, 1500));

  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_SERVER_URL}/book/search?q=${q}`,
    { cache: "force-cache" }
  );

  if (!response.ok) {
    throw new Error('검색 중 오류가 발생했습니다.');
  }

  const books: BookData[] = await response.json();

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

// 페이지 컴포넌트
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;

  return (
    <div>
      <h1>검색 결과</h1>

      {/* key prop으로 쿼리 변경 시 리렌더링 보장 */}
      <Suspense
        key={q}
        fallback={
          <div className="text-center p-8">
            <div className="animate-pulse">검색 중...</div>
          </div>
        }
      >
        <SearchResult q={q || ""} />
      </Suspense>
    </div>
  );
}

Suspense key prop의 중요성

// ✅ key를 사용하면 쿼리 변경 시마다 새로 로딩 UI 표시
<Suspense key={q} fallback={<LoadingUI />}>
  <SearchResult q={q} />
</Suspense>

// ❌ key가 없으면 쿼리 변경 시 로딩 UI가 표시되지 않음
<Suspense fallback={<LoadingUI />}>
  <SearchResult q={q} />
</Suspense>

중첩된 Suspense 활용

export default function ComplexPage() {
  return (
    <div>
      {/* 빠른 컨텐츠는 즉시 표시 */}
      <Header />
      <Navigation />

      {/* 각각 다른 로딩 시간 */}
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<UserInfoSkeleton />}>
          <UserInfo />
        </Suspense>

        <Suspense fallback={<RecentPostsSkeleton />}>
          <RecentPosts />
        </Suspense>
      </div>

      <Suspense fallback={<DataTableSkeleton />}>
        <HeavyDataTable />
      </Suspense>
    </div>
  );
}

4. 스켈레톤 UI

개념

  • 실제 콘텐츠 로딩 전에 레이아웃 구조를 미리 보여주는 UI
  • 사용자에게 "무엇이 로딩되고 있는지" 예상할 수 있게 해줌
  • YouTube, Facebook 등에서 널리 사용

직접 구현

// components/BookItemSkeleton.tsx
export function BookItemSkeleton() {
  return (
    <div className="border rounded-lg p-4 animate-pulse">
      {/* 책 커버 이미지 영역 */}
      <div className="bg-gray-300 h-48 w-full mb-4 rounded"></div>

      {/* 책 제목 영역 */}
      <div className="bg-gray-300 h-4 w-3/4 mb-2 rounded"></div>

      {/* 저자 영역 */}
      <div className="bg-gray-300 h-3 w-1/2 mb-2 rounded"></div>

      {/* 가격 영역 */}
      <div className="bg-gray-300 h-4 w-1/4 rounded"></div>
    </div>
  );
}

// 리스트용 스켈레톤
export function BookListSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {Array.from({ length: 6 }).map((_, i) => (
        <BookItemSkeleton key={i} />
      ))}
    </div>
  );
}

라이브러리 활용

npm install react-loading-skeleton
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';

export function BookItemSkeleton() {
  return (
    <SkeletonTheme baseColor="#ebebeb" highlightColor="#f5f5f5">
      <div className="border rounded-lg p-4">
        <Skeleton height={192} className="mb-4" />
        <Skeleton count={2} />
        <Skeleton width="60%" />
      </div>
    </SkeletonTheme>
  );
}

5. 에러 핸들링

error.tsx 파일 구조

app/
├── layout.tsx
├── global-error.tsx        # 루트 레벨 에러 (layout.tsx 에러 포함)
├── error.tsx              # 앱 전역 에러
├── page.tsx
├── search/
│   ├── error.tsx          # /search 경로 에러
│   └── page.tsx
└── book/
    ├── [id]/
    │   ├── error.tsx      # /book/[id] 경로 에러
    │   └── page.tsx
    └── error.tsx          # /book 경로 에러

기본 에러 컴포넌트

// app/error.tsx
"use client";

import { useEffect } from "react";

export default function Error({
  error,
  reset
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // 에러 로깅 (Sentry, LogRocket 등)
    console.error("에러 발생:", error.message);
    console.error("에러 스택:", error.stack);
  }, [error]);

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] p-8">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          오류가 발생했습니다
        </h2>
        <p className="text-gray-600 mb-6">
          일시적인 문제일 수 있습니다. 다시 시도해주세요.
        </p>
        <button
          onClick={reset}
          className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
        >
          다시 시도
        </button>
      </div>
    </div>
  );
}

reset 함수의 한계와 해결책

❌ 기본 reset의 문제점

  • 서버 컴포넌트를 다시 실행하지 않음
  • 데이터 페칭을 다시 시도하지 않음
  • 백엔드 문제는 해결되지 않음

✅ 개선된 에러 처리

"use client";

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

export default function Error({
  error,
  reset
}: {
  error: Error;
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    console.error("에러 발생:", error.message);
  }, [error]);

  const handleRetry = () => {
    startTransition(() => {
      // 서버 컴포넌트를 다시 불러옴
      router.refresh();
      // 에러 상태를 초기화하고 컴포넌트를 다시 렌더링
      reset();
    });
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px] p-8">
      <div className="text-center">
        <h2 className="text-2xl font-bold text-red-600 mb-4">
          오류가 발생했습니다
        </h2>
        <details className="mb-4 text-left">
          <summary className="cursor-pointer text-gray-600 hover:text-gray-800">
            상세 정보 보기
          </summary>
          <pre className="mt-2 p-4 bg-gray-100 rounded text-sm overflow-auto">
            {error.message}
          </pre>
        </details>
        <div className="space-x-4">
          <button
            onClick={handleRetry}
            className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
          >
            다시 시도
          </button>
          <button
            onClick={() => window.location.reload()}
            className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600"
          >
            새로고침
          </button>
        </div>
      </div>
    </div>
  );
}

글로벌 에러 처리

// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="flex flex-col items-center justify-center min-h-screen p-8">
          <h1 className="text-3xl font-bold text-red-600 mb-4">
            심각한 오류가 발생했습니다
          </h1>
          <p className="text-gray-600 mb-6 text-center">
            애플리케이션에 예상치 못한 오류가 발생했습니다.<br />
            잠시 후 다시 시도해주세요.
          </p>
          <button
            onClick={reset}
            className="bg-red-500 text-white px-6 py-2 rounded hover:bg-red-600"
          >
            애플리케이션 재시작
          </button>
        </div>
      </body>
    </html>
  );
}

6. 종합적인 활용 예시

검색 페이지 완전한 구현

// app/search/page.tsx
import { Suspense } from 'react';
import { BookListSkeleton } from '@/components/BookListSkeleton';
import { SearchResult } from '@/components/SearchResult';

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">
        {q ? `"${q}" 검색 결과` : '전체 도서'}
      </h1>

      <Suspense
        key={q}
        fallback={<BookListSkeleton />}
      >
        <SearchResult query={q || ""} />
      </Suspense>
    </div>
  );
}
// app/search/error.tsx
"use client";

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

export default function SearchError({
  error,
  reset
}: {
  error: Error;
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    console.error("검색 중 에러 발생:", error.message);
  }, [error]);

  const handleRetry = () => {
    startTransition(() => {
      router.refresh();
      reset();
    });
  };

  return (
    <div className="text-center p-8">
      <h2 className="text-xl font-semibold text-red-600 mb-4">
        검색 중 오류가 발생했습니다
      </h2>
      <p className="text-gray-600 mb-6">
        네트워크 연결을 확인하고 다시 시도해주세요.
      </p>
      <button
        onClick={handleRetry}
        className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
      >
        다시 검색
      </button>
    </div>
  );
}

7. 모범 사례

스트리밍 최적화

  1. 적절한 Suspense 경계 설정: 너무 세분화하거나 너무 큰 단위로 묶지 않기
  2. 로딩 상태의 일관성: 스켈레톤과 실제 콘텐츠 레이아웃 맞추기
  3. 중요도 기반 우선순위: 중요한 콘텐츠를 먼저 스트리밍

에러 처리 최적화

  1. 사용자 친화적 메시지: 기술적 오류보다는 이해하기 쉬운 안내
  2. 복구 옵션 제공: 다시 시도, 새로고침, 대체 경로 등
  3. 에러 로깅: 운영 환경에서 에러 추적을 위한 로깅 시스템 구축

성능 고려사항

  1. 불필요한 Suspense 피하기: 빠른 컴포넌트는 스트리밍 불필요
  2. 스켈레톤 최적화: 복잡한 애니메이션보다는 단순하고 빠른 로딩 UI
  3. 에러 바운더리 최소화: 필요한 곳에만 배치하여 성능 영향 최소화

 

이 링크를 통해 구매하시면 제가 수익을 받을 수 있어요. 🤗

https://inf.run/4oB9v

반응형