Post

Next.js에서의 렌더링

Next.js가 SSR을 사용하기에 요새 많은 기업들에서 사용한다는 말을 많이 들어봤을거다. SSR을 사용하면 초기 페이지 로딩 속도가 빨라서 사용한다고들 하던데, 왜 초기 로딩 속도가 빠른건지, CSR과의 차이는 뭐고, ISR과 ISG는 무엇인지, 새로 등장한 PPR은 무엇인지 알아보자.

렌더링이란

렌더링이란, 코드를 시각적으로 표현 가능한 형태로 변환하는 과정이다. 한마다로 브라우저 화면에 뭔가를 보여주기 위해 필요한 모든 과정을 렌더링이라 할 수 있다.

JS로 DOM을 조작해서 업데이트하는 것도 렌더링이고, 서버나 클라이언트에서 HTML 파일을 만들어서 보내주는 것도 렌더링이다.

DOM이란 웹 페이지의 HTML과 CSS의 모든 요소를 노드라는 객체로 변환하여 트리 구조로 표현한 것을 의미한다.

예를 들어 아래와 같은 html 코드가 있다면,

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
  <title>DOM</title>
</head>
<body>
  <h1 id="header">하이염</h1>
  <p>DOM이란?</p>
</body>
</html>

이는 다음과 같은 DOM 트리로 표현할 수 있다.

1
2
3
4
5
6
7
8
9
10
document
└── html
    ├── head
    │   └── title
    │       └── "DOM" (텍스트 노드)
    └── body
        ├── h1 (id="header")
        │   └── "하이염" (텍스트 노드)
        └── p
            └── "DOM이란?" (텍스트 노드)

트리는 부모-자식 관계를 가진다. 이 말은 JS로 부모 요소를 수정하면 자식 요소를 직접 수정하지 않았어도 레이아웃이나 스타일 변경이 발생할 수 있기 때문에, 브라우저가 부모뿐만 아니라 자식까지 리렌더링을 고려하게 된다는 얘기다.

이렇게 변경된 부분을 계산하고 다시 그리는 과정인 리렌더링에는 Reflow와 Repaint가 있다. Reflow는 레이아웃(위치, 크기 …)을 다시 계산하는 것, Repaint는 화면에 다시 그리는 것(색, 글꼴 변경 …)을 말한다.

이런 비효율적인 렌더링을 줄이기 위해 React나 Vue 같은 프레임워크는 메모리에 가상 DOM(Virtual DOM) 을 만들어 사용한다.

실제 DOM을 바로 수정하는 대신, 메모리 상에서 변경사항을 먼저 계산(diff)한 뒤, 진짜 필요한 최소한의 부분만 실제 DOM에 적용하여 렌더링 비용을 줄인다.

물론 개발자가 직접 부모-자식 요소 간의 렌더링 최적화를 신경 쓸 수도 있겠지만, React나 Vue 같은 프레임워크를 사용하면 이런 최적화 과정을 프레임워크가 알아서 처리해주기 때문에 개발자는 이를 신경 쓰지 않고 편하게 개발할 수 있다.

React에서 파생 된 프레임워크 중 가장 대표적인건 Next.js다. Next.js는 기본적으로 모든 페이지에 Pre-Rendering 방식을 따른다.

그 전에, CSR은 뭐고, Pre-Rendering은 뭘까?

CSR

먼저 CSR이란 Client-Side-Rendering의 약자로, 말 그대로 클라이언트딴에서 HTML 파일을 생성(렌더링) 하는걸 말한다. 즉 js의 용량이 아무리 커도 클라이언트에서 그 용량을 다 받아서 JS로 HTML을 생성하는걸 말한다.

Next.js에서 CSR을 사용하고 싶다면 SWR과 같은 데이터 페치 라이브러리를 사용하는 것을 권장하고 있다. 참고

Pre-rendering

이와 반대되는 개념이 Pre-rendering이다. Pre-rendering은 사전에 서버에서 HTML을 미리 생성한다는 의미로 이 안에 SSR과 SSG, ISR이 속해있다.

SSR

Pre-rendering 방법 중 가장 대표적(?)이라 할 수 있는 SSR은 Server-Side-Rendering의 약자로, CSR과 달리 클라이언트의 요청이 있으면 그 때마다 서버딴에서 HTML을 처리한다는거다.

따라서 SSR은 데이터를 다 받아오는데까지의 시간을 모두 기다려야 하므로 초기 로딩 시간이 길어질 수 있지만, 스켈레톤 UI 없이 한꺼번에 완성된 데이터가 보여진다.

고로 SSR가 CSR보다 초기 로딩 속도가 빠른 측면이 있지만, “무조건”적을 보장하는건 아니다. 네트워크 상황과 서버 성능, 데이터 크기에 따라 오히려 SSR이 느려질 수도 있다. 특히 서버에서 데이터 페칭(가져오는 것)과 HTML 생성을 수행하는 과정에서 TTFB가 길어질 수 있다.

TTFB(Time To First Byte): 브라우저가 페이지를 요청한 시점 <-> 서버로부터 첫번째 정보 바이트를 수신한 시점 간의 시간

또한 Pre-render를 사용하면 SEO를 최적화 할 수 있다는 장점도 있다. 검색 엔진이 페이지를 크롤링할 때 완전히 렌더링된 HTML을 볼 수 있기 때문이다.

옛날에 이런 SSR을 개발하려면 React의 renderToString 또는 renderToPipeableStream과 같은 기본 API를 사용해 맞춤형 서버 구성을 개발해야했다. 하지만 Next.js라는 프레임워크가 등장하면서 편하게 SSR을 사용할 수 있게 된거고, 이는 Next.js를 사용하는 이유와 귀결된다.

근데 SSR은 매 요청마다 매번 서버에서 HTML 파일을 생성해야 된다. 쇼핑몰 사이트가 있는데 카테고리별 버튼을 누를 때마다 서버에서 새로운 HTML 파일을 생성해서 보내면 서버가 부담하는 비용이 커질거다.

따라서 서버에서 HTML을 생성해서 보내는게 아닌, 빌드 타임에 페이지를 만들고 서버에서 클라이언트로 보내는 SSG와 ISR 방식이 만들어졌다.

SSG

SSG란 Static-Site-Generation의 약자로, 빌드 타임에 페이지를 아예 미리 만들어 두는걸 의미한다. 클라이언트가 페이지를 요청할 때 서버 렌더링 없이 빌드 타임에 생성된 html을 바로 서빙하는걸 말한다. 따라서 데이터가 잘 바뀌지 않는 페이지에 최적화 되어있다.

즉 SSR은 런타임에 HTML을 생성하지만, SSG는 빌드타임에 HTML을 생성한다는 것이다.

  • 런타임: 사용자가 페이지에 접속했을 때 서버가 그 요청을 받아서 실행함.
  • 빌드타임: 개발자가 코드를 다 짜고, next build 명령어를 실행하면 배포가 되는데 이 때 배포된 파일을 서버에 올림.

근데 만약 내가 사이트를 개발하려는데

  1. 서버에 쓸 돈도 많이 없고,
  2. SEO 최적화도 해야하고,
  3. 모든 사람에게 페이지에 보여지는 데이터도 일주일 마다 바껴야 된다고 가정해보자.

페이지에 보여지는 데이터가 바껴야되니까 SSG는 쓸 수 없고, SEO 최적화를 위해 CSR 보다는 SSR을 쓰겠는데 사용자 요청마다 페이지를 다시 그려야되니까 서버 부담이 클거다. 근데 이러면 안 그래도 돈이 부족한데 더 부족해질거다.

ISR

그래서 등장한게 ISR이다. ISR은 Incremental-Static-Regeneration의 약자다. Incremental이란 점진적으로 증가한다는 “증분”의 뜻을 갖고 있다. 참고

alt text

즉, ISR과 SSG는 빌드 타임에 미리 페이지를 만들어둔다는 점에선 동일하지만, ISR은 SSG와 다르게 일정 시간마다 데이터를 다시 갱신할 수 있다는 특징이 있다.

근데 ISR는 사용자 개인별 취향을 고려하진 못한다.

예를 들어서 내가 음악 스트리밍 사이트를 만들건데, 스포티파이처럼 도입부에 개인별 음악 취향을 띄우고 싶다고 하자.

  • ISR은 정적으로 생성된 페이지를 일정 주기로 업데이트하는 방식이므로, 사용자별 개인화된 콘텐츠를 제공하기 어렵기에 쓸 수 없을거다.
  • 그렇다고 CSR만 사용하면 모든 렌더링 작업이 클라이언트 측에서 이루어지므로 초기 로딩 시간이 길어지고, 네트워크 상태가 좋지 않은 사용자에게 불리할거다.
  • 그럼 SSR을 쓸까? 생각했더니, 이러면 모든 요청마다 서버에서 렌더링을 수행하므로 서버 부하와 비용이 증가할거다.

스트리밍 SSR

그래서 등장한게 스트리밍 SSR이다.

스트리밍 SSR은 전체 페이지가 완성되기를 기다리지 않고, 준비된 부분부터 점진적으로 HTML을 클라이언트에 전송하는 기술을 말한다.

즉 일반 SSR과 달리, 스트리밍 SSR은 페이지 전체가 렌더링 되기 전이라도 준비된 부분부터 보여줄 수 있어서 사용자 체감 속도가 훨씬 빠르다.

근데 이는 SSR 방식을 따르고 있어 매 요청마다 서버에서 페이지를 실시간으로 생성해서 보내기 때문에, 서버 리소스를 꾸준히 사용하게 된다.

이는 트래픽이 많을수록 서버 부담이 커질 수 있다.

그럼 ISG나 SSG처럼 정적인 페이지는 빌드 과정에서 생성하고, 동적인 페이지는 런타임에서 생성하는 방법은 없을까?

하이브리드 렌더링

를 해결해주는 것이 바로 페이지 단위로 “빌드타임/런타임”을 구분해서 사용하는 하이브리드 렌더링 전략이다.

예를 들어서

  • /introduce 페이지는 빌드타임(SSG)
  • /dashboard 페이지는 런타임(SSR)
  • /news 페이지는 ISR(Incremental Static Regeneration) 을 사용하는거다.

근데 한 페이지 안에서는 여전히 통일된 렌더링 방식만 써야 한다. 아니, 한 페이지 안에서도 빌드타임과 런타임을 구분해서 사용하고 싶을 수도 있지 않는가.

PPR

그래서 등장한게 PPR이고, 현대에 앱 라우터 사용이 요구되는 이유다.

PPR은 Partial Prerendering의 약자로,

  • 정적 콘텐츠와 같이 빠르게 불러올 수 있는 것은 먼저 띄우고
  • 동적 콘텐츠와 같이 느린건 나중에 띄우는 방식이다.

조금 더 기술적으로 설명하자면

  • PPR은 한 페이지를 정적 “셸(shell)”과 동적 “홀(hole)”로 나눠서
  • 정적 셸 부분은 빌드 시점에 미리 생성되어 CDN에 캐시되고 즉시 제공한다.
  • 반면 동적 홀 부분은 런타임에 서버에서 생성되어 스트리밍 방식으로 나중에 채워진다.

PPR을 사용하는 방법은 다음과 같다.

  1. next.config.js에서 PPR을 설정한다.
1
2
3
4
5
6
const nextConfig = {
  experimental: {
    ppr: 'incremental',
  },
}
module.exports = nextConfig
  1. 그 후 실제로 사용할 페이지에 experimental_ppr을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/page.tsx
import { Suspense } from 'react'
import { StaticComponent, DynamicComponent, Fallback } from '@/app/ui'

export const experimental_ppr = true

export default function Page() {
  return (
    <>
      <StaticComponent />  {/* 정적 컴포넌트 */}
      <Suspense fallback={<Fallback />}>  {/* 동적 컴포넌트는 Suspense로 감싸서 로드 */}
        <DynamicComponent />
      </Suspense>
    </>
  )
}

스터디에서 렌더링 부분 발표를 맡아 조사하게 됐는데 이렇게나 렌더링 종류가 다양한지 처음 알았다.

확실히 설계가 중요할 것 같다는 생각이 든다.. ㅇㅅㅇ

This post is licensed under CC BY 4.0 by the author.