Lunuy
31
2021-09-18 19:29:18
0
425

초간단 React SSR 라이브러리를 만들었습니다


저는 솔직히 NEXT.js같은 SSR 프레임워크들을 보면서 생각했습니다. getStaticProps니 뭐니 하는것들을 왜 자꾸 추가로 써야 하는걸까? 복잡한 기능이 필요 없는 경우에도 저런 걸 써야 할까? 하구요.

그러던 도중, apollo의 SSR 처리를 보고 영감을 받아 아주 간단한 SSR 라이브러리를 만들었습니다.

useasync-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는 캐시를 쓰지도 읽지도 않습니다.

따라서 재귀적 fetching은, 하나의 비동기함수로 처리하거나, SSR을 disable 하는 방식으로 해결할 수 있습니다. 해결이라고 써놓기는 했는데, 사실 GraphQL을 사용하고 SSR의 목적을 잘 생각하며 코딩한다면 재귀적 fetching 자체를 만날 일이 없습니다.


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

3
  • 댓글 0

  • 로그인을 하시면 댓글을 등록할 수 있습니다.