초간단 React SSR 라이브러리를 만들었습니다
저는 솔직히 NEXT.js같은 SSR 프레임워크들을 보면서 생각했습니다. getStaticProps니 뭐니 하는것들을 왜 자꾸 추가로 써야 하는걸까? 복잡한 기능이 필요 없는 경우에도 저런 걸 써야 할까? 하구요.
그러던 도중, apollo의 SSR 처리를 보고 영감을 받아 아주 간단한 SSR 라이브러리를 만들었습니다.
소개
useasync-ssr은 react-use의 useAsync 함수를 SSR에서 사용할 수 있게 해 주는 라이브러리입니다.
사용법
클라이언트
import { Helmet } from "react-helmet";
import { useAsync } from "useasync-ssr";
function getCount() {
return fetch(API_URI + '/count').then(response => response.text()).then(v => parseInt(v));
}
export const Count1 = () => {
const count = useAsync(() => getCount());
return (
<div>
<Helmet>
<title>{'Count ' + count.value}</title>
</Helmet>
<h1>Count</h1>
<p>Count: {count.value}</p>
</div>
)
};서버
const app = express();
app.get('/render', async (req, res) => {
const path = req.query.path;
const asyncManager = new AsyncManager();
const Tree = (
<AsyncProvider asyncManager={asyncManager}>
<StaticRouter location={path}>
<App/>
</StaticRouter>
</AsyncProvider>
);
// Scan tree
ReactDOM.renderToString(Tree);
Helmet.renderStatic();
StatusCode.rewind();
// Load async requests
const caches = await asyncManager.load();
// Filled content
const content = ReactDOM.renderToString(Tree);
const helmet = Helmet.renderStatic();
const status = StatusCode.rewind() ?? 200;
const html = <Html content={content} helmet={helmet} caches={caches}/>;
res.status(status);
res.send(`<!doctype html>\n${ReactDOM.renderToString(html)}`);
res.end();
});작동 원리
ReactDOM.renderToString을 호출하면 트리에 따라 useAsync들이 실행되고, 각각의 useAsync는 asyncManager에 Promise 객체를 하나하나씩 추가합니다. asyncManager.load가 호출되면, asyncManager 안에 있던 Promise 객체들이 resolve되거나 reject 될 때 까지 기다리고, 그 결과를 캐시합니다. 다음 ReactDOM.renderToString 호출에서는, 이 캐시된 결과를 바탕으로 훅에 기본값을 제공해서 페이지의 내용이 채워지도록 합니다.
이런 단순한 방식이 가능한 이유는 다음 두 가지가 있습니다.
- React Hook의 규칙
- 재귀적 fetching의 SSR 불필요성
React Hook의 규칙
React Hook에는 규칙이 있습니다. 언제나 최상위 레벨에서만 Hook을 호출해야 한다는 규칙. 이 규칙은 React Tree 구조의 변경이 없는 경우, 각각의 컴포넌트의 state와는 상관없이 늘 같은 순서로 Hook들이 호출됨을 보장합니다. 따라서 두 ReactDOM.renderToString 호출에서 React Tree 구조의 차이가 없는 경우(새로운 컴포넌트에서 새 Hook을 호출하지 않는 경우), Hook들의 실행 순서는 일정하게 유지되므로 이를 이용할 수 있습니다.
재귀적 fetching의 SSR 불필요성
React Hook의 규칙만으로는 이러한 useAsync의 작동 방식을 사용하기에는 부족한 부분이 있습니다. Hook들의 실행순서가 보장되는건, 새로운 컴포넌트가 새 hook을 실행하지 않는 경우에만이기 때문입니다. fetching된 결과가 제공되는 두번째 ReactDOM.renderToString 호출에서 새로운 컴포넌트가 추가되어 새로운 hook을 실행해버린다면, Hook들의 실행순서는 망가집니다. 이 부분을 해결할 프로그래밍 트릭은 찾지 못했습니다만, 대신 이러한 경우는 발생하지 않는다는 결론을 만들 수 있었습니다.
"기존 hook의 반환값 변화로 인해 새 컴포넌트가 새 hook을 실행한다"라는 말을 이 라이브러리의 관점으로 바꿔 말하면, "useAsync가 완료된 후에, 새 useAsync가 실행되어야 한다"라고 말할 수 있습니다. 그렇다면, useAsync가 완료된 후에 새 useAsync가 실행되어야 하는 작업은 어떤 것이 있을까요?
그런 작업은 아마도, 재귀적 fetching일 것입니다. 그 전 fetching의 결과가 이후 fetching 요청에 필요한 경우일 겁니다. 그런데, 이런식으로 fetching을 여러번 하는 일은 성능에 좋지 않아 잘 사용되지 않습니다. 게다가 여러번 fetching을 한다고 해도, 그것들을 하나의 async 함수 안에 담기만 하면 손쉽게 하나의 useAsync로 바꿀 수 있습니다.
그런데 만약 이전 fetching이나 이후 fetching의 소요시간이 길어서, 두 fetching을 따로따로 실행하고 싶다면 어떡할까요? SSR을 하면 안됩니다. SSR은 페이지를 서버에서 렌더한 후 보내서, JS가 로드되는 시간동안 사용자가 볼 것을 마련해놓는 데에 의미가 있는데, fetching 시간이 오래걸리는 부분을 SSR하는 것은 말이 안 됩니다. SSR은 빨리 되어야 해요. 그래서 이를 위해 useasync-ssr의 useAsync 함수는, 훅의 SSR을 방지하는 clientOnly 속성을 지원합니다. SSR되지 않는 useAsync는 캐시를 쓰지도 읽지도 않습니다.

끝
useasync-ssr은 이것이 전부입니다. github과 도커를 활용한 예제를 보면 쉽게 이용할 수 있습니다. 의문이 드는 부분이나 지적할 부분이 있다면 댓글 꼭 남겨주세요.