리소스 청크 스플릿이 적용된 CRA 리액트 앱의 무중단 배포 방법

정민석

업데이트:

오늘은 클라이언트 사이드 리액트 앱의 효율적인 배포 방법에 대해서 다뤄보겠습니다.

리액트 앱의 배포

클라이언트에서 렌더링되는 리액트 앱의 배포는 정말 단순합니다. 리액트 앱을 빌드하면 폴더 하나에 들어있는 정적 파일들로 바뀌며, 이 폴더의 내용물을 서버의 정적 서빙 폴더에 배치하고 원하는 경로에서 접근할 수 있도록 서버설정을 해 두면 배포는 끝납니다. 이런 과정은 사용하는 서버에 따라서 조금씩 다르고 복잡해질 수도 있지만 크게 어렵지는 않습니다.

흔한 두 가지 배포 시나리오

  • 특정 도메인의 일부 URL에 대해서만 리액트 앱이 동작
  • 도메인 전체에서 리액트 앱이 동작

CF와 S3의 활용

만약 특정 도메인 전체가 오직 CSR 리액트 앱만을 서빙해도 괜찮다면, 우리는 S3에 정적 파일을 올려두고 이 S3를 CF가 바라보게 함으로써 간단하게 리액트로 동작하는 웹사이트를 구성할 수 있습니다.

CF+S3로 정적 호스팅하는 리액트 앱의 장점과 구조

CF와 S3로 리액트 앱을 배포하면 우리는 깃허브 액션 등으로 S3에 파일을 업로드하고 CF의 캐시무효화를 해서 새로 접근하는 사용자에게 새 앱이 즉시 전달되도록 할 수 있습니다. 이렇게 하면 새 앱을 배포하는 과정에서 서비스의 중단이 일어나지 않으므로 장점이 있다고 할 수 있습니다. 가장 단순하게 빌드된 CRA 리액트 앱을 S3에 넣는다면 S3의 루트 디렉토리에는 이런 파일들이 들어갑니다.

/
├font/...
├locales/...
├static/
│├js/
││├main.f9ej4fi.chunk.js
││├787.a955b512.chunk.js
││├542.e92bde55.chunk.js
││└...
│├css/...
│└media/...
├assets-manifest.json
├favicon.ico
├index.html
├logo192.png
├logo512.png
├robots.txt
└manifast.json

/static/js 폴더에는 가장 중요한 js파일들이 들어가며, main.[hash].chunk.js는 index.html에 의해서 로드되며, 나머지는 webpack의 코드스플릿 기능으로 생성된 청크입니다.

코드 스플릿이 적용된 앱의 배포

그러나 코드 스플릿이 적용되어 있다면 서비스의 중단이 일어납니다. 여러분이 CRA나 webpack같은 도구를 사용한다면 코드 스플릿을 적용하는 것은 어렵지 않을 것입니다. 코드 스플릿이 어떻게 배포 시에 서비스 중단을 일으킬 수 있는지 아래와 같은 예시를 통해서 알아봅시다.

코드스플릿이 적용된 웹사이트의 서비스 중단 시나리오

웹사이트가 CF와 S3의 정적 호스팅으로 구성되어 모든 접근에 대해서 index.html파일을 돌려주고 해당 파일이 클라이언트 측에서 path에 따라 자바스크립트를 로드해서 페이지를 구성한다고 가정해봅시다. 코드스플릿은 path별로 이루어져 있다고 생각해봅시다. 아래와 같은 동작이 가능합니다.

  1. 사용자가 my-website.com/path1에 접근해서 해당 경로의 코드스플릿이 적용된 js와 정적 리소스를 불러와서 페이지를 봅니다.
  2. 새 버전의 서비스가 배포되어 기존의 코드스플릿 기능이 불러올 파일들이 서비스의 S3와 CF에서 사라집니다.
  3. 사용자가 웹페이지 내부의 링크를 눌러서 my-website.com/path2에 접근하려 하고, 이것은 페이지를 처음부터 불러오는 것이 아니기에 path2에 대한 구버전 서비스의 js chunk를 불러오는 시도로 이어집니다.
  4. 파일이 없어서 브라우저 콘솔창에 에러가 뜨고 사용자 웹페이지도 망가집니다.

문제의 실험

CRA앱에서 이런 문제가 일어날 것이라고 생각하고 실제로 실험을 위해서 위의 예시처럼 구성된 코드를 S3에 배포하고 시험해 보아도 서비스 중단은 발생하지 않습니다.

이것은 코드스플릿으로 생성되는 js파일에 파일 내용을 기반으로 한 해시가 붙기 때문입니다. 이 파일이름이 충돌을 피하게 해주기 때문에 새 서비스가 배포되어도 기존의 js청크들이 사라지지 않는 것입니다.

그러나 이렇게 되어도 여전히 문제는 있습니다. static폴더에 들어있지 않는 다른 파일들은 public파일들이고 이것들은 웹팩 번들링의 대상이 아니어서 해시 등이 전혀 붙지 않기 때문에 서비스가 새로 배포될 때 여전히 덮어씌워집니다. 만약 구 서비스에 특정 이미지가 필요한데 이 이미지가 새 서비스에서 덮어씌워진다면 서비스의 내용이 달라질 수 있습니다.

코드스플릿으로 인한 중단 배포의 해결책

새로운 서비스를 배포할 때 발생하는 기존 서비스의 중단 문제를 해결하기 위해서는 아래와 같은 일이 가능해야 합니다.

  1. 한 번 배포된 서비스는 고유한 경로를 가져서 절대로 자신의 파일을 잃어버리지 않습니다.
  2. 유저가 접근할 index.html은 반드시 바로 갱신되어야 합니다.

CRA의 PUBLIC_PATH 속성의 활용

CRA의 PUBLIC_PATH 환경변수는 모든 정적파일이 서버의 특정 경로 아래에 있을 것이라고 명시하는 역할을 합니다. 또 서비스 배포의 파일 경로를 고유하게 하기 위해서 깃 커밋아이디를 사용해보도록 합시다. 이렇게 하기 위해서 Github Action을 아래와 같이 구성해 볼 수 있습니다.

name: release build

on:
  push:
    branches: [ release ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3

    - name: Setup Yarn
      uses: actions/setup-node@v3
      with:
        cache: 'yarn'
    
    - name: Setup AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.RELEASE_AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.RELEASE_AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ secrets.RELEASE_AWS_REGION }}

    - name: Build
      env:
        CI: false
      run: |
        echo "PUBLIC_URL=/static/${{github.sha}}" >> .env.production.local
        yarn install
        yarn build
        
    - name: S3 Sync
      run: |
        aws s3 sync ./build/ s3://my-bucket/static/${{github.sha}}
        aws s3 cp ./build/index.html s3://my-bucket/index.html
      
    - name: CloudFront Cache Invalidation
      run: aws cloudfront create-invalidation --distribution-id ${{ secrets.RELEASE_DISTRIBUTION_ID }} --paths "/index.html"

위의 코드를 보면 CRA의 .env파일의 기능을 잘 활용하는 것을 볼 수 있습니다. bash 스크립트로 깃허브 액션이 실행되는 동안에만 쓰일 설정 파일을 생성하고, 생성 경로는 /static/{깃커밋아이디}가 됩니다. 이 최상단 /static폴더 안의 {깃커밋아이디} 폴더 안에는 다시 static폴더가 있을 수 있지만 이것은 CRA의 빌드 과정에서 웹팩 번들링이 작동하는 내용물이 담기는 폴더이므로 둘을 혼동하면 안 됩니다.

다음으로는 그렇게 생성한 ./build폴더의 내용물을 s3의 동일한 경로에 업로드하게 됩니다.

새로 만들어진 index.html은 새로 접속할 유저들이 즉시 새 서비스를 바라보게 하기 위해서 버킷의 최상단에 다시 복사합니다. 이렇게 하는 이유는 CF의 CLI API로는 버킷의 기본 인덱스파일의 위치를 바꿀 수가 없기 때문입니다.

마지막으로 CF의 캐시 무효화를 해 주는데, 기존 서비스의 파일들은 전혀 캐시를 무효화할 이유가 없기에 오직 최상위의 index.html만 날리도록 합니다.

다른 문제들

위처럼 간단한 환경변수 수정을 Github Action에 적용하고 S3의 파일복사를 적절히 해 주는 것으로 완전한 서비스 무중단 배포가 가능해지지만 실제 서비스에서는 다른 문제가 생길 수 있습니다. 위의 디렉토리 예제를 보면 locales폴더가 있는 것을 알 수 있는데, 이것은 예제로 든 앱이 i18next-http-backend을 사용하는 react-i18next 기능을 구성했기 때문입니다. i18next-http-backend는 기본적으로 서버의 /locales경로에서 번역파일을 찾게 됩니다. 그래서 위에서 제시한 배포위치 수정을 적용하고 나면 더 이상 /경로에 /locales가 없기 때문에 번역이 적용되지 않는 문제가 생깁니다. 이 문제를 수정하기 위해 해당 패키지의 설정 기능을 참고해서 코드를 이렇게 구성합니다.

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

i18n
  .use(Backend)
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    fallbackLng: 'en',
    debug: process.env.NODE_ENV === "development",
    interpolation: {
      escapeValue: false,
    },
    backend:{
      loadPath: process.env.PUBLIC_URL + '/locales/{{lng}}/{{ns}}.json',
    }
  });

export default i18n;

이렇게 하면 번역 파일도 이상없이 불러오게 됩니다. 핵심은 CRA의 PUBLIC_URL환경변수에 영향받지 않는 다른 기능들은 직접 조율할 필요가 있다는 것입니다.

마치며

지금까지 CRA를 사용하는 프로젝트에서 코드 스플릿 기능을 사용했을 때에도 문제없이 작동하는 무중단 배포 방법을 알아봤습니다. 사용하는 개발 환경에 따라서 작업방식은 달라질 수 있으나 핵심적인 아이디어는 동일하게 적용할 수 있을 것입니다.

이 글에서는 구버전 서비스의 무중단을 주목적으로 삼기에 이미 구 버전 서비스를 이용중인 사용자에게 신 버전의 사용을 권하거나 강제하는 기법에 대해서는 다루고 있지 않습니다. 사용자가 서비스의 최신버전을 자주 로드하는 대부분의 시나리오에서 그런 기능은 불필요할 것입니다. 그러나 필요하다면 정기적으로 버전 정보와 연관된 파일 혹은 데이터를 내려받아 내용을 확인하고 적절한 방식으로 페이지 재로드를 유발하는 코드 등을 작성할 수 있을 것입니다.

사용자에게 안정적인 서비스를 제공할 방법을 고민하는 분들에게 이 글이 도움이 되길 바라며 마칩니다.

띵스플로우 팀은 자기의 일을 좋아하고 잘하는 사람들 입니다. 사용자와 서비스를 중심으로 빠르게 실행하고 학습하며, 다양한 직무의 사람들이 협업을 통해 시너지를 내고 있습니다. 다양한 콘텐츠 혁신을 이루고 있는 띵스플로우 팀에 함께할 분을 찾습니다! 언제든 people@thingsflow.com로 이메일을 주시기 바랍니다!

태그: , ,

카테고리:

업데이트:

댓글남기기