사실 지금부터가 진짜다!!
앞선 1, 2편에서의 서버 컴포넌트의 개념은 지난 수년 간 지속적으로 등장했던 것들이다.
하지만, RSC의 클라이언트 컴포넌트는 조금 새로운 개념이다. 단순한 SSR이 아닌 클라이언트 사이드와 서버 사이드의 조합을 추구하는 RSC... 그게 무엇인지 살펴보자.
App.js
전체를 서버에서 렌더링하면서 Prop drilling
이나 Cascade waterfall
과 같은 데이터를 다룰 때 생기는 이슈를 해결했다.
또한, search query params를 사용하여 링크 이동을 하고, 필요하면 form
을 이용해서 서버에서 form을 제출할 수도 있다.
그래서 모든 컴포넌트를 서버에서 렌더링하면 되지 않을까 싶지만 RSC의 궁극적인 목표는 서버 컴포넌트를 더 많이 사용하자가 아니라 클라이언트 컴포넌트와 서버 컴포넌트의 명확한 역할 분리를 통한 조합이다.
서버 컴포넌트에선 사용자와 상호작용 없는 단순 데이터 페칭 관련을, 클라이언트 컴포넌트에선 복잡한 상호작용을 각각 나눠서 hydration 비용을 줄이고 역할을 나눈다.
서버 컴포넌트에서 안 되는 것이 두 가지 있다. 첫 번째는 1편에서도 언급했던, 서버 컴포넌트 내에서 훅과 같은 상태를 사용하는 것이고
두 번째는 클라이언트 컴포넌트에서는 서버 컴포넌트를 import 할 수 없다는 것이다.
'use client'
import { ServerComponent } from './server-component.js'
function ClientComponent() {
return (
<div>
<ServerComponent /> // 불가능
</div>
)
}
이렇게 클라이언트 컴포넌트 하위에 서버 컴포넌트를 넣는 것이 불가능한데, 잘 생각해보면 ServerComponent
의 코드는 서버에서 렌더링 되기 때문에 클라이언트단에서 참조조차도 할 수 없다.
그래서 render props
로 두 컴포넌트를 안전하게 조합할 수 있다.
import { ClientComponent } from './client-component.js'
import { ServerComponent } from './server-component.js'
function App() {
return <ClientComponent serverUI={<ServerComponent />} />
}
서버 컴포넌트인 부모 컴포넌트에서 클라이언트 컴포넌트를 렌더링하면서 renderProps 패턴으로 ServerComponent
를 클라이언트 컴포넌트에게 주입하면 된다.
'use client'
function ClientComponent({ serverUI }) {
return (
<div>
{serverUI} // 가능함
</div>
)
}
앞서 서버에서 렌더링 된 컴포넌트는 RSC Payload의 형태로 클라이언트에게 전달된다고 했다. 위의 예제에서는 어떨까?
App
의 RSC Payload에는 클라이언트 컴포넌트인 ClientComponent
도 포함되어 있다. 그렇다면 클라이언트 js번들에도 ClientComponent
코드가 포함되었을까?
당연히 RSC Payload에는 클라이언트 컴포넌트 코드가 포함되지 않았다. 만약 RSC Payload에도 클라이언트 컴포넌트 코드가 있다면 리소스 중복은 물론이고 클라이언트 컴포넌트와 서버 컴포넌트의 동기화 불일치도 생길 것이다.
그렇기 때문에 RSC Payload는 클라이언트 컴포넌트를 참조로 식별한다.
(RSC Payload의 형식은 언제든지 달라질 수 있기 때문에 멘탈 모델만 챙기자)
여기 RSC Payload가 있다 복잡하니 우리가 필요한 부분만 떼어내 보자.
2:I["/ship-search.js","ShipSearch"] // [모듈 경로, export된 컴포넌트 이름]
// ...
1:[
"$",
"div",
null,
{
"className": "app",
"children": [
[
"$",
"div",
null,
{
"className": "search",
"children": [
"$",
"$L2", // 참조
null,
{ "search": "m", "results": "$L3", "fallback": "$4" }
]
}
],
[
// ...
]
]
}
]
...
위 RSC Payload에서 ID가 2인 I
항목이 있고, 이는 [모듈 경로, export된 컴포넌트 이름]
의 형식을 하고 있다. 그리고 ID가 1인 항목에서 $L2
로 참조되고 있다.
React가 RSC 페이로드를 처리할 때 $L2
가 참조임을 보고, 그 참조를 찾아 클라이언트 컴포넌트임을 인식하게 된다.
그리고 클라이언트(브라우저)가 이 참조를 바탕으로 어떤 모듈에서 찾아야 할지는 id가 2인 항목을 확인하는데,/ship-search.js
에서 ShipSearch
를 import하고 해당 컴포넌트 위치에 렌더링하게 된다.
이렇게 React는 RSC 페이로드에서 클라이언트 컴포넌트 코드를 중복하지 않고도 서버 + 클라이언트 컴포넌트 조합을 한다.
이러한 과정들, 서버 컴포넌트는 payload로, 클라이언트 컴포넌트는 export된 결과의 참조만 가져오는 걸 번들러에서 수행한다.
'use client'
라는 지시어가 있으면 클라이언트 컴포넌트라고 인식하여 번들러에서 자동으로 참조 처리하여 payload에 넣는다.
하지만 지금 예제에선 번들러를 사용하고 있지 않으니 어떻게 처리해야할까?
이제 드디어 Loader
를 사용해야 할 때다.
위에서 보았던 RSC Payload 중 클라이언트 컴포넌트 import 정보를 담고 있었던 부분을 다시 보자
2:I["/ship-search.js","ShipSearch"]
여기서 클라이언트 컴포넌트의 파일 경로 "/ship-search.js"
와 export된 name "ShipSearch"
을 알아야 한다. 이 작업을 Loader
에서 처리한다.
(번들러를 사용한다면 코드가 import되고 실제로 평가 및 실행 되는 사이 시점에서 모듈 경로를 payload에 추가함)
일단 로더를 등록해보자.
로더를 생성할 rcs-loader.js
파일을 커스텀 ES 모듈 로더로 등록한다. 두 번째 인자인 import.meta.url
은 현재 모듈의 URL을 기준으로 상대 경로를 해석하게 한다.
일단 로더를 등록하면 이후에 import되는 모든 ES 모듈를 불러오는 과정에 rsc-loader.js
가 개입할 수 있게 된다.
그리고 rcs-loader.js
에서 로더를 만들어보자.
코드를 간단히 설명하면 load
함수는 로드하려는 모듈의 경로인url
과 context
, 그리고 node.js 기본 로더 함수로, 원래 모듈을 로드하는 데 사용하는 defaultLoad
를 인자로 받아서 textLoad
에 넘긴다.
react-server-dom-esm/node-loader
에서 제공하는 reactLoad
는 모듈을 import할 때 소스 코드를 읽어서 "use client"
가 있는지 내부적으로 검사한다. 그리고 클라이언트 컴포넌트의 경우, 참조 형태로 바꾼다.
textLoad
함수는 defaultLoad
를 사용하여 파일 시스템에서 모듈을 읽어 온다.
우리가 처리해야할 모듈은 export, import
를 사용하는 ES 모듈이다. 그렇기 때문에 foramt이 'module'
인 경우, 즉 모듈이 ES 모듈인 경우르 찾아 아래와 같이 변환한다.
Buffer.from(result.source).toString('utf8')
이런 식으로 포매팅을 하는 이유는 모듈이 바이너리 데이터일 경우에 string
타입으로 일관되게 통일하기 위해서다.
정리하자면 Node.js Loader
는 node에 등록하고 파일을 import할 때마다 평가하기 전에 파일의 소스를 개발자에게 넘겨달라고 요청할 수 있는 기능이다. 그리고 커스텀 로드(ES 모듈만 발라내는)을 통해 string으로 변환한다.
마지막으로 script 실행 시 로드 등록 파일을 먼저 import하도록 추가하면 된다.
여기까지 하면 로더는 ES 모듈을 평가(실행)하기 전use client
를 확인하고 참조 객체로 변환하는 것까지 하게 된다.
이제, 신규로 추가된 EditableText
란 클라이언트 컴포넌트를 사용하려고 해보자.
EditableText
컴포넌트 Detail 컴포넌트에서 배의 정보를 수정할 수 있는 컴포넌트로 각종 훅들과 상태 관리가 쓰인다는 것만 알아두면 된다.
일단, 이 클라이언트 컴포넌트를 use client
없이 서버 컴포넌트에서 import 해서 앱을 실행시켜보자
바로 이런 에러와 직면한다. 현재 서버 컴포넌트들은 React Server 환경에서 구동되고 있다. 즉, 클라이언트 전용 훅들이 제거된 환경이기 때문에 에러가 나는 것이다.
그럼 클라이언트 EditableText
에 use client
를 추가하고 재실행해보자.
무언가 다른 출력을 내고 있다. 로더가 일을 잘하고 있다는 걸 확인할 수 있다.
그럼, 로그를 하나씩 보면서 서버 컴포넌트에서 클라이언트 컴포넌트를 import 했을 때부터 흐름과 대조해보자.
먼저 로더는 런타임에서 컴포넌트의 use client
를 확인하고 해당 클라이언트 컴포넌트를 실제 코드가 아닌 참조 객체로 반환하는데,
이 과정에서 클라이언트 컴포넌트에 있는 모든 export와 import를 가져와서 import는 제거하고, export된 값들을 클라이언트 참조로 변환한다.
// 아래 로그는 reactLoad에 의해 변환된 EditText의 참조 관련
import {registerClientReference} from "react-server-dom-esm/server";
export const EditableText = registerClientReference(
function () {
throw new Error(
"Attempted to call EditableText() from the server but EditableText is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component."
);
},
'file:///Users/dongha.kim/Desktop/dongha/react/react-server-components/client/edit-text.js',
'EditableText'
);
참조는 서버 호출 시 던지는 에러 메시지와 모듈 경로, 그리고 export된 모듈 이름을 가지고 있다. 그리고 registerClientReference
를 통해 참조를 등록한다.
다음으로 참조로 등록된 EditableText
는 어떤 상태인지 살펴보자.
{
length: '0',
name: '',
prototype: {},
$$typeof: 'Symbol(react.client.reference)',
$$id: 'file:///Users/dongha.kim/Desktop/dongha/react/react-server-components/client/edit-text.js#EditableText'
}
$$typeof
엔 참조된 컴포넌트임을, $$id
엔 모듈 경로가 있다. 저 typeof
를 renderToPipeableStream
에서 식별하여 RSC Payload에 클라이언트 컴포넌트는의 참조가 들어가게 되는 것이다.
이제 로더의 일은 끝났으니 해당 앱을 실행해보자.
use client
도 사용했으니 앱이 잘 되겠지 싶었는데 앱을 실행시켜보면 에러가 나온다.
호스트된 경로 밖에서 컴포넌트를 실행시키려고 했다는 건데, 즉 브라우저가 이 클라이언트 모듈(EditableText
)을 어디서 가져와야 하는지 모르는 상태라는 것이다.
로더가 만든 참조 객체를 떠올려보면
$$id: 'file:///Users/.../client/edit-text.js#EditableText'
file://
로 시작하는 절대경로로 적혀있는데, 브라우저는 파일 시스템에 접근하지 못 한다. 즉, 브라우저가 알 수 있도록 모듈의 경로를 변경해줘야 하는 것이다. 다시 renderToPipeableStream
로 돌아가보자.
RCS Payload를 만들어 pipe로 스트리밍했던 renderToPipeableStream
로 돌아왔다.
그리고 renderToPipeableStream
의 데피니션을 타고 들어가면 두 번째 인자에 moduleBasePath
가 있는 걸 확인할 수 있다.
즉, moduleBasePath
를 ../client
로 설정하여 RCS Payload에서 클라이언트 컴포넌트의 경로가 참조될 수 있도록 하면 되는 것이다.
new URL
생성자는 상대 경로와 base URL을 받아 절대 URL을 계산한다.
/client
하위에 있는 클라이언트 컴포넌트의 경로를 알아야 하니 ../client
과 base URL(import.meta.url
)를 넘겨file:///Users/.../react/project/client/
과 같은 절대 경로로 변환한다.
이렇게 moduleBasePath
를 넘겨주면 renderToPipeableStream
내부적으로 서버 렌더러가 클라이언트 컴포넌트의 전체 경로에서 moduleBasePath
부분을 자동으로 slice해서 상대 경로만 RSC 페이로드에 기록하게 된다.
즉, 전체 경로 file:///Users/.../react/project/client/edit-text.js
에서 moduleBasePath
를 제거하여 edit-text.js
만 남기게 되는 것이다.
제대로 RSC 페이로드에 기록된 것을 확인할 수 있다.
자, 이제 상대 경로 edit-text.js
를 브라우저가 알 수 있는 전체 URL로 변경해줘야 한다.
RSC 페이로드를 해석하는 함수인 createFromFetch
에 moduleBaseURL
프로퍼티에 http://localhost:4000/client
를 추가하여 상대 경로와 합쳐서 파일을 다운로드하게 하면 되는 것이다.
자 이제 대망의 앱을 다시 실행켜보자!
아주 잘 작동한다!
use client
리액트 런타임에서 어떻게 해석되고 서버 컴포넌트 하위에서 실행된 클라이언트 컴포넌트가 브라우저에서 어떻게 불러와지는지를 내부 구현 단계에서 알아봤다.
단순히 use client
를 사용하면 클라이언트 컴포넌트를 사용할 수 있다. 라고 아는 것보다 역시 내부적으로 어떻게 돌아가는지를 알아야 속이 시원하다. 리액트 내부를 공부하면 할수록 리액트 팀에 대한 경외심만 늘어간다.
이제 다음은 클라이언트 사이드의 라우팅이다..