Skip to main content

DeepDive 브라우저, 리액트, 서버 렌더링 - 2편

목차

1.브라우저 렌더링
2.리액트 렌더링
3.Next.js 렌더링
4.Revisit = NextJS 에서 서버 컴포넌트를 렌더링하는 과정


Revisit = NextJS 에서 서버 컴포넌트를 렌더링하는 과정

Alt text

https://www.plasmic.app/blog/how-react-server-components-work

서버 컴포넌트란 (What are React server components)?

  • 서버 컴포넌트는 서버에서만 실행되는 컴포넌트
  • 서버와 클라이언트(브라우저)가 협력하여 React 애플리케이션을 렌더링할 수 있게 해주는 기능

서버 컴포넌트가 해결해주는 문제

alt

  • Client Side Waterwall fetching
    • 자식 컴포넌트 들의 렌더링과 API 호출 > 반복, 반복, 반복..
  • 자유로운 서버 리소스 접근
  • 번들 크기 감소 : 최종 전달되는 JS 양을 줄인다.
  • 자동 코드 분할 : 서버 컴포넌트에선 React.lazy와 dynamic import 사용하지 않아도 된다.

SSR vs RSC

Alt text

  • SSR Phase : RSC + RCC
  • CSR Phase : RCC
    • Next.js RSC 도입전에는 리액트 컴포넌트를 통해서 SSR 진행.
    • 즉, SSR에 반드시 RSC를 쓰지 않아도 된다.

서버 렌더링 장점

1.데이터 소스에 더 직접적으로 접근 가능

  • 서버는 데이터베이스, GraphQL 엔드포인트, 또는 파일 시스템과 같은 데이터 소스에 더 직접적으로 접근

2.“무거운” 코드 모듈을 효율적으로 사용 가능

  • 서버는 무거운 코드 모듈 사용 가능, 브라우저 처럼 의존성을 사용할 때마다 다운로드할 필요가 없기 때문이에요.
  • 브라우저는 자바스크립트 번들 크기가 작아진다.

그 결과, 페이지 로드 속도가 빨라진다.

The high-level picture

브라우저에서 리액트 작업을 몽땅 처리하지 않고

  • 서버에서 할만큼 하고, 브라우저에게 넘긴다. ( RSC payload, Placeholder )
  • 브라우저는 그 이후 후속작업을 이어간다. (HTML Preview, Reconcile, hydration, LifeCycle)

The server-client component divide

1.네이밍

  • .server.jsx 서버 컴포넌트를 포함
  • .client.jsx 클라이언트 컴포넌트를 포함.
  • 둘 다 없으면 서버 및 클라이언트 컴포넌트로 모두 사용

2.경계

  • 가장 중요한 것은 클라이언트 컴포넌트가 서버 컴포넌트를 임포트할 수 없다는 점이에요!
  • 이는 서버 컴포넌트가 브라우저에서 실행될 수 없으며,
  • 브라우저에서 작동하지 않는 코드를 포함할 수 있기 때문이에요.

클라이언트 컴포넌트에서 서버 컴포넌트를 임포트하여 렌더링할 수는 없지만, 컴포지션을 사용할 수 있어요.

// ClientComponent.client.jsx
// ❌ NOT OK:
import ServerComponent from './ServerComponent.server'
export default function ClientComponent() {
return (
<div>
<ServerComponent />
</div>
)
}
// 1.지원되지 않는 패턴: 서버 구성 요소를 클라이언트 구성 요소로 가져오기
'use client'
import ServerComponent from './Server-Component'

export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>

<ServerComponent />
</>
)
}

// 2.지원되는 패턴: 서버 구성 요소를 클라이언트 구성 요소로 Props로 전달
'use client'
import { useState } from 'react'

export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
const [count, setCount] = useState(0)

return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}

//3.
// This pattern works:
// You can pass a Server Component as a child or prop of a
// Client Component.
import ClientComponent from './client-component'
import ServerComponent from './server-component'

// Pages in Next.js are Server Components by default
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#unsupported-pattern-importing-server-components-into-client-components

  • 서버컴포넌트만 서버 및 클라이언트 컴포넌트 Import 가능.
  • 서버컴포넌트에서 클라이언트 컴포넌트를 Import 해서 합성해서 사용.
    alt

React 서버 컴포넌트(RSC) 렌더링 과정 (Life of an RSC render)

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

  • React 컴포넌트를 렌더링하기 위한 API 호출에 대한 응답으로 시작
  • 서버가 일부 렌더링을 처리해야 하기 때문에, "루트" 컴포넌트는 항상 서버 컴포넌트 이다.

2. 서버가 루트 컴포넌트 요소를 JSON으로 직렬화함

  • 1.클라이언트 컴포넌트 "플레이스홀더"로 이루어진 트리로 렌더링
  • 2.기본 HTML 태그 추출
  • 3.그런 다음 이 트리를 직렬화하고, 이를 브라우저에 보내면, 브라우저가 이 트리를 역직렬화하여 클라이언트 플레이스홀더를 실제 클라이언트 컴포넌트로 채우고 최종 결과를 렌더링.

RSC Payload Tree 객체의 직렬화 방법

  • 단순히 JSON.stringify(<OuterServerComponent />)로 직렬화된 요소 트리를 얻을 수 없다.
  • React 요소 = 객체이다. ( type, props .. 등 속성을 가짐. )
    • type 필드
      • 2가지 경우로 생각.
      • 1.문자열일 경우 기본 HTML 태그(예: "div")
      • 2.함수일 경우 React 컴포넌트 인스턴스를 나타내요.

예를 들어:

// <div>oh my</div>에 대한 React 요소
React.createElement("div", { title: "oh my" })
{
$$typeof: Symbol(react.element),
type: "div",
props: { title: "oh my" },
...
}

// <MyComponent>oh my</MyComponent>에 대한 React 요소
function MyComponent({children}) {
return <div>{children}</div>;
}
React.createElement(MyComponent, { children: "oh my" })
{
$$typeof: Symbol(react.element),
type: MyComponent // MyComponent 함수에 대한 참조
props: { children: "oh my" },
...
}
  • 1번째 케이스의 경우,

  • 기본 HTML 태그를 위한 것이라면 (type 필드가 "div"와 같은 문자열일 경우), 직렬화 가능.

  • 2번째 케이스의 경우, 함수는 JSON으로 직렬화할 수 없어요!

  • 서버 컴포넌트를 위한 것이라면 : 서버에서 렌더링 해여, 서버 컴포넌트를 기본 HTML 태그로 변환.

  • 클라이언트 컴포넌트를 위한 것이라면 : 모듈 참조 객체로 직렬화.


2.1 "모듈 참조" 객체란 무엇인가요?

React 서버 컴포넌트(RSC)는 React 요소의 type 필드에 들어갈 수 있는 새로운 값인 "모듈 참조" 객체를 도입했어요. 이 값은 컴포넌트 함수 대신 직렬화할 수 있는 "참조"를 의미해요.

예를 들어, 클라이언트 컴포넌트의 요소는 다음과 같이 생겼을 수 있어요:

{
$$typeof: Symbol(react.element),
// 이제 type 필드는 실제 컴포넌트 함수 대신 참조 객체를 가지고 있어요
type: {
$$typeof: Symbol(react.module.reference),
// ClientComponent는 기본 내보내기(export)...
name: "default",
// 이 파일에서 가져와요!
filename: "./src/ClientComponent.client.js"
},
props: { children: "oh my" },
}

"모듈 참조" 객체 = 바로 번들러가 수행

  • React 팀은 react-server-dom-webpack이라는 Webpack 로더
  • 서버에서 구성된 React 트리에는 클라이언트 컴포넌트 함수가 포함되지 않는다.

위의 예시를 다시 살펴보면, <OuterServerComponent />를 직렬화하려고 할 때 우리는 다음과 같은 JSON 트리를 얻게 돼요:

{
// 모듈 참조를 가진 ClientComponent 요소 플레이스홀더
$$typeof: Symbol(react.element),
type: {
$$typeof: Symbol(react.module.reference), //
name: "default",
filename: "./src/ClientComponent.client.js"
},
props: {
// ClientComponent에 전달된 children, 즉 <ServerComponent />.
children: {
// ServerComponent는 직접 HTML 태그로 렌더링돼요;
// 여기서 ServerComponent에 대한 참조는 전혀 없고,
// 대신 우리가 `span`을 직접 렌더링한 걸 볼 수 있어요.
$$typeof: Symbol(react.element),
type: "span",
props: {
children: "Hello from server land"
}
}
}
}

2.2 The serializable React tree

alt

이 과정의 끝에서, 서버에서 직렬화된 React 트리는 브라우저로 전송되어 "마무리" 작업을 수행하기 위해 아래와 같은 형태로 되어 있을 거예요:

Serializable React tree

  • 서버 컴포넌트는 네이티브 HTML 태그로 렌더링
  • 클라이언트 컴포넌트는 플레이스홀더로 대체된 React 트리

2.3 모든 props는 직렬화 가능해야 함

서버 컴포넌트에서 하위 컴포넌트로 이벤트 핸들러와 같은 함수를 props로 전달할 수 없다

// 잘못된 예시: 서버 컴포넌트는 직렬화할 수 없는 함수를
// 하위 요소에 prop으로 전달할 수 없어요.
function SomeServerComponent() {
return <button onClick={() => alert('OHHAI')}>Click me!</button>
}

클라이언트 컴포넌트 간에는 함수 prop을 전달하는 것이 괜찮다.

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

function ClientComponent1({children}) {
// 클라이언트 컴포넌트 간에는 함수 prop을 전달하는 것이 괜찮아요
return <ClientComponent2 onChange={...}>{children}</ClientComponent2>;
}

3. 브라우저에서 React 트리 재구성

브라우저는 서버에서 전달된 JSON 출력을 받아, 이를 기반으로 브라우저에서 렌더링할 React 트리를 재구성해야 해요.

  • 이 과정에서 type이 모듈 참조인 요소를 만나면,
  • 이를 실제 클라이언트 컴포넌트 함수의 참조로 교체해야 해요.

이 작업은 다시 한 번 번들러의 도움이 필요 하다.

  • 서버에서 클라이언트 컴포넌트 함수를 모듈 참조로 교체한 것도 번들
  • 브라우저에서 이 모듈 참조를 실제 클라이언트 컴포넌트 함수로 교체하는 방법을 알고 있는 것도 번들러.

재구성된 React 트리는 다음과 같아요.

  • 기본 HTML 태그와 클라이언트 컴포넌트가 포함된 트리이다.
  • 실제로는 기본 태그와 Placeholder가 클라이언트 컴포넌트만 교체된 상태.

alt

  • 그 후 이 트리를 평소처럼 DOM에 렌더링하고 커밋해요.

Suspense와의 호환성

React 서버 컴포넌트(RSC)는 Suspense와 잘 호환돼요.

  • 간단히 설명하자면, Suspense는 React 컴포넌트에서 필요한 데이터나 컴포넌트가 아직 준비되지 않았을 때 promise를 던질 수 있도록 해줘요
  • (예: 데이터를 가져오거나, 지연 로드되는 컴포넌트를 임포트하는 경우 등).
  • 이러한 promise는 "Suspense 경계(Suspense boundary)"에서 포착돼요.

Suspense 하위 트리에서 promise가 던져지면, React는 그 하위 트리의 렌더링을 중단하고, promise가 해결될 때까지 기다렸다가 다시 렌더링을 시도해요.

  • 서버에서 RSC 출력을 생성하기 위해 서버 컴포넌트 함수를 호출할 때, 이런 promise를 만나면, 일단 플레이스홀더를 출력해요.
  • promise가 해결되면 서버 컴포넌트 함수를 다시 호출해 성공적으로 완료된 청크를 출력해요.
  • 사실, 우리는 RSC 출력을 스트리밍하는 동안 promise가 던져질 때마다 잠시 멈췄다가, 해결되면 추가적인 청크를 스트리밍하는 방식으로 진행해요.

Suspense 덕분에

  • 1.서버는 서버 컴포넌트가 데이터를 가져오는 동안 RSC 출력을 스트리밍
  • 2.브라우저는 데이터가 사용 가능해질 때마다 점진적으로 렌더링
    • 필요할 때 클라이언트 컴포넌트 번들을 동적으로 가져올 수 있어요.
    • 사용자에게 더 나은 경험을 제공할 수 있어요.


RSC Wire Format

React 서버 컴포넌트(RSC)가 브라우저로 전달하는 데이터 이다.

  • JSON 형태이다.
    • key:JSON
  • 스트리밍 가능 하다.
    • key:JSON,key:JSON,key:JSON

*RSC Payload : React Tree -> JSON 직렬화 -> Streaming

RSC는 단순한 형식을 사용해 데이터를 스트리밍하며, 각 줄에 하나의 JSON 블롭이 있고, ID로 태그가 지정 되어 있다

  • 예를 들어, <OuterServerComponent/>의 RSC 출력은 다음과 같아요:
export default function ServerComponent() {
return <span>Hello from server land</span>
}
---
import ClientComponent from './ClientComponent.client'
import ServerComponent from './ServerComponent.server'
export default function OuterServerComponent() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
---
M1:{"id":"./src/ClientComponent.client.js","chunks":["client1"],"name":""}
J0:["$","@1",null,{"children":["$","span",null,{"children":"Hello from server land"}]}]
  • M으로 시작하는 줄은 클라이언트 컴포넌트 모듈 참조를 정의.
    • 이 참조에는 클라이언트 번들에서 컴포넌트 함수를 찾는 데 필요한 정보
  • J로 시작하는 줄은 실제 React 요소 트리를 정의
  • @1 같은 참조는 M 줄에서 정의된 클라이언트 컴포넌트를 가리켜요.

이 포맷은 매우 스트리밍에 적합해요. 클라이언트는 한 줄을 읽으면 바로 JSON 스니펫을 파싱하고, 다음 작업을 진행할 수 있어요. 서버가 렌더링 중에 Suspense 경계를 만나면, 해당 경계가 해결될 때마다 여러 J 줄이 출력돼요.

더 복잡한 예시

다음은 좀 더 복잡한 예시예요:

// Tweets.server.js
import { fetch } from 'react-fetch' // React의 Suspense를 지원하는 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.client.js
export default function Tweet({ tweet }) {
return <div onClick={() => alert(`Written by ${tweet.username}`)}>{tweet.body}</div>
}
// OuterServerComponent.server.js
export default function OuterServerComponent() {
return (
<ClientComponent>
<ServerComponent />
<Suspense fallback={'Loading tweets...'}>
<Tweets />
</Suspense>
</ClientComponent>
)
}

이 경우, RSC 스트림은 다음과 같이 보여요:

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"}]]}]

// J0의 @3는 추후 스트림 되서 온다.
...
// 드디어 도착함 J3 -> M4

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":{...}}}]}]]}]

J0 줄에는 새로운 Suspense 경계가 추가

  • 이 경계의 자식은 @3을 가리키고 있어요. 주목할 점은, 이 시점에서는 @3이 아직 정의되지 않았다는 거예요!
  • 서버가 트윗 로딩을 완료하면, Tweet.client.js 컴포넌트에 대한 모듈 참조를 정의하는 M4 줄과, @3 위치에 삽입될 또 다른 React 요소 트리를 정의하는 J3 줄이 출력돼요.
  • 또한, 번들러가 ClientComponentTweet을 자동으로 두 개의 별도 번들로 분리했다는 점도 주목할 만해요. 덕분에 브라우저는 나중에 Tweet 번들을 다운로드할 수 있어요.

Consuming the RSC format

import { createFromFetch } from 'react-server-dom-webpack'

function ClientRootComponent() {

const response = createFromFetch(fetch('/rsc?...'))
return (
<Suspense fallback={null}>
{response.readRoot() /* 이 메서드는 React 요소를 반환해요! */}
</Suspense>
)
}
  • RSC 스트림을 브라우저에서 실제 React 요소로 변환 하기 위해 react-server-dom-webpack 패키지 사용.
  • 스트림이 읽히기 전에는 콘텐츠가 아직 준비되지 않았으므로 즉시 promise 리턴.
  • 스트림이 읽히면 promise가 해결되고 React는 다시 렌더링을 재개.
  • 이렇게 RSC 응답을 스트리밍하면서 Suspense 경계에 의해 정의된 청크마다 요소 트리를 계속 업데이트하고 렌더링.

클라이언트 컴포넌트에서 데이터만 가져오는 것보다 이게 더 나을까요?

정답은 없다. 2가지 옵션을 선택해야 한다.

  • 완성된 요리 옵션 : 처리된 데이터를 내려줄 것인가?
  • 재료와 조리 도구 옵션 : 처리할 데이터를 위한 도구와 데이터를 직접 받을 것인가? (JS번들 크기, API 레이턴시, 데이터의 크기)

메타 프레임워크(Nextjs)가 주는 이점

  • SSR 과정, RSC 스트림을 생성하고 이를 브라우저에서 소비, 번들러의 협력, 하이드레이트(hydrate) 등등.. 알아서 처리

Playground : https://app-router.vercel.app/


ref