하위 태스크 1
기본 페이지 생성
about, contact, book 페이지 생성
책에 관한 인터페이스 Book을 선언하고 책 목록을 제공하는 useBooks Hook을 정의한다.
src/domains/book.ts:
export default interface Book {
id: number;
title: string;
authors: string[];
genres: string[];
summary: string;
price: number;
}src/hooks/use-books.ts:
import Book from "@/domains/book";
import { useEffect, useState } from "react";
export default function useBooks() {
const [books, setBooks] = useState<Book[]>([]);
useEffect(() => {
let ignore = false;
fetch("/api/books")
.then((response) => response.json())
.then((books) => {
if (!ignore) {
setBooks(books);
}
});
return () => {
ignore = true;
};
}, []);
return books;
}/api/books에서 책 목록을 제공하는 REST API를 동작시키기 위해 API 핸들러를 정의한다.
src/pages/api/books.ts:
import Book from "@/domains/book";
import type { NextApiRequest, NextApiResponse } from "next";
export default function handler(
_request: NextApiRequest,
response: NextApiResponse<Book[]>,
) {
response.status(200).json(BOOKS);
}
const BOOKS = [
{
id: 1,
title: "침묵의 기록",
authors: ["김지훈"],
genres: ["인문", "에세이"],
summary: "소란한 세상 속에서 나만의 고유한 속도를 되찾아주는 문장들.",
price: 16000,
},
{
id: 2,
title: "코드 너머의 연결",
authors: ["이진우"],
genres: ["기술", "교양"],
summary: "디지털 시대, 우리가 잃어버린 인간적 유대와 기술의 공존을 묻다.",
price: 18500,
},
{
id: 3,
title: "내일의 계절",
authors: ["박서영"],
genres: ["소설"],
summary: "끝나지 않을 것 같던 겨울을 지나, 다시 시작되는 우리들의 이야기.",
price: 14800,
},
];소개 페이지, 연락처 페이지, 책 목록 페이지에 대응하는 컴포넌트를 작성한다.
src/pages/about.tsx:
export default function About() {
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">소개</h1>
</header>
<main>
<h2 className="mb-2 italic">구름 출판, 당신의 일상에 작은 쉼표를 찍습니다.</h2>
<hr />
<p className="my-1">
우리는 단순히 종이 위에 글자를 새기는 것을 넘어, 지친 하루의 끝에 건네는 따뜻한 문장 한 줄의 힘을 믿습니다.
일상의 소소한 발견, 삶을 관통하는 깊은 위로, 그리고 더 나은 내일로 나아가는 성장의 기록들을 책으로 엮습니다.
구름 출판의 책들이 당신의 서재에서 가장 다정한 친구가 되기를 소망합니다.
</p>
</main>
</div>
);
}
src/pages/contact.tsx:
export default function Contact() {
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">연락처</h1>
</header>
<main>
<h2 className="mb-2 italic">언제든 문을 두드려 주세요</h2>
<p className="my-1">
원고 투고, 도서 구입 문의, 강연 요청 등 구름 출판의 이야기에 동참하고 싶은 모든 분의 메시지를 기다립니다.
</p>
<ul>
<li>주소: 서울특별시 OO구 OO로 OO길 OO, OO빌딩 13층</li>
<li>전화: 02-123-4567 (평일 10:00 ~ 18:00)</li>
<li>이메일: [email protected]</li>
</ul>
</main>
</div>
);
}
src/pages/book/index.tsx:
import useBooks from "@/hooks/use-books";
import Link from "next/link";
export default function BookList() {
const books = useBooks();
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">책 목록</h1>
</header>
<main>
<h2 className="mb-2 italic">구름 출판이 선별한 문장들, 당신의 세계를 넓히는 페이지를 만나보세요.</h2>
<ul>
{books.map(({ id, title, authors, price }) => (
<li key={id}>
<ul className="my-2">
<li>제목: <Link className="underline" href={`/book/${id}`}>{title}</Link></li>
<li>작가: {authors.join(", ")}</li>
<li>가격: {price}</li>
</ul>
</li>
))}
</ul>
</main>
</div>
);
}
하위 태스크 2, 4
동적 라우팅 구현
[id].tsx 패턴으로 동적 경로 생성
useRouter 훅 활용
동적 파라미터 및 쿼리 파라미터 접근
src/pages/book/[id].tsx:
import useBooks from "@/hooks/use-books";
import { useRouter } from "next/router";
export default function bookDetail() {
const router = useRouter();
const books = useBooks();
const book = books.find((book) => {
const { id } = router.query;
if (typeof id !== "string") {
return false;
}
return book.id === Number(id);
});
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">책 상세</h1>
</header>
<main>
{book !== undefined && (
<>
<h2 className="mb-2 italic">{book.title}</h2>
<ul>
<li>작가: {book.authors.join(", ")}</li>
<li>장르: {book.genres.join("/")}</li>
<li>요약: {book.summary}</li>
<li>가격: {book.price}</li>
</ul>
</>
)}
</main>
</div>
);
}
하위 태스크 3, 5
Link 컴포넌트 사용
클라이언트 사이드 네비게이션 구현
네비게이션 바 구현
공통 네비게이션 컴포넌트 생성
src/components/Navigation.tsx:
import Link from "next/link";
import { ReactNode } from "react";
export default function Navigation({ children }: { children: ReactNode }) {
return (
<>
<div className="w-80 p-2 mx-auto bg-slate-200">
<nav>
<ul className="flex justify-around">
<li><Link className="underline" href="/about">소개</Link></li>
<li><Link className="underline" href="/contact">연락처</Link></li>
<li><Link className="underline" href="/book">책 목록</Link></li>
</ul>
</nav>
</div>
{children}
</>
);
}src/pages/_app.tsx:
Component 컴포넌트를 Navigation 컴포넌트로 감싼다.
import Navigation from "@/components/Navigation";
import "@/styles/globals.css";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return (
<Navigation>
<Component {...pageProps} />;
</Navigation>
);
}하위 태스크 6
쿼리 파라미터 처리
검색 페이지에서 쿼리 파라미터 활용
src/pages/search/index.tsx:
import useBooks from "@/hooks/use-books";
import Link from "next/link";
import { useRouter } from "next/router";
export default function Search() {
const books = useBooks();
const router = useRouter();
const { q } = router.query;
const searchedBooks = books.filter(({ title }) => {
if (typeof q !== "string") {
return false;
}
return title.includes(q);
});
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">검색 결과</h1>
</header>
<main>
<h2 className="mb-2 italic">'{q}'에 대한 검색 결과입니다.</h2>
<ul>
{searchedBooks.map(({ id, title, authors, price }) => (
<li key={id}>
<ul className="my-2">
<li>제목: <Link className="underline" href={`/book/${id}`}>{title}</Link></li>
<li>작가: {authors.join(", ")}</li>
<li>가격: {price}</li>
</ul>
</li>
))}
</ul>
</main>
</div>
);
}하위 태스크 7
_app.tsx 구성
전역 레이아웃 및 설정 적용
src/pages/_app.tsx:
import GlobalLayout from "@/components/GlobalLayout";
import "@/styles/globals.css";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { ReactNode } from "react";
type NextPageWithLayout = NextPage & {
getLayout?: (page: ReactNode) => ReactNode;
};
export default function App({ Component, pageProps }: AppProps & { Component: NextPageWithLayout }) {
const getLayout = Component.getLayout ?? ((page) => page);
return (
<GlobalLayout>
{getLayout(<Component {...pageProps} />)}
</GlobalLayout>
);
}src/components/GlobalLayout.tsx:
import { ReactNode } from "react";
import Navigation from "./Navigation";
export default function GlobalLayout({ children }: { children: ReactNode }) {
return (
<Navigation>{children}</Navigation>
);
}
하위 태스크 8
getLayout 패턴 구현
페이지별 커스텀 레이아웃 적용
src/components/SearchableLayout.tsx:
import { ReactNode } from "react";
import Form from "next/form";
export default function SearchableLayout({ children }: { children: ReactNode }) {
return (
<>
<div className="flex w-80 p-2 mx-auto bg-slate-300">
<Form className="flex gap-x-1" action="/search">
<input className="bg-slate-400 border border-white" type="text" name="q" required />
<button className="px-1 bg-slate-50 border border-white" type="submit">검색</button>
</Form>
</div>
{children}
</>
);
}SearchableLayout 레이아웃을 책 목록 페이지와 검색 결과 페이지에 적용한다.
src/pages/book/index.tsx:
import { ReactNode } from "react";
import Link from "next/link";
import SearchableLayout from "@/components/SearchableLayout";
import useBooks from "@/hooks/use-books";
export default function BookList() {
const books = useBooks();
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">책 목록</h1>
</header>
<main>
<h2 className="mb-2 italic">구름 출판이 선별한 문장들, 당신의 세계를 넓히는 페이지를 만나보세요.</h2>
<ul>
{books.map(({ id, title, authors, price }) => (
<li key={id}>
<ul className="my-2">
<li>제목: <Link className="underline" href={`/book/${id}`}>{title}</Link></li>
<li>작가: {authors.join(", ")}</li>
<li>가격: {price}</li>
</ul>
</li>
))}
</ul>
</main>
</div>
);
}
BookList.getLayout = (page: ReactNode) => (
<SearchableLayout>{page}</SearchableLayout>
);src/pages/search/index.tsx:
import { ReactNode } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import SearchableLayout from "@/components/SearchableLayout";
import useBooks from "@/hooks/use-books";
export default function Search() {
const books = useBooks();
const router = useRouter();
const { q } = router.query;
const searchedBooks = books.filter(({ title }) => {
if (typeof q !== "string") {
return false;
}
return title.includes(q);
});
return (
<div className="w-80 p-2 mx-auto bg-slate-100">
<header>
<h1 className="mb-2 text-lg font-bold">검색 결과</h1>
</header>
<main>
<h2 className="mb-2 italic">'{q}'에 대한 검색 결과입니다.</h2>
<ul>
{searchedBooks.map(({ id, title, authors, price }) => (
<li key={id}>
<ul className="my-2">
<li>제목: <Link className="underline" href={`/book/${id}`}>{title}</Link></li>
<li>작가: {authors.join(", ")}</li>
<li>가격: {price}</li>
</ul>
</li>
))}
</ul>
</main>
</div>
);
}
Search.getLayout = (page: ReactNode) => (
<SearchableLayout>{page}</SearchableLayout>
);
하위 태스크 9
라우팅 구조 문서화
파일 구조와 URL 매핑 관계 정리
Next.js의 Pages Router는 파일 트리를 기반으로 URL 라우팅을 제공한다. pages 디렉터리에 포함된 각 파일과 디렉터리는 URL의 경로 요소에 해당한다.
예를 들어, pages/example/path/about/index.tsx 파일을 생성하고 컴포넌트 하나를 export default하면 /example/path/about 경로에 매핑된다.