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>
  );
}

설계 포인트

Need Help

도움이 필요하신가요?

주님의교회 PCL 디자인 시스템을 적용하시다가 막히는 부분이 있다면, 다음 경로로 직접 문의하실 수 있습니다.