Koa 서버에서 React의 서버 사이드 렌더링(RSC)과 하이드레이션을 구현해보자

Explanation

그동안 별다른 생각 없이 클라이언트 사이드 렌더링 프로젝트는 React를 사용하고 서버 사이드 렌더링 프로젝트에는 Next.js를 사용해서 프로젝트를 진행했었는데요. 그렇다보니 React에서 서버 사이드 렌더링 관련 개선 업데이트 부분을 봐도 그냥 ‘Next.js 가 더 좋아지겠구나?’ 정도로만 생각하고 넘겼던 거 같아요.

그래서 오늘은 Koa 서버에서 React의 서버 사이드 렌더링(RSC)과 하이드레이션을 간단히 구현하며, 그 개념을 조금이나마 이해해보는 시간을 가져보려 합니다!

1. 서버 사이드 렌더링(SSR)

우선은 React에서 제공하는 ‘renderToPipeableStream’를 활용해서 SSR를 구현해보려고 하는데요. 최대한 간단하게 웹 서버는 koa 프레임워크를 사용하고 jsx을 해석하기 위해 Babel을 사용하여 빌드하였습니다.

“renderToPipeableStream API”는 React v18에 추가되어 기존의 ‘renderToString’와 ‘renderToNodeStream’를 개선하여 스트리밍과 ‘Suspense’, ‘React Server Components(RSC)’를 추가로 지원합니다.

간단한 동작 확인을 위해서 타입스크립트를 사용하지는 않았어요.

짜잔, 어떤가요? 엄청 간단하죠. Babel을 사용해서 빌드하고 빌드한 js 파일을 Node.js에서 실행하면 ‘localhost:3000’을 통해서 React의 컴포넌트가 서버에서 렌더링되서 브라우저로 html로 전달되어 출력되는 것을 확인할 수 있습니다.

간단하다고 적었지만, 사실 엄청 헤맨 건 안비밀..

여기까지의 전체 코드는 https://github.com/falsy/blog-post-example/tree/4b8a30836acccd41338bbec5f1e7759cb07da3ce/rsc-koa에서 확인하실 수 있습니다.

2. 리액트 서버 컴포넌트(RSC)와 하이드레이션(Hydration)

이번에는 클라이언트 컴포넌트와 서버 컴포넌트를 나눠서 구성하고 이를 하이드레이션 하는 과정까지 간단하게 만들어 보고 확인해 볼게요.

2025.02.20 추가사항

혹시라도 제목에 오해할 수 있을 거 같아서, 조금 추가하면.. 리액트 서버 컴포넌트(RSC)는 내부에서 클라이언트 컴포넌트를 함께 사용하지 않는다면 클라이언트에서 실행되지 않기 때문에 하이드레이션이 필요하지 않습니다.

그전에, 서버에서 동작하는 컴포넌트와 다르게 클라이언트에서 동작하는 스크립트가 필요하기 때문에 저는 Webpack을 추가로 사용하여 번들링하여 클라이언트 스크립트를 구성하였습니다.

간략하게 코드 구조를 먼저 보자면

모든 코드를 적을 수 없으니 여기서 ServerMessage 컴포넌트는 그냥 뷰만 있는 컴포넌트이고 ClientMessage 컴포넌트는 useState를 추가로 사용한 컴포넌트입니다. 이제 App.js를 보면,

이렇게 생겼는데요. 기대하는 것은 서버 사이드에서 렌더링 하고 전체 HTML 파일을 클라이언트에 응답해주고. 클라이언트에서는 하이드레이션되어서 useState 같은 이벤트가 동작하는 것입니다.

이제 핵심인 ‘server/index.js’ 코드를 살펴보면,

간단하게 요약해서 살펴보면, Webpack으로 클라이언트 코드를 빌드해서 dist 디렉토리 안에 client 안에 ‘client.bundle.js’ 파일로 만들거고 이를 ‘bootstrapModules’ 속성에 경로로 추가한 거에요. 그리고 번들링된 파일을 정적 경로로 접근할 수 있도록 koa-static를 사용해서 dist 디렉토리를 퍼블릭 경로로 설정해 주었어요.

동작을 생각해보면, ‘renderToPipeableStream’를 통해 첫번째 인자인 App 컴포넌트를 서버 사이드에서 렌더링한 후에 html 파일을 응답해 줍니다. 그리고 그안에는 ‘bootstrapModules’로 설정한 스크립트 파일을 모듈로 자동으로 추가줍니다. 저는 그곳에 Webpack으로 빌드한 React 번들이 위치하고 hydrateRoot가 동작하여 하이드레이션됩니다.

그러면 이제 서버 코드와 클라이언트 코드를 모두 빌드해주고 실행하면, 서버 사이드에서 컴포넌트가 렌더링 되고 클라이언트에서 하이드레이션되어서 useState가 동작하는 것을 확인할 수 있습니다!

여기까지의 전체 코드는 https://github.com/falsy/blog-post-example/tree/f3a40e7e0c5381a98d8834fce8b0b22cd8cc70c5/rsc-koa에서 확인하실 수 있습니다.

2025.02.20 추가사항

글에서 사용하는 예시 코드는 RSC를 서버 사이드 렌더링 방식(SSR)으로 사용한 예시입니다. 그리고 많이 사용되는 Next.js 프레임워크는 RSC를 처리할 때 SSR 방식이 아닌 JSON 직렬화 방식을 사용합니다.
(이 부분에 대한 자세한 이야기는 아마도 다음 포스팅에서 할 것 같습니다!)

3. 데이터 통신

이제, 서버 사이드에서 데이터를 통신한 후 이를 출력하고 하이드레이션되는 것을 확인해 볼 건데요.

서버에서 사용하는 코드는 ‘.server.js’ 라는 네이밍을 사용하고 클라이언트에서 사용하는 컴포넌트는 그냥 .js로 끝나도록 설정하였습니다. 그리고 ‘DataFetch.server.js’ 라는 데이터 가져오는 서버 컴포넌트를 만들어 주었는데요.

평범한 코드지만, 뜬금없는 스크립트 코드가 있죠!?

이건 이따가 클라이언트에서 서버에서 통신해서 받아온 message 값을 초깃값으로 설정하여 하이드레이션할 수 있게 하기 위해서입니다. 조금 더 이야기를 하면 이 컴포넌트는 서버 컴포넌트이기 때문에 클라이언트 코드 번들에는 포함되어서는 안되는데요. 그러면서 동시에 클라이언트에서 하이드레이션 하려면 동일한 DOM 구조를 가지고 있어야 하기 때문에, 이를 간단하게 구현하기 위해서 이 스크립트 코드를 사용해서 DOM을 동일하게 만들어 줄 거예요.

Next.js와 같은 프레임워크는 RSC를 지원하기 때문에 자체적으로 서버 컴포넌트와 클라이언트 컴포넌트를 분리해서 클라이언트에서는 RSC 전용 플레이스 홀더를 만들어서 자동으로 분리 처리해 줍니다.

이야기한 것 처럼 클라이언트에서는 RSC를 포함하면 안되기 때문에 이전의 App.js 파일을 클라이언트 용과 서버 용으로 나누었는데요. 우선 서버 App.js를 보면,

이전 코드와 크게 다르지 않고 DataFetch 컴포넌트만 추가되었습니다.

이제 새롭게 추가한 클라이언트의 App.js는,

클라이언트에서 알고 있는 DOM과 서버 사이드에서 렌더링 된 DOM이 다르면 하이드레이션 되지 않고 오류가 발생하기 때문에, 위와 같이 서버 컴포넌트를 사용하지 않으면서 동일한 DOM 구조로 만들어 주었습니다.

ServerMessage 컴포넌트는 그냥 은글슬쩍 복붙했지만, 샘플이니까 이해해주세요.

이제 마지막으로 server의 index.js 파일도 약간 수정이 필요한데요.

우선, 테스트를 위해 간단하게 “/api/message” API를 추가해 주었고, “ctx.respond = false”를 설정해서 koa가 자동으로 응답을 종료하는 것을 막아주고 “/” 라우터를 Promise로 감싸주어서 React의 스트리밍이 모두 완료되었을 때까지 기다리게 해주었습니다.

그리고 ‘onAllReady’ 시점을 추가해서 resolve 하였는데요. ‘onShellReady’는 HTML 콘텐츠가 준비되었을 때 호출되고 onAllReady는 모든 Suspense 경계가 완료되고 완전한 HTML이 준비가 되었을 때 호출됩니다.

짜잔! 이제 서버와 클라이언트 코드를 모두 빌드한 후 서버를 실행하면, 서버는 API 통신을 포함한 서버 컴포넌트를 렌더링하여 HTML을 클라이언트(브라우저)에 응답합니다. 이후 클라이언트는 번들된 스크립트 파일을 통해 해당 HTML을 하이드레이션하여 동작합니다.

여기까지의 전체 코드는 https://github.com/falsy/blog-post-example/tree/main/rsc-koa에서 확인하실 수 있습니다.

간단하게 막 만들다보니.. 잘못된 부분들이 있을 수 있습니다.
혹시, 잘못된 부분이 있다면 댓글이나 이슈를 통해 알려주시면 감사하겠습니다! 🙇‍♂️