프론트엔드 개발/Next.js

Next.js 13의 App Routing 방식을 이해해보자 (with RSC)

방구석 코딩쟁이 2024. 2. 10. 00:44

저는 Next.js를 공부하기 전까지는 SSR 방식으로 React를 동작시키는 프레임워크인 줄만 알았었습니다. 그러나 공부하면 할수록, 이는 매우 큰 오해였음을 깨닫게 되었습니다. 조금씩 그 베일을 벗겨가는 와중에 좋은 영상을 보게 되었고 이에 대한 정리를 하고자 합니다.

[영상 링크]를 첨부해두었습니다.

 

CSR / SSR 변천사

 

Next.js는 기본적으로 React를 기반으로 만든 프레임워크이며, 완전한 SPA는 아니고, PESPA(Progressively Enhanced Single Page App)라고 생각하면 될 것 같습니다. 최초 1회에 한해, HTML을 받고, 새로고침을 하는 등의 인터랙션이 있기 전까지는 HTML을 계속 활용합니다.

 

먼저 CSR로 동작하는 일반적인 상황에서의 React의 렌더링 방식을 먼저 보도록 합시다.

사용자가 서버에 요청을 보내게 되면 서버는 빈 index.html 파일과 (리액트와 여러 소스코드가 번들링 된) JS 파일을 응답해주게 됩니다. 그렇게 되면 리액트가 컴포넌트를 렌더링하고, 이를 DOM에 반영하기까지의 시간동안 아무것도 할 수 있는게 없어집니다.

즉 TTV, FCP, TTI 시간이 동일하며, JS 파일이 커질 수록,TTV FCP TTI 모두 좋지 않게 됩니다.

 

즉, React18 이전의 리액트 코드는 아래와 같이 동작합니다. 

CSR 방식의 리액트 동작 방식

 

때문에 초기에는 서버에서 렌더링된 HTML을 보어주여 FCP를 높이는 방법을 고민하게 되었습니다.

즉, 첫 요청을 보낼 때는 SSR방식으로 콘텐츠가 있는 HTML을 보여주고, JS 파일을 받은 후에는 hydration을 통해 인터랙션을 추가하게 됩니다. React에서는 `hydrate()`라는 클라이언트 API와 `renderToString()`이라는 서버 API를 활용하여 이를 구현할 수 있고, 이 부분에 대한 글은 제 예전 글을 보시면 조금 이해될 수 있으실 겁니다.

(최초 요청은 SSR로 렌더링된 HTML을 보내주고, subsequent navigate를 통해서 앱을 사용할 때는 SPA로 동작하게 됩니다)

 

첫 번째 요청 시, 새로 고침시 발생하는 일

 

Link 태그나 router/navigation 등을 이용하여 navigation하는 일

 

이렇게 동작을 하게 된다면, 사용자 경험 측면에서는 꽤나 좋아진 것 같습니다. 왜냐하면 사용자는 첫 번째 콘텐츠를 보는 시간 자체는 빨라졌기 때문입니다.

하지만 아직 좀 더 풀어야 할 숙제가 있습니다. 

첫 번째는 HTML이 이미 구성되어 왔기 때문에 화면은 보여지지만 JS가 와서 동작하기 까지의 시간(TTI)는 빨라지지 않았습니다. 이 과정을 hydration이라고 합니다. (만들어진 DOM에 hydration을 진행하여 인터랙션을 추가합니다) TTI가 빨라져야 성미가 급한 사용자가 버튼도 누르고 여러가지 일을 할 수 있게 됩니다.

두 번째는 서버 사이드 렌더링 자체의 속도가 느린 경우가 존재합니다. 서버에서 많은 코드가 실행되거나 DB 요청 작업이 기다리고 나서 HTML을 만들고 보내기 때문에 속도가 느릴 수 있습니다. 

 

이러한 문제들을 해결하기 위해 리액트 팀은 RSC(React Server Component)를 고민하게 되었습니다.

 

React Server Component는 무엇이고 어떤 점이 좋을까요?

RSC는 클라이언트가 아닌 서버에서 실행되는 리액트 컴포넌트입니다. RSC를 왜 쓸까요?

RSC를 지원하는 프레임워크는 SSR 만을 지원하는 프레임워크에 비해 2가지 장점을 가지게 됩니다.

 

첫 번째는 RSC를 지원하는 프레임워크는 우리의 코드가 동작하는 곳을 명시적으로 정의할 수 있는 방법을 제공해줍니다. 즉, 한 번 더 생각할 기회를 주는 것입니다.

우리의 컴포넌트 코드는 2가지로 나뉩니다. 

서버(클라이언트 서버)에서만 동작하는 코드(PHP 시절 처럼)와 클라이언트에서 실행하는 코드이며, 이를 각각 서버 컴포넌트와 클라이언트 컴포넌트라고 합니다. 코드가 실행되는 위치를 명시적으로 지정할 수 있으며, 서버 컴포넌트의 js 코드는 클라이언트에 전송될 필요가 없기 때문에 JS 번들 크기가 작아지고, hydration 작업량이 줄어듭니다. 서버 컴포넌트에서 사용된 JS 코드는 이미 서버에서 다운로드 받아서 사용되었고, 브라우저의 요청을 서버에서 처리할 때 이미 사용하므로 JS는 필요없게 됩니다.

예시를 들어 설명을 하자면, 저희는 게임을 할 때, 미리 설치된 게임을 하지 매번 게임을 할 때마다 설치해서 하지 않는 것과 동일한 것이죠

 

두 번째는 서버 컴포넌트는 컴포넌트 내에서 직접 데이터를 fetch할 수 있습니다. 가져오기가 완료되면 서버 컴포넌트는 해당 데이터를 클라이언트(브라우저)에 스트리밍할 수 있습니다. 이러한 점 덕분에 SSR 렌더링 속도가 느려지는 경우를 예방할 수 있습니다. 기다릴 필요없이 먼저 준비가 된 컴포넌트를 클라이언트에 보내면 되기 때문입니다.

 

이를 통해 서버와 브라우저가 각자 잘 수행하는 작업을 처리할 수 있게 됩니다. 서버 컴포넌트는 데이터를 가져오고, 콘텐츠를 렌더링하는데 초점을 맞출 수 있으며 페이지 로딩 속도가 빨라지게 되어 사용자 경험이 좋아질 수 있습니다.

 

RSC와 CSC를 잘 설명해주는 Dan의 그림입니다.

 

Next.js 13의 App Routing의 렌더링 방식은 뭔데?

Streaming SSR + React Server Component을 활용한 렌더링 방식이며, PESPA라고 보면 될 것 같습니다.

Streaming SSR을 통해서 HTML을 chunk 단위로 쪼개어 먼저 보낼 수 있는 콘텐츠를 client에 보내고, 해당 chunk에 대한 js hydrating을 빠르게 시작할 수 있어 TTV, TTI 시간을 당길 수 있습니다.

 

즉, Streaming SSR은 HTML을 빠르고, 그리고 먼저 보여줄 수 있는 UI를 Streaming 방식으로 빠르게 보여주기 위한 용도이며, RSC는 js 번들 사이즈를 줄이고, 서버와 브라우저가 각자 잘하는 역할을 구분짓기 위한 컴포넌트 구분 방식이라고 보시면 될 것 같습니다.

 

그리고, 저는 이를 공부하게 되면서 Server Component에 대해 궁금해져서 계속 파게 되었습니다.

 

React Server Component의 렌더링 방식은 어떻게 될까

  1. 서버가 렌더링 요청을 받습니다 (브라우저에서 서버로 요청을 보낸다)
  2. 서버가 Root Component Element를 JSON으로 직렬화합니다.
  3. 브라우저가 React Tree를 재구조화합니다.

순서대로 살펴보도록 합시다.

예시 코드는 다음과 같습니다.

// ClientComponent.jsx // 클라이언트 컴포넌트
export default function ClientComponent({ children }) {
  return (
    <div>
      <h1>Hello from client land</h1>
      {children}
    </div>
  )
}

// ServerComponent.jsx // 서버 컴포넌트
export default function ServerComponent() {
  return <span>Hello from server land</span>
}

// OuterServerComponent.jsx // 서버커포넌트
// OuterServerComponent 는 클라와 서버 모두에서 초기화 가능하다
// 따라서 서버 컴포넌트를 클라이언트 컴포넌트의 children으로 보낼 수 있다.
import ClientComponent from './ClientComponent'
import ServerComponent from './ServerComponent'
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

 

우리의 코드는 서버 컴포넌트와 클라이언트 컴포넌트로 구분되어지며, DOM 트리 상 다음과 같은 형태와 유사하게 되는 것이죠

우리가 작성한 컴포넌트 구조들

 

1. 서버가 렌더링 요청을 받는다.

서버가 렌더링 과정을 수행해야 하므로, 리액트 서버 컴포넌트를 모든 페이지는 항상 서버에서 시작됩니다. 서버는 브라우저의 요청을 받고, 서버 렌더링을 시작합니다.

2. 서버가 Root Component Element를 JSON으로 직렬화합니다.

받은 요청에 따라 컴포넌트를 JSON으로 직렬화합니다.

이때, 서버에서 렌더링할 수 있는 것은 직렬화해서 내보내고, 클라이언트 컴포넌트로 표시된 부분은 해당 공간을 placeholder 형식으로 비워두고 나타냅니다. 브라우저는 이후에 이 결과물을 받아서 다시 역직렬화한 다음 최종 결과를 렌더링합니다.

  • 기본 HTML 태그의 경우 JSON으로 처리가 가능하므로 특별히 처리할 것이 없음
  • 서버 컴포넌트라면 서버 컴포넌트 함수를 props와 함께 호출한 결과를 JSON으로 만들어서 내려보냅니다. 이렇게 하면 서버 컴포넌트가 효과적으로 렌더링됩니다.
    실행하는 목적은 서버 컴포넌트를 html 요소로 변경하는 것입니다.
  • 클라이언트 컴포넌트라면 JSON으로 직렬화가 가능하며 필드가 컴포넌트 함수가 아닌 module reference object를 가리키고 있습니다.
module reference object란?

RSC는 module reference라고 불리는 것을 리액트 앨리먼트의 type 필드에 새로운 값을 넣을 수 있도록 제공합니다.
이 값으로 컴포넌트 함수 대신 이 참조를 직렬화합니다.
{
  // ClientComponent 엘리먼트를 `module reference` 와 함께 placeholder로 배치
  $$typeof: Symbol(react.element),
  type: {
    $$typeof: Symbol(react.module.reference),
    name: "default",
    filename: "./src/ClientComponent.client.js"
  },
  props: {
    // 자식으로 ServerComponent가 넘어간다.
    children: {
      // ServerComponent는 바로 html tag로 렌더링됨
      $$typeof: Symbol(react.element),
      type: "span",
      props: {
        children: "Hello from server land"
      }
    }
  }
}

 

클라이언트 컴포넌트 함수에 대한 참조를 직렬화할 수 있는 ‘module reference object’로의 변환은 누가해주는 것일까요?

이러한 작업은 번들러에서 이루어진다. 리액트 팀은 RSC를 웹팩에서 사용할 수 있는 react-server-dom-webpack을 webpack loader나 node-register 에서 제공하고 있다.서버 컴포넌트가 클라이언트 컴포넌트를 가져올 때, 실제로 import 하는 것이 아닌 파일 이름과 그 것을 참조하는 모듈 참조 객체만을 가져온다. 즉, 클라이언트 컴포넌트 함수는 서버에서 구성되는 리액트 트리의 구성요소가 아니었다.

그렇다면 다음과 같은 직렬화 트리로 변환되게 됩니다.

직렬화된 트리 (RSC는 호출되어 element로 변환되고 CSC는 module reference object 형태가 됨)

전체 리액트 트리를 JSON 직렬화하고 있기 때문에 클라이언트 컴포넌트가 기본 html 태그에 전달하는 props도 직렬화할 수 있어야 합니다. 그러나 서버 컴포넌트는 이벤트 핸들러와 같은 함수를 props로 전달할 수 없습니다. 

함수는 직렬화할 수 없기 때문입니다.

그러나 RSC 프로세스 중 클라이언트 컴포넌트를 마주하게 된다면 클라이언트 컴포넌트 함수를 호출하거나 클라이언트 컴포넌트를 인스턴스화하는 클라이언트 컴포넌트가 있는 경우, 해당 컴포넌트는 RSC 트리에서 나타나지 않습니다. 예를 들어 설명해보도록 하죠

function SomeServerComponent() {
  return <ClientComponent1>Hello world!</ClientComponent1>;
}

function ClientComponent1({children}) {
  // 클라이언트에서는 가능
  return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}

ClientComponent2는 RSC 트리에서 나타나지 않습니다. 대신 module reference가 있는 엘리먼트와 ClientComponent1의 props만 볼 수 있습니다. 그러므로 ClientComponent1의 이벤트 핸들러를 ClientComponent2로 보내는 것은 안전합니다.

3. 브라우저가 React Tree를 재구조화합니다.

브라우저가 서버로 스트리밍 방식으로 JSON 결과물을 받았다면 이 구문을 다시 parsing한 결과물을 바탕으로 트리를 재구성해 컴포넌트를 만들어나갑니다 typemodule reference인 엘리먼트를 만날 때마다, 실제 클라이언트 컴포넌트 함수에 대한 참조로 대체를 시도합니다. 이 작업은 다시 번들러의 도움이 필요합니다. 클라이언트 컴포넌트 함수의 기능을 서버 module reference로 대체해 주었던 것도 번들러였고, 이 module reference를 브라우저가 클라이언트 컴포넌트 함수로 대체하는 것을 아는 것도 번들러입니다.

이를 그림으로 나타나면 다음과 같습니다.

 

이제 트리를 렌더링하고, DOM에 커밋하게 됩니다.

 

Suspense에서는 어떻게 동작할까?

그렇다면 Suspense를 사용했을 때도 같은 원리로 동작을 하는 것일까요?

Suspense는 아직 준비되지 않는 요소가 필요할 때, 리액트 컴포넌트에서 promises를 던질 수 있습니다. 이 Promise는 Suspense boundary에서 잡을 수 있습니다. Suspense에서 하위 트리를 렌더링할 때, promise가 던져질 때마다 리액트는 이 promise가 resolve 될 때까지 리액트 하위 트리 렌더링을 일시 중지한 다음 다시 시도합니다. (suspense는 fallback을 먼저 보여주고, resolve되면 정상적으로 컴포넌트를 로딩합니다)

 

우리가 RSC 결과물을 만들기 위해 서버 내에서 서버 컴포넌트 함수를 호출하게 됩니다. 이 때, 이 함수들은 각자 필요한 데이터를 가져오는 경우 promise를 던질 수 있습니다. 그리고 이 promise를 만나게 된다면 placeholder(@1, @2, ...)를 위치시키고, 이 promise가 resolve되면 서버 컴포넌트 함수를 다시 호출하고, 성공하면 이 완료된 chunk를 내보냅니다. 실제 RSC 출력 스트림을 생성하고, promise가 나타나면 일시 중지하고, promise가 resolve된 경우, 추가적인 chunk를 스트리밍합니다.

 

또, 브라우저에서 fetch 함수 호출로 RSC JSON 결과물을 스트리밍합니다. 이 프로세스 역시 결과물에서 placeholder를 마주하거나 (서버에서 던진 promise를 맞닥뜨린 경우), 스트림에서 placeholder를 아직 보지 못한 경우 promise를 던지는 것으로 끝날 수도 있다. 또는 클라이언트 컴포넌트 module reference를 마주치지만, 아직 브라우저에 로드된 클라이언트 컴포넌트 함수를 가지고 있지 않은 경우에도 promise를 던질 수 있습니다.

 

Suspense를 활용하면 서버 컴포넌트가 데이터를 가져올 때, 서버 스트리밍 RSC출력을 사용할 수 있으며 브라우저가 데이터를 점진적으로 렌더링하고 필요에 따라 클라이언트 컴포넌트 번들을 동적으로 가져올 수 있습니다.

 

RSC Wire Format (RSC Payload)

그런데 어떤 데이터가 서버에서 브라우저로 스트리밍 되는 것일까요?  JSON blob 데이터가 있고, ID로 태그되어 있는 간단한 형식입니다. 

이 같은 데이터 형태를 wire format이라고 하며, 서버는 이 값을 스트리밍하여 클라이언트에 제공합니다.

M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
  • M: 클라이언트 컴포넌트를 의미합니다. 클라이언트 번들에서 해당 함수를 렌더링하기 위한 정보가 어떤 chunk에 담겨있는지에 대한 참조(module reference)를 전달해줍니다.
  • S: Suspense를 의미합니다.
  • J: 서버에서 렌더링된 서버 컴포넌트이며, 실제 리액트 컴포넌트 element 트리를 정의합니다.
    • J0: App.server.js를 표현한 것임을 알 수 있습니다.
    • 렌더링에 필요한 모든 element, className, props, children 정보들이 들어가 있습니다.
    • @1, @2와 같은 값들은 나중에 렌더링이 완료될 때 들어가야 할 컴포넌트를 의미
      • @1에는 M1이 렌더링되었을 때 들어가는 곳임을 placeholder로 표시함
    • 이 형식의 포맷은 스트리밍으로 전송이 가능하며, 클라이언트가 전체 행을 읽는 즉시 JSON의 일부 구문을 분석하여 작업을 진행할 수 있으며 서버가 렌더링하는 동안 suspense 바운더리에 도달한 경우 resolve전까지는 fallback에 해당하는 부분을 출력해주고, resolve를 하면 각 청크에 해당하는 J를 가져옵니다.

서버에서는 클라이언트에서 리액트 컴포넌트 트리 구성에 필요한 정보를 최대한 경제적인 포맷으로 클라이언트에 전달합니다. 

 

Wire Format은 스트리밍으로 전송이 가능합니다. 클라이언트가 전체 행을 읽는 즉시, JSON의 일부 구문을 분석하여 작업을 진행할 수 있습니다. 서버가 렌더링하는 동안 suspense 바운더리에 도달한 경우, resolve시 각 청크에 해당하는 여러 J라인을 볼 수 있습니다. 예시를 들어 살펴보도록 하죠

 

// Tweets.jsx (서버 컴포넌트)
import { fetch } from 'react-fetch' // React's Suspense-aware fetch()
import Tweet from './Tweet.client'
export default function Tweets() {
  const tweets = fetch(`/tweets`).json()
  return (
    <ul>
      {tweets.slice(0, 2).map((tweet) => (
        <li>
          <Tweet tweet={tweet} />
        </li>
      ))}
    </ul>
  )
}

// Tweet.jsx (클라이언트 컴포넌트)
export default function Tweet({ tweet }) {
  return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}

// OuterServerComponent.jsx (서버 컴포넌트)
export default function OuterServerComponent() {
  return (
    <ClientComponent>
      <ServerComponent />
      <Suspense fallback={'Loading tweets...'}>
        <Tweets />
      </Suspense>
    </ClientComponent>
  )
}

위 예제에서 RSC 스트림은 아래와 같은 형태가 됩니다.

M1:{"id":"./src/ClientComponent.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.js","chunks":["client8"],"name":""}
J3:["$","ul",null,{"children":[["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}],["$","li",null,{"children":["$","@4",null,{"tweet":{...}}}]}]]}]

Suspense로 감싼 Tweet 서버 컴포넌트가 J0의 @3으로 들어가게 됩니다. 다만 이 rsc 스트림을 받은 순간에는 @3가 아직 정의되지 않았다는 점에 주목해야 합니다. 서버가 Tweets.jsx를 완전히 로드를 하게 되면 Tweet 클라이언트 컴포넌트에 대한 module reference를 참조하는 M4와 @3이 있는 위치로 스왑되어야 하는 J3를 출력하게 됩니다.

번들러가 ClientComponent.jsx와 Tweet.jsx 두 개의 클라이언트 컴포넌트 파일을 개별 번들로 나누어 Tweet 번들 다운로드를 뒤로 미룰 수 있다는 점도 알아두면 좋습니다.

 

그렇다면 RSC 스트림을 브라우저에서는 실제 엘리먼트로 어떻게 변환할까요?

react-server-dom-webpack은 진입점을 가지고 있는데, 여기서 RSC 응답을 받아 리액트 엘리먼트 트리를 다시 만듭니다.

import { createFromFetch } from 'react-server-dom-webpack'
function ClientRootComponent() {
  // fetch() from our RSC API endpoint.  react-server-dom-webpack
  // can then take the fetch result and reconstruct the React
  // element tree
  const response = createFromFetch(fetch('/rsc?...'))
  return (
    <Suspense fallback={null}>
      {response.readRoot() /* Returns a React element! */}
    </Suspense>
  )
}

 

API endpoint에서 RSC 응답을 읽도록 `react-server-dom-webpack`에 요청합니다.스트림을 읽기에 앞서, 클라이언트가 준비되지 않은 상태이므로 promise가 반환되고,  `response.readRoot()`는 응답 스트림이 처리될 때 업데이트되는 react element를 반환합니다. 

 

즉, 리액트는 렌더링을 하면서 준비되지 않은 @3 참조가 발견되면 promise를 던지고, J3을 `createFromFetch(fetch())`를 통해 가져오게 되면 (promise가 resolve되면) 리액트는 다시 렌더링을 재개하여 완료가 됩니다.

즉, RSC 응답을 스트리밍할 때 Suspens boundary에 의해 정의된 청크로 resolve되고 있는 element 트리를 계속 업데이트하고 렌더링하게 됩니다.

 

이렇게 공부를 해두니 막연하게만 생각했던 부분들에 대해서 이해가 되기 시작했습니다. 하지만 여전히 의문점이 몇가지 존재하는데 참조한 블로그글과 달리 현재 Next.js 14버전의 RSC Wire Format이 조금 바뀌어 있다는 것이었습니다.

물론 전체적인 그림은 비슷하지만 J, S, M 을 사용하지 않고 다른 문자열을 사용하는 것처럼 보이는데 이부분을 찾아봐도 설명해주는 글을 찾지 못해서 스스로 알아봐야할 부분인 것 같다는 생각이 듭니다.

 

빌드한 후의 format

예상하기로는 L이 @역할을 하는 듯하고, 0에서부터 트리를 만드는 것 같다는 합리적인 추측만 있습니다..

 

출처 
https://www.youtube.com/watch?v=yFC9GxYGWqo
https://yceffort.kr/2022/01/how-react-server-components-work
https://www.mux.com/blog/what-are-react-server-components
https://www.reason-to-code.com/blog/why-we-couldn't-feel-the-difference-of-nextjs/
https://app-router.vercel.app/
https://www.code-insights.dev/posts/nextjs-spa-or-mpa
https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#basic-example
https://github.com/reactwg/server-components/discussions/5
https://junghan92.medium.com/번역-how-react-server-components-work-an-in-depth-guide-aaf90ebd3c45
https://www.plasmic.app/blog/how-react-server-components-work#what-are-react-server-components
https://pyjun01.github.io/v/rsc/
https://blog.mathpresso.com/conceptual-model-of-react-suspense-a7454273f82e
https://github.com/reactwg/server-components/discussions/4