[Next.js] 라우팅

Jeris·2023년 6월 1일
0

코드잇 부트캠프 0기

목록 보기
102/107

1. 파일시스템 기반 라우팅이란?

파일시스템 기반 라우팅

라우팅이란 어떤 주소에 어떤 페이지를 보여줄지 정하는 것입니다.

리액트 라우터는 특정 경로가 보여줄 컴포넌트에 매칭되는 라우팅 방식을 사용합니다.

파일시스템 기반 라우팅은 파일의 경로가 주소에 매칭되는 라우팅 방식입니다. Next.js는 파일시스템 기반 라우팅을 지원하여 html로 개발할 때와 비슷하게 개발할 수 있습니다.


2. 페이지 나누기

페이지는 /pages 디렉토리 내에 JavaScript 또는 TypeScript 파일로 생성되며, 파일 및 폴더 구조가 URL 경로로 자동으로 변환됩니다.

파일 이름 앞에 대괄호를 추가하고 대괄호 안에 param을 넣어 생성하면 다이나믹 라우팅을 구현할 수 있습니다.


Next.js의 Link 컴포넌트는 클라이언트 사이드 네비게이션을 가능하게 합니다. 즉, 브라우저가 새 페이지를 로드할 때 전체 페이지를 새로고침(Full reload)하지 않고 JavaScript를 사용하여 변경된 부분만 업데이트하며, 이를 통해 더 빠른 페이지 전환을 경험할 수 있습니다.

Link 컴포넌트의 href attribute에 외부 링크를 작성하면 Link 컴포넌트가 a태그처럼 동작합니다.

Link 컴포넌트를 사용하는 방법은 다음과 같습니다:

import Link from 'next/link';

export default function Home() {
  return (
    <>
      <h1>Codeitmall</h1>
      <ul>
        <li>
          <Link href="/products/1">첫 번째 상품</Link>
        </li>
        <li>
          <Link href="/products/2">두 번째 상품</Link>
        </li>
        <li>
          <Link href="/products/3">세 번째 상품</Link>
        </li>
        <li>
          <Link href="https://codeit.kr">코드잇</Link>
        </li>
      </ul>
    </>
  )
}

4. useRouter: 쿼리 사용하기

Next.js의 useRouter는 현재 페이지의 라우트 정보에 접근할 수 있는 Hook입니다. 이 Hook을 사용하면 URL 경로, URL 쿼리 파라미터 등과 같은 정보에 접근할 수 있습니다.

useRouter Hook은 Router 객체를 반환하며, 이 객체는 다음과 같은 주요 프로퍼티를 포함하고 있습니다:

  • pathname 현재 라우트의 경로 이름입니다. (예: /products/[id])
  • query: URL 쿼리 객체입니다. 동적 라우트를 사용할 때 해당 값에 접근할 수 있습니다. (예: { q: '검색내용' })
    asPath: 브라우저에 표시되는 실제 경로입니다. (예: /products/1)

이 중에서 router.query는 URL의 쿼리 파라미터와 동적 라우트 세그먼트를 포함한 객체입니다. 예를 들어, /posts/[id]와 같은 동적 라우트에서 id 값을 가져오려면 router.query.id를, 쿼리 스트링에서 q 값을 가져오려면 router.query.q를 사용하면 됩니다.

import { useRouter } from "next/router";

export default function Product() {
  const router = useRouter();
  const { id } = router.query;

  return <div>Product {id} 페이지</div>;
}
import { useRouter } from "next/router";

export default function Search() {
  const router = useRouter();
  const { q } = router.query;

  return (
    <div>
      <h1>Search 페이지</h1>
      <h2>{q} 검색 결과</h2>
    </div>
  );
};

5. useRouter: 페이지 이동하기

router.push 메서드는 클라이언트 사이드에서 새로운 URL로 이동하도록 하는 기능을 제공합니다. 이 메서드를 호출하면, 현재 페이지는 변경된 URL에 해당하는 새로운 페이지로 대체되며, 페이지의 이력(history) 스택에 추가됩니다.

router.push 메소드는 두 개의 주요 arguments를 받을 수 있습니다:

  • url (필수): 이동할 URL. 동적 라우팅의 경우, 라우트 패턴을 지정할 수 있습니다 (예: /products/[id]).
  • as (선택적): 실제 브라우저에서 보여질 URL. url 인자가 동적 라우트 패턴일 경우 이를 사용하여 실제 경로를 지정할 수 있습니다.
import { useRouter } from 'next/router';
import { useState } from 'react';

export default function SearchForm({ initialValue = '' }) {
  const router = useRouter();
  const [value, setValue] = useState(initialValue);

  function handleChange(e) {
    setValue(e.target.value);
  };

  function handleSubmit(e) {
    e.preventDefault();
    if (!value) {
      router.push('/');
      return;
    }

    router.push(`/search?q=${value}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" value={value} onChange={handleChange} />
      <button>검색</button>
    </form>
  );
}

SearchForm 컴포넌트는 initialValue prop을 받아, 이를 value 상태의 초기값으로 설정합니다. 이 value는 검색 입력 필드에 바인딩됩니다.

handleChange 함수는 검색 입력 필드의 값이 변경될 때마다 호출되며, 이 함수는 value 상태를 업데이트합니다.

handleSubmit 함수는 폼이 제출될 때 호출됩니다. 이 함수는 폼 제출의 기본 동작(쿼리 스트링과 함께 페이지 이동하는 것)을 방지한 후(e.preventDefault()), value 상태가 비어있는지 확인합니다. 만약 value가 falsy 값이라면, 루트 페이지(/)로 리다이렉트합니다.

만약 value 상태가 truthy 값이라면, 검색 쿼리를 URL에 추가하여 검색 결과 페이지(/search)로 리다이렉트합니다. 여기서 router.push 메서드는 페이지를 이동하는데 사용됩니다.

마지막으로, 이 컴포넌트는 검색 입력 필드와 제출 버튼을 포함한 폼을 렌더링합니다. 입력 필드의 값은 value 상태에 바인딩되며, 값이 변경되면 handleChange 함수가 호출됩니다. 폼이 제출되면 handleSubmit 함수가 호출됩니다.

이 컴포넌트를 통해 사용자는 검색 쿼리를 입력하고 결과를 보기 위해 페이지를 이동할 수 있습니다. /search 페이지에서는 URL의 쿼리 파라미터를 사용하여 검색 결과를 표시할 수 있습니다.


6. API 연동하기

// @/lib/axios.js
import axios from 'axios';

const instance = axios.create({
  baseURL: 'https://learn.codeit.kr/api/codeitmall',
});

export default instance;
// @/pages/products/[id].js
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import axios from '@/lib/axios';
import styles from '@/styles/Product.module.css';
import SizeReviewList from '@/components/SizeReviewList';
import StarRating from '@/components/StarRating';
import Header from '@/components/Header';
import Container from '@/components/Container';

export default function Product() {
  const [product, setProduct] = useState();
  const [sizeReviews, setSizeReviews] = useState([]);
  const router = useRouter();
  const { id } = router.query;

  async function getProduct(targetId) {
    const res = await axios.get(`/products/${targetId}`);
    const nextProduct = res.data;
    setProduct(nextProduct);
  }

  async function getSizeReviews(targetId) {
    const res = await axios.get(`/size_reviews/?product_id=${targetId}`);
    const nextSizeReviews = res.data.results ?? [];
    setSizeReviews(nextSizeReviews);
  }

  useEffect(() => {
    if (!id) return;

    getProduct(id);
    getSizeReviews(id);
  }, [id]);

  if (!product) return null;

  return (
    <>
      <Header />
      <Container>
        <h1 className={styles.name}>
          {product.name}
          <span className={styles.englishName}>{product.englishName}</span>
        </h1>
        <div className={styles.content}>
          <div>
            <img className={styles.image} src={product.imgUrl} alt={product.name} />
          </div>
          <div>
            <section className={styles.section}>
              <h2 className={styles.sectionTitle}>제품 정보</h2>
              <div className={styles.info}>
                <table className={styles.infoTable}>
                  <tbody>
                    <tr>
                      <th>브랜드 / 품번</th>
                      <td>
                        {product.brand} / {product.productCode}
                      </td>
                    </tr>
                    <tr>
                      <th>제품명</th>
                      <td>{product.name}</td>
                    </tr>
                    <tr>
                      <th>가격</th>
                      <td>
                        <span className={styles.salePrice}>
                          {product.price.toLocaleString()}</span>{' '}
                        {product.salePrice.toLocaleString()}</td>
                    </tr>
                    <tr>
                      <th>포인트 적립</th>
                      <td>{product.point.toLocaleString()}</td>
                    </tr>
                    <tr>
                      <th>구매 후기</th>
                      <td className={styles.starRating}>
                        <StarRating value={product.starRating} />{' '}
                        {product.starRatingCount.toLocaleString()}
                      </td>
                    </tr>
                    <tr>
                      <th>좋아요</th>
                      <td className={styles.like}>{product.likeCount.toLocaleString()}
                      </td>
                    </tr>
                  </tbody>
                </table>
              </div>
            </section>
            <section className={styles.section}>
              <h2 className={styles.sectionTitle}>사이즈 추천</h2>
              <SizeReviewList sizeReviews={sizeReviews ?? []} />
            </section>
            <section className={styles.section}>
              <h2 className={styles.sectionTitle}>사이즈 추천하기</h2>
            </section>
          </div>
        </div>
      </Container>
    </>
  );
}

  • 컴포넌트가 처음 렌더링될 때, useRouter 훅을 통해 URL 경로의 id 파라미터를 가져옵니다. 이 id는 제품의 고유 식별자로 사용됩니다.
  • getProduct 함수와 getSizeReviews 함수를 통해 해당 id에 해당하는 제품의 정보와 사이즈 리뷰를 비동기적으로 불러옵니다. 불러온 정보는 각각 productsizeReviews 상태에 저장됩니다.
  • useEffect 훅을 사용하여 id 값이 변경될 때마다 제품 정보와 사이즈 리뷰를 다시 불러옵니다.
  • product 상태가 아직 설정되지 않았다면, 컴포넌트는 아무것도 렌더링하지 않습니다(return null). 이는 제품 정보가 아직 불러와지지 않았을 때 컴포넌트가 렌더링되는 것을 방지합니다.
  • product 상태가 설정되면, 제품 정보와 사이즈 리뷰를 포함한 페이지를 렌더링합니다. 제품 정보에는 제품의 이미지, 브랜드, 제품 코드, 이름, 가격, 적립 포인트, 후기 등이 포함됩니다. 사이즈 리뷰 리스트는 SizeReviewList 컴포넌트에 전달되어 렌더링됩니다.
  • 페이지 레이아웃은 HeaderContainer 컴포넌트를 사용하여 구성되며, CSS 모듈을 사용하여 스타일링됩니다.

7. 리다이렉트

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  async redirects() {
    return [
      {
        source: '/products/:id',
        destination: '/items/:id',
        permanent: false,
      },
    ]
  },
}

module.exports = nextConfig
  • redirects() 특정 URL을 다른 URL로 리다이렉션하는 규칙을 정의합니다. 이 함수는 리다이렉션 객체의 배열을 반환하며, 각 객체는 source, destination, permanent 속성을 가지고 있습니다.
    • source 리다이렉션의 원본 경로를 지정합니다.
    • destination 리다이렉션의 대상 경로를 지정합니다.
    • permanent: 리다이렉션이 영구적인지를 지정합니다. true로 설정하면 HTTP 상태 코드 301(영구적 이동)을, false로 설정하면 HTTP 상태 코드 307(임시적 이동)을 반환합니다.

참고 자료

Redirects - Next.js


8. 커스텀 404 페이지

// @/pages/404.js
import ButtonLink from '@/components/ButtonLink';
import Container from '@/components/Container';
import Header from '@/components/Header';
import styles from '@/styles/NotFound.module.css';

export default function NotFound() {
  return (
    <>
      <Header />
      <Container>
        <div className={styles.notFound}>
          <div className={styles.content}>
            찾을 수 없는 페이지입니다.
            <br />
            요청하신 페이지가 사라졌거나, 잘못된 경로를 이용하셨어요 :)
          </div>
          <ButtonLink className={styles.button} href="/">홈으로 이동</ButtonLink>
        </div>
      </Container>
    </>
  );
} 

pages 디렉토리에 404.js 파일로 커스텀 404 page를 보여줄 수 있습니다.


9. 커스텀 App과 Document

_app.js

Next.js에서 _app.js 파일은 모든 페이지에 공통으로 적용되는 컴포넌트입니다. 이 파일은 Next.js의 루트 컴포넌트이며, 이를 통해 모든 페이지에서 공유되는 상태를 유지하거나, 공통 레이아웃을 적용하거나, 페이지 전환 시 추가 로직을 수행할 수 있습니다.

import Container from '@/components/Container';
import Header from '@/components/Header';
import  '@/styles/global.css';

export default function App({ Component, pageProps }) {
  return (
    <>
      <Header />
      <Container>
        <Component {...pageProps} />
      </Container>
    </>
  );
}

함수 컴포넌트 AppComponentpageProps를 인자로 받습니다. Component는 현재 페이지의 컴포넌트를 나타내며, pageProps는 그 컴포넌트에 전달되는 props를 나타냅니다. 여기에서

_document.js

_document.js는 Next.js에서 application의 HTML document 구조를 커스터마이징할 수 있게 해주는 파일입니다. 이 파일은 서버에서만 렌더링되며, 초기 페이지 로드에만 적용됩니다. 따라서 이벤트 핸들러와 같은 클라이언트 사이드의 코드는 _document.js에 추가할 수 없습니다.

_document.js는 주로 lang 속성 설정, CSS-in-JS 라이브러리의 서버 사이드 렌더링(SSR) 설정, Google Fonts 등 외부 리소스의 link 설정 등에 사용됩니다.

import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
  return (
    <Html lang="ko">
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

여기서 Html, Head, Main, NextScript는 각각 HTML 문서의 <html>, <head>, <body>, 그리고 Next.js에 필요한 스크립트들을 나타냅니다.

이 코드는 lang 속성을 "ko"로 설정하여 웹 페이지의 기본 언어를 한국어로 명시하고 있습니다. 이렇게 언어를 명시하면 스크린 리더와 검색 엔진 등이 페이지의 내용을 더 정확하게 이해하는 데 도움이 됩니다.


10. Context 활용하기

// @/pages/_app.js
import Container from '@/components/Container';
import Header from '@/components/Header';
import { ThemeProvider } from '@/lib/ThemeContext';
import  '@/styles/global.css';

export default function App({ Component, pageProps }) {
  return (
    <ThemeProvider>
      <Header />
      <Container>
        <Component {...pageProps} />
      </Container>
    </ThemeProvider>
  );
}
// @/lib/ThemeContext.js
import { createContext, useContext, useEffect, useState } from 'react';

export const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');

  useEffect(() => {
    document.body.classList.add(theme);

    return () => {
      document.body.classList.remove(theme);
    }
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const themeContext = useContext(ThemeContext);
  if (!themeContext) {
    throw new Error('ThemeContext 안에서 써야 합니다');
  }

  return themeContext;
}
  • ThemeContextcreateContext 함수를 통해 생성된 새로운 context입니다. 이 context는 애플리케이션의 여러 컴포넌트에서 접근할 수 있는 값을 저장하는 데 사용됩니다.
  • ThemeProvider 함수는 ThemeContext.Provider 컴포넌트로 자식 컴포넌트를 렌더링합니다. value prop을 통해 themesetTheme을 context value로 제공합니다. 그래서 이 ThemeProvider 컴포넌트의 자식 컴포넌트들은 useContext(ThemeContext)를 통해 현재 theme 값을 가져올 수 있고, setTheme 함수를 통해 theme 값을 변경할 수 있습니다.
  • useEffect 내에서는 theme 값을 바탕으로 document.body의 클래스를 설정하고, theme 값이 변경되면 이전 theme의 클래스를 제거하고 새 theme의 클래스를 추가합니다.
  • useTheme은 커스텀 훅으로, ThemeContext에 접근할 수 있게 해줍니다. 만약 ThemeContext 외부에서 이 훅을 호출하면, 에러를 던지게 됩니다. 이렇게 하면 개발자가 이 훅을 잘못 사용하는 것을 방지할 수 있습니다.

11. API 라우팅

Next.js에서는 페이지를 만드는 것처럼 간단하게 백엔드 API를 만들 수 있습니다. 사실상 작은 Node.js 서버를 구현할 수 있습니다.

우선 /pages 폴더 아래에 /api라는 폴더를 만들고 특별한 형태의 자바스크립트 파일을 추가하면 됩니다.

//@/pages/api/cart.js
let cart = [];

export default function handler(req, res) {
  if (req.method === 'GET') {
    return res.status(200).json(cart);
  } else if (req.method === 'PUT') {
    cart = req.body;
    return res.status(200).json(cart);
  } else {
    return res.sendStatus(404);
  }
}

이렇게 default export로 리퀘스트 객체(req)와 리스폰스 객체(res)를 파라미터로 받는 함수를 만들면 됩니다. 리퀘스트 객체와 리스폰스 객체는 Node.js의 리퀘스트 객체와 리스폰스 객체입니다.

위 코드는 GET 리퀘스트를 보냈을 때 cart 배열을 리스폰스로 보내 주고, PUT 리퀘스트를 보냈을 때 cart 배열을 수정하는 간단한 코드입니다.

이 API의 주소는 Next.js에서 페이지를 만들었을 때의 주소와 마찬가지입니다. /api/cart.js라는 경로이므로 /api/cart라는 주소로 리퀘스트를 보내면 파일에 있는 핸들러 함수를 실행해서 리스폰스를 보내 주는 형태입니다.

웹 브라우저에서 http://localhost:3000/api/cart라는 주소로 접속하거나, API 테스트를 해 보면 아래와 같은 JSON 데이터가 리스폰스로 전달됩니다.

GET 리퀘스트를 보낼 때

GET http://localhost:3000/api/cart
Content-Type: application/json

GET 리퀘스트를 보내고 받은 리스폰스 예시

[]

PUT 리퀘스트를 보낼 때

PUT http://localhost:3000/api/cart
Content-Type: application/json

[1, 2, 3]

PUT 리퀘스트를 보내고 받은 리스폰스 예시

[1, 2, 3]

Feedback

  • Next.js의 장단점을 정리하자.
  • Express와 Next를 연결해보자.

Reference

profile
job's done

0개의 댓글