Prisma Korea
Published in

Prisma Korea

리액트 서버 컴포넌트와 프리즈마

리액트 18버전에서 가장 관심을 많이 받고있는 기능은 단연 서버 컴포넌트입니다 (RSC). 이전까지는 페이지 전체를 서버에서 렌더하는 server-side rendering (SSR)이 주목받았었지만 서버 컴포넌트를 사용하게 되면 페이지의 일부만 서버에서 렌더한 후 페이지에 주입하는게 가능합니다.

일반적인 웹앱의 경우 API를 통해 데이터베이스의 정보를 받아오는데요, 서버 컴포넌트를 사용하면 REST API (혹은 GraphQL API)를 통하지 않고도 필요한 정보를 렌더할 수 있다는 장점이 있습니다. 이건 기존 SSR에서도 충분히 가능한 부분이지만, SSR로는 첫 페이지 렌더만 서버에서 이뤄지는 반면에 서버 컴포넌트는 페이지를 받아오고 난 이후에도 비동기적으로 개별 컴포넌트를 서버에서 받아올 수 있다는게 차이점입니다.

프리즈마 현황

리액트의 이러한 변화에 맞춰 프리즈마 팀에서도 RSC에 대한 지원을 준비중입니다.

데모 소스코드 뜯어보기

이제 프리즈마 팀과 리액트 팀이 만든 데모의 소스코드를 뜯어보며 서버 컴포넌트는 어떤식으로 구현되는지 알아봅시다!

구조

데모용으로 만들어진 노트 정리 앱의 백엔드는 Express 그리고 프론트엔드는 (당연히) 리액트로 만들어져 있습니다. 데이터베이스는 프리즈마와 Sqlite3를 사용했네요. 프로젝트 구조는 다음과 같습니다:

  • server
    - api.server.js
  • src
    - index.client.js
    - LocationContext.client.js
    - Cache.client.js
    - Root.client.js
    - db.server.js
    - App.server.js
    - 기타 컴포넌트 모듈들.

server/api.server.js

모든 서버 로직은 이 모듈 안에 들어있습니다. HTML을 반환하는 경로는 / 하나 뿐이며, 따라서 실질적으로 이 앱은 single page application (SPA) 입니다.

특이한건 /react 경로입니다. GET /react는 흥미롭게도 JSON이나 HTML이 아닌 serialize 된 RSC를 반환합니다. SSR의 경우 HTML과 JS를 반환하고 REST API의 경우 JSON을 반환하는 점과 대비됩니다. 이렇게 클라이언트로 전달된 RSC는 react-server-dom-webpack 이라는 패키지를 사용해서 deserialize 됩니다.

src/LocationContext.client.js

이 앱은 전역 상태로 location이라는 상태를 가지고 있습니다 (window.location과는 별개). {selectedId, isEditing, searchText} 의 형태인데, 사견으로는 location 보다 appState 정도의 이름이 더 정확하지 않았을까 싶습니다. 해당 상태는 LocationContext에 저장되며, Provider은 Root.client.js에서 사용됩니다.

주목할 점으로, 클라이언트가 서버에 /react 요청을 보낼 때 location을 X-Location이라는 헤더에 담아서 보냅니다. 이렇게 전달된 location object는 <App> 컴포넌트를 렌더할 때 props로 사용됩니다.

src/Cache.client.js

리액트 18에 추가될 가능성이 있는 또 다른 기능으로 suspense cache가 있고, 이 데모 앱에서 사용되었습니다. 이 모듈에 정의된 useServerResponse 훅은 /react 응답 값을 react-server-dom-webpack 패키지를 사용해 deserialize 한 후 suspense cache에 넣습니다. 이후 같은 location으로 /react에 요청을 보낼 일이 있다면 캐싱된 컴포넌트를 재사용합니다. 새로운 노트를 추가하거나 기존 노트를 지우는 등 cache invalidation이 필요한 경우 이 모듈에 정의된 useRefresh 훅을 통해서 캐시를 refresh 할 수 있습니다.

src/Root.client.js

이 리액트 앱의 최상위 컴포넌트입니다. index.client.js 모듈에서 createRoot 함수를 사용해 DOM에 추가됩니다. 특기할 점으로, error boundary가 Root 컴포넌트에 들어가 있습니다. 자식 함수 컴포넌트에서 throw Error을 할 경우 해당 error boundary에서 처리됩니다.

주석: 리액트 concurrent mode는 ReactDOM.render이 아닌 createRoot을 사용해야 합니다.

src/db.server.js

react-prismaPrismaClient 인스턴스가 이 모듈 안에 들어있습니다. 모듈 이름에서 알 수 있듯이 서버 컴포넌트에서만 import 가능합니다.

src/App.server.js

이 SPA의 알맹이입니다. location을 props로 받는 서버 컴포넌트이며, 클라이언트에 전달된 이후 Root의 child로 렌더됩니다. App의 자식들은 클라이언트 컴포넌트와 서버 컴포넌트가 섞여있음을 볼 수 있습니다.

한계점

전체적인 구현을 알고 나니 이 데모 앱의 한계점을 알 수 있었습니다.

  1. 페이지가 하나뿐임. X-Location 헤더를 사용한 props 전달.
    만들고자 하는 앱이 SPA가 아닌 경우 이 데모 앱의 아키텍처를 그대로 사용할 수는 없을 것으로 보입니다. 그리고 HTTP 헤더로 props를 전달하는 구조라서 JSON.stringify 할 수 없는 객체들은 props로 쓸 수 없습니다 (예: 함수, 날짜).
  2. Mutation 결과가 페이지 (리액트 컴포넌트)임.
    리액트 팀은 mutation 결과로 컴포넌트를 반환함으로써 클라이언트의 캐시 관리를 보다 수월하게 해줄 수 있다고 언급했지만, 데모 앱에서는 페이지 하나를 통채로 반환하기 때문에 RSC가 특별히 더 효율적으로 보이진 않았습니다. 그리고 채팅 앱 같이 여러 스크린이 존재하는 경우, 업데이트 되어야 하는 스크린들을 일일히 확인해서 서버가 보내주는건 기존 cache invalidation보다 더 쉬운 방법이라고 말하긴 어려울 것 같습니다.

데모 앱의 한계점과는 별개로 React 18이 아직 실제 프로젝트에 사용하기 어려운 이유도 있습니다.

  1. 타입스크립트 지원 미비. 인터페이스가 아직 확정되지 않음.
    지금 실험 단계에 있는 기능들은 전부 unstable_ 접두사를 붙여야 사용할 수 있으며, 아직 인터페이스가 확정되지 않은것으로 보입니다 (예: unstable_useTransition). 이에 따른 당연한 결과로 타입스크립트 지원이 없으며, 프리즈마의 중요한 장점인 타입을 활용하기 힘듭니다.
  2. 주요 리액트 프레임워크들의 지원 미비.
    리액트 팀은 현재 NextJS와 파트너쉽을 맺고 concurrent mode와 RSC지원을 추가하는 중입니다. 하지만 NextJS 문서에 따르면 클라이언트 컴포넌트에서 서버 컴포넌트를 import하지 못하는 등의 치명적인 한계가 아직 남아있습니다. 널리 사용되고 있는 Create React App (CRA)의 경우에는 아직 리액트 18 지원에 대한 언급이 없습니다. 클라이언트 전용이었던 리액트가 서버쪽 기능을 추가하는 것이기 때문에 여러모로 어려움이 있을것으로 예상됩니다.
    주석: nextreact-prisma 패키지가 서로 다른 react 버전을 peer dependency로 지정했기 때문에 정확히 어떤 버전의 react를 설치해야 할 지 확인하기 어려웠습니다.

데모 소스코드 개선해보기

데모 앱에서 부족한 부분들을 확인했으니, 개선 가능한 부분들을 보완해 보았습니다. 여기에서 제가 작업한 결과물을 확인하실 수 있습니다: https://github.com/prisma-korea/prisma-server-component-example

React Router 적용

데모 앱은 SPA라서 React Router를 사용한 앱들에도 RSC를 적용할 수 있을지 먼저 확인해 보았습니다.

일단 각 스크린별로 다른 컴포넌트를 렌더해주어야 하므로, 백엔드의 renderReactTreeApp 말고도 다른 컴포넌트를 렌더할 수 있게 범용성을 개선했습니다.

그리고 각 스크린별로 서버 컴포넌트를 요청할 수 있는 경로를 지정해주었습니다. (app.use('/react', router) 이므로 /react/Home 컴포넌트에 대응됩니다.)

이에 대응되게 useServerResponsepathname에 따라 다른 서버 컴포넌트를 요청하도록 수정했습니다.

마지막으로 Root 컴포넌트에 BrowserRouter를 추가하고, suspense 캐싱은 pathname을 키로 사용하도록 했습니다.

이렇게 수정하고 나니 스크린을 전환할 때 마다 /react/* 경로에서 컴포넌트를 받아오고, 이미 방문한 스크린은 캐싱된 컴포넌트를 재사용하는걸 확인할 수 있었습니다. 결과적으로 SPA가 아니라도 RSC는 무리없이 사용 가능합니다!

Cache Invalidation

데모 앱은 mutation 응답으로 컴포넌트를 받아와서 클라이언트에 렌더하는걸 보여주는데, SPA에서는 가능한 방법이라도 라우터가 있는 상황에서는 서버가 캐시를 관리하는게 간단하지 않아보였습니다. 따라서 클라이언트에서 캐시를 관리하는 일반적인 구조로 수정했습니다.

일단 CRUD API가 컴포넌트가 아닌 JSON을 반환하도록 수정하고,

Suspense cache를 invalidate하는 훅을 작성했습니다.

마지막으로 handleSubmit에서 mutation이 끝난 후에 cache invalidate을 하게 해주었습니다.

역시 cache invalidation은 RSC를 쓰든 쓰지 않든 피해갈 수 없는 난제인 것 같습니다…

기대되는 점

아직 리액트 18은 베타 단계라 불안정한 면이 있고, 관련 라이브러리들도 적응하는 기간이 꽤 필요할 것으로 보입니다. 하지만 앞서 제가 데모 앱에서 확인한 한계점들도 지금 약간의 노력으로 해결 가능하거나 출시 이후 시간이 지나면 저절로 해결될 부분들이라서 걱정이 되진 않습니다. 오히려 제 예상으로 RSC는 SSR의 상위호환격 기능으로써 18버전 출시 이후 많은 기업들에서 채택할 것 같습니다. 뻔히 예상되는 기술적 어려움이 있음에도 불구하고 장기적인 안목으로 리액트를 만들어 나가는 리액트 팀을 응원합니다.

Priska Korea 오픈 채팅방: https://open.kakao.com/o/g4oQTEqd

--

--

Prisma community held by dooboolab in Korea

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store