06 · Developer Guide · 레시피
오류 · 로딩 · 빈 화면Error · Loading · Empty States
행복한 길(golden path)이 아닌 상태도 같은 디자인 수준으로 다뤄야 신뢰가 쌓입니다.
Next.js App Router의 loading.tsx · error.tsx · not-found.tsx 세 파일이 묶음입니다.
언제 쓰는가
- 모든 동적 라우트 (게시판 인덱스·상세·검색)
- 외부 API 호출이 포함된 페이지 (영상·결제·날씨 등)
- 검색 결과 없음·필터 결과 없음 등 「데이터는 있지만 비어있다」 상태
① loading.tsx — 데이터 로드 중
라우트 폴더 안에 loading.tsx를 두면 그 페이지가 서버에서 fetch 중일 때 자동으로 보여집니다. 스피너 대신 실제 레이아웃을 흉내낸 스켈레톤이 인지된 속도를 높입니다.
app/(site)/site/yard/news/loading.tsxtsx
// app/(site)/site/yard/news/loading.tsx — 게시판 로딩 스켈레톤
export default function Loading() {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border bg-white">
<div className="aspect-[16/10] animate-pulse bg-cream-100" />
<div className="space-y-2 p-4">
<div className="h-4 w-3/4 animate-pulse rounded bg-cream-100" />
<div className="h-3 w-1/2 animate-pulse rounded bg-cream-100" />
</div>
</div>
))}
</div>
);
}② error.tsx — 페이지 오류 경계
app/(site)/site/yard/news/error.tsxtsx
// app/(site)/site/yard/news/error.tsx — 게시판 오류 경계
"use client";
import { useEffect } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Sentry 등에 보고
console.error("[news] " + error.message, error.digest);
}, [error]);
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-10 text-center">
<AlertTriangle size={28} className="mx-auto text-red-500" />
<h2 className="mt-4 text-[18px] font-bold">새소식을 불러오지 못했습니다</h2>
<p className="mt-2 text-[13px] text-text-muted">
잠시 후 다시 시도해 주세요. 같은 화면이 반복되면 관리자에게 알려 주세요.
</p>
{error.digest && (
<p className="mt-3 font-en text-[11px] text-text-subtle">
오류 코드 · {error.digest}
</p>
)}
<button
onClick={reset}
className="mt-6 inline-flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-white"
>
<RotateCw size={14} /> 다시 시도
</button>
</div>
);
}③ not-found.tsx — 존재하지 않는 리소스
not-found.tsxtsx
// app/(site)/site/yard/news/[id]/not-found.tsx — 없는 게시물
import Link from "next/link";
import { FileQuestion } from "lucide-react";
export default function NotFound() {
return (
<div className="py-20 text-center">
<FileQuestion size={32} className="mx-auto text-text-muted" />
<h2 className="mt-4 text-[20px] font-bold">존재하지 않거나 삭제된 게시물입니다</h2>
<p className="mt-2 text-[13px] text-text-muted">
주소가 정확한지 확인하시거나 목록에서 다른 글을 찾아 주세요.
</p>
<Link
href="/site/yard/news"
className="mt-6 inline-block rounded-md bg-accent px-5 py-2 text-white"
>
새소식 전체 보기
</Link>
</div>
);
}[id]/page.tsxtsx
// app/(site)/site/yard/news/[id]/page.tsx — notFound() 호출
import { notFound } from "next/navigation";
import { getNewsPost } from "@/lib/preview/news";
export default async function NewsDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await getNewsPost(id);
if (!post) notFound();
return <NewsDetail post={post} />;
}④ Suspense — 영역별 부분 로딩
홈처럼 여러 데이터 소스가 섞인 페이지는 페이지 단위 loading.tsx로는 부족합니다. 영역별로 Suspense를 두면 빠른 영역부터 차례로 보여집니다.
tsx
// 부분 로딩 — Suspense + Skeleton
import { Suspense } from "react";
import { NewsList } from "@/components/preview/NewsList";
import { NewsListSkeleton } from "@/components/preview/NewsListSkeleton";
import { SermonShelf } from "@/components/preview/SermonShelf";
export default function HomePage() {
return (
<div className="space-y-12">
<Hero />
{/* 페이지 전체가 멈추지 않게 영역별로 분리 */}
<Suspense fallback={<NewsListSkeleton count={3} />}>
<NewsList />
</Suspense>
<Suspense fallback={<NewsListSkeleton count={4} />}>
<SermonShelf />
</Suspense>
</div>
);
}⑤ EmptyState — 데이터 0건
components/preview/EmptyState.tsxtsx
// components/preview/EmptyState.tsx — 검색 결과 없음·게시물 없음
import { Inbox } from "lucide-react";
import Link from "next/link";
export function EmptyState({
icon: Icon = Inbox,
title,
body,
action,
}: {
icon?: React.ComponentType<{ size?: number; className?: string }>;
title: string;
body?: string;
action?: { href: string; label: string };
}) {
return (
<div className="rounded-lg border border-dashed border-border py-16 text-center">
<Icon size={28} className="mx-auto text-text-subtle" />
<p className="mt-4 text-[15px] font-semibold">{title}</p>
{body && <p className="mt-1 text-[12.5px] text-text-muted">{body}</p>}
{action && (
<Link
href={action.href}
className="mt-6 inline-block rounded-md bg-accent px-4 py-2 text-[13px] text-white"
>
{action.label}
</Link>
)}
</div>
);
}