하위 태스크 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 ?? "오류" };
}
 
// ...