nextJS가 지원하는 파일 기반 라우팅도 굉장히 훌륭한 기술이지만 제가 생각했을 때 nextJS의 핵심은 사전 렌더링 기술입니다. 클라이언트에서 데이터를 사용자에게 제공하기 전에 서버 측에서 미리 렌더링된 페이지를 사용자에게 제공합니다. 이 기술을 사용하면 웹 페이지 특성에 맞는 페이지를 제공할 수 있습니다.
이번 포스팅에는 사전 렌더링에 사용되는 함수들에 대해서 알아 보겠습니다.
nextJS에서는 기본적으로 페이지를 사전 렌더링 합니다. getStaticProps 함수를 사용하면 사전 렌더링을 강제할 수 있습니다. 이 함수를 통해서 추가적인 작업을 할 수있습니다. 이 함수는 빌드 타임에 컴포넌트 보다 먼저 실행되며, 컴포넌트에 필요한 데이터를 제공할 수 있습니다. 물론 페이지 컴포넌트가 데이터를 필요로하지 않으면 사용하지 않아도 됩니다. 만약 컴포넌트에 데이터가 필요한 경우 이 함수 내부에서 데이터를 가공하여 props로 컴포넌트에게 전달할 수 있습니다.
또한, getStaticProps는 서버 측에서 동작하는 코드로 fs, path와 같은 Node.js 모듈을 사용할 수 있습니다.
페이지가 사전 생성된 경우에 브라우저에 나타나는 데이터가 오래된 데이터 일 수 있습니다. 이 때, getStaticProps가 리턴하는 객체의 revalidate 프로퍼티를 추가하면 일정 시간마다 유효성 검사를 실시하여 새로운 페이지를 재생성하도록 nextJS에 요청합니다.
//index.js
import fs from "fs/promises";
import path from "path";
import Link from "next/link";
export default function Mypage(props) {
const { products } = props; // getStaticProps 함수가 리턴한 객체의 props 프로퍼티의 value값을 전달 받습니다.
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>{product.title}</Link>
</li>
))}
</ul>
);
}
export async function getStaticProps(context) {
// 서버에서 작동되는 코드로 node.js 모듈을 사용합니다.
const filePath = path.join(process.cwd(), "data", "dummy.json"); // root 경로 -> data 폴더 -> dummy.json 파일 경로
const jsondata = await fs.readFile(filePath); // 비동기적으로 dummu.json 파일을 읽어 옵ㄴ디ㅏ.
const data = JSON.parse(jsondata); // JSON 형태를 자바스크립트 객체 형태로 파싱합니다.
if (!data) {
// 데이터 없을 경우 "/no-data"로 이동
return {
redirect: {
destination: "/no-data", // 'path/no-data' 경로로 이동 시킵니다.
},
};
}
if (data.products.length === 0) {
// products 내부에 데이터가 없으면 notFound 페이지 Mypage 컴포넌트의 props로 전달 됩니다.
return { notFound: true }; // notFound 프로퍼티 값이 true이면 notFound 페이지를 보여줍니다.
}
return {
props: {
products: data.products, // props 프로퍼티의 value 값이
},
revalidate: 10, // 10초 마다 유효성 검사를 통해서 오래된 데이터를 최신화 시켜줍니다.
};
}
// dmmy.json
/*
{
"products": [
{ "id": "p1", "title": "Product 1", "description": "This is product 1" },
{ "id": "p2", "title": "Product 2", "description": "This is product 2" },
{ "id": "p3", "title": "Product 3", "description": "This is product 3" }
]
}
*/
위의 코드를 보면 getStaticProps 함수를 사용하여 서버측에서 데이터를 읽어오고 그 데이터를 바탕으로 html 파일을 생성합니다. 그리고 생성된 html 파일으 클라이언트에게 전달되어 사용자에게 제공됩니다. 이렇게 서버측에서 빌드타임에 미리 페이지를 페이지를 사전 생성하여 사용자에게 제공하는 방식을 SSG(Static Site Generation, 정적 렌더링)이라고 합니다. html 파일을 미리 생성하기 때문에 서버 부담이 적고 응답이 빠르다는 장점이 있습니다.
그렇기 때문에 블로그와 같이 페이지에서 데이터 변경이 많이 발생하지 않고 기존의 데이터를 가지고 있는 경우 getStaticProps를 사용하기에 적합합니다.
그러나 페이지를 동적으로 보여주어야 할 경우 SSG 방식 보다는 SSR, CSR 방식으로 렌더링하는 것이 좋습니다. nextJS에서는 ISR(Incremental Static Regeration, 증분 정적 생성) 기능도 제공합니다. getStaticProps 함수가 리턴하는 객체의 revalidate 프로퍼티 값을 이용해 일정 시간 마다 유효성 검사를 통해 페이지를 재생성하는 방식으로 데이터를 최신화 시켜줍니다.
만약 동적 파라미터로 페이지가 구성될 경우에 페이지에서는 해당 페이지에서 어느 정도의 인스턴스가 사전 렌더링 되어야할지를 nextJS에 알려줘야 합니다. 이때 getStaticPaths 함수를 추가하면 됩니다.
다양한 파라미터로 표현되는 동적 페이지의 경우에 getStaticPaths 함수가 nextJS에게 생성해야할 인스턴스 정보를 알려주고 getStaticProps를 통해 정적 페이지를 사전 생성합니다. 인스턴스가 어느정도 필요한지 nextJS에 알려주지 않을 경우 미리 준비해야할 페이지양을 알 수 없어 에러가 발생합니다.
getStaticPaths 함수도 객체를 리턴하는데 path 프로퍼티를 가지며 그 값은 파라미터 객체의 배열입니다. 이 배열을 통해 next.js는 사전 렌더링을 위한 페이지 인스턴스를 생성하게 됩니다. Path 프로퍼티 외에 fallback 프로퍼티를 가지는데 값이 false이면 paths 파라미터에 있는 파라미터에 대해서만 사전 렌더링을 한다. 이 함수도 파일 시스템이나 데이터 베이스에 접근이 가능합니다.
// [pid].js => 동적 파라미터를 가지는 페이지
import path from "path";
import fs from "fs/promises";
import { Fragment } from "react";
export default function productDetailPage(props) {
const { loadedProduct } = props;
// 동적으로 페이지 생성 시 없을 경우 로딩 중임을 표시합니다.
if (!loadedProduct) {
return <p>Loading...</p>;
}
return (
<Fragment>
<h1>{loadedProduct.title}</h1>
<p>{loadedProduct.description}</p>
</Fragment>
);
}
async function getData() {
const filePath = path.join(process.cwd(), "data", "dummy.json");
const jsondata = await fs.readFile(filePath);
const data = JSON.parse(jsondata);
return data;
}
export async function getStaticProps(context) {
const { params } = context; // 동적 페이지의 경우 전달받은 파라미터를 context로 받아올 수 있습니다.
const productId = params.pid;
const data = await getData();
const product = data.products.find((product) => product.id === productId);
if (!product) {
return { notFound: true };
}
return {
props: {
loadedProduct: product,
},
};
}
// 동적인 파라미터를 받아서 렌더링하기 위해 동적 페이지의 어떤 인스턴스를 생성할 것인지 NextJS 에 알려줍니다.
export async function getStaticPaths() {
const data = await getData();
const ids = data.products.map((product) => product.id);
const pathsWithParams = ids.map((id) => ({ params: { pid: id } }));
return {
// 아래 처럼 세가지 params를 정의하면 페이지가 세 개가 사전 생성됩니다. 즉, NextJS에서 파라미터(p1, p2, p3)가 전달될 것은 인식하고 그에 따른 페이지를 사전 생성합니다.
paths: pathsWithParams, // paths: [{ params: { pid: "p1" } }, { params: { pid: "p2" } }, { params: { pid: "p3" } }],
fallback: false,
// fallback 프로퍼티를 true로 설정하고 paths를 아래와 같이 명시해주면 빌드 타임에 p1, p2 페이지만 사전 생성 합니다.
// 클라이언트에서 p3 페이지를 요청할 경우 요청이 서버에 도착하는 순간에 p3 페이지를 생성하게됩니다.
// paths: [{ params: { pid: "p1" } }, { params: { pid: "p2" } }],
// fallback: true,
};
}
위의 코드를 보면 fallback 프로퍼트를 볼 수 있습니다. 정적 페이지를 사전에 생성하는 것은 서버의 부담을 줄여 주므로 특정 상황에서 굉장히 좋은 방법이 될 수 있겠지만, 모든 페이지를 사전 생성하는 것은 자원 낭비일 수 있기 때문에 fallback 프로퍼티를 통해 효율적으로 페이지를 생성할 수 있습니다.
npm run build 명령어를 실행하면 빌드 타임 동안에 p1.html, p2.html, p3.html 파일이 생성됩니다.. 이렇게 생성된 html 파일과 json 파일이 브라우저에 전송되어 화면에 렌더링 됩니다.
p1.html의 내용은 아래와 같습니다. 코드를 보면 h1, p 태그 안에 내용이 미리 삽입된 상태로 브라우저에게 전달되는 것을 알 수 있습니다.
<!DOCTYPE html><html><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width"/><meta name="next-head-count" content="2"/><link rel="preload" href="/_next/static/css/49861c0d8668ac82.css" as="style"/><link rel="stylesheet" href="/_next/static/css/49861c0d8668ac82.css" data-n-g=""/><noscript data-n-css=""></noscript><script defer="" nomodule="" src="/_next/static/chunks/polyfills-c67a75d1b6f99dc8.js"></script><script src="/_next/static/chunks/webpack-69bfa6990bb9e155.js" defer=""></script><script src="/_next/static/chunks/framework-4556c45dd113b893.js" defer=""></script><script src="/_next/static/chunks/main-3ca3795fd84140ee.js" defer=""></script><script src="/_next/static/chunks/pages/_app-13c45e213587f026.js" defer=""></script><script src="/_next/static/chunks/pages/products/%5Bpid%5D-25312b538889f71d.js" defer=""></script><script src="/_next/static/sc3CDwYLZJzc6qrhwxiMo/_buildManifest.js" defer=""></script><script src="/_next/static/sc3CDwYLZJzc6qrhwxiMo/_ssgManifest.js" defer=""></script></head><body><div id="__next"><h1>Hello!! Next.js</h1><h1>Product 1</h1><p>This is product 1</p></div><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"loadedProduct":{"id":"p1","title":"Product 1","description":"This is product 1"}},"__N_SSG":true},"page":"/products/[pid]","query":{"pid":"p1"},"buildId":"sc3CDwYLZJzc6qrhwxiMo","isFallback":false,"gsp":true,"scriptLoader":[]}</script></body></html>
서버측에서 정적 페이지를 사전 생성해서 클라이언트에 넘겨주는 경우, 처리해야하는 데이터가 많아 유효성 재검사를 사용해도 데이터가 업데이트가 되지 않는 경우가 발생 할 수 있습니다. 이런 경우에 들어오는 요청마다 응답하거나 요청에 직접적으로 접근이 필요한 경우에 getServerSideProps 함수를 사용할 수 있습니다.
이 함수를 사용하면 서버 사이드 렌더링을 구현할 수 있으며, 함수 내부에 작성된 코드는 빌드 프로세스 과정이 아닌 서버에서만 실행이 됩니다. 함수 내부에서 params, req, res에 모두 접근이 가능하며, getStaticProps와 마찬가지로 객체를 반환하며, 컴포넌트의 props로 전달됩니다. 다만, getStaticProps와 차이점이 있다면, 정적 페이지를 사전 생성하는 것이 아니기 때문에 유효성 검사 과정은 필요하지 않습니다.
// user-profile.js
export default function UserProfilePage(props) {
return <h1>{props.username}</h1>; // getServerSideProps 함수가 리턴한 객체의 props 프로퍼티의 value값을 전달 받습니다.
}
// getStaticProps와 같은 포멧을 가지고 있습니다.
// 들어오는 요청에 따라 모두 유효성 검사를 진행하므로 revalidate 속성은 사용하지 않습니다.
// 빌드 타임이 아닌 서버에서 요청이 들어 올 때마다 실행됩니다.
export async function getServerSideProps(context) {
const { params, req, res } = context; // context를 통해 파라미터, 요청, 응답 객체에 접근이 가능합니다.
return {
props: {
username: "LMH",
},
};
}
nextJS에서는 서버측 코드를 작성할 때에 getStaticProps, getServerSideProps 함수 중 하나만 사용해야합니다. 그렇지 않을 경우 충돌이 발생합니다.
서버측 코드와 클라이언트측 코드를 결합하여 효과적으로 데이터 처리할 수 있습니다. 아래의 코드처럼 서버측에서 사전 패칭과 사전 렌더링을 통해 클라이언트에 전달하고 클라이언트에서 클라이언트에서는 데이터 변화를 감지해 브라우저를 최신화 시킬 수 있습니다. LastSalesPage 컴포넌트를 보면 useSWR 리액트 훅을 사용하여 데이터 재패칭 후 변경 사항을 브라우저에 즉각적으로 반영합니다.
이것이 가능한 이유는 SWR(Stale-while-revalidate) 훅의 내장기능으로 데이터 캐싱, 데이터 유효성 검사 등이 자동적으로 이루어지기 때문입니다. 이 훅은 이번 포스팅의 범위를 넘어서기 때문에 다음 포스팅에서 정리하도록 하겠습니다.
import { useEffect, useState } from "react";
import useSWR from "swr"; // swr hook를 import 합니다.
// 클라이언트 코드
export default function LastSalesPage(props) {
const [sales, setSales] = useState(props.sales);
const { data, error } = useSWR( // firebase의 데이터베이스 요청
"https://nextjs-course-53bbe-default-rtdb.firebaseio.com/sales.json",
(url) => fetch(url).then((res) => res.json())
);
useEffect(() => {
if (data) {
const transforedSales = [];
console.log(data);
for (let key in data) {
transforedSales.push({
id: key,
username: data[key].username,
volume: data[key].volume,
});
}
setSales(transforedSales);
}
}, [data]);
if (error) {
return <p>Failed to load </p>;
}
if (!data && !sales) {
return <p>Loading...</p>;
}
return (
<ul>
{sales.map((sale) => (
<li key={sale.id}>
{sale.username} - ${sale.volume}
</li>
))}
</ul>
);
}
// 서버 사이드 코드
export async function getStaticProps() {
const response = await fetch(
"https://nextjs-course-53bbe-default-rtdb.firebaseio.com/sales.json" // firebase의 데이터베이스 요청
);
const data = await response.json();
const transforedSales = [];
for (let key in data) {
transforedSales.push({
id: key,
username: data[key].username,
volume: data[key].volume,
});
}
console.log(transforedSales);
return { props: { sales: transforedSales } };
}
// sales.json 코드 예시
/*
sales = [
{ "id": "p1", "username": "Paul", "volume": 100 },
{ "id": "p2", "username": "Jenny", "volume": 50 },
]
*/