하위 태스크 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>이메일: info@example.com</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 경로에 매핑된다.