Next.js의 loading.tsx, error.tsx, not-found.tsx는 어떤 상태를 맡나요?
Next.js의 준비 중·없는 항목·조회 실패는 서로 다른 상태입니다. loading.tsx는 대기 UI, notFound()와 not-found.tsx는 없는 데이터, error.tsx는 처리하지 못한 예외를 담당합니다.
적용 조건·범위: 스트리밍을 시작한 응답은 notFound UI가 표시되어도 HTTP 200일 수 있습니다. 비스트리밍 응답은 404이며, 상태 코드와 함께 화면·noindex를 확인합니다. 공식 기준: https://nextjs.org/docs/app/api-reference/file-conventions/not-found
본문 근거: Next.js not-found 응답·noindex 기준 · Loading UI · Error Handling
독립 실습: 정상·없는 항목·오류를 따로 재현하기
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ mode: string }> }) {
const { mode } = await params;
await new Promise(resolve => setTimeout(resolve, 1000));
if (mode === 'missing') notFound();
if (mode === 'error') throw new Error('학습용 조회 실패');
return <h1>정상 결과</h1>;
}
export default function Loading() { return <p>준비 중</p>; }
export default function NotFound() { return <p>항목 없음</p>; }
"use client";
export default function ErrorPage({ reset }: { error: Error; reset: () => void }) {
return <section><h2>조회 오류</h2><button onClick={reset}>다시 시도</button></section>;
}
기대 결과: 정상 결과·항목 없음·조회 오류가 구분됩니다. missing이 스트리밍된 경우 HTTP 코드가 200일 수 있으므로 noindex와 화면도 확인합니다. error는 항상 실패하도록 만든 예제라 reset 후 다시 오류가 나는 것이 예상됩니다.
직접 실행: 환경 준비와 전체 파일
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
npm run dev 후 /practice/ok, /practice/missing, /practice/error를 각각 엽니다. 개발 오류 오버레이와 실제 error 경계 UI는 구분하며 production에서도 확인합니다.
app/practice/[mode]/page.tsx 전체 코드
app/practice/[mode]/loading.tsx 전체 코드
app/practice/[mode]/not-found.tsx 전체 코드
app/practice/[mode]/error.tsx 전체 코드
자가 확인: 일시적 요청 실패와 영구적으로 던지는 학습용 예외에서 재시도 결과가 다른 이유를 설명합니다.
loading.tsx
같은 route segment의 페이지가 준비되는 동안 표시할 UI를 정의합니다.
export default function Loading() {
return <p>작업을 불러오는 중...</p>;
}
error.tsx
error.tsx는 Client Component여야 합니다.
'use client';
export default function ErrorPage({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<section>
<h2>문제가 발생했습니다.</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</section>
);
}
not-found.tsx와 notFound()
데이터가 존재하지 않을 때 직접 404 UI로 전환할 수 있습니다.
조회 결과가 없다는 조건을 확인한 서버 코드에서 notFound()를 호출하면 해당 segment의 not-found UI로 전환합니다. 네트워크 실패처럼 예상하지 못한 예외는 error 경계가 맡습니다. 아래 독립 예제는 missing과 error를 각각 재현하고 하단 프로젝트 적용은 없는 작업 ID에 연결합니다.
자주 발생하는 오류
- error.tsx에 use client 누락
- notFound()와 일반 예외 throw를 같은 용도로 사용
- 모든 로딩을 루트 loading 하나에 몰아넣음
- 너무 상위 error boundary로 모든 화면을 감춤
스트리밍을 시작한 응답은 notFound UI가 표시되어도 HTTP 200일 수 있습니다. 비스트리밍 응답은 404이며, 상태 코드와 함께 화면·noindex를 확인합니다. 공식 기준: https://nextjs.org/docs/app/api-reference/file-conventions/not-found
없는 항목은 조회가 성공했지만 자료가 없는 결과일 수 있으므로 일반 예외와 구분합니다. error 경계의 reset은 렌더링을 다시 시도하며 데이터나 실패 원인을 자동으로 고치지 않습니다. loading 파일의 경계와 실제로 시간이 걸리는 작업의 위치도 함께 확인합니다.
원리 확인: Next.js not-found 응답·noindex 기준 (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
작업 영역에 loading/error/not-found 파일을 추가합니다. 정상 URL·없는 ID·조회 오류를 구분합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js params should be awaited 오류 해결의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: app/(app)/tasks/error.tsx, app/(app)/tasks/loading.tsx, app/(app)/tasks/not-found.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
개념 설명에서 사용한 프로젝트 부분 코드
다음은 없는 데이터에 notFound()를 호출하는 조건만 보여 주는 부분 조각입니다. getTask와 id의 선언은 아래 TaskFlow 전체 파일에서 확인합니다. 상단 mode 경로의 독립 예제에는 TaskFlow 조회 함수를 import하지 않습니다.
import { notFound } from 'next/navigation';
const task = await getTask(id);
if (!task) {
notFound();
}
직접 확인: 정상·없는 데이터·조회 오류
독자 질문: /tasks/task-does-not-exist가 404라면 error.tsx가 떠야 할까요? 이 완료 코드에서는 없는 작업에 notFound()를 호출하고 app/(app)/tasks/not-found.tsx가 ‘작업을 찾을 수 없습니다.’를 표시합니다.
app/(app)/tasks/loading.tsx,error.tsx,not-found.tsx의 실제 문구와 경로를 확인합니다.error.tsx의"use client"와 ‘다시 시도’ 버튼도 살펴봅니다./tasks/task-1직접 접근의 상세와 없는 ID의 not-found를 구분합니다. 스트리밍 중이라면 not-found 화면이더라도 HTTP 상태가 200일 수 있으므로 화면·응답 코드·noindex를 따로 기록합니다.- 현재 데이터는 로컬 배열이므로 로딩 UI가 눈에 띄지 않을 수 있습니다. 완료 코드에는 의도적 예외도 없어서 일반 탐색만으로 error 화면을 확인했다고 할 수 없습니다. 로컬 실습 복사본에서 지연·임시 예외를 재현했다면 확인 후 제거합니다.
npm run typecheck와npm run build결과를 각각 기록합니다.
오류 점검
error.tsx가 보이지 않으면 예외 발생 위치와 경계의 범위, Client 지시문, reset을 확인합니다. 없는 데이터에는 일반 throw와 notFound()를 구분합니다.
검증 상태: 이 편집에서 지연·임시 예외, 서버·브라우저·빌드를 실행하지 않았습니다. loading/error/not-found의 실제 화면과 응답 코드는 미확인입니다.
완료 코드 ZIP 다운로드 — next-step-11-route-states
이전 체크포인트: next-step-10-async-params
이번 회차의 변경
작업 영역에 loading/error/not-found 파일을 추가합니다. 정상 URL·없는 ID·조회 오류를 구분합니다. 상태 UI를 시험할 때 발생시킨 임시 throw는 확인 후 제거합니다.
변경·추가 파일:
app/(app)/tasks/loading.tsxapp/(app)/tasks/error.tsxapp/(app)/tasks/not-found.tsx
전체 파일·코드 보기
실행과 검증
Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.
npm install
npm run dev
npm run build
npm run start
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-11-route-states
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
app/(app)/@modal/(.)tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<Modal>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
</Modal>
);
}
app/(app)/@modal/[…catchAll]/page.tsx
export default function ResetModal() {
return null;
}
app/(app)/@modal/default.tsx
export default function Default() {
return null;
}
app/(app)/layout.tsx
import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<SiteHeader />
{children}
{modal}
</>
);
}
app/(app)/settings/page.tsx
export default function SettingsPage() {
return (
<main>
<h1>설정</h1>
<p>TaskFlow 설정 화면입니다.</p>
</main>
);
}
app/(app)/tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<main>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
<Link href="/tasks">목록</Link>
</main>
);
}
app/(app)/tasks/error.tsx
"use client";
export default function ErrorPage({
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<main>
<h1>작업을 불러오지 못했습니다.</h1>
<button onClick={reset}>다시 시도</button>
</main>
);
}
app/(app)/tasks/loading.tsx
export default function Loading() {
return (
<main>
<p>작업을 불러오는 중...</p>
</main>
);
}
app/(app)/tasks/not-found.tsx
export default function NotFound() {
return (
<main>
<h1>작업을 찾을 수 없습니다.</h1>
</main>
);
}
app/(app)/tasks/page.tsx
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link>
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</main>
);
}
app/_components/SiteHeader.tsx
import Link from "next/link";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
</nav>
</header>
);
}
app/globals.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font-family: Arial, sans-serif;
line-height: 1.6;
}
header,
main {
width: min(880px, calc(100% - 32px));
margin: 24px auto;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
a {
color: inherit;
text-underline-offset: 3px;
}
button,
input,
select {
min-height: 44px;
padding: 8px 12px;
font: inherit;
border: 1px solid #aaa;
background: #fff;
color: #171717;
max-width: 100%;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
input {
display: block;
width: 100%;
}
label {
display: block;
}
form,
section,
aside {
margin: 20px 0;
}
ul {
padding: 0;
list-style: none;
}
li {
padding: 12px;
margin: 12px 0;
border: 1px solid #ccc;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.controls form {
margin: 0;
}
.modal {
width: min(640px, calc(100% - 32px));
border: 1px solid #777;
padding: 24px;
background: #fff;
color: #171717;
}
.modal::backdrop {
background: rgb(0 0 0 / 55%);
}
:focus-visible {
outline: 3px solid #555;
outline-offset: 3px;
}
[data-theme="dark"] {
background: #202020;
color: #fafafa;
padding: 16px;
}
[data-density="compact"] li {
padding: 8px;
margin: 8px 0;
}
img {
max-width: 100%;
height: auto;
}
app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/lib/data.ts
import type { Task } from "./types";
const tasks: Task[] = [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId: "demo",
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId: "demo",
},
];
export async function getTasks() {
return tasks;
}
export async function getTask(id: string) {
return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
const ts = await getTasks();
return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/ui/modal.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
const router = useRouter();
useEffect(() => {
const dialog = ref.current;
if (dialog && !dialog.open) dialog.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
className="modal"
ref={ref}
aria-label="작업 상세"
onCancel={(event) => {
event.preventDefault();
router.back();
}}
>
<button type="button" onClick={() => router.back()}>
닫기
</button>
{children}
</dialog>
);
}
app/ui/task-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.11",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차

Next.js · 11/38 · TaskFlow 누적 실습
Next.js loading error not-found 라우트 상태 처리
Next.js · 11 / 38 · 필수 학습 · loading/error/not-found
기다림·없는 데이터·조회 실패에는 어떤 파일을 사용할까요? loading.tsx는 준비 중 UI, notFound()와 not-found.tsx는 없는 항목, error.tsx는 처리하지 못한 예외를 담당합니다. 독립 경로 세 개로 결과를 나누어 보고 재시도가 언제 성공할 수 있는지 판단합니다.
이번 글에서 배우는 내용
- loading.tsx
- error.tsx
- not-found.tsx
- notFound()
- route segment별 상태 UI
- 오류 경계 배치 기준
목차
다음 학습
다음 글에서는 서버에서 window·document·localStorage를 읽을 때 발생하는 오류를 해결합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.