하위 태스크 1
App Router 프로젝트 생성
create-next-app으로 App Router 프로젝트 생성
셸:
npx create-next-app@latest mission-07-04 --ts --app
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
✔ Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? … No / Yes하위 태스크 2
서버 컴포넌트 구현
데이터 페칭이 포함된 서버 컴포넌트 작성
src/domains.ts:
export interface Book {
id: string;
title: string;
authors: string[];
genres: string[];
summary: string;
price: number;
}src/utils.ts:
export async function load<T>(url: string): Promise<T> {
const response = await fetch(url);
const data = await response.json();
return data;
}src/components/ServerComponent.tsx:
import type { Book } from "@/domains";
import { load } from "@/utils";
export default async function ServerComponent() {
const data = await load<Book[]>("http://localhost:3005/books");
return (
<ul className="pl-6 list-disc">
{data.map((datum) => <li key={datum.id}>{datum.title}</li>)}
</ul>
);
}하위 태스크 3
클라이언트 컴포넌트 구현
“use client”를 사용한 인터랙티브 컴포넌트 작성
src/components/ClientComponent.tsx:
"use client"
import { useState } from "react";
export default function ClientComponent() {
const [count, setCount] = useState<number>(0);
function handleClick() {
setCount((c) => c + 1);
}
return (
<div className="flex gap-x-2">
<p>방문자 수: {count}명</p>
<button className="px-2 bg-slate-500 text-slate-50" type="button" onClick={handleClick}>출석</button>
</div>
);
}하위 태스크 4
컴포넌트 조합
서버와 클라이언트 컴포넌트를 함께 사용
src/app/page.tsx:
import type { Book } from "@/domains";
import Link from "next/link";
import { load } from "@/utils";
export default async function ServerComponent() {
const data = await load<Book[]>("http://localhost:3005/books");
return (
<ul className="pl-6 list-disc">
{
data.map((datum) => (
<li key={datum.id}>
<Link className="underline" href={`/book/${datum.id}`}>{datum.title}</Link>
</li>
))}
</ul>
);
}
하위 태스크 5
루트 레이아웃 구성
app/layout.tsx로 전역 레이아웃 설정
src/app/layout.tsx:
import { ReactNode } from "react";
import "./globals.css";
export default function RootLayout({
children,
}: Readonly<{ children: ReactNode; }>) {
return (
<html lang="ko">
<body className="mx-auto w-80">
<header className="p-2 bg-slate-400">
<h1 className="font-bold">구름 출판</h1>
</header>
{children}
</body>
</html>
);
}하위 태스크 6
중첩 레이아웃 구현
섹션별 레이아웃 생성 및 중첩 적용
src/app/book/layout.tsx:
import Link from "next/link";
import { ReactNode } from "react";
export default function BookLayout({ children }: { children: ReactNode }) {
return (
<>
<nav>
<ul className="flex justify-evenly py-1 underline bg-slate-500">
<li><Link href="/">홈</Link></li>
<li><Link href="/book">책 목록</Link></li>
</ul>
</nav>
<div className="p-4 bg-slate-50 text-slate-900">{children}</div>
</>
);
}src/app/book/page.tsx:
import ServerComponent from "@/components/ServerComponent";
export default function BooksPage() {
return (
<main>
<h2 className="mb-4 font-bold">책 목록</h2>
<ServerComponent />
</main>
);
}
src/app/book/[id]/page.tsx:
import type { Book } from "@/domains";
import { load } from "@/utils";
export default async function BookDetailPage({ params }: {
params: Promise<{ id: string; }>
}) {
const books = await load<Book[]>("http://localhost:3005/books");
const { id } = await params;
const book = books.find((book) => book.id === id);
return (
<main>
<p>{params.id}</p>
{ book !== undefined ? (
<>
<h2 className="mb-4 font-bold">{book.title}</h2>
<ul>
<li>작가: {book.authors.join(", ")}</li>
<li>장르: {book.genres.join("/")}</li>
<li>요약: {book.summary}</li>
<li>가격: {
new Intl.NumberFormat("ko-KR", {
style: "currency", currency: "KRW"
}).format(book.price)
}</li>
</ul>
</>) : <p className="italic">책을 찾을 수 없습니다.</p>
}
</main>
);
}
하위 태스크 7
Route Groups 활용
괄호로 그룹화하여 레이아웃 공유
src/app/(marketing)/layout.tsx:
import { ReactNode } from "react";
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
<>
<div className="flex justify-evenly py-1 bg-orange-400">
오픈 기념 50% 할인 쿠폰 증정
</div>
<div className="p-4 bg-slate-50 text-slate-900">{children}</div>
</>
);
}src/app/(marketing)/about/page.tsx:
export default function AboutPage() {
return (
<main>
<h2 className="mb-4 font-bold">소개</h2>
<p>
우리는 삶의 화려한 순간보다, 묵묵히 자리를 지키는 일상의 소중함을 믿습니다. 지친 마음을 다독이는 에세이부터 새로운 내일을 꿈꾸게 하는 소설까지, 구름 출판은 당신의 서재에 가장 따뜻한 온기를 전하는 책을 만듭니다.
</p>
</main>
);
}
src/app/(marketing)/contact/page.tsx:
export default function ContactPage() {
return (
<main>
<h2 className="mb-4 font-bold">책 목록</h2>
<ul>
<li>주소: 서울특별시 OO구 OO동 OO로 OO길, OO빌딩 13층</li>
<li>전화: 02-123-4567</li>
<li>메일: [email protected]</li>
</ul>
</main>
);
}
하위 태스크 8
loading.tsx 구현
페이지별 로딩 UI 생성
로딩 상태 가시화를 위해 load 함수에 3초 이내의 대기 시간을 포함한다.
src/utils.ts:
export async function load<T>(url: string): Promise<T> {
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, Math.random() * 3000);
});
// ...
}src/app/book/[id]/loading.tsx:
export default async function BookDetailLoading() {
return <p>책의 상세 정보를 가져오고 있습니다...</p>
}
하위 태스크 9
error.tsx 구현
에러 경계 및 에러 처리 UI 생성
오류 상태 가시화를 위해 load 함수에 일정 확률로 에러를 발생시키는 코드를 포함한다.
src/utils.ts:
export async function load<T>(url: string): Promise<T> {
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, Math.random() * 3000);
});
if (Math.random() > 0.7) {
throw Error("데이터 가져오기 오류");
}
// ...
}src/app/book/[id]/error.tsx:
"use client"
export default function BookDetailError({ error }: { error: Error }) {
return (
<main>
<p>오류가 발생했습니다.</p>
</main>
);
}
하위 태스크 10
not-found.tsx 구현
404 페이지 생성
src/app/not-found.tsx:
export default function NotFoundPage() {
return (
<main className="p-2 bg-slate-50 text-slate-900">
<h2 className="font-bold">404 Not Found</h2>
<p className="italic">요청한 페이지를 찾을 수 없습니다.</p>
</main>
);
}
하위 태스크 11
메타데이터 관리
generateMetadata로 동적 SEO 설정
src/app/book/[id]/page.tsx:
// ...
import { Metadata } from "next";
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const books = await load<Book[]>("http://localhost:3005/books");
const { id } = await params;
const book = books.find((book) => book.id === id);
return { title: book?.title ?? "오류" };
}
// ...