리액트 어플리케이션 업데이트 하기

리액트 어플리케이션 업데이트 하기

React, 아니 대다수의 Single Page Application 에서 나타나는 문제 중 하나로 업데이트를 들 수 있다. 여기서 업데이트는 react 나 redux 같은 javascript package 를 말하는 것이 아니라 서비스 자체에 필요한 업데이트를 이야기한다.

  1. 새로운 컴포넌트를 추가하거나, 기존의 컴포넌트를 변경 혹은 삭제하는 경우
  2. 웹 어플리케이션의 데이터 구조를 변경해야 하는 경우
  3. 개발 과정에서 일어난 버그를 수정해야 하는 경우

위와 같은 것이 대표적이라고 볼 수 있는데, PHP 등과 같은 전통적인 서버 사이드 라우팅을 통한 앱과는 달리 SPA 는 라우팅 과정에서 서버에 전혀 접근하지 않는 경우도 많기 때문에, 치명적인 버그나 서버측 API 변경 등과 같이 프론트엔드 변경사항이 즉시에 적용되야 하는 경우 사용자에게 업데이트를 알릴 방법이 많지 않다.

1. query string

CDN 을 Invalidation 하거나 말거나 동일한 이름을 가진 asset 은 browser 에서는 기본적으로 disk cache 에 있는 파일을 가져다 쓰게 된다.

<script src="/bundle.js" />

때문에 위와 같이 script 를 단순히 로드하게 되면 bundle.js 의 실제 내용이 달라지더라도 서버에 접근하지 않고 cache 에 접근한다. (물론 response 헤더에 있는 Etag, Access-Control-Max-Age 등과 같은 값에 따라 브러우저에서 매번 소스에 접근하는 경우도 있다.)

때문에 보통 아래처럼 bundle 파일의 뒤에 날짜나 timestamp 와 같은 버젼을 식별할 수 있는 값을 추가하여 새로 브라우저에서 bundle.js 파일을 다운로드 하도록 유도한다.

<script src="/bundle.js?v=20180301" />

이러한 방법은 배포시마다 넣어주는 방법도 있고, gulp 나 webpack 같은 툴을 활용할 수 있으나, 셋업에 따라 매우 달라지므로 여기에서는 스킵하도록 한다.

2. webpack chunkhash

webpack 과 같은 javascript bundler 를 사용하는 경우 자주 업데이트 하지 않는 패키지들을 entry 를 분리하는 방식으로 vendor script 와 실제 코드를 분리하는 방식을 많이 사용하게 된다.

const config = {
  ...
  entry: {
    app: './src/app.js',
    vendor: ['react', 'react-dom', 'redux'],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  ...
}

이러한 방식으로 webpack 을 설정하면 bundling 된 결과물은 app.js 와 vendor.js 로 나누어지게 된다. query string 으로 script 를 강제로 리로딩 하는 방식의 단점은 실제로 거의 변하지 않는 vendor.js 도 배포시마다 유저는 매번 새로 다운로드 받기 때문에 에셋 로딩 속도가 그만큼 느려지게 된다. 또한 매번 script 를 덮어씌우기 때문에 롤백이 필요할 경우 새로 빌드를 해야한다. webpack 이 기본적으로 제공하는 CommonChunkPlugin 을 사용하면 이러한 문제를 개선할 수 잇다.

const config = {
  ...
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[chunkhash].js',
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
    }),
  ],
  ...
}

이렇게 하면 이제 결과물은 아래와 같이 3가지 파일이 만들어진다.

app.5ec8e954e32d66dee1aa.js
vendor.719796322be98041fff2.js
manifest.5ec8e954e32d66dee1aa.js

각각의 파일은 빌드하는 컴퓨터, 실제 번들의 내용에 따라 hash 가 바뀌게 되고 vendor 파일은 버젼 업데이트나 기타 이에 준하는 수정사항이 없다면 항상 동일한 hash 를 가지게 되어, 동일한 에셋을 계속 다운로드해야할 필요가 없어진다.

3. manifest.json

이런저런 방법을 사용한다고 해도 유저가 계속 어플리케이션에 접속하고 있다면 결국 outdate 된 화면을 바라볼 수 밖에 없다. 이런 경우에 업데이트를 확인할 수 있는 방법이 여러가지가 있다.

  1. 서버쪽 특정 API 를 주기적으로 호출한다.
  2. API 호출을 할때마다 헤더 등에 버젼 정보를 넣어 서버쪽에 체크한다.

1번과 2번이 일반적으로 많이 쓰이는 어프로치인데 두방법 전부 프론트엔드/벡엔드 에서 현재 버젼이 어떠한 버젼인지 알고 있어야 하기 때문에 배포 과정이 복잡해지고, 사용자가 많아질 경우 불필요한 연산 부담을 백엔드에 전가할 수 있다. 단순히 버젼을 확인하기 위해서 매번 API 서버에 접근해야 하는 것은 그다지 좋은 방법이라고 할 수 없다. 결국에는 DB 에 해당 버젼 값을 가지고 있어야 하는데, 롤백시 등 버그를 유발할 수 있다.

따라서 manifest.json 을 활용하여, 업데이트를 체크하는 방법을 구현하였다. Web App Manifest 는 어플리케이션의 정보를 담고있는 파일인데 이 파일에 해당 script 들을 넣어놓고 setInterval 과 같은 함수를 사용하여 주기적으로 업데이트를 체크한다.

const ManifestPlugin = require('webpack-manifest-plugin')

const config = {
  ...
  plugins: [
    ...
    new ManifestPlugin(),
  ]
}

output 소스 파일명을 manifest.json 파일로 바인딩 해주는 webpack-manifest-plugin 을 설정해주고, 다음과 같이 script 를 react 소스 어딘가에 위치시킨다. root 역할을 하는 컴포넌트에 바인딩 해주는 편이 좋을것이라고 생각한다.

const scripts = Array
  .from(document.getElementsByTagName('script'))
  .filter(x => /(vendor|app)\.[\w\S]+(\.js)/.test(x.src))

regular expression 을 사용해서 app 과 bundle script 를 분리한다.

componentDidMount() {
  const interval = setInterval(() => {
    fetch('/manifest.json')
      .then(x => x.json)
      .then(manifest => scripts.some((value, key) => value!== manifest[key]))
      .then(isChanged => {
        if (!isChanged) return

        clearInterval(interval)
        alert('New versions is available. Please refresh to update!')
      })
  }, 1000 * 60 * 5) // Check update in every 5 mins
}

주기적으로 manifest.json 을 체크하여 dom 의 스크립트와 비교한다. 간단한 방법이지만 서버에 큰 부담을 주지 않고 업데이트를 체크할 수 있다. 환경에 따라 interval 을 변경하거나, browserHistory 에 바인딩 하여 url 이 변경될 때 수행해도 좋다. api 호출이 빈번하지 않은 경우라면 XMLHttpRequest 함수에 연결해도 나쁘지 않다. 비교 과정이 복잡하다면 이렇게 해시만 비교하는 방법도 있다.

결론

물론 websocket 을 적극적으로 사용하고 있다면 broadcasting 을 해주는 것도 좋지만, 이 역시 배포 과정에서 번거로운 것이 사실이고 이러한 식으로 업데이트 전략을 만들면 크게 고민 없이 script 가 수정될때마다 사용자에게 업데이트가 있음을 알릴 수 있다. 다만 CDN 에 약간의 비용 부담이 전가되는 것이 사실이니 적당한 시간 간격을 찾는 것이 좋을 것이다.


이 포스트는 향후 아래 내용을 업데이트 할 예정임

TODO
  • Sample code with create-react-app
  • Polling by service worker
  • Typo and syntax check