Epic React - React Server Component (3)

김동하·1일 전
0

react

목록 보기
25/25
post-thumbnail

Client Component 에 대해

사실 지금부터가 진짜다!!

앞선 1, 2편에서의 서버 컴포넌트의 개념은 지난 수년 간 지속적으로 등장했던 것들이다.

하지만, RSC의 클라이언트 컴포넌트는 조금 새로운 개념이다. 단순한 SSR이 아닌 클라이언트 사이드와 서버 사이드의 조합을 추구하는 RSC... 그게 무엇인지 살펴보자.

1. Node.js Loader

1.1 리액트하면 조합

App.js 전체를 서버에서 렌더링하면서 Prop drilling이나 Cascade waterfall과 같은 데이터를 다룰 때 생기는 이슈를 해결했다.

또한, search query params를 사용하여 링크 이동을 하고, 필요하면 form을 이용해서 서버에서 form을 제출할 수도 있다.

그래서 모든 컴포넌트를 서버에서 렌더링하면 되지 않을까 싶지만 RSC의 궁극적인 목표는 서버 컴포넌트를 더 많이 사용하자가 아니라 클라이언트 컴포넌트와 서버 컴포넌트의 명확한 역할 분리를 통한 조합이다.

서버 컴포넌트에선 사용자와 상호작용 없는 단순 데이터 페칭 관련을, 클라이언트 컴포넌트에선 복잡한 상호작용을 각각 나눠서 hydration 비용을 줄이고 역할을 나눈다.

1.2 또 다시 등장하는 render props

서버 컴포넌트에서 안 되는 것이 두 가지 있다. 첫 번째는 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>  
	)  
}

1.3 그렇다면 RSC Payload는?

앞서 서버에서 렌더링 된 컴포넌트는 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 페이로드에서 클라이언트 컴포넌트 코드를 중복하지 않고도 서버 + 클라이언트 컴포넌트 조합을 한다.

1.4 'use client'

이러한 과정들, 서버 컴포넌트는 payload로, 클라이언트 컴포넌트는 export된 결과의 참조만 가져오는 걸 번들러에서 수행한다.

'use client'라는 지시어가 있으면 클라이언트 컴포넌트라고 인식하여 번들러에서 자동으로 참조 처리하여 payload에 넣는다.

하지만 지금 예제에선 번들러를 사용하고 있지 않으니 어떻게 처리해야할까?

이제 드디어 Loader를 사용해야 할 때다.

위에서 보았던 RSC Payload 중 클라이언트 컴포넌트 import 정보를 담고 있었던 부분을 다시 보자

2:I["/ship-search.js","ShipSearch"]

여기서 클라이언트 컴포넌트의 파일 경로 "/ship-search.js"와 export된 name "ShipSearch"을 알아야 한다. 이 작업을 Loader에서 처리한다.

(번들러를 사용한다면 코드가 import되고 실제로 평가 및 실행 되는 사이 시점에서 모듈 경로를 payload에 추가함)

1.5 /server/rsc-loader.js

일단 로더를 등록해보자.

로더를 생성할 rcs-loader.js파일을 커스텀 ES 모듈 로더로 등록한다. 두 번째 인자인 import.meta.url은 현재 모듈의 URL을 기준으로 상대 경로를 해석하게 한다.

일단 로더를 등록하면 이후에 import되는 모든 ES 모듈를 불러오는 과정에 rsc-loader.js가 개입할 수 있게 된다.

그리고 rcs-loader.js 에서 로더를 만들어보자.

코드를 간단히 설명하면 load 함수는 로드하려는 모듈의 경로인urlcontext, 그리고 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를 확인하고 참조 객체로 변환하는 것까지 하게 된다.

1.6 /client/editableText.js

이제, 신규로 추가된 EditableText란 클라이언트 컴포넌트를 사용하려고 해보자.

EditableText 컴포넌트 Detail 컴포넌트에서 배의 정보를 수정할 수 있는 컴포넌트로 각종 훅들과 상태 관리가 쓰인다는 것만 알아두면 된다.

일단, 이 클라이언트 컴포넌트를 use client 없이 서버 컴포넌트에서 import 해서 앱을 실행시켜보자

바로 이런 에러와 직면한다. 현재 서버 컴포넌트들은 React Server 환경에서 구동되고 있다. 즉, 클라이언트 전용 훅들이 제거된 환경이기 때문에 에러가 나는 것이다.

그럼 클라이언트 EditableTextuse client를 추가하고 재실행해보자.

무언가 다른 출력을 내고 있다. 로더가 일을 잘하고 있다는 걸 확인할 수 있다.

그럼, 로그를 하나씩 보면서 서버 컴포넌트에서 클라이언트 컴포넌트를 import 했을 때부터 흐름과 대조해보자.

1.7 Node.js 로더가 하는 일

먼저 로더는 런타임에서 컴포넌트의 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엔 모듈 경로가 있다. 저 typeofrenderToPipeableStream에서 식별하여 RSC Payload에 클라이언트 컴포넌트는의 참조가 들어가게 되는 것이다.

이제 로더의 일은 끝났으니 해당 앱을 실행해보자.

2. Module Resolution

use client도 사용했으니 앱이 잘 되겠지 싶었는데 앱을 실행시켜보면 에러가 나온다.

호스트된 경로 밖에서 컴포넌트를 실행시키려고 했다는 건데, 즉 브라우저가 이 클라이언트 모듈(EditableText)을 어디서 가져와야 하는지 모르는 상태라는 것이다.

로더가 만든 참조 객체를 떠올려보면

$$id: 'file:///Users/.../client/edit-text.js#EditableText'

file://로 시작하는 절대경로로 적혀있는데, 브라우저는 파일 시스템에 접근하지 못 한다. 즉, 브라우저가 알 수 있도록 모듈의 경로를 변경해줘야 하는 것이다. 다시 renderToPipeableStream로 돌아가보자.

2.1 /server/app.js

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 페이로드에 기록된 것을 확인할 수 있다.

2.2 /client/index.js

자, 이제 상대 경로 edit-text.js를 브라우저가 알 수 있는 전체 URL로 변경해줘야 한다.

RSC 페이로드를 해석하는 함수인 createFromFetchmoduleBaseURL 프로퍼티에 http://localhost:4000/client 를 추가하여 상대 경로와 합쳐서 파일을 다운로드하게 하면 되는 것이다.

자 이제 대망의 앱을 다시 실행켜보자!

아주 잘 작동한다!

3. 마무리

use client 리액트 런타임에서 어떻게 해석되고 서버 컴포넌트 하위에서 실행된 클라이언트 컴포넌트가 브라우저에서 어떻게 불러와지는지를 내부 구현 단계에서 알아봤다.

단순히 use client 를 사용하면 클라이언트 컴포넌트를 사용할 수 있다. 라고 아는 것보다 역시 내부적으로 어떻게 돌아가는지를 알아야 속이 시원하다. 리액트 내부를 공부하면 할수록 리액트 팀에 대한 경외심만 늘어간다.

이제 다음은 클라이언트 사이드의 라우팅이다..

profile
프론트엔드 개발

0개의 댓글