Next.js 13버전의 SSR 알아보기 (Feat.RSC)

GY·2023년 9월 2일
0

Next.js

목록 보기
6/7
post-thumbnail

Next.js 13버전의 SSR은 뭐가 달라졌을까?

이전 포스팅에서 던진 질문 중 하나에 대해, 이번 포스팅에서 답해보겠습니다. 🙌

SSR이 달라졌다

Next.js 12 렌더링 방식 적용의 한계

Next.js 12버전까지는 페이지별 다른 렌더링 방식을 적용할 수 있었죠. 그러나 이것은 명확한 한계가 있었는데, 페이지 단위로 적용하다보니 서버 사이드 렌더링을 위한 페이지에서는 클라이언트 로직을 실행할 수 없다는 것입니다.

Next.js 13 부터는 가능합니다

13버전 부터는 페이지가 아닌 컴포넌트 단위로 서버사이드와 클라이언트 사이드 렌더링을 섞어 사용할 수 있게 되었습니다.

아니 어떻게??

이러한 변화는 React 18에서 도입된 RSC와 연관이 있습니다.


React 18에서의 RSC의 등장! RSC란?

React Server Component (RSC)

RSC란

  • RSC는 서버에서 실행되는 컴포넌트입니다
  • HTML을 클라이언트로 전송하던 기존 SSR과 다르게 JSX를 전송해 렌더링하는 프로세스를 지님으로써, RSC를 사용한 SSR에서 streaming SSR을 구현해 여러 장점을 가질 수 있게 되었습니다.

갑자기 어디에서 등장한 걸까

  • 2020년 12월, React 18 기능 중 하나로 소개되었음
  • React 18의 initial release에는 포함되지 않았지만 여전히 많은 개발자들이 기대를 가지고 기다리고 있었음

SSR이랑 다른걸까?

'서버'라는 말이 들어가서 서버사이드 렌더링과 많이 헷갈립니다.
이 둘은 서로를 대체할 수 있는 관계가 아니라 별도의 개념이고, 다만 함께 사용해 각각의 이점을 취할 수는 있습니다.

Next.js 12의 SSR을 기준으로, SSR과 RSC의 차이점을 먼저 알아보겠습니다.

참고: 카카오페이 기술블로그: 리액트 서버 컴포넌트 준비하기

SSR과 RSC의 차이점

  • SSR의 모든 컴포넌트의 코드는 자바스크립트 번들에 포함되어 클라이언트로 전송됩니다.
    서버 컴포넌트의 코드는 클라이언트로 전달되지 않습니다.

  • SSR은 HTML로 전달되므로 전체를 리렌더링해야 변경된 사항을 업데이트해 표시할 수 있습니다.
    서버 컴포넌트는 클라이언트 상태를 유지하며 refetch될 수 있습니다.
    (HTML이 아닌 다른 형태로 컴포넌트를 클라이언트로 전달하기 때문인데, 이에 대해서는 아래에서 다시 살펴보겠습니다.)

  • SSR은 최상위 페이지에서 getServerProps()나 getInitialProps()등의 메서드를 통해 서버에 접근할 수 있습니다.
    서버 컴포넌트는 페이지 레벨에 상관없이 모든 컴포넌트에서 서버에 접근이 가능합니다.


카카오페이 기술블로그에서는 Next.js 12버전을 예로 들면서 이렇게 언급했습니다.

SSR과 RSC를 함께 사용한다면?
서버 컴포넌트는 서버 사이드 렌더링 대체가 아닌 보완의 수단으로 사용할 수 있습니다. 서버 사이드 렌더링으로 초기 HTML 페이지를 빠르게 보여주고, 서버 컴포넌트로는 클라이언트로 전송되는 자바스크립트 번들 사이즈를 감소시킨다면 사용자에게 기존보다 훨씬 빠르게 인터랙팅한 페이지를 제공할 수 있을 것입니다.

그리고 이제 Next.js 13 버전이 나왔죠!

RSC의 행보

Next.js는 버전 12 릴리즈 노트에서 React 18의 기능이 적용된 알파 버전을 발표했고, 13버전에서는 RSC를 기반으로 한 새로운 SSR 적용방식을 구현함과 동시에 이를 반영한 app router를 업데이트했습니다.

이에 대해 리액트 블로그 글에서도 가볍게 확인할 수 있습니다.
React Labs: What We've Been Working On – March 2023

React Server Components has shipped in Next.js App Router.


RSC가 해결한 문제

before

Next.js12버전까지는 ISR, SSG, SSR과 같은 렌더링 방식을 페이지 단위로만 적용할 수 있었습니다.
페이지 전체를 서버에서 prerendering해 HTML형태로 클라이언트에 전송했기 때문입니다. 따라서 해당 페이지는 페이지 전체가 다시 로드되어 리렌더링되기 전까지 특정 영역만 변경사항을 적용해 표시할 수 없었습니다.

after

그런데 RSC를 구현하는 서버는 이제 각 컴포넌트에서 필요로 하는 데이터를 세부적인 캐싱정책을 적용해 각각 유효한 기간동안 저장해둘 수 있게 되었습니다.
이에 더해, 이를 Suspense와 조합하면 streaming SSR 역시 구현이 가능하게 되었죠.

RSC는 각 컴포넌트의 갱신 정책을 컴포넌트 단위로 가져갈 수 있도록 세분화합니다.

- 기본적으로 만료기한이 없으므로 default는 SSG
- 결과물을 일정 기간 동안 갱신하도록 하면 ISR
- 매 요청마다 갱신하도록 하면 SSR

결과물들은 모두 Suspense 경계를 단위로 Streaming 됩니다.

  • 기존 페이지단위의 SSR이 아니기 때문에 세부적인 특정 부분에 대한 리렌더링이 필요할 경우 Streaming SSR을 사용하면 SSR이더라도 점진적으로 완성한 요소들을 받아와 표시할 수 있습니다.
  • 각 요청의 만료 여부는 리액트에 새로 도입된 Cache에서 관리됩니다.

"streaming? cache?"
이 부분은 아래에서 더 자세히 알아보겠습니다.


Next.js 13의 SSR에서 어떻게 RSC를 사용한다는 거지?

서버 컴포넌트가 도입되었다는 것은 한 페이지 내에서 서버 컴포넌트와 클라이언트 컴포넌트의 동작이 구분된다는 것을 의미합니다.

Next.js 13에서는'use client'라는 directive로 이 둘을 구분합니다. 기본적으로 app 디렉토리의 모든 컴포넌트는 서버 컴포넌트로 동작하되, 'use client'를 파일의 최상단에 명시하면 해당 컴포넌트는 클라이언트 컴포넌트로 동작합니다.


streaming SSR??

Next.js에 대한 원티드 프리온보딩 챌린지에 참여하면서 알게된 내용을 참고했습니다.

streaming SSR은 페이스북의 bigPipe를 전신으로 하는 기능입니다.

bigPipe?

페이스북에서는 BigPipe라는 기술을 사용해 웹 페이지 로딩을 최적화 해왔습니다. BigPipe는 웹 페이지를 여러 페이지릿(pagelet)으로 나누고, 각 페이지릿이 독립적으로 로드되고 렌더링될 수 있게 함으로써 전체 페이지 로딩 시간을 줄일 수 있었습니다.

Suspense는 원래.. SSR을 위해 탄생했어요

처음 Suspense는 React SSR에 BigPipe 아키텍처를 가져오기 위해 설계되었습니다. SSR 특성상 한 번 트리를 렌더링하면, HTML로 렌더링되었기 때문에 페이지를 불러오지 않고는 다시 렌더링할 수 없었으므로 BigPipe처럼 각 영역을 독립적으로 렌더링해 전체 로드 시간을 더 획기적으로 줄일 수가 없었기 때문입니다.

하지만 CSR에 먼저 도입되었습니다

그러나 서버에서 데이터 가져오는 것에 대한 기술적인 어려움 때문에, 먼저 클라이언트에 적용되었습니다.

그리고 RSC가 등장했죠!

이전 포스팅에서 React 18의 new feature인 suspense와 streaming을 Next.js 13에서 반영하도록 했다는 RFC에 대해 이야기한 적이 있습니다.

서버 데이터 패칭에 대한 고민의 결과물로 React Server Components(RSC)가 탄생하게 되었고, suspense와 streaming SSR이 다시금 SSR에서도 적용될 수 있게 되었습니다.


요약

앞서 말한 내용을 되짚어보겠습니다.

  • SSR은 전체 html을 갱신하지 않고서는 리렌더링을 할 수 없었고, 필요한 부분만 독립적으로 로드해 리렌더링할 수 없다는 단점이 있었습니다.
  • SSR 역시 각 영역을 나누어 로드해 전체 페이지 로드를 가볍고 빠르게 개선하기 위해 페이스북의 BigPipe 아키텍처를 가져오고자 했고, 이를 위해 고안된 것이 streaming SSR과 Suspense입니다.
  • 하지만 서버로부터 데이터를 불러오는 것에 대한 풀어야할 과제가 존재했기 때문에 당장 SSR에 적용할 수 없었습니다. 그래서 Suspense는 클라이언트 사이드 렌더링에 먼저 적용되었을 뿐 SSR에는 적용되지 않았습니다.
  • 그리고 RSC가 등장하면서 이것이 가능해졌습니다.

서버로부터 데이터를 불러오는 것에 어떤 문제가 있었던 걸까요?
RSC는 이런 문제점을 어떻게 해결했을까요?

기존 SSR은 서버에서 미리 요소들을 로드해 완성된 HTML을 클라이언트로 보내 렌더링하는 방식이었습니다. streamingSSR을 구현하려면 도중에 변경되는 사항이 업데이트가 되어야 하는데, HTML은 전체 내용을 다시 만들어 보내주지 않으면 수정할 수가 없었다는 것이 문제였습니다.

RSC는 HTML이 아닌 다른 형태로 데이터를 클라이언트로 보내 이 문제를 해결했습니다.
바로 JSX입니다.
RSC는 어떻게 동작하고 어떻게 렌더링을 하는지 알아보겠습니다.


RSC의 동작 원리 (렌더링 프로세스)

요약하자면, 다음과 같은 streaming SSR의 프로세스를 거쳐 렌더링이 됩니다.

- 첫번째 chunk 전송: 미리 렌더링 된 HTML
- 두번째 chunk 전송: Suspense 경계로 인해 추가 스트리밍 된 HTML 
- hydration: CSR을 위한 자바스크립트 번들 전송

이 부분에 대한 좋은 예시가 있어 아래 출처에서 가져온 코드로 예를 들며 정리해보겠습니다.
리액트 서버 컴포넌트의 동작 방식

1. 컴포넌트 트리 직렬화

페이지 요청을 받은 서버는 컴포넌트 트리를 root부터 실행하며 직렬화된 Json 형태로 재구성하기 시작합니다.

직렬화된 형태는 다음과 같습니다.

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
S2:"react.suspense"
J0:["$","@1",null,{"children":[["$","span",null,{"children":"Hello from server land"}],["$","$2",null,{"fallback":"Loading tweets...","children":"@3"}]]}]
M4:{"id":"./src/Tweet.client.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

M: 클라이언트 번들에서 컴포넌트 함수를 조회하는데 필요한 정보와 클라이언트 컴포넌트 module reference를 정의 하는 라인입니다.
J:앞선 M라인에서 정의된 클라이언트 컴포넌트를 참조하는 것으로, 실제 리액트 컴포넌트 element 트리를 정의합니다.

이 형식의 포맷은 스트리밍으로 전송이 가능합니다. 클라이언트가 전체 행을 읽는 즉시 JSON의 일부 구문을 분석하여 작업을 진행하게 됩니다.

이 stream이 어떤 식으로 처리되는지는 suspense와 streaming SSR의 동작방식에 대해 정리하면서 별도의 포스팅에서 다시 다루겠습니다.


2. 클라이언트 컴포넌트는 placeholder로 대체

직렬화를 할 때, 클라이언트 컴포넌트는 placeholder를 대신 배치해 렌더링할 곳을 표시해놓고 건너뜁니다.RCC는 곧 함수이기 때문에 직렬화를 할 수 없기 때문입니다.
따라서 함수를 직접 참조하지 않고 module reference라고 하는 새로운 타입을 적용하고, 해당 컴포넌트의 경로를 명시해 직렬화를 우회합니다.

{
  // The ClientComponent element placeholder with "module reference"
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // children passed to ClientComponent, which was <ServerComponent />.
    children: {
      // ServerComponent gets directly rendered into html tags;
      // notice that there's no reference at all to the
      // ServerComponent - we're directly rendering the `span`.
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}

코드 예시 출처: how react server components work


3. Stream 형태로 클라이언트에 전송

이 결과물을 Stream형태로 클라이언트에 전송하면 이를 렌더링합니다.
클라이언트 컴포넌트는 이 때 이 클라이언트에서 렌더링됩니다.
js bundle을 참조해 module reference 타입이 등장할 때마다 RCC를 렌더링해서 빈 공간을 채워놓은 뒤, DOM에 반영해 렌더링합니다.

그런데.. 그래서 stream형태로 클라이언트에 전송한다는 건 정확히 뭐고, suspense는 무슨 관련이 있다는 걸까요?
이 부분에 대한 자세한 이야기는 다음 포스팅에서 다뤄보겠습니다.


이런 렌더링 프로세스를 가지고 있기 때문에 RSC는 다음과 같은 장점이 있습니다.

RSC의 장점

zero bundle size

  • RSC는 서버에서 모두 실행된 후 직렬화된 JSON형태로 전달되어 번들이 필요하지 않음. 그리고 이 RSC에서 사용하는 외부 라이브러리도 번들에 포함되지 않아 번들 용량감소에 효과적
  • 이는 TTI (Time to Interactive)지표 개선에도 효과적임
  • SSR만 사용했을 때는 초기 로딩속도는 빠르지만 CSR과 동일한 사이즈의 js번들을 다운받아야 하므로 번들 사이즈는 동일했음

Progressive Rendering

  • nextjs 13에서 컴포넌트가 서버에서 한차례 렌더링되고, 그 결과물로 직렬화된 JSON이 생성되면 클라이언트는 그 결과물을 stream의 형태로 수신한다.
  • 따라서 클라이언트는 먼저 수신된 부분부터 반영하기 시작해 화면에 표시할 수 있다.

컴포넌트 단위 refetch

  • 이전 SSR은 html을 완성해보내주므로 전체 페이지를 새로 그려서 받아와야 변경사항 업데이트가 가능했음
  • RSC는 html이 아닌 직렬화된 스트림 형태로 데이터를 받아오기 때문에, 클라이언트에서 stream을 해석해 VirtualDOM을 형성하고, Reconcilation을 통해 View를 갱신한다.
  • 즉 화면에 변경사항이 생겨서 서버에서 새로운 정보를 받아와야 하는 상황이 생기더라도, 새로운 스크린으로 갈아끼우는 것이 아니라 기존 화면의 state등 context를 유지한 채로 변경된 사항만 선택적으로 반영할 수 있게된다.

서버에서의 데이터 직접 접근 가능

당연합니다. 서버에서 컴포넌트가 실행되니까요! 대신 클라이언트 컴포넌트가 아니므로 browser API는 사용할 수 없다는 점을 염두에 두어야 합니다.

Automatic Code Splitting

  • RSC에서 RCC를 import할 경우 자동으로 코드스플리팅된다.
  • RSC가 서버에서 렌더링될 때 RCC는 실행되지 않으므로 즉시 import할 필요가 없기 때문

그런데 잠깐, 마지막 코드스플리팅에 대해서 조금 더 알아볼 내용이 있습니다.


RSC의 코드 스플리팅, 뭔가 다르다구

Next.js에서는 리액트에서처럼 별도로 코드 스플리팅을 명시적으로 해주지 않아도 페이지 단위의 코드 스플리팅을 자동 지원했습니다.
이 떄는 클라이언트 중심으로 라우팅 로직을 계산했습니다.
HTML이 새로 로드되는 것은 새로고침을 해 전체 HTML을 다시 받아올 때 뿐이었기 떄문에, 클라이언트 사이드 렌더링 시 CSR렌더링을 위한 전체로직이 번들에 포함되었습니다.

하지만 RSC는 직렬화된 JSX를 주고받고, 클라이언트에서는 서버로부터 받은 JSX를 가상 DOM에 반영하는 방식으로 라우팅을 합니다.

app router가 새로 등장한 이유? 라우팅의 중심은 이제 클라이언트에서 서버로

이전 포스팅에의 layouts RFC에 포함된 내용 중 하나로, 라우팅이 서버로 이동했다는 이야기가 있었습니다. 이제 이에 대한 이야기를 할 때가 왔습니다. ㅎ ㅎ ㅎ

  1. JSX는 서버에서 먼저 생성됩니다.
    정적인 요소들만을 pre rendering한 HTML이 streaming SSR의 첫번째 chunk로 전송됩니다.
  2. 이후 추가 chunk가 replace script와 함께 전송됩니다.
  3. 클라이언트에서는 이것을 기존 HTML에 반영해 업데이트 합니다.
    • 위에서 언급했던 대로 클라이언트 컴포넌트를 서버에서 렌더링할 수 없으니 참조만 걸어두었던 것을 이때 찾아서 렌더링하는 겁니다.

즉, 서버에서는 서버와 클라이언트의 diff를 계산한 다음 클라이언트에서 필요한 만큼의 페이로드만 전달해주어야 합니다. 첫번째 이후 다음 chunk에서 업데이트될 요소를 JSX diff로 보내주어야 하니까요.
(그래서 Next.js는 RSC를 렌더링하는 별도의 서버를 갖고 있다고 하네요.)

그래서 Next.js 12버전에서는 pages하위에 페이지 컴포넌트를 작성했다면, 13부터는 app하위에 넣는 것입니다. 기존 router가 아닌 app router가 새로 구현되었기 때문이죠.
이 app router는 방금 살펴본 RSC를 고려해 트리 업데이트 로직, 서버 측 로직 등을 고려해 새로 만든 것으로 보입니다.

+) Next.js 13에서 RSC를 내장해 지원하는 이유?

크게 중요한 부분은 아니긴 하지만,
이렇듯 RSC를 사용하기 위해서는 꽤 복잡한 부분들까지 고려해야 하나 봅니다.
위에서 잠깐 인용했던 리액트 블로그 글의 이어지는 내용까지 가져왔으니 함께 읽어봐요!

React Server Components has shipped in Next.js App Router. This showcases a deep integration of a router that really buys into RSC as a primitive, but it’s not the only way to build a RSC-compatible router and framework. There’s a clear separation for features provided by the RSC spec and implementation. React Server Components is meant as a spec for components that work across compatible React frameworks.
We generally recommend using an existing framework, but if you need to build your own custom framework, it is possible. Building your own RSC-compatible framework is not as easy as we’d like it to be, mainly due to the deep bundler integration needed. The current generation of bundlers are great for use on the client, but they weren’t designed with first-class support for splitting a single module graph between the server and the client. This is why we’re now partnering directly with bundler developers to get the primitives for RSC built-in.

RSC에서 고려해야 하는 로직들 이외에도 동적으로 로드하는 컴포넌트에 대한 참조도 처리해야 하고.. 기타 등등의 이슈들을 생각해서 번들러를 통합하려면 RSC를 사용해 프레임워크를 직접 구축하는 것은 쉽지 않다고 합니다. 따라서 Next.js 13에서 RSC를 위한 기본 요소를 내장하게 된 듯 합니다.


Reference

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글