[Next.js] 프리렌더링

Jeris·2023년 6월 1일
0

코드잇 부트캠프 0기

목록 보기
104/107

1. 프리렌더링이란?

프리렌더링이란?

프리렌더링(Pre-rendering)은 웹 페이지의 HTML을 서버에서 미리 생성하는 작업을 말합니다. 이를 통해 브라우저에서 페이지를 렌더링하는 시점에 이미 필요한 HTML이 준비되어 있게 되므로, 사용자에게 웹 페이지의 내용을 빠르게 제공할 수 있게 됩니다. React 프로젝트를 Next.js를 통해 프리렌더링을 하면 빈 HTML 대신 프리렌더링된 HTML 파일을 제공하므로 검색엔진 최적화가 가능합니다.

프리렌더링 방식

  • 정적 생성(Static Generation): 이 방식은 빌드 타임에 모든 필요한 HTML을 미리 생성합니다. 각 페이지는 빌드 타임에 콘텐츠를 불러와 HTML을 생성하며, 이 HTML은 사용자가 페이지를 요청할 때마다 재사용됩니다. 이 방식은 콘텐츠가 사용자에 따라 달라지지 않는 사이트에 적합합니다.
  • 서버-사이드 렌더링 (Server-Side Rendering, SSR): 이 방식은 사용자가 페이지를 요청할 때마다 HTML을 생성합니다. 이는 콘텐츠가 자주 업데이트되거나, 각 사용자에게 개별적으로 맞춤화된 콘텐츠를 제공해야 하는 사이트에 적합합니다.

하이드레이션

하이드레이션(Hydration)이란 웹 개발에서 클라이언트 측 JavaScript가 이미 렌더링된 HTML 마크업을 가져오는(혹은 재사용하는) 과정을 말합니다. 이 과정에서 JavaScript는 HTML 요소에 이벤트 리스너를 추가하거나, DOM 조작을 통해 동적인 기능을 추가하는 등의 작업을 수행합니다.

이러한 작업을 통해, 프리렌더링된 정적인 HTML 페이지가 동적인 웹 애플리케이션이 됩니다.

Next.js, Gatsby, Nuxt.js 등의 프레임워크에서는 이러한 하이드레이션 과정을 자동으로 수행해주며, 이를 통해 개발자는 서버 사이드 렌더링과 클라이언트 사이드 렌더링을 적절히 조합하여 웹 페이지의 초기 로드 성능을 향상시키고, 동시에 웹 애플리케이션의 동적인 기능을 유지할 수 있습니다.


2. 정적 생성

Next.js에서는 정적 생성을 기본적으로 해줍니다. getStaticProps 내장 함수를 통해 api로 불러오는 데이터로도 정적 생성을 할 수 있습니다.

getStaticProps는 Next.js에서 제공하는 함수로서, 빌드 시간에 데이터를 가져와서 페이지를 미리 생성하는 데 사용됩니다. 이를 정적 생성(Static Generation)이라고 합니다.

getStaticProps는 서버 사이드에서만 실행되며, 클라이언트 사이드에서는 실행되지 않습니다. 따라서, 이 함수 내에서 직접 데이터베이스에 접근하는 등의 작업을 수행해도 노출될 위험이 없습니다.

getStaticProps 함수는 객체를 반환하며, 이 객체는 props라는 필드를 가져야 합니다. 이 props는 생성된 페이지의 React 컴포넌트에 전달됩니다.

// @/pages/index.js
import { useEffect, useState } from 'react';
import ProductList from '@/components/ProductList';
import SearchForm from '@/components/SearchForm';
import axios from '@/lib/axios';
import styles from '@/styles/Home.module.css';
import Head from 'next/head';

export async function getStaticProps() {
  const res = await axios.get('/products');
  const products = res.data.results;

  return {
    props: {
      products,
    }
  }
}

export default function Home({ products }) {
  return (
    <>
      <Head>
        <title>Codeitmall</title>
      </Head>
      <SearchForm />
      <ProductList className={styles.productList} products={products} />
    </>
  )
}
  • getStaticProps: 이 함수는 Next.js가 서버 사이드에서 빌드 타임에 호출합니다. 여기서는 /products 경로에서 제품 데이터를 가져와서 props로 반환하고 있습니다. 이 propsHome 컴포넌트에 전달되어, 빌드 타임에 HTML 페이지에 미리 렌더링됩니다.
// @/pages/products/[id].js
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Image from 'next/image';
import axios from '@/lib/axios';
import styles from '@/styles/Product.module.css';
import SizeReviewList from '@/components/SizeReviewList';
import StarRating from '@/components/StarRating';
import Spinner from '@/components/Spinner';

export async function getStaticPaths() {
  const res = await axios.get('/products/');
  const products = res.data.results;
  const paths = products.map((product) => ({
    params: { id: String(product.id) },
  }));

  return {
    paths,
    fallback: true,
  }
}

export async function getStaticProps(context) {
  const productId = context.params['id'];
  let product;
  try {
    const res = await axios.get(`/products/${productId}`);
    product = res.data;
  } catch {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      product,
    }
  }
}

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

  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;

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

  if (!product) return (
    <div className={styles.loading}>
      <Spinner />
    </div>
  );

  return (
    <>
      <h1 className={styles.name}>
        {product.name}
        <span className={styles.englishName}>{product.englishName}</span>
      </h1>
      <div className={styles.content}>
        <div className={styles.image}>
          <Image fill 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>
    </>
  );
}
  • getStaticPaths: 이 함수는 동적 라우트를 가진 페이지에서 사용됩니다. 즉, 페이지의 경로에 변수가 포함된 경우 (예: /products/[id]) 해당 함수를 사용하여 필요한 모든 경로를 생성할 수 있습니다.
    • 위의 코드에서는 axios.get('/products/')를 통해 모든 제품의 정보를 가져온 후, 이를 바탕으로 각 제품에 해당하는 경로를 생성합니다. 생성된 경로는 params 객체 안에 id라는 키로 저장되며, 이 id는 getStaticProps에서 사용됩니다.
    • fallback: true 옵션은 빌드 타임에 생성되지 않은 경로에 접근했을 때의 동작을 정의합니다. true로 설정하면, 빌드 타임에 생성되지 않은 경로에 대해서는 첫 방문시 서버 사이드 렌더링을 수행하여 페이지를 제공합니다.
  • getStaticProps: 이 함수는 빌드 타임에 각 페이지가 필요로 하는 데이터를 불러옵니다. 이 함수는 context 객체를 인자로 받는데, 이 객체는 여러 정보 중에서 params라는 필드를 포함하고 있습니다. paramsgetStaticPaths에서 생성한 경로의 파라미터를 포함하고 있습니다.
    • 위의 코드에서는 context.params['id']를 통해 제품의 id를 가져온 후, 이를 이용하여 해당 제품의 상세 정보를 가져옵니다. 만약 해당 제품의 정보를 가져오는 데 실패하면 notFound: true를 반환하여 404 페이지를 표시하게 됩니다.
  • 이렇게 getStaticPathsgetStaticProps를 사용하면, 빌드 타임에 필요한 모든 페이지를 미리 생성할 수 있습니다. 이렇게 생성된 페이지는 요청이 있을 때마다 재사용되므로, 서버 부하를 줄이고 응답 시간을 단축시킬 수 있습니다. 또한, 정적으로 생성된 페이지는 CDN에 캐싱이 가능하므로, 더욱 빠른 페이지 로드를 가능하게 합니다.

3. 서버사이드 렌더링

// @/pages/search.js
import ProductList from '@/components/ProductList';
import SearchForm from '@/components/SearchForm';
import axios from '@/lib/axios';
import styles from '@/styles/Search.module.css';
import Head from 'next/head';

export async function getServerSideProps(context) {
  const q = context.query['q'];

  const res = await axios.get(`/products/?q=${q}`);
  const products = res.data.results ?? [];

  return {
    props: {
      products,
      q,
    },
  }
}

export default function Search({ q, products }) {
  return (
    <>
      <Head>
        <title>{q} 검색 결과 - Codeitmall</title>
      </Head>
      <SearchForm initialValue={q} />
      <h2 className={styles.title}>
        <span className={styles.keyword}>{q}</span> 검색 결과
      </h2>
      <ProductList className={styles.productList} products={products} />
    </>
  );
}
  • getServerSideProps: 이 함수는 각 요청이 있을 때마다 실행되며, 페이지의 props를 반환합니다. 즉, 요청이 들어올 때마다 필요한 데이터를 서버에서 불러온 후, 이를 바탕으로 페이지를 생성합니다.
    • 위 코드에서는 context.query['q']를 통해 검색어를 가져오고, 이를 이용하여 제품을 검색하는 API를 호출합니다. 반환된 제품 리스트와 검색어를 props로 반환하여, 이를 통해 검색 결과 페이지를 생성합니다.
  • Search component: getServerSideProps 함수를 통해 반환된 props를 받아, 이를 이용하여 페이지를 렌더링합니다. 여기서는 검색어와 검색 결과를 이용하여 검색 결과 페이지를 보여주고 있습니다.
  • 이런 방식을 사용하면, 사용자가 매번 다른 검색어로 검색할 수 있으므로, 이 경우에는 정적 생성 방식보다 서버 사이드 렌더링 방식이 더 적합합니다. 이는 검색어가 무수히 많을 수 있으므로, 모든 검색어에 대해 페이지를 미리 생성하는 것은 불가능하기 때문입니다. 또한, 검색 결과는 시간에 따라 변할 수 있으므로, 요청이 있을 때마다 새로운 데이터를 불러와 페이지를 생성하는 것이 필요합니다.
import Image from 'next/image';
import axios from '@/lib/axios';
import styles from '@/styles/Product.module.css';
import SizeReviewList from '@/components/SizeReviewList';
import StarRating from '@/components/StarRating';
import Spinner from '@/components/Spinner';

export async function getServerSideProps(context) {
  const productId = context.params['id'];
  let product;
  try {
    const res = await axios.get(`/products/${productId}`);
    product = res.data;
  } catch {
    return {
      notFound: true,
    };
  }

  const res = await axios.get(`/size_reviews/?product_id=${productId}`);
  const sizeReviews = res.data.results ?? [];

  return {
    props: {
      product,
      sizeReviews
    }
  }
}

export default function Product({ product, sizeReviews }) {
  if (!product) return (
    <div className={styles.loading}>
      <Spinner />
    </div>
  );

  return (
    <>
      <h1 className={styles.name}>
        {product.name}
        <span className={styles.englishName}>{product.englishName}</span>
      </h1>
      <div className={styles.content}>
        <div className={styles.image}>
          <Image fill 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>
    </>
  );
}
  • SSR과 SSG는 동시에 사용할 수 없습니다.

4. 클라이언트에서 데이터 주고 받기

import { useState } from 'react';
import Image from 'next/image';
import axios from '@/lib/axios';
import styles from '@/styles/Product.module.css';
import SizeReviewList from '@/components/SizeReviewList';
import StarRating from '@/components/StarRating';
import Spinner from '@/components/Spinner';
import Dropdown from '@/components/Dropdown';
import Input from '@/components/Input';
import Button from '@/components/Button';
import sizeReviewLabels from '@/lib/sizeReviewLabels';

export async function getServerSideProps(context) {
  const productId = context.params['id'];
  let product;
  try {
    const res = await axios.get(`/products/${productId}`);
    product = res.data;
  } catch {
    return {
      notFound: true,
    };
  }

  const res = await axios.get(`/size_reviews/?product_id=${productId}`);
  const sizeReviews = res.data.results ?? [];
  
  return {
    props: {
      product,
      sizeReviews,
    }
  }
}

export default function Product({ product, sizeReviews: initialSizeReviews }) {
  const [sizeReviews, setSizeReviews] = useState(initialSizeReviews);
  const [formValue, setFormValue] = useState({
    size: 'M',
    sex: 'male',
    height: 173,
    fit: 'good',
  });

  async function handleSubmit(e) {
    e.preventDefault();
    const sizeReview = {
      ...formValue,
      productId: product.id,
    };
    const res = await axios.post('/size_reviews/', sizeReview);
    const newSizeReview = res.data;
    setSizeReviews((prevSizeReviews) => [
      newSizeReview,
      ...prevSizeReviews,
    ]);
  };

  async function handleInputChange(e) {
    const { name, value } = e.target;
    handleChange(name, value);
  }

  async function handleChange(name, value) {
    setFormValue({
      ...formValue,
      [name]: value,
    });
  };

  if (!product) return (
    <div className={styles.loading}>
      <Spinner />
    </div>
  );

  return (
    <>
      <h1 className={styles.name}>
        {product.name}
        <span className={styles.englishName}>{product.englishName}</span>
      </h1>
      <div className={styles.content}>
        <div className={styles.image}>
          <Image fill 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>
            <form className={styles.sizeForm} onSubmit={handleSubmit}>
              <label className={styles.label}>
                사이즈
                <Dropdown
                  className={styles.input}
                  name="size"
                  value={formValue.size}
                  options={[
                    { label: 'S', value: 'S' },
                    { label: 'M', value: 'M' },
                    { label: 'L', value: 'L' },
                    { label: 'XL', value: 'XL' },
                  ]}
                  onChange={handleChange}
                />
              </label>
              <label className={styles.label}>
                성별
                <Dropdown
                  className={styles.input}
                  name="sex"
                  value={formValue.sex}
                  onChange={handleChange}
                  options={[
                    { label: sizeReviewLabels.sex['male'], value: 'male' },
                    { label: sizeReviewLabels.sex['female'], value: 'female' },
                  ]}
                />
              </label>
              <label className={styles.label}><Input
                  className={styles.input}
                  name="height"
                  min="50"
                  max="200"
                  type="number"
                  value={formValue.height}
                  onChange={handleInputChange}
                />
              </label>
              <label className={styles.label}>
                사이즈 추천
                <Dropdown
                  className={styles.input}
                  name="fit"
                  value={formValue.fit}
                  options={[
                    { label: sizeReviewLabels.fit['small'], value: 'small' },
                    { label: sizeReviewLabels.fit['good'], value: 'good' },
                    { label: sizeReviewLabels.fit['big'], value: 'big' },
                  ]}
                  onChange={handleChange}
                />
              </label>
              <Button className={styles.submit}>작성하기</Button>
            </form>
          </section>
        </div>
      </div>
    </>
  );
}

5. 렌더링 과정 살펴보기

맨 처음 접속했을 때에는 프리렌더링된 html 파일을 전부 보내줍니다. Link를 클릭하기 전에 코드 스플리팅된 자바스크립트 파일을 적절한 시기에 받아서 리액트에서 실행하고, Link를 클릭하면 필요한 데이터를 props가 있는 JSON으로 받아와서 화면을 업데이트합니다.


6. 정적 생성 vs SSR

홈페이지

홈페이지(/pages/index.js)에서는 상품 데이터가 자주 업데이트되지 않는다고 가정했기 때문에, 정적 생성을 했었습니다. getStaticProps() 함수를 구현하고 데이터를 불러와서 정적 생성을 하도록 구현했습니다. 만약에 관리자가 상품을 자주 업데이트하거나, 항상 최신의 상품을 보여 줘야 한다면 서버사이드 렌더링을 선택했을 겁니다.

검색 페이지

검색 페이지(/pages/search.js)에서는 주소에 있는 쿼리스트링 값에 따라서 다른 검색 결과를 보여 줘야 하기 때문에, 서버사이드 렌더링을 했었습니다. getServerSideProps() 함수를 구현해서 쿼리스트링에 따라 다른 데이터를 가지고 서버사이드 렌더링을 하도록 구현했습니다.

상품 상세 페이지

상품 상세 페이지(/pages/items/[id].js)에서는 항상 최신의 리뷰 데이터를 보여주기 위해서, 서버사이드 렌더링을 했습니다. getServerSideProps() 함수를 구현해서 아이디 값에 따라 다른 데이터를 가지고 서버사이드 렌더링을 하도록 구현했습니다. 리퀘스트가 들어올 때마다 매번 리뷰 데이터를 불러와서 렌더링하기 때문에, 항상 최신의 리뷰 데이터로 프리렌더링할 수 있었습니다.

설정 페이지

설정 페이지(/pages/settings.js)에서는 딱히 데이터를 사용하지 않았기 때문에, Next.js에서 기본적으로 정적 생성을 하고 있었습니다.

이렇게 페이지마다 데이터를 어떻게 보여줄 것인가에 따라 서버사이드 렌더링, 정적 생성을 다르게 할 수 있습니다. Next.js에서는 특별한 경우가 아니라면 되도록이면 정적 생성으로 구현할 것을 권장하고 있는데, 리퀘스트가 들어올 때마다 매번 렌더링을 하는 것보다 미리 렌더링을 해서 저장해 둔 것을 보내 주는 게 훨씬 빠르기 때문입니다.

서버사이드 렌더링을 고려하면 좋은 경우

  • 항상 최신 데이터를 보여 줘야 하는 경우
  • 데이터가 자주 바뀌는 경우
  • 리퀘스트의 데이터를 사용해야 하는 경우 (예: 헤더, 쿼리스트링, 쿠키 등)
    그 외에 특별한 이유가 없다면 정적 생성을 하는 걸 권장합니다.

7. 프리 렌더링 정리

프리 렌더링(Pre-renering)

웹 브라우저가 페이지를 로딩하기 이전에 렌더링하는 걸 말합니다. 크게 정적 생성(Static Generation)과 서버사이드 렌더링(Server-side Rendering)으로 나뉩니다. Next.js에서는 기본적으로 모든 페이지를 정적 생성합니다.

정적 생성(Static Generation)

프로젝트를 빌드하는 시점에 미리 HTML을 렌더링하는 걸 말합니다.

getStatiProps() 함수
정적 생성할 때 필요한 데이터를 받아와서 렌더링하고 싶다면 getStaticProps() 함수를 구현하고 export하면 됩니다. 객체의 props 프로퍼티로 넘겨줄 Props 객체를 지정하고, 이것을 페이지 컴포넌트에서 사용할 수 있습니다.

export async function getStaticProps() {
  const res = await axios('/products/');
  const products = res.data;

  return {
    props: {
      products,
    },
  };
}

export default function Home({ products }) {
  return (
    <ProductList products={products} />
  );
}

getStaticPaths() 함수
/pages/products/[id].js 와 같이 다이나믹 라우팅을 하는 페이지를 정적 생성을 할 때에는 어떤 페이지를 정적 생성할지 지정해줘야 하는데, getStaticPaths() 라는 함수를 구현하고 export해서 정해줄 수 있습니다.

getStaticPaths() 함수에서는 리턴 값으로 객체를 리턴하는데, paths 라는 배열에서 각 페이지에 해당하는 정보를 넘겨줄 수 있스빈다. 예를 들어서 id 값이 '1'인 페이지를 정적 생성하려면 { params: { id: '1' } }과 같이 쓸 수 있습니다.

그리고 fallback 이라는 속성을 사용해서 정적 생성되지 않은 페이지를 처리해 줄 것인지 지정할 수 있었는데, fallback: true면 생성되지 않은 페이지로 접속했을 때 getStaticProps() 함수를 실행해 페이지를 만들어서 보여줍니다.

export async function getStaticPaths() {
  return {
    paths: [
      { params: { id: '1' }},
      { params: { id: '2' }},
    ],
    fallback: true,
  };
}

getStaticProps() 함수에서는 context 파라미터를 사용해서 필요한 Params(context.params) 값이나 쿼리스트링(context.query) 값을 참조할 수 있습니다.

그리고 fallback: true로 지정했다면, 필요한 데이터가 존재하지 않을 수 있기 때문에 적절한 예외처리를 해야 합니다. { notFound: true } 를 리턴하면 데이터를 찾을 수 없을 때 404 페이지로 이동시킬 수 있습니다.

export async function getStaticProps(context) {
  const productId = context.params['id'];

  let product;

  try {
    const res = await axios(`/products/${productId}`);
    product = res.data;
  } catch {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      product,
    },
  };
}

만약 fallback: true 를 설정했다면 getStaticProps()를 실행하는 동안 보여줄 로딩 페이지를 구현해야 하는데, 페이지 컴포넌트에서 필요한 데이터가 존재하지 않을 때를 처리해 주면 됩니다.

export default function Product({ product }) {
  if (!product) {
    return <>로딩 중 ...</>
  }
  return <>상품 이름: {product.name}</>;
}

서버사이드 렌더링(Server-side Rendering)

Next.js 서버에 리퀘스트가 도착할 때마다 페이지를 렌더링해서 보내주는 방식입니다. getServerSideProps() 함수를 구현하고 export하면 됩니다. 이때 리턴 값으로는 객체를 리턴하는데, 정적 생성때와 마찬가지로 props 프로퍼티로 Props 객체를 넘겨주면 페이지 컴포넌트에서 받아서 사용할 수 있습니다.

export async function getServerSideProps() {
  const res = await axios('/products/');
  const products = res.data;

  return {
    props: {
      products,
    },
  };
}

export default function Home({ products }) {
  return (
    <ProductList products={products} />
  );
}

8. App Router 소개

App Router란?

Next.js 13.4 이후 버전부터는 새로운 방식의 라우팅을 지원하기 시작했습니다. 바로 App Router입니다. 이번 포스팅에서 사용한 것은 Pages Router라고 합니다.

Page RouterApp Router의 가장 큰 차이는 /pages 폴더가 아닌 /app 폴더에 페이지 컴포넌트들을 추가한다는 것입니다. 그리고 이 페이지 컴포넌트들은 기본적으로 리액트 서버 컴포넌트(React Server Component)입니다. 기존에 우리가 사용하던 리액트 컴포넌트와는 조금 다른 컴포넌트인데, 간단히 설명해서 서버에서만 렌더링되는 컴포넌트입니다. 그 밖에도 라우팅의 여러 기능들이 달라졌습니다. 공통된 레이아웃을 적용하는 방식이나, 메타데이터를 사용하는 방식, 그리고 데이터를 받아오는 방식 등이 달라졌습니다.

React Server Component(RSC)란?

리액트 서버 컴포넌트는 서버에서만 렌더링하는 컴포넌트입니다. 2023년 5월을 기준으로 리액트에서 아직까지는 실험적인 기능인데, 곧 리액트의 정식 기능이 될 것으로 보입니다. 기존에 사용하던 컴포넌트 방식은 서버 컴포넌트와 구분하기 위해서 리액트 클라이언트 컴포넌트라고 부르기도 합니다.

리액트 서버 컴포넌트와 리액트 클라이언트 컴포넌트의 문법에서 가장 큰 차이는 데이터를 가져오는 방식입니다. 클라이언트에서 리퀘스트를 보내서 데이터를 받아오거나, Next.js에서 프리렌더링을 한다면 데이터를 Props로 내려받았는데, 리액트 서버 컴포넌트를 사용하면 컴포넌트를 async/await 함수로 만들 수 있고, 함수 최상위(top-level)에서 await로 데이터를 가져올 수 있습니다.

async/await를 사용해서 컴포넌트 함수를 작성하기 때문에 훨씬 직관적인 문법으로 컴포넌트를 개발할 수 있고, 서버에서 모든 데이터를 가져온 다음 렌더링까지 해서 보내주기 때문에 서버와 클라이언트가 여러 번 리퀘스트를 주고받을 때보다 빠르게 페이지를 보여줄 수 있습니다. 게다가 서버 컴포넌트 렌더링에 필요한 자바스크립트는 서버에서만 실행하기 때문에 클라이언트가 다운로드해야 할 자바스크립트 코드의 양도 줄어듭니다.

async function getData() {
  const res = await fetch('https://api.example.com/...');
  return res.json();
}
 
export default async function Page() {
  const data = await getData();
 
  return <main> ... </main>;
}

Next.js 팀에서는 새롭게 시작하는 프로젝트에는 App Router 사용을 권장하고 있습니다. 정적 생성, 서버사이드 렌더링뿐만 아니라, 이제 서버 컴포넌트까지 활용해서 최적화된 웹사이트를 만들 수 있게 되었습니다.

참고 자료


Feedback

  • getStaticPaths, getStaticProps, getServerSideProps의 동작 원리가 궁금하다.
  • Next.js 공식 문서를 공부하자.
  • App Router 참고 자료를 포스팅하자.
  • 이번주 프로젝트는 Page Router로 먼저 구현해보고 App Router로 사이트를 구현해보자.

Reference

profile
job's done

0개의 댓글