Single Page Application 은 개발 생산성이 높고 유닛 테스트 도입이 용이하기 때문에 완성도가 높은 어플리케이션을 만들 수 있지만, 팀 버너스리가 주창한 월드와이드 웹 페이지로서의 가치로는 다소 떨어지는 것 또한 사실이다. 이것은 무슨말이냐 하면, javascript 코드 없이 어플리케이션은 동작하지 않게 되어 저사양 컴퓨터나 오래된 브라우저에서 일관성을 떨어뜨린다. 또한 javascript 의 긴 구동시간 및 다운로드 시간 때문에 바쁜 크롤러는 스크립트 돌릴 시간이 없다 이를 지원하지 않는 크롤러의 경우 그저 하얀색 화면만을 크롤링하게 된다. 때문에 최근 프론트엔드 엔지니어링에 필수적인 부분이 Server Side Rendering 이나 근본적으로 많은 한계를 지니고 있다.

Server Side Rendering 이 동작하는 방식

react-router 를 사용하는 경우 서버사이드 렌더링은 일반적으로 다음과 같은 절차로 이루어진다.

  1. GET /products/1 리퀘스트가 서버에 전달된다.
  2. react-router 패키지의 match 함수를 이용하여 해당 request path 에 해당하는 rendering path 가 routes 에 있는지 확인한다.
  3. redux 같은 패키지를 사용하는 경우 필요한 state 를 주입한다.
  4. axiosfetch-polyfill 을 사용하여 API 호출을 하여 실제 application 을 mocking 할 수도 있고,
  5. 미리 memoryredis 등을 사용해 캐싱해둔 state 자체를 주입할 수도 있다.
  6. ReactDOMServerrenderToString 메소드를 사용하여 View 를 서버에서 마련해둔다.
  7. 만들어둔 View 를 서버 렌더링 메소드 등을 이용하여 브라우저에 전달한다.

state 를 주입하는 단계를 제외한다고 하더라도, 서버에서는 response 를 보내기 전에 전체 화면을 렌더링해서 보내야 하기 때문에, 속도가 느리기도 하거니와 동시성을 희생하게 된다. 또한 이 서버사이드 렌더링을 하는
과정에서 적지 않은 연산이 일어나기 때문에 스케일 아웃 문제가 발생하게 된다. SPA 의 장점 중 하나인 스태틱 캐시 서버를 통한 스케일링을 희생해야 한다. 특히 동접이 많은 페이지의 경우 초기 응답성이 낮아질 수 밖에 없다.

SSR 꼭 해야 하나?

AWS 의 경우 그냥 S3 에 업로드하고 cloudfront 로 캐싱을 하면 쉽고, 편리한데다 스케일링 문제도 상당부분 해결 되는데, 이러한 많은 단점에도 불구하고 SSR 은 꼭 해야하나? 하는 질문에 자답해보면 그렇다.

1. Search Engine Optimization

우선 한국 인터넷 환경상 중요하다고 하지 않을수도 있지만 어쨋든 Search Engine Optimization 은 중요하다. 왜냐하면 비용일 전혀 들이지 않고 트래픽을 올릴 수 있는 거의 유일무이한 방법이기 때문이다. 거래소의 예를 들면 organic 유입은 많지 않다. 그렇다고는 하지만 없는 것도 아니다.

적은 트래픽인 것은 사실이나 없는 것도 아니다

구체적은 숫자를 공개할 수는 없지만, 1% 내외로 차지하고 있다. 이는 충성도가 높은 암호화폐 거래소의 유저 특성상 어쩔 수 없는 부분이긴 하지만, 이더리움을 키워드로 검색했을때 고팍스 컨텐츠가 표시된다면 클릭률은 낮더라도 홍보효과를 무시할 수는 없을 것이다.

2. 초기 페이지 로딩 속도 개선

페이지 로드 타임에 따른 이탈율

웹서비스의 성공은 사실상 이탈율에 달렸다고 해도 과언이 아니다. 위 그래프는 이제는 유명한 현상(?)으로 6초 이후로는 이탈율이 꾸준히 상승하는 것을 볼 수 있다. 그래프 출처 비싼 마케팅 비용을 치르고 고객을 유치했음에도 페이지 로딩 속도 때문에 20% 이상의 유저를 이탈시킬 수 있다는 것이다. 웹 프론트엔드 개발자로서 이러한 부분은 반드시 해결해야 한다고 볼 수 있다.

Server Side Rendered HTML

여기서 제안하고 싶은 것이 static 한 html 페이지를 만들어서 제공하자는 것이다. 결국 SSR 의 핵심은 필요한 페이지를 서버에서 만들어 제공하자는 것인데, 이를 반대로 이해하면 static 한 html 페이지를 미리 만들어서 s3-cloudfront 와 같은 CDN 에 path 에 맞춰 index.html 형식으로 업로드해두면 게시판 같은 형태의 컨텐츠들은 어렵더라도 기본적인 서비스의 정보들은 충분히 크롤러에게 제공해줄 수 있게 된다.

특히 랜딩 페이지, 회사정보, 서비스 안내, FAQ 등과 같이 자주 업데이트되지 않는 내용들은 서버를 거치지 않고 매우 빠르게 서빙해줄 수 있다. 그리고 또한 products/1 과 같이 api 호출이 필요한 페이지들도 그 수가 많지 않다면 미리 만들어두고 서빙할 수 있다. (효율적인지는 모르겠다.) 또한 AWS Lambda 나 Firebase functions 등과 같은 서버리스 아키텍쳐를 활용하면 부분적으로 SSR 을 지원하는 것 또한 가능하다.

CODE

const staticRoutes = [
  '/',
  '/about',
  '/contact',
]

우선 일반적으로 서버 사이드 렌더링을 http GET request 를 기반으로 하는 것과 달리 미리 어떠한 페이지를 rendering 할지 정해야 한다.

const generateSite = () => {
  const template = fs.readFileSync('./public/index.html', 'utf8')

  ...
}

렌더링에 필요한 index 템플릿을 불러온다

const generateSite = () => {
  ...

  staticRoutes.forEach((location) => {
    const rendered = ReactDOMServer.renderToString(
      <StaticRouter location={location} context={{}}>
        <App />
      </StaticRouter>
    )
  
    const fullRendered = template
      .replace(
        '<div id="root"></div>',
        `<div id="root">${rendered}</div>`
      )

    ...

    })
}

기본적으로는 server-sider-rendering 절차와 유사하다.

const generateSite = () => {
  ...

  staticRoutes.forEach((location) => {
    ...

    const path = `dist${location.replace('/', '')}/`
    fs.writeFileSync(`${path}index.html`, fullRendered, 'utf8')
  })
}

렌더링 된 파일을 원하는 위치에 write 하면 완료. 전체 코드는 아래와 같다.

import fs from 'fs'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
import childProcess from 'child_process'

import App from './src/App'

const staticRoutes = [
  '/',
  '/about',
  '/contact',
]

const checkDirectorySync = (directory) => {
  try {
    fs.statSync(directory)
  } catch (e) {
    childProcess.execSync(`mkdir -p ${directory}`)
  }
}

const generateSite = () => {
  const template = fs.readFileSync('./public/index.html', 'utf8')

  staticRoutes.forEach((location) => {
    const rendered = ReactDOMServer.renderToString(
      <StaticRouter location={location} context={{}}>
        <App />
      </StaticRouter>
    )

    const fullRendered = template
      .replace(
        '<div id="root"></div>',
        `<div id="root">${rendered}</div>`
      )

    const path = `dist${location.replace('/', '')}/`
    checkDirectorySync(path)
    fs.writeFileSync(`${path}index.html`, fullRendered, 'utf8')
  })
}

generateSite()

위의 파일을 실행시키면 아래와 같이 파일들이 생성된 것을 확인할 수 있다.

$ ll dist
drwxr-xr-x   5 colus001  staff   160 Aug 15 14:34 .
drwxr-xr-x  18 colus001  staff   576 Aug 15 14:34 ..
drwxr-xr-x   3 colus001  staff    96 Aug 15 14:34 about
drwxr-xr-x   3 colus001  staff    96 Aug 15 14:34 contact
-rw-r--r--   1 colus001  staff  1805 Aug 15 14:34 index.html

각 path 의 index.html 에는 다음과 같이 rendering 된 페이지가 삽입되어 있고, s3 등의 static-site hosting 을 사용하면 server-side-rendering 과 유사한 효과를 얻을 수 있다.

<!DOCTYPE html>
<html lang="en">
  <body>

    <div id="root"><div class="App" data-reactroot=""><ul><li><a class="active" aria-current="page" href="/">Home</a></li><li><a href="/about">About Us</a></li><li><a href="/contact">Contact</a></li></ul><div><h1>HOME</h1></div></div></div>

  </body>
</html>

전체 소스코드는 여기

다만 React 16 의 새로운 API 인 React.createPortal 과 함께 사용할 경우 html 파싱 과정에서 sibling div 와 연결되는 버그가 생기는 현상이 있을 수 있으니 주의하도록 하자.

결론

이러한 방법은 암호화폐 거래소라고 하는 독특한 어플리케이션을 만들때 scalability 를 희생하지 않으면서 server side rendering 의 이점을 가져가기 위해 만들어낸 일종의 꼼수라고 할 수 있다. 다음 포스트는 API 호출이 필요한 posts/1 과 같은 형식의 웹사이트 구조에서 server-side-rendering 퍼포먼스 개선을 위한 방법을 제안해 보겠다.