Hits

🌁 배경

기존 블로그 각 게시글 페이지의 경우, NextJS에서 제공하는 Dynamic Routing을 사용하고 있으며, SSG 방식이 아닌 SSR 방식을 사용하고 있었습니다. pages/post/[id].tsxgetServerSideProps

NextJS 공식 문서에서는, 블로그 게시글과 같이 내부 콘텐츠가 자주 변하지 않는 페이지의 경우에는, SSG 사용을 권장하고 있습니다.

공식 문서에서 권장하는 SSG가 아닌 SSR형태로 구현한 이유는 SSG에 대한 이해가 부족해서였습니다.

딱히, 불편한 점은 없었지만, SSR 형태로 구현되다 보니, 서버에서 매번 HTML 파일을 만들게 되어 로딩이 살짝 느렸습니다.

그래서, 이번에 SSG에 대한 학습과 함께, 블로그 게시글 페이지를 SSR방식에서 SSG로 변경 한 후, 성능 분석까지 진행해보게 되었습니다.


🧐 SSR? SSG?

먼저, SSR은 Server Side Rendering의 약자이고, SSG는 Server Site Generation의 약자이며, SSR과 SSG , pre-rendering(서버에서 페이지를 HTML 문서로 생성 후 반환)을 위한 방식입니다.

SSR의 경우, 유저의 요청 마다, 그에 상응하는 HTML 문서를 생성해, 반환하는 방식입니다. → https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props

SSG의 경우, HTML을 Build Time에 생성하고, 해당 페이지로 요청이 올 경우, Build Time에 생성한 HTML을 반환하는 방식입니다. → https://nextjs.org/docs/basic-features/data-fetching/get-static-props

SSG 방식은 미리 생성된 HTML을 전달만 하면 되기 때문에, SSR 방식에 비해, 응답속도가 매우 빠릅니다.

따라서, 다음과 같은 상황에서는 SSG방식 사용을 권장합니다.

  • Performance가 중요할 때 → 미리 만들어 놓은 HTML 파일들을 CDN을 통해 더 빠르게 응답
  • 마케팅 페이지 / 블로그 게시글 / 제품의 목록 등 한번 생성된 후, 각 요청에 대해 동일한 문서를 반환할 수 있는 경우

반대로, 다음과 같은 상황에서는 SSR방식 사용을 권장합니다.

  • 항상 최신 상태를 유지해야 하는 경우 → 요청에 따라, 응답 내용이 시시각각 변하는 경우
  • 요청 마다 다른 내용 또는 형식의 HTML 문서가 반환되는 경우
  • SSG의 경우, 모든 URL(요청 주소)에 매핑되는 HTML 파일을 생성해야 하기 때문에, URL을 예측할 수 없는 경우

✍️ SSR → SSG

내부적으로, React Query를 사용하고 있기 때문에, Server에서 렌더링 하는 단계에서 데이터를 Prefetch한 QueryClient를 반환하는 방식으로 SSR을 구현해놓은 상태였습니다. → https://tanstack.com/query/v4/docs/react/guides/ssr

기존 SSR 코드

AS-IS
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(["Post", Number(query.id)], () =>
    getOnePostAPI(Number(query.id))
  );
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};
AS-IS
export const getServerSideProps: GetServerSideProps = async ({ query }) => {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(["Post", Number(query.id)], () =>
    getOnePostAPI(Number(query.id))
  );
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

SSG 방식으로 변경 → Dynamic Routing을 사용하고 있기 때문에, getStaticPaths와 함께 사용

TO-BE
export const getStaticPaths = async () => {
  const data = await getAllPostsId();
  const paths = data.map(({ id }) => {
    return { params: { id: String(id) } };
  });
  return {
    paths,
    fallback: true,
  };
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};
TO-BE
export const getStaticPaths = async () => {
  const data = await getAllPostsId();
  const paths = data.map(({ id }) => {
    return { params: { id: String(id) } };
  });
  return {
    paths,
    fallback: true,
  };
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

SSG의 경우, 각 URL(페이지)에 매핑되는 HTML파일을 만들어 놓아야 하기 때문에, 어떤 URL(페이지)이 있어야 하는지 알아야 합니다.

그래서, 서버에서 getAllPostsId를 통해, 존재하는 모든 게시글의 id를 가져와, 어떤 URL(페이지)가 있는지를 paths 변수를 통해 넘겨주었습니다.

getStaticPaths에서 반환하는 fallback의 경우, 미리 만들어 놓은 HTML이 없을 때, 어떻게 대응 할 것인가에 대해 설정하는 값이다.

간단하게만 알아보면 다음과 같다. → https://nextjs.org/docs/api-reference/data-fetching/get-static-paths

  • fallback : false → getStaticPaths가 반환하지 않은 모든 path에 대해서 404 페이지를 반환한다.
  • fallback : true → 반환하지 않은 path에 대해 fallback 버전을 먼저 보여준 뒤, getStaticProps를 이용해, HTML파일과 JSON파일을 만든다. 이후, 같은 path에 대한 요청에 대해 이때, 생성한 HTML을 반환한다.
  • fallback. : blocking → 반환하지 않은 path에 대해 fallback 상태를 보여주지 않고, SSR처럼 동작한다.

✨ISR

ISR은 Incremental Static Regeneration의 약자로, 정적생성(Static Generation)을 통해, 미리 만들어 놓은 사이트들도 업데이트가 필요하다면, 다시 생성하도록 만드는 방식입니다.

이번에, SSG에 대해서 자료를 찾아보고, 학습하기 전까지는 SSG를 통해 HTML을 생성한 후, 해당 HTML파일에 들어가야하는 내용이 수정되면 어떻게 처리하지?... 라는 의문이 있었습니다.

NextJS에서는 ISR과 On-Demand Revalidation을 통해, 정적 생성 해놓은 HTML 파일을 업데이트 할 수 있습니다.

이번 구현에서는 ISR방식만 사용했으며, 이후에 NextJS 서버 내부 API를 구현해, On-Demand Revalidation으로 변경할 예정이며 이에 대한 글을 추후에 작성할 예정입니다.

NextJS에서는 getStaticProps에서 반환하는 revalidate값을 통해, 미리 만들어 놓은 HTML을 업데이트 할 수 있습니다.

사용자가 해당 페이지로 진입한 이후 revalidate에 설정한 초(Second)가 지나게 되면, 해당 페이지에 대해서 다시 정적생성을 진행하게 됩니다.

ISR적용 getStaticProps

export const getStaticPaths = async () => {
  const data = await getAllPostsId();
  const paths = data.map(({ id }) => {
    return { params: { id: String(id) } };
  });
  return {
    paths,
    fallback: true,
  };
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
    //요청이 들어온 이후, 60초 후에 재생성revalidate: 60,
  };
};
export const getStaticPaths = async () => {
  const data = await getAllPostsId();
  const paths = data.map(({ id }) => {
    return { params: { id: String(id) } };
  });
  return {
    paths,
    fallback: true,
  };
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
 
  await queryClient.prefetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
 
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
    //요청이 들어온 이후, 60초 후에 재생성revalidate: 60,
  };
};

🤔 HTML? JSON?

지금까지 SSG 방식은 빌드 타임에 HTML 파일을 생성하고, 반환하는 방식이라고 생각했습니다.

하지만, 실제로 네트워크탭을 통해 살펴보면, 언제는 HTML파일을 반환하고, 또 언제는 JSON데이터를 가져 오는 것을 확인할 수 있었습니다.

💾 JSON 데이터를 가져오는 상황

📃 HTML파일을 받아오는 상황

🎬 그렇다면, 언제?

NextJS 공식문서에 나온 getStaticProps에 대해서 살펴보니(getStaticProps in NextJS), 빌드타임에 해당 페이지에 총 2가지의 형태를 생성합니다.

  1. 데이터 기반으로 다 그려놓은 HTML파일
  2. 미리 받아온 json 형식의 데이터

이미 HTML 파일이 클라이언트에게 있는 경우(ex, 버튼 클릭을 통한 이동)에는 HTML파일을 반환하는 것이 아니라, 빌드타임에 생성한 JSON 데이터를 가져와서, HTML파일을 구성하게 됩니다.

반대로, HTML 파일이 클라이언트에게 없는 경우(ex, 새로고침, 외부링크를 통한 접근)에는 빌드타임에 생성한 HTML파일을 가져옵니다.

따라서, 기존에 HTML파일이 있는 경우에는, 빌드 타임에 생성한 JSON데이터를 가져와서, 화면을 그려야 하기 때문에(데이터를 가져오는 네트워크 통신 과정에서 Delay 발생), CSR과 거의 유사하게 동작한다(스켈레톤 컴포넌트 또는 로딩 스피너를 보여준다).


🚗 router.push, next/link

결국 기존에 HTML파일이 있는 상황에서도, SSG 방식과 유사하게 동작하기 위해서는, JSON 데이터를 받아오는 딜레이가 없어야 합니다.

이 말은 JSON 데이터에 대해서 prefetch가 이루어져야 한다는 말과 같습니다.

NextJS에서 routing을 하는 방법은 router.push과 이벤트 핸들러를 이용한 방법과 next/link의 Link 태그를 이용하는 방법이 있습니다.

기존에는, 둘의 차이가 SEO 관점에서의 차이만 존재하는 것으로 알고 있었습니다. (Link태그의 경우 내부적으로 a태그 생성하기 때문)

하지만, 공식문서를 통해 살펴보니, Link 태그의 경우, production 모드에서, SSG로 생성된 JSON 데이터에 대해서 Prefetch를 제공합니다. → next/link in NextJS

따라서, router.push를 이용한 routing 방식에서, next/link를 이용한 방식으로 변경해, JSON 데이터를 받아오는 딜레이를 없앨 수 있었습니다.


⏱ SSR, SSG 성능비교

먼저, SSR방식을 사용했을 때의 성능입니다.

🛠 SSR

크롬 Network

크롬 LightHouse

사용자에게 화면을 보여주기까지 1초 이상의 시간이 걸리는 것을 확인할 수 있고, LightHouse를 통한 성능측정에서도, 초기 서버 응답 시간 단축을 권장하고 있으며, 뷰포트에 콘텐츠가 눈에 띄게 채워지는 속도인 Speed Index 값이 2.9초로 상당히 느린 것을 확인할 수 있습니다.

🛠 SSG

크롬 Network

사용자에게 화면이 보여지기 까지 0.2초 정도의 시간이 걸리는 것을 확인할 수 있습니다. (SSR방식에 비해, 약 1초 단축)

크롬 LightHouse

성능 점수가 93 → 100 향상되었으며, 뷰포트에 콘텐츠가 눈에 띄게 채워지는 속도인 Speed Index 또한 2.9초 → 1.1초로 향상되었습니다.

이번 기회를 통해, SSG에 대해 조금 더 확실하게 이해할 수 있었던 거 같습니다.