C
Next.js/기초/Lesson 03

파일 컨벤션 — loading.tsx · error.tsx · not-found.tsx

30분·theory
이 챕터
3/3
TypeScript

파일 컨벤션 — loading.tsx · error.tsx · not-found.tsx

💡 왜 배워야 할까요? — '파일 이름이 곧 동작' 컨벤션

🎯 Pages Router 시절: `` 컴포넌트 손수 만들고, 페이지마다 import 해서 조건부 렌더링.
💼 App Router: 같은 폴더에 `loading.tsx` 파일만 두면 끝. Next.js 가 자동으로 `}>` 으로 감쌈.
에러 처리도 마찬가지 — `error.tsx` 만 두면 그 폴더 하위에서 발생한 에러를 자동으로 잡아 fallback UI 표시.
🔗 이걸 모르면 '왜 로딩이 자동으로 뜨지?', '에러가 어디서 잡히는지 모르겠다' 같은 디버깅 함정에 빠집니다.
📈 중첩 라우트에서는 가장 가까운 파일이 적용 — 폴더 트리가 곧 UI 트리.
🏢 실무에서는
Server Component 가 await 으로 데이터를 fetch 하는 동안 사용자는 빈 화면을 봅니다. 같은 폴더에 `loading.tsx` 하나 두면 그 동안 스켈레톤 UI 가 자동 노출. 만약 fetch 가 throw 하면 `error.tsx` 가 자동 잡아서 "다시 시도" 버튼 보여줌. 이게 코드 0줄 추가로 가능합니다.

5가지 특수 파일 — 이름이 곧 컨벤션

1. 5가지 파일과 역할

파일자동 적용되는 것컴포넌트 종류
page.tsxURL 라우트의 페이지 본체Server 또는 Client
layout.tsx그 폴더 하위 모든 페이지의 공통 레이아웃 (상태 유지)Server 또는 Client
loading.tsx그 폴더 페이지가 로드 중일 때 <Suspense> fallbackServer 또는 Client
error.tsx그 폴더 하위 에러 발생 시 React Error Boundary fallbackClient 만 ('use client' 강제)
not-found.tsxnotFound() 호출 또는 매치 안 되는 경로Server 또는 Client

2. loading.tsx — Suspense 자동 래핑

tsx
// app/posts/loading.tsx
export default function Loading() {
  return <div className="animate-pulse">로딩 중...</div>;
}
tsx
// app/posts/page.tsx — 이 컴포넌트가 await 으로 대기하는 동안
export default async function PostsPage() {
  const posts = await fetch('https://api/posts').then(r => r.json());
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// → loading.tsx 가 자동으로 fallback 으로 표시됨
//   <Suspense fallback={<Loading />}><PostsPage /></Suspense> 과 동등

3. error.tsx — 자동 Error Boundary

tsx
// app/posts/error.tsx
'use client'; // ★ error 경계는 Client 강제

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>에러가 발생했습니다</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  );
}
  • reset() 을 호출하면 Error Boundary 를 리셋하고 페이지를 다시 렌더 시도.
  • error.digest 는 서버 측 에러를 식별하는 ID (로깅용).
  • error.tsx같은 폴더의 layout.tsx 에서 발생한 에러는 잡지 못함 — 한 단계 위 폴더에 두어야.

4. not-found.tsx — 404 UI

tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // 이걸 호출하면 not-found.tsx 가 렌더됨
  return <article>{post.title}</article>;
}
tsx
// app/posts/[id]/not-found.tsx
export default function NotFound() {
  return <div>찾는 글이 없습니다. <Link href="/posts">목록으로</Link></div>;
}

5. 중첩 시 가장 가까운 파일이 적용

code
app/
├── error.tsx          ← 전역 fallback
├── posts/
│   ├── error.tsx      ← /posts/* 에러는 이게 잡음
│   ├── loading.tsx    ← /posts/* 로딩 시 이게 표시
│   └── [id]/
│       └── error.tsx  ← /posts/[id] 에러는 이게 가장 가까움 → 이게 잡음

가장 가까운 파일이 우선. 같은 폴더에 파일 없으면 부모 폴더로 거슬러 올라가서 찾음.

💻 🅰️ Pages Router — 직접 Suspense + Error Boundary
// ❌ Pages Router — 컴포넌트 손수 작성 + 조건부 렌더링

// 📁 components/Loading.tsx
export function Loading() {
  return <div className="animate-pulse">로딩 중...</div>;
}

// 📁 components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  render() {
    if (this.state.error) {
      return (
        <div>
          <h2>에러: {this.state.error.message}</h2>
          <button onClick={() => this.setState({ error: null })}>다시</button>
        </div>
      );
    }
    return this.props.children;
  }
}
export { ErrorBoundary };

// 📁 pages/posts/index.tsx
import { useState, useEffect } from 'react';
import { Loading } from '../../components/Loading';
import { ErrorBoundary } from '../../components/ErrorBoundary';

export default function PostsPage() {
  const [posts, setPosts] = useState(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch('/api/posts')
      .then(r => { if (!r.ok) throw new Error('실패'); return r.json(); })
      .then(setPosts)
      .catch(setError);
  }, []);

  if (error) return <div>에러: {error.message}</div>;
  if (!posts) return <Loading />;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 pages/_error.tsx — 전역 에러 (Pages Router 전용)
function Error({ statusCode }: { statusCode: number }) {
  return <p>{statusCode ? `서버 ${statusCode} 에러` : '클라이언트 에러'}</p>;
}
Error.getInitialProps = ({ res, err }) => {
  const statusCode = res?.statusCode ?? err?.statusCode ?? 404;
  return { statusCode };
};
export default Error;

// 📁 pages/404.tsx — 404 페이지 (Pages Router 전용)
export default function Custom404() {
  return <h1>404 — 찾을 수 없습니다</h1>;
}
💻 🅱️ App Router — 파일 4개만 두면 끝
// ✅ App Router — 파일 이름만으로 자동 적용

// 📁 app/posts/page.tsx (Server Component)
interface Post { id: number; title: string; }

export default async function PostsPage() {
  const posts: Post[] = await fetch('https://api/posts', {
    next: { revalidate: 60 },
  }).then(r => {
    if (!r.ok) throw new Error('posts fetch 실패');
    return r.json();
  });

  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 app/posts/loading.tsx — 자동으로 Suspense fallback
export default function Loading() {
  return (
    <div className="animate-pulse space-y-2">
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded w-2/3" />
    </div>
  );
}

// 📁 app/posts/error.tsx — 자동으로 Error Boundary
'use client'; // ★ error 는 항상 Client

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>posts 를 불러오지 못했습니다</h2>
      <p className="text-gray-600">{error.message}</p>
      {error.digest && <p className="text-xs">에러 ID: {error.digest}</p>}
      <button onClick={() => reset()}>다시 시도</button>
    </div>
  );
}

// 📁 app/posts/[id]/page.tsx — 특정 글 페이지
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // ★ 이걸 호출하면 not-found.tsx 가 렌더
  return <article>{post.title}</article>;
}

// 📁 app/posts/[id]/not-found.tsx — 자동으로 404 UI
import Link from 'next/link';
export default function NotFound() {
  return (
    <div>
      <h2>찾는 글이 없습니다</h2>
      <Link href="/posts">← 목록으로</Link>
    </div>
  );
}

// 폴더 트리:
// app/posts/
// ├── page.tsx          ← 목록 페이지
// ├── loading.tsx       ← 목록 로딩 UI
// ├── error.tsx         ← 목록 에러 UI
// └── [id]/
//     ├── page.tsx      ← 상세 페이지
//     └── not-found.tsx ← 상세 404 UI
// 등록·import 코드 0줄. 파일 이름만으로 작동.

💡 💡 파일 컨벤션 실전 5

1. error.tsx 는 항상 'use client'
React Error Boundary 가 클라이언트 전용이라 컨벤션상 강제. 빼먹으면 빌드 에러.

2. error.tsx 는 같은 폴더의 layout.tsx 에러를 못 잡는다
layout 에서 throw 가 일어나면 한 단계 위 폴더의 error.tsx 가 잡음. 루트 레이아웃 에러는 global-error.tsx (Client 전용) 가 잡음.

3. loading.tsx 는 page.tsx 의 첫 await 동안만 표시
페이지가 한 번 렌더링 끝나면 사라짐. 이후 client-side 네비게이션 시에는 다시 표시.

4. notFound() 는 throw 와 비슷 — 그 아래 코드는 실행 안 됨

tsx
if (!post) notFound();
console.log(post.title); // 여기 도달 안 함 (notFound 가 흐름 종료)

TS 가 narrowing 까지 해줘서 post 가 non-null 로 좁혀짐.

5. 같은 폴더에 모든 컨벤션 파일 두기

code
posts/
├── page.tsx
├── layout.tsx
├── loading.tsx
├── error.tsx
└── not-found.tsx

폴더가 곧 UI 상태머신. 한눈에 페이지의 모든 상태가 보임.

⚡ 직접 실행해보기 — 파일 컨벤션 매핑

URL 별로 어떤 컨벤션 파일이 어떤 순서로 적용되는지 시뮬레이션합니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

App Router 의 `error.tsx` 가 반드시 `'use client'` 여야 하는 이유는?
💡 React Error Boundary 는 `componentDidCatch`·`getDerivedStateFromError` 같은 클래스 컴포넌트 라이프사이클을 사용하는데, 이는 **클라이언트 React** 의 기능입니다. Next.js 는 `error.tsx` 를 자동으로 Error Boundary 로 감싸므로 그 컴포넌트는 반드시 Client 여야 합니다. 그래서 파일 맨 위에 `'use client'` 가 강제됩니다.
파일 컨벤션 — loading / error / not-found - Next.js