NextJS와 ISR

seungchan.dev·2022년 7월 7일
31

NextJS 

목록 보기
4/4
post-thumbnail

😛 tl;dr

ISR과 On-demand revlidation을 이용하면 정적생성으로도 사용자에게 실시간으로 업데이트된 페이지를 제공할 수 있다.

🥏 기존의 렌더링 방식

일반적으로 NextJS를 활용하게 되면 크게 3가지 렌더링 방식을 사용할 수 있다.

  • CSR (클라이언트 사이드 렌더링) : useEffect훅을 이용하거나, SWR 같은 상태관리 툴을 이용해 렌더링의 책임을 사용자에게 전가하는 것. 화면 로딩이 사용자 눈에 보여 사용자 경험을 감소시키는 단점이 있다

  • SSR (서버사이드 렌더링) : 렌더링의 책임이 프론트엔드 서버에게 주어지며, 웹사이트 사용자가 접속할때마다 새로운 페이지를 생성해내는 방식. 매번 최신 정보를 유지해야한다면 좋은 방식이긴 하지만, 성능상 이슈가 있고 화면 깜빡임 현상이 있다.

  • SSG (정적 생성) : 렌더링의 책임이 역시 프론트엔드 서버에게 주어지지만, 프론트엔드 build 시간에 미리 화면에 대한 HTML을 미리 생성하여 사용자에게 미리 만들어진 화면을 제공한다. 이를 통해 성능상의 이점은 챙길 수 있으나, 미리 생성된 페이지를 제공하는 방식 이기 때문에 페이지 내 데이터가 변화하더라도 변화된 내용들을 전혀 제공해주지 못한다.

이러한 상황 속에서 NextJS에서 성능상의 이점은 챙기면서도 변화된 내용에 대한 업데이트를 제공해줄 수 있는 방식이 바로 ISR(Incremental Static Regeneration) 방식이다. 오늘은 이 ISR 방식이 어떠한 구조로 이루어졌는지를 이해해보고 실제 사용해볼 것이다.

📡 ISR이란?

공식문서에 따르면 다음과 같이 작성되어 있다.

Next.js allows you to create or update static pages after
you’ve built your site. Incremental Static Regeneration (ISR) enables you to use static-generation on a per-page basis, without needing to rebuild the entire site. With ISR, you can retain the benefits of static while scaling to millions of pages.

즉, 정적생성으로 미리 만들어놓은 사이트들도 필요하다면 업데이트가 가능하다는 이야기이다. 이를 이용한다면 정적생성의 장점을 취하되 단점을 보완할 수 있게 되는 것이다. ISR은 기존의 정적생성 방식에 몇가지 옵션들을 추가하면 바로 적용이 가능하다.

function Blog({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

// This function gets called at build time on server-side.
// It may be called again, on a serverless function, if
// the path has not been generated.
export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  // Get the paths we want to pre-render based on posts
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))

  // We'll pre-render only these paths at build time.
  // { fallback: blocking } will server-render pages
  // on-demand if the path doesn't exist.
  return { paths, fallback: 'blocking' }
}

// This function gets called at build time on server-side.
// It may be called again, on a serverless function, if
// revalidation is enabled and a new request comes in
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  return {
    props: {
      posts,
    },
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 10 seconds
    revalidate: 10, // In seconds
  }
}

export default Blog

위의 코드는 공식문서에서 제시하고 있는 ISR 예시이다. 우선 build 시간에 정적 생성할 경로들을 getStaticPaths 를 이용해 전달 받는데 기존과 다르게 여기서 fallback: 'blocking' 옵션이 추가되어 있다. fallback 옵션에 대한 자세한 설명은 공식문서에도 정리되어 있고 한국어로 더 잘 정리해놓은 내용이 있어 해당 블로그 링크로 설명을 대체한다. 따라서, 해당 예시에서는 fallback: 'blocking' 이기 때문에 build 시간에 만들어지지 않은 경로는 SSR처럼 요청시 새롭게 생성된다는 의미이다.

getStaticPaths 가 정적생성할 경로 id들을 제공해줬다면, getStaticProps 에서는 해당 경로들에 대해서만 정적생성을 진행한다. 여기서 revalidate: 10 옵션이 추가되어 있는데, 이는 해당 페이지로 어느 사용자가 진입한 이후 10초 후에 해당 페이지에 대해서 정적생성을 진행한다는 의미이다. 이때 정적 생성된 페이지를 통해 다음 사용자에게 업데이트된 내용이 제공된다. 간혹 일부 블로그들에서 다음과 같이 옵션을 설정하면, 매 10초마다 정적생성을 진행하는 것으로 설명이 되어있는데 이는 잘못된 이야기다. 아래의 그림을 보면 이해하기 조금 더 수월해진다.

사용자1이 해당 페이지에 진입하면 이때 부터 60초 동안은 어느 사용자가 들어오더라도 미리 생성해두었던 페이지를 제공해 준다. 이후 60초가 지나면 NextJS 백그라운드에서 해당 페이지의 업데이트가 이루어지고 이 업데이트가 완료되면 앞으로 새롭게 만들어진 페이지를 사용자에게 제공해주는 방식이다. 이를 통해 SSG 의 성능상 이점을 챙기면서도 사용자에게는 업데이트된 내용을 제공해줄 수 있다. 이제 부터는 어떻게 실제로 사용하는지 작은 실습을 통해 알아볼 것이다.

🕹 ISR 실습해보기

1. API 구성

이를 위해서는 간단한 백엔드 API와 NextJS 프로젝트가 필요한데 API는 json-server 를 이용해 간단하게 구동해볼 것이다. 이를 이용하면 json파일만으로도 간단하게 API를 구성해볼 수 있다.

우선, json-server 를 전역적으로 설치해준다.

npm i -g json-server

이후 db.json 라는 이름으로 파일을 하나 생성해준다.

{
  "books": [
    { "id": 1, "title": "book1", "description": "this is book1" },
    { "id": 2, "title": "book2", "description": "this is book2" },
    { "id": 3, "title": "book3", "description": "this is book3" },
    { "id": 4, "title": "book4", "description": "this is book4" },
    { "id": 5, "title": "book5", "description": "this is book5" },
    { "id": 6, "title": "book6", "description": "this is book6" },
    { "id": 7, "title": "book7", "description": "this is book7" },
    { "id": 8, "title": "book8", "description": "this is book8" },
    { "id": 9, "title": "book9", "description": "this is book9" },
    {
      "id": 10,
      "title": "book10",
      "description": "this is book10 which is last statically generated"
    }
  ]
}

그 다음 다음의 명령어를 이용하면 4000번포트에 API를 생성해 놓을 수 있다.

json-server --watch db.json --port 4000

이렇게 실행해놓으면 다음의 API가 자동으로 구성된다.

  • http://localhost:4000/books - 모든 books를 조회
  • http://localhost:4000/books/${id} - id인 book을 조회

2. NextJS 구성하기

  • pages/books/index.tsx
export default function Books({ data }: BooksProps) {
  return (
    <>
      {data?.map(({ id, title, description }) => (
        <Link href={`/books/${id}`} key={id}>
          <div style={{ padding: "10px", cursor: "pointer", borderBottom: "1px solid black" }}>
            <span style={{ marginRight: "10px" }}>{title}</span>
            <span>{description}</span>
          </div>
        </Link>
      ))}
    </>
  );
}

export async function getStaticProps() {
  try {
    const { data } = await axios.get("http://localhost:4000/books");
    return {
      props: { data },
      revalidate: 6000,
    };
  } catch (err) {
    return {
      notFound: true,
    };
  }
}
  • pages/books/[id].tsx
type GetSpecificBookResponse = {
  data: Book;
};

interface IdParams extends ParsedUrlQuery {
  id: string;
}

export default function SpecificBook({ data: { id, title, description } }: BookProps) {
  return (
    <>
      <div key={id}>
        <span style={{ marginRight: "10px" }}>{title}</span>
        <span>{description}</span>
      </div>
    </>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const { data } = await axios.get("http://localhost:4000/books");
  const paths = (data as Book[]).map(({ id }) => ({ params: { id: String(id) } }));
  return { paths, fallback: "blocking" };
};

export const getStaticProps: GetStaticProps = async (context) => {
  try {
    const { id } = context.params as IdParams;
    const { data } = await axios.get<GetSpecificBookResponse>(`http://localhost:4000/books/${id}`);
    return {
      props: { data },
      revalidate: 5,
    };
  } catch (err) {
    return {
      notFound: true,
    };
  }
};

위와 같이 구성한다면 사용자 진입 후 5초 뒤에 revalidate를 진행하여 해당 페이지의 업데이트된 내용을 제공한다.

3. 결과 확인하기

참고로 정적생성의 효과를 제대로 확인하려면 development로 실행하면 안되고 production으로 실행해야한다. 이는 development 모드로 실행할 경우 사용자가 진입할 때마다 getStaticProps가 실행되기 때문이다.

npm run build

npm run start

실행하고 나서 데이터 베이스에 해당하는 db.json 내용을 변경했을때 변화가 반영되는지 아래화면을 통해 확인해 볼 수 있다. 특정 경로의 데이터가 업데이트 되면, 일정 시간 이후 업데이트 된 내용이 반영되는 모습이다.

업데이트가 아니라 새로운 책 정보가 생성되는 경우, 일정 시간 이후 새로운 경로를 생성하는 것도 확인할 수 있다.

다만, 위에서 볼 수 있듯 갱신/생성 되는데 있어서 revalidate 옵션의 값만큼 딜레이가 발생하게 된다. 실제로 데이터가 업데이트 되었더라도, 일정시간동안은 사용자가 업데이트 되지 않은 내용을 확인하게 된다는 것이다. 또한 이러한 방식을 사용하게 될 경우, 실제 업데이트 여부와 관계없이 revalidate 가 이루어지기 때문에 다소 비효율적으로 프론트엔드 서버의 리소스가 이용되는 한계점이 있다. 이러한 한계점을 조금 극복할 수 있는 새로운 기능이 생겼는데 이는 On-Demand Revaliation 이다.

🍏 On-demand Revalidation

단어에서 추측해 볼 수 있듯, 필요할때에만 Revalidation을 진행하여 업데이트 시기를 효율적으로 설정할 수 있다. 그렇다면 어떻게 Revalidation이 필요한 때 인지를 프론트엔드서버가 알 수 있게 될까? 바로 NextJS 내 API를 이용해 실현해 낼 수 있다. 대략적인 과정은 다음과 같다.

  1. 데이터베이스 내 데이터가 업데이트 된다.
  2. 프론트엔드에게 API 통신을 통해 Revalidation 이 필요함을 알린다.
  3. 이를 통해 업데이트가 필요한 페이지 컴포넌트들을 Revalidate를 한다.

이를 직접 실습해보자.

참고로 On-demand Revalidation은 안정화 버전이 next@12.2.0부터 제공되니 package.json의 next 버전을 꼭 확인하자. 맞지 않는다면 12.2.0버전 이상으로 설치하는 것이 요구된다.

  • pages/api/revalidate-books.ts
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { method, query, body } = req;

  if (method !== "POST") {
    return res.status(400).json({ error: "Invalid HTTP method. Only POST method is allowed." });
  }

  // Unauthorized access as invalid token
  if (query.secret !== process.env.SECRET_REVALIDATE_TOKEN) {
    return res.status(401).json({ message: "Invalid token" });
  }

  try {
    if (!body) {
      res.status(400).send("Bad reqeust (no body)");
      return;
    }

    const idToRevalidate = body.id;

    if (idToRevalidate) {
      await res.revalidate("/books");
      await res.revalidate(`/books/${idToRevalidate}`);
      return res.json({ revalidated: true });
    }
  } catch (err) {
    return res.status(500).send("Error while revalidating");
  }
}

pages 폴더 안에 api 폴더를 생성한 뒤 적절하게 api 경로를 생성한다. 위의 코드 내용을 대략적으로 설명하면,

  • POST 요청이 아닌 경우 400에러를 발생시킨다.
  • query의 secret값이 환경 변수 SECRET_REVALIDATE_TOKEN 과 일치하지않으면 Unauthorized 401에러를 발생시킨다. (.env 파일에 SECRET_REVALIDATE_TOKEN 값을 적절히 설정해주어야한다.)
  • body 안에 Id 값이 없으면 400에러를 발생시킨다.
  • 이러한 조건들을 통과한다면 /books 페이지와 /books/${id}revalidate 시킨다.

이를 제대로 확인해보려면, 위에서 설정했던 revalidate 값을 크게해야 한다(예를 들어 6000). 혹은 revalidate 옵션을 생략하는 것도 가능한데 이는 아래 사진처럼 직접 revalidate 시켜주지 않는한 사이트가 자동으로는 갱신이 이루어지지 않는 형태이다.

서버를 실행한뒤 데이터를 업데이트 하고 적절히 API요청을 보내주면 아래처럼 잘 갱신됨을 확인할 수 있다.

API요청을 통해 revalidate가 필요함을 알림으로써 바로바로 갱신되는 것을 확인해 볼 수 있다. 다만, 이런식으로 수작업으로 API요청을 보내는 것은 매우 비효율적이기 때문에 Webhook을 이용하거나 백엔드에서 데이터 업데이트 이후 API요청을 보냄으로써 이를 자동화 할 수 있을 것으로 기대된다.

🚩 출처와 참고자료

https://github.com/tumetus/cooking-with-tuomo

Next JS Static Generation - fallback

profile
For the enjoyful FE development

4개의 댓글

comment-user-thumbnail
2023년 1월 15일

감사합니다~

답글 달기
comment-user-thumbnail
2023년 1월 16일

자료 잘 읽었습니다! 그런데 on demand 방식에서는 Next.js 공식문서에서와 같이 revalidate 속성을 추가하지 않으면 revalidate callback이 활성화될 때만 revalidate된다는 설명이 들어가도 더 좋을 것 같습니다 :)

If revalidate is omitted, Next.js will use the default value of false (no revalidation) and only revalidate the page on-demand when revalidate() is called.

1개의 답글
comment-user-thumbnail
5일 전

잘 보고 갑니당~ 🥸

답글 달기