DeepDive 브라우저, 리액트, 서버 렌더링 - 2편
목차
1.브라우저 렌더링
2.리액트 렌더링
3.Next.js 렌더링
4.Revisit = NextJS 에서 서버 컴포넌트를 렌더링하는 과정
Revisit = NextJS 에서 서버 컴포넌트를 렌더링하는 과정
https://www.plasmic.app/blog/how-react-server-components-work
서버 컴포넌트란 (What are React server components)?
- 서버 컴포넌트는 서버에서만 실행되는 컴포넌트
- 서버와 클라이언트(브라우저)가 협력하여 React 애플리케이션을 렌더링할 수 있게 해주는 기능
서버 컴포넌트가 해결해주는 문제
- Client Side Waterwall fetching
- 자식 컴포넌트 들의 렌더링과 API 호출 > 반복, 반복, 반복..
- 자유로운 서버 리소스 접근
- 번들 크기 감소 : 최종 전달되는 JS 양을 줄인다.
- 자동 코드 분할 : 서버 컴포넌트에선 React.lazy와 dynamic import 사용하지 않아도 된다.
SSR vs RSC
- 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 해서 합성해서 사용.
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
이 과정의 끝에서, 서버에서 직렬화된 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가 클라이언트 컴포넌트만 교체된 상태.
- 그 후 이 트리를 평소처럼 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
줄이 출력돼요. - 또한, 번들러가
ClientComponent
와Tweet
을 자동으로 두 개의 별도 번들로 분리했다는 점도 주목할 만해요. 덕분에 브라우저는 나중에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
400줄의 코드로 나만의 리액트 만들기 (부제: 리액트의 원리에 대한 심층 연구) : https://ricki-lee.medium.com/400%EC%A4%84%EC%9D%98-%EC%BD%94%EB%93%9C%EB%A1%9C-%EB%82%98%EB%A7%8C%EC%9D%98-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EB%B6%80%EC%A0%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%9D%98-%EC%9B%90%EB%A6%AC%EC%97%90-%EB%8C%80%ED%95%9C-%EC%8B%AC%EC%B8%B5-%EC%97%B0%EA%B5%AC-f4c51b96001d
React 18: 리액트 서버 컴포넌트 준비하기 https://tech.kakaopay.com/post/react-server-components/#no-client-server-waterfall
New Suspense SSR Architecture in React 18 #37 https://github.com/reactwg/react-18/discussions/37