React SSR 그 고통의 기록

React SSR 그 고통의 기록

QWER.GG 의 트래픽은 지난해 대비 500% 성장했고, 대부분 검색엔진을 통해서 유입되고 있다. 과정은 당연히 순탄치 않았는데 그 고통의 기록을 남겨볼까 한다.

이 페이지는 사실 서버사이드 렌더링을 사용하진 않는다.

우선 SSR 의 전제조건은 다음과 같다.

1. CRA 를 Eject 하지 않는다.
한번 eject 하면 되돌릴 수 없고, react-scripts 가 해주는 많은 편의기능을 포기하는 동시에 업데이트도 골치아파 진다. timarney/react-app-rewired 와 같은 패키지도 존재하지만 이 역시 메이저 업데이트를 바로 지원하지 않을 가능성이 높고, create-react-app 의 dependency 도 충분히 피곤하다.

2. 전체 페이지를 렌더링한다
SEO 를 위해서 전체 페이지 렌더링이 필수적인 것은 아니나 분명 전체 페이지를 그리는 것이 좋은 것은 자명하고, First Meaningful Paint 라는 측면에서도 그렇고, 유저에게 매번 로딩화면을 보여주고 싶지 않았다.

3. 로그인 처리도 한다.
많은 SPA 가 로그인처리를 browser 에 jwt 등을 저장하는 방식을 사용하여, 로딩 시에 다시한번 서버쪽 API 를 접근하게 하는데, 이 부분을 역시 해소하고 싶었다. 가급적이면 서버에서 처리를 해서 한번 화면이 그려지면 repaint 되는 부분을 최소화 하고 싶었다.

4. TTFB 를 고려한다.
사실 초반에는 큰 문제가 없었고, 어플리케이션에 담기는 데이터 양이 늘어나면서 생겨난 문제로 어쩔 수 없는 부분이 있더라도 속도를 개선하고 싶었다.

1. CRA 를 eject 하지 않는다.

QWER.GG 는 CRA 의 CSS 를 SASS 로만 변경하여 사용하고 있다. 따라서 일반적인 component 의 구조는 다음과 같다.

import ‘./Button.scss’;

import React from 'react';
import classNames from 'classnames';

type Props = {
  className?: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button: React.FC<Props> = ({ className, ...props }) => {
  return (
    <button 
      className={classNames('Button', className)}
      {...props}
    />
  );
}

export default Button;

때문에 두가지 제약사항(?)이 생겨난다.

  1. Typescript 를 컴파일 해야 한다.
  2. Sass 파일이 ts 코드 상에서 import 된다.

서버에서 react 코드에 접근하려면 build 되어야 하고, CRA 의 build process 는 eject 하지 않으면 접근할 수 없기 때문에 다른 방법을 사용해야 했다. 다행히 typescript 의 기본 compiler 인 tsc 가 jsx 또한 문제없이 처리하기 때문에, tsc 를 사용하기로 했다. 그런데 import ‘./Button.scss’; 이 부분이 문제였다.

첫번째 시도 - 빌드시에 scss 파일을 스킵하자

.ts.js 같은 일반적인 스크립트 extension 이 아니면 tsc 는 처리할 수 없고, 빌드만 스킵하더라고 해도 해당 코드가 script 상에 포함되어 있어, node.js 런타임 에서 해당 component 를 불러와 서버 사이드 렌더링을 시도할 때 syntax error 를 throw 하게 된다. 때문에 다른 방법이 필요했다.

두번째 시도 - 전처리

CRA 에서 webpack 을  이미 포함하기 때문에 다시 설치해서 빌드할 수 없었고, webpack 빌드는 기본적으로 browser 를 위한 것이라 node.js 서버 쪽 빌드와 잘 어울리지 않았다. 애초에 node.js server 에서는 code 를 하나의 .js 파일로 만들 이유가 없기 때문에 불필요한 요소라 생각했다. 물론 그렇게 해도 되었겠지만, 경험상 어울리지 않는 조합을 엮는 행위는 미래의 생산성에 분명히 영향을 끼쳤다.

webpack 을 쓰지 않고 전처리를 하려면 무엇을 사용할까 고민했다. 태초에 gulp 가 있었다. webpack 이전에 많이 사용되던 툴 중 grunt 는 설정파일이 귀찮으니 패스., gulp 를 사용하기로 했다. 코드는 상당히 단순하다 .scss 구문이 있는 줄을 찾아내서 // Removed 로 교체한다. 코드는 다음과 같다.

const gulp = require(‘gulp’);
const replace = require(‘gulp-replace’);

gulp.task(‘build’, () => {
  return gulp.src(‘src/**/*.{ts,tsx}’, { base: ‘src’ })
    .pipe(replace(/.+\.s?css.+/g, ‘// Removed’))
    .pipe(replace(/.+\.yml.+/g, ‘// Removed’))
    .pipe(gulp.dest(‘server’));
});
세번째 시도 - 빌드 스크립트로 통합하기

일단 여기까지 했으니 큰 난관은 헤처왔고, 나머지는 빌드 과정에 녹여내면 된다.

{
  "scripts:" {
    “build:server”: “rimraf server && gulp build && npm run tsc”,
    “tsc”: “node node_modules/typescript/bin/tsc -p tsconfig.server.json”,
  }
}

tsconfig.server.json 이란 파일을 새로 만든 이유는 약간의 설정이 달라야 하기 때문이다. CRA 의 webpack 은 기본적으로 tsc 는 typecheck 만 하고 실제 jsx 나 code compile 은 babel 을 사용하기 때문에 두 타겟의 설정은 달라져야 한다. 자세한 설명보다는 현재 사용하고 있는 설정 파일을 첨부한다.

아래 파일이 일반적으로 react 에 필요한 tsconfg.json 이고

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "es6",
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "typeRoots": [
      "@types"
    ],
    "jsx": "preserve"
  },
  "include": [
    "src"
  ]
}

필요없는 부분을 삭제하고 일부 수정한 tsconfig.server.json 이다.

{
  "compilerOptions": {
    "outDir": "build/",
    "module": "commonjs",
    "target": "es6",
    "noImplicitAny": false,
    "sourceMap": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "jsx": "react",
    "moduleResolution": "node"
  },
  "include": [
    "server"
  ]
}

오래전에 작성한 코드라 불필요한 설정이 들어가있을 수 있으나 그냥 첨부한다. 이 정도라도 누군가에겐 큰 도움이 될 수 있으니.

2. 전체 페이지를 렌더링한다.

초기부터 코드가 많이 변경되어 몇가지 핵심적인 요소만 언급한다. QWER.GG 는 Restful API 를 redux store 에 저장하는 방식으로 개발을 시작해서 현재 Apollo 기반 GraphQL 을 사용하고 있다. 아직도 redux state 를 완전히 걷어내지는 못했고 따라서 redux state 주입 코드와 apollo 데이터 주입 코드가 공존하고 있다.

1. 어떻게 state 를 주입할까?

기본적으로 redux 는 서버 렌더링이 간단하다. react-reduxProvider 에 store 를 만들어서 주입해주면 된다. 그런데 문제는 CRA 를 그대로 사용하려면 템플릿을 사용하지 않고 index.html 을 그대로 살려야 한다는 점이다. eject 하면 간단하겠지만, 상술했듯 최대한 원형을 보존하고 react-scripts 의 업데이트 이점을 살리는 것이 기본 전제였다.

일단 파일을 불러온다.

const indexTemplate = fs.readFileSync(path.resolve(__dirname, ‘../../build/index.html’), ‘utf8’);

여기서 주의할 점은 빌드된 index.html 파일을 불러오는 것이다. 이 부분도 참 귀찮은 부분 중 하나였는데 빌드 되기 전 index.html 을 사용하게 되면 빌드된 <script src="..." />  구문이 포함되어 있지 않기 때문에 반드시 cra 의 build 된 이후의 코드를 사용해야 한다.

그 다음 필요한 코드를 다음과 같이 String.prototype.replace  를 사용해서 교체해준다. cheeriojs/cheerio 를 쓰면 훨씬 쉬웠겠지만 그땐 거기까지 생각이 닿지 않았고, 다음과 같이 다소 원시적인 방법으로 수정해주었다.

export function renderDefault(req: express.Request) {
  const language = getLanguageFromRequest(req);
  const theme = getThemeFromRequest(req);
  const helmet = Helmet.renderStatic();

  return indexTemplate
    .replace('lang="en"', `lang="${language}"`)
    .replace('data-theme="dark"', `data-theme="${theme}"`)
    .replace('window.__language__', `window.__language__='${language}'`)
    .replace(/<title>.*?<\/title>/g, helmet.title.toString())
    .replace('<meta name="placeholder">', helmet.meta.toString())
    .replace('<link rel="placeholder">', helmet.link.toString());
}

킬링 포인트는 index.html 에 넣어준 placeholder 코드들. 다시 말하지만 cheerio 를 사용하면 훨씬 덜 원시적으로 처리할 수 있다.

<html lang="en" data-theme="dark">
  <head>
    <meta name="placeholder">
    <link rel="placeholder">
    ...
2. 실제로 데이터 주입하기

실제로 데이터를 주입할때는 그냥 절차적으로 진행했다. default state 를 가져와서 부어주는 방식. 역시 원시적인 String.prototype.replace 로 교체해주었다. 다른점이라면 index.html 이 아니라 renderToString 된 코드를 수정했다는 정도.

export async function renderFromServer(req: express.Request) {
  const preloaded = await getDefaultState(req);
  const apolloClient = getApolloClient(req);
  const hydratedApp = await getHydratedApp(req, preloaded, apolloClient);

  // TODO: 여기가 가장 느림. 퍼포먼스 개선 포인트
  await getDataFromTree(hydratedApp);

  const rendered = ReactDOMServer.renderToString(hydratedApp);

  return renderDefault(req)
    .replace(‘window.__PRELOADED_STATE__={}’, `window.__PRELOADED_STATE__=${JSON.stringify(preloaded).replace(/</g, '\\u003c')}`)
    .replace(‘window.__APOLLO_STATE__={}’, `window.__APOLLO_STATE__=${JSON.stringify(apolloClient.extract()).replace(/</g, '\\u003c')}`)
    .replace(‘<div id=“root”></div>’, `<div id="root">${rendered}</div>`);
}

마지막으로 말하는데 왠만하면 cheerio 를 쓰자. 결과물은 devtools 로 보면 이렇게 된다. window.__APOLLO_STATE__ 의 일부분인데 이런걸 browser 에 뿌려줘야 한다는 사실이 진짜 너무 괴롭다.

브라우저에서 반복 쿼리를 하지 않기위한 preloaded state
async function getHydratedApp(
  req: express.Request,
  preloadedState: any,
  apolloClient: ApolloClient<NormalizedCacheObject>,
) {
  const url = req.originalUrl;
  const { store } = configureStore(preloadedState);
  const language = getLanguageFromRequest(req);

  return (
    <ApolloProvider client={apolloClient}>
      <Provider store={store}>
        <StaticRouter location={url} context={{ url }}>
          <GlobalStateProvider cookies={req.cookies}>
            <App language={language} />
          </GlobalStateProvider>
        </StaticRouter>
      </Provider>
    </ApolloProvider>
  );
}

Provider 가 무척 많다고 생각되는 건 기분 탓이다. hook, redux, apollo, react-router 가 섞여 있다보니 자연스럽게 이렇게 된다. 왜 이렇게 됬냐고 하기 전에 싱페어 (SPA) 의 피로감 이 글을 읽고 오자. 다만 이렇게 하다보니 위에 주석에도 남겼지만 Performance 문제가 발생한다. 후술하겠다.

3. 로그인 처리도 한다.

로그인 처리를 위해서는 Cookie 에서 로그인 처리를 해주어야 한다. browser 가 가지고 있는 long time storage 중 서버와 동시에 사용할 수 있는 유일한 부분이기 때문이다. 요즘엔 fetch 도 표준이 바뀌어서 same-origin 기준 cookie 를 기본적으로 보내기 때문에 다소 편리해지긴 했다. Using Fetch - Web API | MDN 링크를 참고하자.

* 보통 fetch는 **쿠키를 보내거나 받지 않습니다.**  사이트에서 사용자 세션을 유지 관리해야하는 경우 인증되지 않는 요청이 발생합니다. 쿠키를 전송하기 위해서는 자격증명(credentials) 옵션을 반드시 설정해야 합니다.
 [2017년 8월 25일](https://github.com/whatwg/fetch/pull/585)  이후. 기본 자격증명(credentials) 정책이 same-origin 으로 변경되었습니다. 파이어폭스는 61.0b13 이후 변경되었습니다.

static site 즉 빌드 된 index.html 을 CDN 을 통해서 서빙하지 않으면 이런 장점이 생긴다. nginx 와 같은 웹서버에서 load balancing 을 할때 reverse proxy 를 이용해서 같은 도메인으로 request 를 보낼 수 있게 하면, 로그인 처리를 jwt 와 같은 걸 사용하지 않고도 node.js 의 session 처럼 관리할 수 있다. 다만 이런 경우 ip address 등의 항목이 유실될 수 있지 않도록 설정에 주의하자.

getHydratedApp 함수의 아래 코드가 바로 이런 부분을 처리해주기 위함이다.

<GlobalStateProvider cookies={req.cookies}>
  <App language={language} />
</GlobalStateProvider>

GlobalState 를 cookie 에 넣어주게 되면, 어떤 state 를 서버에서 렌더링 할때도 유용하게 사용할 수 있다. QWER.GG 에서는 default state 를 주입하기 위해 주로 사용한다. geocode lookup 이라거나 i18n 처리 등의 작업도 해당된다.

4. TTFB 를 고려한다.

상당히 많은 과정과 시행착오가 생략되었지만 기본적으로 SSR 은 다음과 같은 순서로 이루어진다.

  1. 기본적으로 필요한 state 를 주입한다.
    theme 설정, geocode lookup, application 구동에 필요한 기본 설정들
  2. route 가 어딘지 확인하고 필요한 데이터를 가져온다.
  • redux 라면 필요한 state 를 주입할테고, (http 나 직접 db 에 접근하거나)
  • graphql 이라면 query 를 execute 해줄것이다.
  1. 데이터와 함께 page 를 렌더링하고
  2. title 이나 meta 등 react root 외에 부분들을 rendering 한다.

이러한 부분을 connection 핸들링 까지 해야 하다보니 react 의 SSR 은 기본적으로 빠를 수가 없다. 모든 route 의 component 를 쪼개서 부분부분 미리 rendering 을 하는 방식이라면 나름대로 가능한 부분도 존재하겠지만, React 의 핵심 코드에 접근해서 마개조를 해야하기 때문에 작은 팀에겐 불가능하다.

또한 CPU 도 많이 쓰고 Memory 도 많이 사용한다. 전체 react 코드를 recursive 하게 훑어야 하다보니 당연히 cpu 의존도가 커지고, 그 과정에서 해당 페이지의 모든 API 에 접근해야 하니 당연히 memory 도 많이 쓰게 된다. 그러다보니 고작(?) SSR 서버 주제에 서버 리소스를 과하게 차지하는 기현상이 생겨난다. 이쯤되면 React 를 쓰는게 너무 괴롭다… 작은 팀에서 감당하기엔 어려운지라 프론트 서버가 너무 맣ㄴ은 리소스를 먹게되면 SSR 을 포기하는 코드를 만들어 두었다.

export const renderServerSide = (function() {
  const monitor = monitorSystemUsage(1 * A_SECOND).init();

  return async (req: express.Request, res: express.Response) => {
    if (req.path === '/') {
      return renderDefault(req);
    }

    if (monitor.usage.memory > 70 || monitor.usage.cpu > 70) {
      logger.warn(`System usage exceeds 70%. Skip SSR. Current mem: ${monitor.usage.memory}%, cpu: ${monitor.usage.cpu}%`);

      return renderDefault(req);
    }

    return await renderFromServer(req);
  };
})();

덕분에 지금은 TTFB 가 최대 10초(!!!!!!) 였던 것이 최대 2초로 줄어들었고, 인덱스 페이지 (/) 는 어차피 SEO 에서 의미가 없기 때문에 SSR 을 포기했다. 컨텐츠가 있는 페이즈가 아니고서는 검색엔진에서 가져갈 데이터가 거의 없기 때문이기도 하다.

여전히 2초나 걸리지만, 장족의 발전이다

UA 를 보고 검색엔진인 경우 SSR 해주는 것도 좋은 방법이겠지만, 귀찮아서 당분간은 현재 구조를 유지하기로 했다.


결론

React CRA 를 eject 하지 않고도 충분히 SSR 을 달성할 수 있다. 다만 그대로 사용할 순 없고, 전처리를 해주거나 전처리가 필요하지 않은 styled-component 같은 패키지 (안써봄) 를 사용하면 된다. React 가 개발 생산성 측면에서 매우 뛰어난 것은 사실이나, SSR 이라는 어찌보면 별것 아닌 기능에 많은 리소스가 투여되어야 한다는 점이 무척 아쉽다.

이 글의 서두에 적어놓았듯이 작은 스타트업에게 절대적으로 큰 부분을 차지하는 것이 검색엔진을 통한 유입인데, 개발 편의성을 위해서 비지니스 이점을 포기한다는 것은 장기적인 관점에서 마케팅 비용을 더 크게 지불해야 한다는 것이기 때문에 React 를 위시한 SPA 의 미래가 다소 어둡게 보인다.

요즘에는 Phoenix LiveView 나 거기에서 영감을 받은 Laravel Livewire 등도 개발이 되고 있으니 앞으로의 frontend 나아가 웹 개발의 패러다임이 크게 변할지도 모르겠다는 생각이 든다. LiveView 스타일 (? 뭐라고 해야할지도 잘 모르겠다.) 의 웹 개발 방식이 PHP 의 부활을 가져올지도 모르겠다 Elixir 보다는 아무래도 PHP 가 대중적이니 말이다.

기승전라이브뷰