반응형
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 적용 규칙
- 적용 범위: 동일 폴더 + 모든 하위 폴더의 page.tsx
- 레이아웃과 유사한 동작: 상위에서 하위로 상속됨
- 비동기 컴포넌트만 해당: async 함수 컴포넌트에서만 작동
- 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. 모범 사례
스트리밍 최적화
- 적절한 Suspense 경계 설정: 너무 세분화하거나 너무 큰 단위로 묶지 않기
- 로딩 상태의 일관성: 스켈레톤과 실제 콘텐츠 레이아웃 맞추기
- 중요도 기반 우선순위: 중요한 콘텐츠를 먼저 스트리밍
에러 처리 최적화
- 사용자 친화적 메시지: 기술적 오류보다는 이해하기 쉬운 안내
- 복구 옵션 제공: 다시 시도, 새로고침, 대체 경로 등
- 에러 로깅: 운영 환경에서 에러 추적을 위한 로깅 시스템 구축
성능 고려사항
- 불필요한 Suspense 피하기: 빠른 컴포넌트는 스트리밍 불필요
- 스켈레톤 최적화: 복잡한 애니메이션보다는 단순하고 빠른 로딩 UI
- 에러 바운더리 최소화: 필요한 곳에만 배치하여 성능 영향 최소화
이 링크를 통해 구매하시면 제가 수익을 받을 수 있어요. 🤗
https://inf.run/4oB9v
반응형
'기술, 개발 > nextjs' 카테고리의 다른 글
| 10차 강의 노트(고급 라우팅 패턴) (0) | 2025.08.28 |
|---|---|
| 9차 강의 노트 (서버 액션) (0) | 2025.08.28 |
| 7차 강의 노트 (페이지 캐싱) (0) | 2025.08.28 |
| 6차 강의 노트 (데이터 페칭) (0) | 2025.08.28 |
| 5차 강의 노트 (앱라우터) (0) | 2025.08.28 |