Recoil 레시피: 서버 사이드 렌더링

촉촉하게 상태 공급 해주는 방법

글 목차

  1. 소개
  2. 비동기 액션
  3. 스냅샷과 상태 모니터링
  4. 서버 사이드 렌더링

이제 Recoil.js 사용의 마지막 단원, 서버 사이드 렌더링(Server-side rendering)까지 왔다. 사실 서버 사이드 렌더링은 React 애플리케이션에 있어 필수 기능은 아니다. 검색 엔진 최적화(SEO)나 초기 렌더링 속도를 위해, 어떻게 보면 부가적으로 제공하는 기능이라서 다룰까 말까 고민을 했는데 Redux에서 가능한 기능을 Recoil에서는 어떻게 풀어내는지 보여주고 싶어서 이 주제에 대한 글을 작성하기로 했다. 먼저 서버 사이드 렌더링의 정의부터 간단하게 살펴보자.

서버 사이드 렌더링이란?

React 컴포넌트를 클라이언트(브라우저)가 아닌 서버에서 렌더링해서 결과를 반환하는 방식이다. 이 때 결과물은 HTML 파일 형식으로 서버에서 클라이언트로 전송된다. 클라이언트 렌더링에 ReactDOM.render 메서드가 사용되는데 서버 렌더링에는 ReactDOMServer.renderToString 메서드가 사용된다. 메서드의 이름에서 알 수 있듯이 정적인 문자열로 만들어서 HTML 문자열을 전송한다.

전송했다고 해서 끝이 아니다. 서버에서 받아온 결과물은 단순한 HTML일 뿐이므로 이를 React의 노드에 일치하는 작업이 필요하다. 이 과정을 수분 공급(hydrate)이라고 하는데, 바짝 말라있던 HTML에 React를 공급해준다. 실제로 이러한 기능을 하는 API의 이름도 ReactDOM.hydrate이다. React를 마치 한 편의 동영상이라고 생각해보자. 서버 사이드 렌더링은 동영상을 일시 멈춤하고 캡쳐한 스냅샷과 마찬가지다. 이를 클라이언트로 전송한 후, 다시 동영상으로 만드는 과정이 바로 hydrate인 것이다. 수분 공급이 끝나고 나면 동영상은 다시 재생될 것이다.

Redux와 같은 상태 관리 라이브러리를 사용한다면 상태의 스냅샷 또한 함께 전달이 되어야 한다. 상태가 컴포넌트의 렌더링을 결정하기 때문이다. 아주 간단한 예로 상태가 "checked: false"인 체크박스 컴포넌트가 체크된 채로 렌더링되면 안되기 때문에 상태와 컴포넌트의 렌더링 상태는 항상 동기화가 되어 있어야 한다. 이렇게 전달한 상태의 스냅샷은 hydrate 과정에서 상태 보관 스토어에 넣으면 된다.

Redux의 상태 공급

먼저 익숙한 Redux의 상태 공급 방법부터 비교도 할 겸 살펴보자. 정식 용어는 hydration이고, 좋은 비유적 표현이라고 생각하지만 어쩐지 어색해서 Redux, 그리고 다음에 나올 Recoil에 한해서는 수분 공급을 "상태 공급"이라는 말로 바꾸어 보았다. 다음은 Redux 공식 사이트에서 가져온 서버 사이드 렌더링 코드를 아주 살짝 변형한 것이다.

import { renderToString } from 'react-dom/server';

const handleRender = (req, res) => {
  const params = qs.parse(req.query);
  const counter = parseInt(params.counter, 10) || 0;

  // 초기 상태 준비
  const preloadedState = { counter };

  // Redux 스토어 인스턴스 생성
  const store = configureStore(preloadedState);

  // 컴포넌트를 문자열로 렌더링
  const html = renderToString(
    <Provider store={store}>
      <App />
    </Provider>
  );

  // Redux 스토어에서 최초 상태 가져오기
  const finalState = store.getState();

  // 렌더링된 결과를 클라이언트로 전송
  res.send(renderFullPage(html, finalState));
};

서버 사이드에서 렌더링 할 때 사용할 상태를 공급하는 건 configureStore에서 하고 있다. createStore는 스토어를 작성하면서 초기값으로 preloadedState의 값을 사용한다.

import { createStore, applyMiddleware } from 'redux'

const configureStore = (preloadedState) => {
  const store = createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(thunk)
  );

  return store;
};

위에서 보다시피 결국은 Redux에서 제공하는 createStore를 사용하는데 생략 가능한 createStore의 두 번째 인수는 스토어의 초기 상태를 의미한다. 실제로 상태 스냅샷을 공급하는 부분은 renderFullPage에서 담당하고 있다. 이 함수는 다음과 같이 정의되어 있다.

function renderFullPage(html, preloadedState) {
  return `
    <!doctype html>
    <html>
      <head>
        <title>Redux Universal Example</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script>
          // 경고: HTML에 JSON을 포함할 때 발생할 수 있는 보안 이슈는 다음을 참고하세요.
          // https://redux.js.org/recipes/server-rendering/#security-considerations
          window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(
            /</g,
            '\\u003c'
          )}
        </script>
        <script src="/static/bundle.js"></script>
      </body>
    </html>
    `
}

눈여겨 봐야 할 것은 window.__PRELOADED_STATE__를 할당하는 부분이다. JSON 객체로 만들어진 이 값은 클라이언트에 상태를 공급하는 과정에서 다시 한 번 createStore에 전달된다. 마지막으로 Redux 공식 사이트에 나타난 코드를 보면 다음과 같다.

import { hydrate } from 'react-dom'
import { createStore } from 'redux'

// 서버에서 생성한 HTML에 주입된 전역 변수에서 상태를 가져옴
const preloadedState = window.__PRELOADED_STATE__

// 상태 전역 변수의 메모리를 해제할 수 있도록 준비
delete window.__PRELOADED_STATE__

// 초기 상태를 설정하며 Redux 스토어 생성
const store = createStore(counterApp, preloadedState)

hydrate(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

Redux의 서버 사이드 렌더링 과정을 다시 한 번 정리하면 다음과 같다.

  1. 상태를 가져와서 스토어를 만든다.
  2. renderToString을 이용해 컴포넌트를 문자열로 렌더링한다.
  3. HTML을 출력할 때 전역 변수에 상태 스냅샷 객체도 저장한다.
  4. 클라이언트에서 hydration할 때 상태 스냅샷에 있는 값을 초기 상태로 삼아 스토어를 작성한다.

Recoil의 상태 공급 과정도 이와 비슷하다.

Recoil의 상태 공급

Redux에서는 스토어를 만들고 스토어를 Provider에 공급하지만, Recoil에서는 사용자가 직접 스토어를 만들지 않는다. 소개 편에서 살펴본 <RecoilRoot>를 호출하면 내부에 자동으로 스토어가 만들어진다. 직접 스토어를 다룰 방법이 없다.

잠깐, Redux에서는 스토어를 만들 때 초기 상태를 설정해 주었다. 그렇다면 Recoil에서는 초기 상태를 설정하고 싶을 때 어떻게 해야할까?

해답은 역시 <RecoilRoot>에 있다. 이 컴포넌트는 사실 initialState라는 prop을 통해 초기화 함수를 전달받을 수 있다. 초기화 함수에는 MutableSnapshot 객체가 전달된다. MutableSnapshot 인스턴스에는 setreset 메서드가 있는데 초기화에서 필요한 건 set 뿐이므로 처음 살펴본 Redux 예제를 Recoil 식으로 변화해보면 다음과 같다.

import { renderToString } from 'react-dom/server';

const handleRender = (req, res) => {
  const params = qs.parse(req.query);
  const counter = parseInt(params.counter, 10) || 0;

  // Recoil 상태 초기화 함수
  const initializeState = ({set}) => {
    set(counterState, counter);
  };

  // 컴포넌트를 문자열로 렌더링
  const html = renderToString(
    <RecoilRoot initializeState={initializeState}>
      <App />
    </RecoilRoot>
  );

  // TODO: 최초 상태 가져오기
  const finalState = ???; // 어떻게 가져오면 될까?

  // 렌더링된 결과를 클라이언트로 전송
  res.send(renderFullPage(html, finalState));
};

코드가 Redux 때보다 짧아진 것 같아 보이지만 이는 착각에 가깝다. Redux는 플레인 오브젝트를 전달해서 전역 상태를 설정할 수 있지만 Recoil에서는 개별 상태의 종류가 많아질수록 일일이 아톰 혹은 셀렉터를 불러와야 하기 때문에 Recoil 접근법의 코드가 더 번잡해질 수 밖에 없다. 간단하게 상태 몇 가지만 더 추가해봐도 금세 차이를 알 수 있을 것이다.

게다가 HTML 코드에 끼워 넣을 최초 상태 객체를 가져오는 부분의 코드도 감안해야 한다. Redux에서는 생성한 스토어의 getState() 메서드를 사용해서 가져올 수 있었지만 Recoil에서는 스토어에 직접 접근할 수 없기 때문이다.

그래서 코드를 살짝 수정해보았다. preloadedState를 만들어두고 이를 초기화 함수에도 전달하고 웹 페이지에도 전역 변수로서 전달하기로 했다. 변경된 코드는 다음과 같다.

import { renderToString } from 'react-dom/server';
import { getInitializer } from './shared';

const handleRender = (req, res) => {
  const counter = parseInt(req.query.counter, 10) || 0;

  const preloadedState = { counter };

  const html = renderToString(
    <RecoilRoot initializeState={getInitializer(preloadedState)}>
      <App />
    </RecoilRoot>
  );

  res.send(renderFullPage(html, preloadedState));
};

여기서 getInitializer는 플레인 객체를 전달받고 상태 초기화 함수를 반환하는 함수이다. 이 함수는 다음과 같이 정의되어 있다.

// share.js 파일
function getInitializer(preloaded) {
  return ({ set }) => {
    for (const [key, value] of Object.entries(preloaded)) {
      const state = ssrStates[key];
      if (state) {
        set(state, value);
      }
    }
  };
}

// states.js 파일
const counterState = atom({
  key: 'counter',
  default: 0,
});

const ssrStates = {
  counter: counterState,
};

ssrStates에는 플레인 객체의 키에 대응하는 아톰/셀렉터을 매핑해두었는데 이를 통해서 Recoil 스토어의 초기 상태를 설정할 수 있게 된다. 이렇게 작성한 후 페이지를 호출하면 다음과 같은 결과를 볼 수 있다.

서버 사이드 렌더링을 적용한 웹 페이지

div#root의 내부 HTML은 모두 리액트 컴포넌트가 문자열로 렌더링 된 결과이며, 상태의 스냅샷 또한 window.__PRELOADED_STATE__로 작성되었음을 알 수 있다. 이 상태에서 클라이언트측에서 hydration을 해주지 않으면 그냥 정적인 카운터 숫자와 아무 기능도 하지 않는 Up, Down 버튼만 남게 된다.

눌러도 대답없는 버튼...

클라이언트에서 hydration을 할 때는 앞서 __PRELOADED_STATE__에 저장한 전역 상태를 Recoil 스토어의 초기값으로 적용해야 한다. 이 과정은 먼저 살펴 본 Redux의 적용 방법을 기반으로 하여 getInitializer를 사용하면 어렵지 않게 완료할 수 있다.

// 서버에서 생성한 HTML에 주입된 전역 변수에서 상태를 가져옴
const preloadedState = window.__PRELOADED_STATE__;

// 상태 전역 변수의 메모리를 해제할 수 있도록 준비
delete window.__PRELOADED_STATE__;

hydrate(
  <RecoilRoot initializeState={getInitializer(preloadedState)}>
    <App />
  </RecoilRoot>,
  document.getElementById('root')
);

서버와 똑같은 getInitializer가 사용되었는데 이 때문에 이 함수가 정의된 파일 이름이 shared.js였던 것이다. hydration까지 적용하고 나면 서버 사이드 렌더링도 잘 되고 클라이언트와 서버의 상태 또한 동기화 된다. 아래 URL에서 counter 값을 설정한 후 전송된 HTML 코드를 보면 서버에서 전송된 React 렌더링 결과물과 상태 스냅샷이 일치하는 것을 볼 수 있다.

데모: https://xhlkf.sse.codesandbox.io/?counter=2021

촉촉해 진 카운터 애플리케이션

리듀서?

앞서 다룬 카운터 값과 달리 여러 상태가 중첩된 채로 적용되면 어떻게 될까? 예를 들어, 사용자 이름, 사용자의 성별이 개별 상태로 존재할 때 다음과 같이 preloadedState가 전달된다면?

const user = { name: 'Kim', gender: 'M' };
const preloadedState = {
  counter,
  user,
};

사용자 정보의 개별 조각을 다루는 상태는 있어도 user에 해당하는 상태는 없기 때문에 서버 사이드 렌더링이 제대로 되지 않을 수 있다. 이럴 때는 셀렉터를 하나 작성해서 셀렉터가 리듀서 역할을 하도록 해주면 된다.

const userState = selector({
  get: ({get}) => {
    return {
      name: get(userNameState),
      gender: get(userGenderState),
    };
  },
  set: ({set}, {name, gender}) => {
    set(userNameState, name);
    set(userGenderState, gender);
  }
});

마치며

지금까지 Recoil에 대해서 알아보았다. 첫 글을 쓰고 난 후, 지금 이 마지막 글을 쓰기 전까지 Recoil은 두 번 버전업을 했고, 개인적으로는 네이버 DeView에서 Recoil을 소개할 수 있는 기회를 얻어 발표도 했으며, 국내에 Recoil의 사용자 역시 조금씩 늘고 있다는 게 체감이 된다.

역사가 채 1년도 되지 않은 만큼 부족한 부분이 많은 것도 사실이다. 하지만 명료함, 편의성, 가능성을 본다면 Recoil의 미래는 충분히 밝다고 생각한다. Redux에 비하면 학습 곡선도 짧아서 금세 배울 수 있을 것이다.

댓글을 남겨주세요

This site uses Akismet to reduce spam. Learn how your comment data is processed.