[Next.js] 2. 페이지 라우터

Hjin·2024년 12월 31일

페이지 라우터는 Pages 폴더의 구조를 기반으로 페이지 라우팅을 제공한다.

일단 Next.js 프로젝트부터 생성해보자

npx create-next-app14 section02

생성 후 파일 구조를 살펴보면,

  • App Component는 모든 컴포넌트의 부모 컴포넌트. -> 헤더처럼 모든 페이지에 필요한 경우 앱 컴포넌트에 넣어주면 된다.

  • strict mode는 false로 설정해주자. (엄격한 체킹)

1. 페이지 라우팅 설정하기

페이지 라우터는 Pages 폴더의 구조를 기반으로 자동으로 라우팅을 생성함.
파일 기반 라우팅 -> 파일명이 곧 라우트 경로가 됨.

1) 파일로 생성하기

  • 생성하고자 하는 페이지명을 파일 이름으로 (ex. search.tsx) 생성해주기
  • 단일 파일: search.tsx → /search 경로

2) 폴더로 생성하기

  • 생성하고자 하는 경로 명을 폴더 이름으로 (ex. search) 생성 후 폴더 안에 index.tsx 파일 생성
  • 폴더 방식: search/index.tsx → /search 경로

일단, 우리는 앱라우터가 아닌 페이지라우터를 사용하기때문에 next/router에서 import되는 useRouter를 사용하자.

import { useRouter } from "next/router";

export default function Page() {
  const router = useRouter();
  console.log(router);
  const { q } = router.query;
  return <h1>Search {q}</h1>;
}

이렇게 작성하면 url에서 ?q=홍길동 이렇게 받아올 수 있다.

2. 동적 라우팅

만약 계속 변하는 url 파라미터를 받아서 동적 라우팅으로 페이지를 생성해야 한다면
다음과 같이 진행할 수 있다.

import { useRouter } from "next/router";

export default function Page() {
  const router = useRouter();
  console.log(router);
  const { id } = router.query;

  return <h1>Book {id}</h1>;
}
  1. [id].tsx: 단일 동적 파라미터 (예: /book/1, /book/2)
  2. [...id].tsx: Catch all Segments - 여러 동적 파라미터 허용
  3. [[...id]].tsx: Optional Catch all Segments - 파라미터가 없는 기본 경로도 허용

3. 네비게이션 방식

  • Link 컴포넌트 사용: 선언적 방식
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import Link from "next/link";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <header>
        <Link href={"/"}>index</Link>
        &nbsp;
        <Link href={"/search"}>search</Link>
        &nbsp;
        <Link href={"/book/1"}>book1</Link>
        &nbsp;
      </header>
      <Component {...pageProps} />;
    </>
  );
}
  • useRouter 훅 사용: 프로그래매틱 방식
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import Link from "next/link";
import { useRouter } from "next/router";
export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  const onClickButton = () => {
    router.push("/test");
  };
  return (
    <>
      <header>
        <Link href={"/"}>index</Link>
        &nbsp;
        <Link href={"/search"}>search</Link>
        &nbsp;
        <Link href={"/book/1"}>book1</Link>
        &nbsp;
        <div>
          <button onClick={onClickButton}>/test 페이지로 이동</button>
        </div>
      </header>
      <Component {...pageProps} />;
    </>
  );
}

4. 프리패칭

  • 사용자가 방문할 가능성이 있는 페이지의 JavaScript 코드를 미리 로드
  • Link 컴포넌트는 기본적으로 프리패칭 지원
  • router.prefetch() 메서드로 수동 프리패칭 가능
  • prefetch={false} 옵션으로 비활성화 가능

앞서 보았던 예시에서
Link를 이용한 방식은 프리패칭이 진행되는데, Router로 이동한건 그렇지 않다.

하지만, Router로 구현한것도 프리패칭으로 하고싶으면

import "@/styles/globals.css";
import type { AppProps } from "next/app";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
export default function App({ Component, pageProps }: AppProps) {
  const router = useRouter();
  const onClickButton = () => {
    router.push("/test");
  };
  useEffect(() => {
    router.prefetch("/test");
  });
  return (
    <>
      <header>
        <Link href={"/"} prefetch={false}>
          index
        </Link>
        &nbsp;
        <Link href={"/search"}>search</Link>
        &nbsp;
        <Link href={"/book/1"}>book1</Link>
        &nbsp;
        <div>
          <button onClick={onClickButton}>/test 페이지로 이동</button>
        </div>
      </header>
      <Component {...pageProps} />;
    </>
  );
}

이렇게 작성할 수 있다.

5. API Routes

  • /pages/api 폴더 내에 API 엔드포인트 정의 가능
  • 서버사이드 로직 구현에 활용

Routing: API Routes

import { NextApiRequest, NextApiResponse } from "next";

export default function handler(
	req: NextApiRequest, 
    res: NextApiResponse) {
  const date = new Date();
  res.json({ time: date.toLocaleString() });
}

6. 스타일링

  1. inline (ex. style=={{}})
  2. css -> global app에서만 불러올 수 있음.
  3. css module
    -> index.module.css
import style from ""

<h1 className={style.h1}>인덱스</h1>

이렇게 하여 클래스 이름이 겹치는 문제를 차단한다.

7. 페이지별 레이아웃

Index.tsx

import SearchableLayout from "@/components/searchable-layout";
import { ReactNode } from "react";

export default function Home() {
  return <h1>인덱스</h1>;
}

Home.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};

이렇게 getLayout을 이용하여 레이아웃을 지정할 수 있다.

App.tsx

import GlobalLayout from "@/components/global-layout";
import "@/styles/globals.css";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  const getLayout = Component.getLayout;
  return <GlobalLayout>{getLayout(<Component {...pageProps} />)}</GlobalLayout>;
}

아래는 SearchableLayout

SerachablaeLayout.tsx

import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";

export default function SearchableLayout({
  children,
}: {
  children: ReactNode;
}) {
  const [search, setSearch] = useState("");
  const router = useRouter();

  const q = router.query.q as string;

  useEffect(() => {
    setSearch(q || "");
  }, [q]);

  const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
  };
  const onSubmit = () => {
    if (!search || q === search) return;
    router.push(`/search?q=${search}`);
  };
  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      onSubmit();
    }
  };
  return (
    <div>
      <div>
        <input
          value={search}
          onChange={onChangeSearch}
          onKeyDown={onKeyDown}
          placeholder="검색어를 입력하세요 ..."
        />
        <button onClick={onSubmit}>검색</button>
      </div>
      {children}
    </div>
  );
}

Index.module.css

.searchbar_container {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.searchbar_container > input {
  flex: 1;
  padding: 15px;
  border-radius: 5px;
  border: 1px solid rgb(220, 220, 220);
}

.searchbar_container > button {
  width: 80px;
  border-radius: 5px;
  border: none;
  background-color: rgb(37, 147, 255);
  color: white;
  cursor: pointer;
}

8. 사전렌더링과 프리패칭의 차이

사전 렌더링(Pre-rendering):

  • 목적: 초기 페이지 로드 성능 개선
  • 동작: 서버에서 HTML을 미리 생성
  • 시점: 빌드 타임(SSG) 또는 요청 시점(SSR)
  • 대상: 페이지의 초기 HTML 컨텐츠
  • 결과물: 완전한 HTML 문서
// 사전 렌더링: 서버에서 HTML 생성
export async function getStaticProps() {
  const data = await fetchData();
  return { props: { data } };
}
- 사전 렌더링은 "첫 방문"을 빠르게 하는 것이 목적

프리패칭(Prefetching):

  • 목적: 페이지 전환 성능 개선
  • 동작: 다음 페이지의 JavaScript 코드를 미리 다운로드
  • 시점: 현재 페이지 로드 후 백그라운드에서
  • 대상: 링크된 페이지의 JavaScript 번들
  • 결과물: 캐시된 JavaScript 코드
// 프리패칭: 클라이언트에서 다음 페이지 준비
<Link href="/about" prefetch>
  About
</Link>
- 프리패칭은 "페이지 이동"을 빠르게 하는 것이 목적

사전 렌더링(Pre-rendering)의 필요성

  • React의 일반적인 렌더링: 클라이언트 사이드에서 모든 렌더링 수행

    React
    1) 불러온 데이터들을 보관할 State 생성
    2) 데이터 페칭 함수 생성
    3) 컴포넌트 마운트 시점에 fetchData 호출
    4) 데이터 로딩중일 때의 예외처리

  • 문제점: 초기 로딩이 느리고, 데이터 페칭 후 추가 대기 시간 발생

  • Next.js 해결책: 사전 렌더링을 통해 초기 HTML을 미리 생성

    • 사전 렌더링중 발생 (컴포넌트 마운트 이후에도 발생 가능)
    • 데이터 요청 시점이 매우 빨라지는 장점이 있음.

하지만 사전 렌더링이 오래 걸릴 것 같은 경우가 있다. 이런 경우에는 빌드타임에 미리 사전 렌더링할 수 있도록 여러가지 기능들이 있음.

  • 서버사이드 렌더링 (SSR)
    - 가장 기본적인 사전 렌더링 방식으로, 요청이 들어올 때마다 사전 렌더링을 진행 함.
  • 정적 사이트 생성 (SSG)
    - 빌드 타임에 미리 페이지를 사전 렌더링 해 둠.
  • 증분 정적 재생성(ISR)

9. 서버 사이드 렌더링(SSR)

SSR

  • getServerSideProps 함수를 사용하여 구현
  • 특징:
    • 요청마다 페이지를 새로 생성
    • 서버에서만 실행됨
    • 항상 최신 데이터 반영 가능
  • 사용예시:
export const getServerSideProps = async () => {
  const data = await fetchData();
  return {
    props: { data }
  };
};

이때, return은 객체여야 한다 ! (그냥 받아들이기)

이때, 이 getServerSideProps() 함수는 딱 한번만 실행됨 (오직 서버 측에서만 실행이 됨)
그렇기에, 로그를 저 함수내에서 찍으면 웹에는 로그가 안뜨고 그냥 터미널에서만 뜰거임.

Home 컴포넌트는 서버에서 한 번(사전렌더링), 브라우저(Hydration)에서 한 번 실행이 되어 총 두 번 실행이 됨!

server에서 이용하려하면 server는 undefined이기때문에 만약 브라우저에서만 실행되는 코드를 쓰고 싶으면? -> useEffect()를 사용하면 컴포넌트가 마운트된 이후에 실행이 된다.

export const getServerSideProps = async () => {
  // const allBooks = await fetchBooks();
  // const recoBooks = await fetchRandomBooks();
  const [allBooks, recoBooks] = await Promise.all([fetchBooks,fetchRandomBooks])
  // Promise.all을 통해 배열 안에 있는 함수들을 순차적이 아닌 병렬적으로 한번에 실행. -> 더 빠름
  return {
    props: {
      allBooks,
      recoBooks,
    },
  };
};
import { BookData } from "@/types";

export default async function fetchBooks(): Promise<BookData[]> {
  const url = `http://localhost:12345/book`;
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error();
    }
    return await response.json();
  } catch (err) {
    console.error(err);
    return [];
  }
}

10. 정적 사이트 생성(SSG)

  • getStaticProps 함수를 사용
  • 특징:
    • 빌드 시점에 페이지 생성
    • 매우 빠른 페이지 로드
    • 데이터가 자주 변경되지 않는 페이지에 적합
  • 동적 라우팅 시 getStaticPaths 필요

    장점 : 빠르게 응답 가능 / 단점 : 빌드 타임 이후에서 페이지를 새로 생성하지 않아 최신 데이터를 반영하기 어렵다. -> 데이터가 자주 업데이트 되지 않는 정적인 페이지에 적합 (매번 똑같은 페이지만 응답)

import BookItem from "@/components/book-item";
import SearchableLayout from "@/components/searchable-layout";
import fetchBooks from "@/lib/fetch-books";
import fetchRandomBooks from "@/lib/fetch-random-books";
import { InferGetStaticPropsType } from "next";
import { ReactNode } from "react";
import style from "./index.module.css";

export const getStaticProps = async () => {
  // const allBooks = await fetchBooks();
  // const recoBooks = await fetchRandomBooks();
  console.log("인덱스 페이지");
  const [allBooks, recoBooks] = await Promise.all([
    fetchBooks(),
    fetchRandomBooks(),
  ]);
  // Promise.all을 통해 배열 안에 있는 함수들을 순차적이 아닌 병렬적으로 한번에 실행. -> 더 빠름
  return {
    props: {
      allBooks,
      recoBooks,
    },
  };
};

export default function Home({
  allBooks,
  recoBooks,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <div className={style.container}>
      <section>
        <h3>지금 추천하는 도서</h3>
        {recoBooks.map((book) => (
          <BookItem key={book.id} {...book} />
        ))}
      </section>
      <section>
        <h3>등록된 모든 도서</h3>
        {allBooks.map((book) => (
          <BookItem key={book.id} {...book} />
        ))}
      </section>
    </div>
  );
}

Home.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};
import BookItem from "@/components/book-item";
import SearchableLayout from "@/components/searchable-layout";
import fetchBooks from "@/lib/fetch-books";
import { BookData } from "@/types";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
// export const getStaticProps = async (context: GetStaticPropsContext) => {
//   //context에는 현재 브라우저에서 받은 모든 요청에 대한 정보가 다 있음.
//   const q = context.query.q;
//   const books = await fetchBooks(q as string);
//   return {
//     props: { books },
//   };
// };
export default function Page() {
  const [books, setBooks] = useState<BookData[]>([]);
  const router = useRouter();
  const q = router.query.q;
  const fetchSearchResult = async () => {
    const data = await fetchBooks(q as string);
    setBooks(data);
  };
  useEffect(() => {
    if (q) {
      fetchSearchResult();
    }
  });
  return (
    <div>
      {books.map((book) => (
        <BookItem key={book.id} {...book} />
      ))}
    </div>
  );
}

Page.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};

dynamic한 경로를 갖는 페이지는 이렇게 getStaticPath라는 함수가 필요함!!
이때, fallback에 대해 알아보자.

import fetchOneBook from "@/lib/fetch-one-book";
import { GetStaticPropsContext, InferGetStaticPropsType } from "next";
import style from "./[id].module.css";

export const getStaticProps = async (context: GetStaticPropsContext) => {
  const id = context.params!.id;
  const book = await fetchOneBook(Number(id));
  return {
    props: { book },
  };
};
export const getStaticPaths = () => {
  return {
    paths: [
      { params: { id: "1" } },
      { params: { id: "2" } },
      { params: { id: "3" } },
    ],
    fallback:
      //예외상황에 대한 대비
      false, //not found
  };
};
export default function Page({
  book,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  if (!book) return "문제 발생";
  const {
    // id,
    title,
    subTitle,
    description,
    author,
    publisher,
    coverImgUrl,
  } = book;
  return (
    <div className={style.container}>
      <div
        className={style.cover_img_container}
        style={{ backgroundImage: `url('${coverImgUrl}')` }}
      >
        <img src={coverImgUrl} />
      </div>
      <div className={style.title}>{title}</div>
      <div className={style.subTitle}>{subTitle}</div>
      <div className={style.author}>
        {author} | {publisher}
      </div>
      <div className={style.description}>{description}</div>
    </div>
  );
}

fallback

  1. false : 404 not found 반환
  2. blocking : 즉시 생성 (like SSR)
  3. true : 즉시 생성 + 페이지만 미리 반환

11. 증분 정적 재생성(ISR)

  • SSG의 확장된 형태
  • 특징:
    • 정해진 시간 간격으로 페이지 재생성
    • revalidate 옵션으로 시간 설정
      -> 매우 빠른 속도로 응답이 가능함과 동시에 최신 데이터 반영 가능
return {
  props: {
    allBoocks,
    recoBooks,
  },
  revalidate:3,
};
  • On-Demand ISR: API 요청으로 페이지 재생성 가능 (시간과 관련 없이 사용자의 행동에 따라 페이지가 업데이트 되어야하는 경우)
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    await res.revalidate("/");
    return res.json({ revalidate: true });
  } catch (err) {
    console.log(err);
    res.status(500).send("Revalidation Failed");
  }
}


http://localhost:3000/api/revalidate

이렇게 API routes를 통해서 가능.

12. SEO 설정

  • next/head 컴포넌트 사용
  • 메타 태그, 타이틀 등 설정 가능
<Head>
  <title>페이지 제목</title>
  <meta property="og:title" content="제목" />
  <meta property="og:description" content="설명" />
</Head>

      <Head>
        <title>한입북스</title>
        <meta property="og:image" content="/thumbnail.png" />
        <meta property="og:title" content="한입북스" />
        <meta
          property="og:description"
          content="한입북스에 등록된 도서들을 만나보세요"
        />
      </Head>

13. 배포하기

sudo npm install -g vercel
vercel login
vercel
vercel --prod

14. Pages Router의 장단점

장점:

  • 파일 시스템 기반의 간편한 라우팅
  • 다양한 사전 렌더링 방식 제공
  • 간단한 API 라우트 구현

단점:

  • 페이지별 레이아웃 설정이 복잡
  • 데이터 페칭이 페이지 컴포넌트에 집중됨
  • 불필요한 컴포넌트도 JS 번들에 포함되는 문제
profile
HYU Information System

0개의 댓글