Frontend Rendering Paradigm

강은비·2025년 1월 7일
0
post-thumbnail

NextJS 14에서 Partial Prerendering이라는 새로운 렌더링 기법이 소개되었다. CSR부터 PPR까지 프론트엔드 렌더링 최적화 과정에 대해 알아보자~

시작하기 전에..

  • 이 글에서 말하는 렌더링이란? 작성된 코드를 HTML 표현으로 변환하는 작업을 말한다.
  • React Server Component의 정의와 역할을 알고 있다는 가정하에 작성되었습니다.

틀린 부분이 있다면 알려주세요~☺️

Client-side Rendering (CSR)

클라이언트 측에서 화면을 구성하고 렌더링을 진행한다.

  1. (Client) 페이지 요청
  2. (Server) 빈 html 파일 반환
  3. (Client) 브라우저 단 렌더링 진행 / JS 파일 다운로드 및 실행
  4. 콘텐츠가 채워지고 사용자와 페이지가 상호작용 가능해짐

Example w/ React

React App Scaffolding을 생성하면 public/index.htmlindex.js 파일을 볼 수 있다.

  • public/index.html: 컨텐츠가 없는 빈 html 파일
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <!-- 생략 -->
        <title>React App</title>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
  • index.js: React root를 생성하고 JSX를 html 요소로 변환
    // index.js
    const root = ReactDOM.createRoot(document.getElementById('root')) // React root 생성
    root.render(
      <Provider store={store}>
        <React.StrictMode>
          <App />
        </React.StrictMode>
      </Provider>,
    )
  1. 페이지 요청 시 public/index.html를 리소스 반환한다.
  2. index.js 파일이 다운로드되고 실행되어야 컨텐츠를 볼 수 있다.

React App Preview

React App Network Response

위 사진은 React를 기반으로 구현된 웹페이지로, 응답으로 온 HTML 파일을 보면 컨텐츠가 없다. 두번째 이미지에서 Response를 보면 script 태그가 있는데 src로 지정된 JS 파일을 다운로드하고 실행되어야 컨텐츠가 채워진다.

만약 JS 파일을 다운로드하고 실행하는데 시간이 걸리면 사용자는 그동안 컨텐츠가 없는 빈 화면을 봐야한다는 문제점이 있다.

Server-side Rendering (SSR)

클라이언트 사이드 렌더링(CSR)의 초기 콘텐츠 로딩 속도 문제를 해결하기 위해 등장했다.

SSR은 서버 측에서 컨텐츠가 담긴 html 파일을 구성하고 브라우저 측에 전달하여 렌더링하는 방법이다.

  1. (Client) 페이지 요청
  2. (Server) 컨텐츠가 담긴 HTML 파일 구성하고 JS 파일 등 리소스 반환
  3. (Client) 반환된 HTML을 화면에 표시 (브라우저 렌더링)
  4. (Client) JS 파일 다운로드 및 실행 (Hydration)

Hydration

서버에서 생성된 HTML이 사용자의 상호작용을 처리할 수 있도록 만드는 과정을 말한다. 즉, 상호작용 가능한 페이지를 만들기 위해서는 클라이언트 단에서 JS 번들을 다운로드하고 실행해야 한다.

Example

NextJS의 Page Router에서는 getServerSideProps 함수를 이용해 SSR를 이용할 수 있다. 매 요청마다 getServerSideProps 함수를 호출하고 반환값을 이용해 HTML 파일을 구성한다.

export default function Page({ data }) {
  // Render data...
}

// This gets called on every request
export async function getServerSideProps() {
  // Fetch data from external API
  const res = await fetch(`https://.../data`)
  const data = await res.json()

  // Pass data to the page via props
  return { props: { data } }
}

NextJS SSR Rendering Example

NextJS SSR Rendering Example

위 사진들은 NextJS의 Page Router을 통해 구현된 웹페이지이다. 프리뷰를 보면 HTML 파일에 컨텐츠가 채워진 것을 볼 수 있다. 두번째 이미지에서 Response를 보면 __NEXT_DATA__ 아이디를 가진 스크립트 태그 내에 JSON 데이터가 있다. 이 데이터는 라우트 및 페이지 정보뿐만 아니라 getServerSideProps 함수의 반환값을 포함한다. 이 JSON 데이터를 통해 서버 사이드와 클라이언트 사이드 간의 데이터 교환이 가능해진다.


<script id="__NEXT_DATA__" type="application/json">
{
  "props": {
    "pageProps": { ... } 	// 서버에서 전달한 props (getServerSideProps 반환값) -->
   },
   "page": "/about",        // 현재 페이지 경로
   "query": { ... },        // URL 쿼리 매개변수
   "buildId": "some-build-id", // 빌드 ID
   "assetPrefix": "",       // 정적 파일의 경로
   "isFallback": false      // Fallback 여부 (SSG 관련)
}
</script> 

데이터 교환이 필요한 이유?

  • 초기 상태 전달: 서버에서 생성된 데이터를 클라이언트로 전달해 React 애플리케이션이 클라이언트 측에서 동일한 상태로 시작할 수 있도록 한다.
  • 예를 들어, useState와 같은 훅은 클라이언트 사이드에서 실행되는데 초기값으로 getServerSideProps 함수로 주입된 데이터를 사용한다고 했을 때 서버에서 생성된 데이터를 클라이언트 측에서 알아야 한다.
    export default function Page({ initialData }) {
      const [data, setData] = useState(initialData);
      
      // Render data...
    }
     
    // This gets called on every request
    export async function getServerSideProps() {
      // Fetch data from external API
      const res = await fetch(`https://.../data`)
      const initialData = await res.json()
     
      // Pass data to the page via props
      return { props: { initialData } }
    }

장단점

  • 검색 엔진 최적화: HTML 파일에 컨텐츠를 미리 채워 검색 엔진이 데이터를 쉽게 크롤링할 수 있도록 한다.
  • 초기 페이지 로딩 문제 개선: 브라우저에서 즉시 사용자에게 콘텐츠를 보여줄 수 있다.
  • 만약 서버 단에서 data fetching이 오래 걸린다면 서버의 응답이 늦어져 유저는 아무것도 보지 못한 채로 기다려야 한다.

Static-Site Generation (SSG)

런타임에 동적으로 HTML을 렌더링하는 것과 달리, Static Site Generation은 빌드 타임에 HTML을 렌더링하는 전략이다. 미리 만들어진 HTML 파일은 사용자 요청마다 재사용된다.

Example

외부 데이터를 가져오지 않는다면 기본적으로 NextJS의 Page Router는 해당 페이지를 빌드 타임에 렌더링한다.

function About() {
  return <div>About</div>
}

export default About

외부 데이터를 가져와야 하는 경우 다음 두가지 방법으로 SSG를 이용할 수 있다.

  • 페이지 콘텐츠가 외부 데이터에 의존하는 경우: getStaticProps
    export default function Blog({ posts }) {
      // Render posts...
    }
     
    // This function gets called at build time
    export async function getStaticProps() {
      // Call an external API endpoint to get posts
      const res = await fetch('https://.../posts')
      const posts = await res.json()
     
      // By returning { props: { posts } }, the Blog component
      // will receive `posts` as a prop at build time
      return {
        props: {
          posts,
        },
      }
    }
  • 페이지 경로가 외부 데이터에 의존하는 경우: getStaticPaths (보통 getStaticProps와 함께 사용한다)
    export default function Post({ post }) {
      // Render post...
    }
     
    // This function gets called at build time
    export async function getStaticPaths() {
      // Call an external API endpoint to get posts
      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: false } means other routes should 404.
      return { paths, fallback: false }
    }
     
    // This also gets called at build time
    export async function getStaticProps({ params }) {
      // params contains the post `id`.
      // If the route is like /posts/1, then params.id is 1
      const res = await fetch(`https://.../posts/${params.id}`)
      const post = await res.json()
     
      // Pass post data to the page via props
      return { props: { post } }
    }

출처: NextJS 공식문서 - Static Site Generation

Client-side navigation

NextJS는 Client-side navigation을 지원한다. 사용자가 애플리케이션 내에서 페이지를 전환할 때, 전체 HTML 문서를 다시 요청하는 대신 JS를 사용해 필요한 데이터와 자원을 가져와 화면을 업데이트하는 방식이다.

작동 방식

  1. 사용자가 <Link> 컴포넌트 또는 router.push()를 통해 다른 페이지로 이동을 요청한다.
  2. 새 페이지의 JavaScript 코드와 관련 데이터(예: getStaticProps, getServerSideProps로 준비된 데이터)를 가져온다. 전체 페이지를 다시 요청하는 대신 필요한 리소스만 가져온다.
  3. 가져온 데이터를 사용해 DOM을 업데이트한다.
  4. 페이지가 전환되더라도 글로벌 상태 관리 라이브러리(예: Zustand) 또는 React Context를 사용해 애플리케이션 상태를 유지할 수 있다.

Example

영화 상세 페이지로 이동했을 때 JS 파일과 JSON 데이터(__NEXT_DATA__)를 서버로부터 가져오는 것을 볼 수 있다.

프레임워크 없이 React만으로 SSR를 구현할 수 없나요?

react-dom/server API를 사용해서 구현할 수 있는데 렌더링을 담당할 서버는 직접 구현해야 한다. ㅎㅎ

NextJS App router

Static Rendering & Dynamic Rendering

  • App router는 기본적으로 정적 렌더링 방식을 사용한다. 즉, 라우트가 빌드 타임에 pre-rendering된다. (Page router의 SSG와 상응한다.)
  • 만약 캐싱되지 않은 데이터 또는 dynamic API를 찾으면, 자동으로 내부에서 동적 렌더링 방식을 사용한다. 즉, 라우트 요청 시에 서버에서 pre-rendering 된다. (Page router의 SSR과 상응한다.)

⭐ Page router에서는 페이지 렌더링 방식(SSR, SSG)을 개발자가 직접 결정해야 했다. App router를 사용한다면 개발자는 컴포넌트 단위에서의 렌더링 환경(RSC or RCC)을 결정해야 하고, 페이지 단위에서 어떤 방식으로 렌더링할 것인지는 NextJS가 알아서 최적화해준다.

RSC & RCC 렌더링 방식

Next.js는 기본적으로 클라이언트 및 서버 컴포넌트 모두에 대해 서버에서 정적 HTML 미리보기를 렌더링한다.

  • 초기 페이지 로드 최적화
  • 애플리케이션 방문 시 페이지의 콘텐츠를 즉시 볼 수 있다.
ServerClient
RSC
RCC

On Server

  1. React: RSC payload 생성 (렌더링에 필요한 binary 데이터)
    a. 서버 컴포넌트의 렌더링된 결과물
    b. Placeholder (클라이언트 컴포넌트가 렌더링 될 위치의 빈자리)
    c. 클라이언트 컴포넌트에서 사용될 JS 파일들의 위치
    d. 서버 컴포넌트가 클라이언트 컴포넌트에게 전달 할 인자 (props)
  2. Next.js: RSC 페이로드와 클라이언트 컴포넌트 JS 코드를 사용하여 HTML을 렌더링
    a. 클라이언트 컴포넌트에 대해서는 정적 데이터들만 활용해 pre-rendering한다. (useState 등과 같이 각종 hook 를 실제로 서버에서 실행은 못하고 초기 값만 가져올수 있음)
    b. 서버 컴포넌트 + 클라이언트 컴포넌트가 합쳐진 전체 HTML이 만들어진다.
  3. RSC payload와 HTML 파일을 함께 클라이언트에게 전달한다.

사진 출처: https://blog.kmong.com/react-server-component로-프론트엔드-개발-혁신하기-part-2-5cf0bf4416b0

On Client

  1. HTML을 즉시 브라우저에 보여준다.
  2. JSON 형태의 DOM tree를 deserialize해서 placeholder 자리에 RCC를 채운다. (JS 번들 다운로드 후, 클라이언트 컴포넌트를 실행한다.)
  3. 클라이언트 컴포넌트에 대한 Hydration이 진행된다. (click 등의 상호작용 인터렉션)

RSC & RCC 작동 방식을 더 자세히 알고 싶다면, 아래 튜토리얼을 방문해보는 것을 추천드립니다!
https://demystifying-rsc.vercel.app/

SSR vs RSC

SSR은 페이지 렌더링 기법 중 하나이고, RSC는 “서버의 컴포넌트화”이다. 전혀 다른 개념이다.

SSRRSC
주요 목적초기 컨텐츠가 채워진 HTML 제공컴포넌트의 일부를 서버에서 처리하고 JSON으로 클라이언트에 전달
결과물HTML 파일RSC Payload
Hydration 필요 여부필요불필요
번들 크기클라이언트에 모든 JavaScript 번들이 전달JS 번들에서 서버 컴포넌트 번들 제외

Streaming ⭐

만약 한 페이지를 렌더링하기 위해 서버 단에서 여러 API를 호출하는 경우를 생각해보자.

  • SSR 방식을 사용한다면 모든 API 호출이 성공적으로 마치고 나서야 HTML 구성이 완료될 것이다.
  • 성공적으로 가져온 데이터를 우선적으로 렌더해 보여줄 순 없을까?
  • SuspenseRSC를 활용하면 된다. 이 방식을 NextJS 공식문서에서는 Streaming 이라고 소개한다.

크몽 개발 블로그에서 사용한 비유를 인용하자면

  • SSR: 전체 물탱크를 채우고 한 번에 붓는 것
  • Streaming SSR: 기본적인 뼈대를 먼저 브라우저로 던지고 수도꼭지를 열어둔 뒤, 데이터가 응답이 오는대로 수도꼭지로 데이터(chunk)를 내려줌. 다 내리면 수도꼭지는 닫힘
    • HTML을 점진적으로 클라이언트로 전송하기 때문에, 사용자는 전체 데이터가 준비될 때까지 기다릴 필요 없이 부분적으로 로드된 콘텐츠를 볼 수 있다. (SSR 단점 해소)
    • 초기 페이지 로드 시간 단축 + 준비된 데이터 먼저 사용자에게 빠르게 보여준다.

Server Side rendering (사진 출처: NextJS 공식문서)
Server-side rendering

Streaming (사진 출처: NextJS 공식문서)
Streaming

Partial Pre-rendering

출처: NextJS 튜토리얼 - Partial Prerendering

등장 배경

오늘날 대부분의 웹페이지는 정적 렌더링과 동적 렌더링 중 하나를 선택한다. 만약 어떤 특정 라우트 내에서 캐싱되지 않은 데이터 또는 dynamic API를 발견하면, 라우트 전체가 요청 시 동적으로 렌더링된다.

하지만 대부분의 라우트는 완전히 정적이거나 동적인 것은 아니다. 예를 들어 쇼핑몰 사이트의 제품 정보 페이지를 생각해 보면, 제품 설명을 정적으로 렌더링할 수도 있지만, 추천 제품 목록 데이터를 가져와 개인화된 콘텐츠를 표시할 수도 있다.

개념

페이지 내의 정적인 부분과 동적인 부분을 분리해 렌더링한다.

예를 들어, 사용자가 특정 라우트를 방문할 때

  • 내비게이션바와 제품 정보와 같이 정적인 부분들이 렌더링되어 정적 페이지가 제공된다. 빠른 초기 로딩을 제공한다.
  • 정적 페이지 구조에는 서버나 데이터베이스에서 가져와야 하는 동적인 콘텐츠가 들어갈 자리는 비어 있다.
  • 동적 콘텐츠들이 병렬적으로 스트리밍되어 전체 페이지 로드 시간이 단축된다.

기존 Streaming SSR과의 차이점은 페이지의 정적인 부분을 빌드 타임 동안 사전 렌더링한다는 것이다. 최적화에 미친 사람들인 것 같다

어떻게 동작하는 걸까?

PPR은 React의 Suspense를 사용해 특정 조건이 충족될 때까지 애플리케이션 일부분의 렌더링을 지연시킨다.

  • Suspense의 fallback은 정적 콘텐츠와 함께 초기 HTML 파일에 포함된다.
  • 빌드 시점 (또는 재검증 시점)에 정적 콘텐츠가 미리 렌더링되어 정적 페이지 구조를 만든다.
  • 동적 콘텐츠의 렌더링은 사용자가 해당 라우트를 요청할 때까지 연기된다.

PPR 적용 방법

  1. 아래와 같이 next.config.jsppr 옵션을 켠다.
/** @type {import('next').NextConfig} */
 
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};
 
export default nextConfig;
  1. Suspense를 사용해 동적 콘텐츠를 감싼다.

Example

Vercel에서 만든 예시 애플리케이션을 참고하면 좋을 것 같다.

📌 CSR부터 PPR까지, 빠른 TTFB/FCP/TTI를 달성하기 위해 프론트엔드 렌더링 최적화는 계속 진행되고 있다. 컴포넌트 분리 전략과 렌더링 환경(서버/클라이언트)의 적절한 선택이 중요해졌으며, 효과적인 컴포넌트 설계를 위한 역량 강화가 필수적이다.

0개의 댓글