NextJS v13 App Router의 스트리밍SSR

SangminL96·2023년 6월 8일
1

스트리밍 서버 렌더링

서버에서 클라이언트로 HTML을 점진적으로 렌더링합니다.(https://github.com/reactwg/react-18/discussions/37)

2022년 3월 29일 React v18 업데이트가 되다

기존에도 suspense가 존재하였지만 리액트18로 업데이트가 되면서 강조된 기능이고 정식기능이 되면서 스트리밍SSR 꼭 알아야되는 아키텍처입니다
https://github.com/facebook/react/releases/tag/v18.0.0


NextJS의 변화

NextJS는 정말 빠르게 리액트18업데이트를 대응하여 App Router라는 새로운 라우터 아키텍처를 업데이트가 되었다고 생각이 듭니다
App Router이전에 NextJS v12에서도 React18을 대응하기 위해 suspense를 지원하도록 빠른 패치를 하였지만
NextJS v13 App Router와 함께 완전히 React18의 아키텍처를 따라가도록 패치가 된거 같습니다.


NextJS의 서버사이드렌더링 좋다고?빠르다고? 사실은...

2년전 중고신입개발자였던 저는 NextJS를 알게되고 SSR이 무작정 좋다고하여 프로젝트에 도입을 하고 모든 데이터를
getServerSideProps으로 데이터를 받았고 해당 데이터를 가지고 UI를 뿌려주고 SEO를 적용을 했었습니다

결과는.. 라우터 이동마다 서버사이드에서 새로운 데이터를 매번 받아오고 사용자 경험이 중요한
TTFB(Time to First Byte)가 늦춰지게 되었고 사용자가 페이지 이동을 하는 버튼을 눌렀음에도 서버사이드에서 데이터를
받아오는 동안 아무런 동작을 하지 않는 경험을 하게 되었습니다

NextJS SSR 사실은...좋다고는 하지만 SSR를 무분별하게 사용되면 오히려 최악의 사용자 경험을 느끼게 될 수 있습니다


그래서 스트리밍SSR 아키텍처를 사용하여 해결하자

NextJS는 기본적으로 Pre-Rendering이기 때문에 TTFB -> FCP(First Contentful Paint) -> LCP(Largest Contentful Paint)를 시작하는 단계까지
최소한의 HTML을 그려주고 시작하기 때문에 거의 '제로'에 가까운 런타임을 제공합니다
이 단계에서 getServerSideProps로 데이터 통신을 하게되면 그 만큼 런타임이 늘어나 의미가 없게됩니다

Suspense를 사용하여 선언적으로 데이터 패치를 하여 점진적으로 렌더링을 시켜줍니다

1. SWR 옵션에 suspense true

필자는 클라이언트사이드에서 데이터패치를 위해 SWR를 사용을 하였고 별도로 suspense true를 주어 활성화를 시켰습니다

2. NextJS에서는 커스텀 Suspense컴포넌트를 만들어야한다.

import { useState, useEffect, Suspense as ReactSuspense } from 'react';

export function Suspense({ fallback, children }: ComponentProps<typeof ReactSuspense>) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (mounted) {
    return <ReactSuspense fallback={fallback}>{children}</ReactSuspense>;
  }
  return <>{fallback}</>;
}

NextJS는 SSG시점(TTFB->LCP) 즉 초기 마운트 이후에 suspense가 실행되어야 하고 SSG시점 동안 fallback을 계속
보여주게 함으로써 즉시 사용자에게 페이지를 보여 줄 수 있다

3. lazy import한다

const Profile = lazy(() => import('./Profile'));
const Card = lazy(() => import('./Card'));

function ProfilePage() {
  return (
    <div>
      <h1>내정보</h1>
        <Suspense fallback={<Loading/>}>
          <Profile />
        </Suspense>
      <h2>내 카드</h2>
      <Suspense fallback={<Loading/>}>
        <Card />
      </Suspense>
    </div>
  )
}

프로필, 카드컴포넌트 내부에서 SWR이용한 데이터 패치를 하고 부모컴포넌트인 프로필페이지에선 Suspense를 이용하여 각각의 컴포넌트를
점진적으로 렌더링을 할 수 있다

하지만 NextJS13에선 많은 것이 바뀌었다

2023년 5월 5일 금요일 Next13.4 업데이트에서 App Router가 Stable되었습니다

"Only JavaScript. Everything is a function"

기존 NextJS에서 /pages 라우터와 함께 getStaticProps, getStaticPaths, getServerSideProps방식이 전부 사라지고
오로지 자바스크립트, 데이터패치는 fetch함수를 사용하도록 바뀌었습니다

기존 NextJS에서의 서버사이드 데이터 패치

function Page({ data }) {
  // 렌더 데이터
}
export async function getServerSideProps() {
  const res = await fetch(`https://.../data`)
  const data = await res.json()
  return { props: { data } }
}
 
export default Page

바뀐 AppRouter의 서버사이드 데이터 패치

async function getData() {
  const res = await fetch('https://api.example.com/...')
  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }
  return res.json()
}
export default async function Page() {
  const data = await getData()
  return 렌더
}

Parallel Data Fetching

페이지내 두개이상의 데이터를 가져올땐 병렬로 가져오는 것이 효율적이다

import Albums from './albums'
 
async function getArtist(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}`)
  return res.json()
}
 
async function getArtistAlbums(username: string) {
  const res = await fetch(`https://api.example.com/artist/${username}/albums`)
  return res.json()
}
 
export default async function Page({
  params: { username },
}: {
  params: { username: string }
}) {
  const artistData = getArtist(username)
  const albumsData = getArtistAlbums(username)
  const [artist, albums] = await Promise.all([artistData, albumsData])
 
  return (
    <>
      <h1>{artist.name}</h1>
      <Albums list={albums}></Albums>
    </>
  )
}

프로미스 상태인 getArtist, getArtistAlbums 프로미스.all사용하여 병렬로 처리하는 패턴이다


AppRouter의 Suspense이용한 스트리밍SSR

최상위 컴포넌트(page)

export default async function Page() {
  return (
    <>
      <h1>스트리밍 SSR</h1>
      <Suspense fallback={<div>Loading...</div>}>
        {/* @ts-expect-error Async Server Component */}
        <ListDetail listId={2} />
      </Suspense>
    </>
  );
}

ListDetail컴포넌트

  async function getListDetail(listId: number) {
    const res = await fetch(`http://localhost:4000/next13-test/${listId}`);
    return res.json();
  }
  export default async function ListDetail({ listId }: { listId: number }) {
    const listDetail = await getListDetail(listId);
    return (
      <ul>
        <button type="button">{listDetail.text}번</button>
        {listDetail.map((item: any) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    );
  }

ListDetail 컴포넌트를 불러올때 lazy import를 해줄필요없이 NextJS13에서는 자동 코드스플리팅, 동적 Import를 지원합니다

getStaticProps, getStaticPaths, getServerSideProps등 제거를 하고 오로지 fetch 함수로
사용하도록 완전히 다른 아키텍처로 업데이트가 되었습니다
따라서 AppRouter에서는 fetch함수를 사용하기때문에 HTTP 캐시전략을 사용하여 데이터 캐싱을 합니다


사실 NextJS /page 페이지 라우터에서 suspense를 이용한 스트리밍 SSR은

NextJS에서 suspense를 사용하기 위해 별도의 컴포넌트를 만들어 초기 마운트가 이루어진 다음 suspense를 사용하는것과
react-query,SWR등 데이터패치를 도와주는 라이브러리를 사용할 시

정식적으로 지원이 되고 있지 않은 방법으로 사용을 하고 있다고 생각이 듭니다


하지만 이번 NextJS-13 AppRouter 아키텍처에서 suspense를 사용하기 위해 기법을 사용하는 것이 아닌 정상적으로
사용을 할 수 있게 되었다고 생각이 듭니다

결국 React18에서 suspense를 이용한 스트리밍 SSR 아키텍처를 중요하게 생각하는 만큼
NextJS에서 /page 라우터를 이용한 프로젝트들은 언젠간 레거시가 될거 라고 생각이 들고 앞으로
신규 페이지 및 프로젝트에서는 AppRouter를 적극 도입하면서 언젠가 미래의 기술적 부채를 조금이나마 덜어내는게 중요하다고 생각이 듭니다

끝으로 NextJS와 Vite...고민

현재 회사내 프로젝트를 스트리밍SSR아키텍처를 적극 사용하며 Vite로 마이그레이션 작업을 하고 있었습니다
TMI:현재 회사프로젝트는 그냥 클라이언트에서 SWR로 데이터를 불러온다

Vite에서 SWR을 사용하고 suspense true를 주어 스트리밍SSR 활용중이지만 NextJS에서 AppRouter가 생각보다 빠르게 안정적 버전이
업데이트되면서 Vite로 프로젝트를 한번에 마이그레이션을 하는 것보다 NextJS AppRouter를 활용하여
점진적으로 마이그레이션하는 방법이 좋아보입니다

Vite로 작업하면서 ESBuild의 빠른속도를 체감하였고 프로덕션 빌드를 하더라도 NextJS와 차이가 나긴했었습니다

하지만 NextJS에서도 Turbopack이라는 번들러를 개발중이고 현재 dev환경에서 적용(베타)이 가능하고
추후에 프로덕션 빌드도 적용을 할 수 있도록 개발중 이라고 합니다.
이부분에 대해서도 Vite와 비교했을때 NextJS가 React에 맞춘 업데이트도 빠르고 번들러마저 따라와버리니 NextJS를 사용을 안할 이유가 없는거 같습니다

profile
안녕하세요

0개의 댓글