Progressive Web App (2) React PWA 적용 방법

개발팀2021-10-24

Progressive Web App 1편에서는 PWA와 serviceworker가 무엇이고 어떻게 동작하는지, PWA의 장/단점은 무엇인지 알아보았습니다. 2편에서는 실제 React PWA 예제를 살펴보며 React PWA 적용 방법에 대해 알아보도록 하겠습니다.


Create React App(CRA)을 사용하기

React PWA를 설정하는 가장 쉬운 방법은 Create React App (CRA)을 사용하는 것입니다. CRA의 커스텀 템플릿 기능을 사용해 PWA가 이미 세팅된 상태로 동작하는 앱을 시작할 수 있습니다.

# JavaScript
npx create-react-app my-app --template cra-template-pwa

# TypeScript
npx create-react-app my-app --template cra-template-pwa-typescript

해당 템플릿에는 몇 가지 설정이 되어 있는데, Google의 Workbox 가 Webpack 과 같이 동작하게 미리 세팅되어 있어 React 앱을 빌드할 때 생성되는 bundle 들을 serviceworker 가 precache 할 수 있게 해줍니다. 이번 포스트에서는 TypeScript 기준으로 만들어진 코드를 분석해보도록 하겠습니다.


service-worker.ts 파일로 프로젝트 빌드하기

service-worker.ts 파일은 Service Worker가 작성되는 파일로 프로젝트의 빌드 후 process.env.PUBLIC_URL/service-worker.js 로 transpile된 버전이 액세스 가능하게 됩니다. 이 파일은 표준 API를 사용하지 않고 Google의 Workbox 를 이용하여 작성되어 기존 service worker의 복잡한 lifecycle 을 모두 이해하지 않아도 쉽게 파일을 수정할 수 있습니다. 이 파일에서 가장 중요한 부분은 precacheAndRoute, registerRoute 함수입니다.

precacheAndRoute(self.__WB_MANIFEST);
...registerRoute(
  ({ request, url }: { request: Request; url: URL }) => {
    ... },
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);

precacheAndRoute, registerRoute함수 사용하기

precacheAndRoute 함수는 workbox-precaching 패키지에서 가져오게 되며 파라미터로 주어진 path 의 엔트리들을 precache 리스트에 넣어 캐싱을 진행하고, 캐싱이 진행된 path에 대해 라우팅이 일어날 경우 이에 응답하는 것을 처리합니다. 여기에 들어있는 self.__WB_MANIFEST 는 빌드 전에 특별한 값을 가지고 있는 것은 아니고 Webpack 이 service-worker.js 파일을 만들 때 참고하는 placeholder 입니다. CRA에는 이미 설정이 되어 있지만, 이것을 수동하는 코드는 참고로 다음과 같습니다.

// webpack.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');

module.exports = {
  ...
  plugins: [
    ...
    new WorkboxPlugin.InjectManifest({
      swSrc: './src/service-worker.ts',
      swDest: 'service-worker.js',
    }),
  ],
};

registerRoute 함수는 Regex, string, 혹은 함수와 handler를 입력받아 원하는 asset이나 path, 파일에 따라 원하는 방식의 캐싱을 설정할 수 있습니다. 여기에서 설정할 수 있는 캐싱 방식은 workbox-strategies 플러그인에서 정의된 5가지 방식이 가능합니다:

  • Stale-While-Revalidate: 만약 캐싱된 response가 있다면 이것으로 바로 응답하고, 만약 그렇지 않다면 network request 로 fallback이 일어납니다. 캐싱된 response로 응답이 일어난 뒤에 network request가 백그라운드에서 캐시의 업데이트를 수행합니다. 가장 기본적으로 많이 사용되는 옵션입니다.
  • Cache First: 업데이트가 자주 수행되지 않아도 되는 static asset을 위해 많이 사용되며, Stale-While-Revalidate 와는 달리 캐싱이 되어 있는 경우 바로 응답하고 캐시의 업데이트를 수행하지 않습니다.
  • Network First: 자주 업데이트가 수행되고 항상 최신 데이터를 가져오는 것이 중요한 자료에 대해 수행하며, 네트워크에서 최신 데이터를 받아오는 것을 우선적으로 수행하며, 이것이 실패할 경우 캐시된 데이터를 응답합니다.
  • Network Only: 캐싱을 전혀 사용하지 않고 네트워크 자원만 사용합니다.
  • Cache Only: 거의 사용되지 않으며, 응답을 캐시에서만 받아오도록 설정합니다. Precaching 이 수동으로 진행되는 경우에만 의미가 있습니다.

serviceWorkerRegistration.ts

Service worker를 등록하고 그 뒤의 이벤트를 동시에 처리할 수 있습니다. PWA가 적용된 웹앱의 경우 캐시 주기에 대해 주의할 필요가 있습니다. 기존 React App을 개발하던 개발자가 실수를 하기 쉬운 부분은, PWA는 같은 사이트에 대해 여러 앱이 동시에 열려 있는 경우 한 탭에서 새로고침을 해도 새로운 앱이 로드되지 않는다는 것입니다.

이 행동은 Create React App의 React PWA 문서 중 Offline-First Considerations 섹션에 자세히 설명되어 있는데, Service worker는 범위 내 페이지가 모두 동일한 SW로 제어되어야 하기 때문에, 한 번에 한 버전의 사이트만 실행되며, 따라서 동일한 웹 사이트가 여러 탭 혹은 여러 윈도우에 걸쳐서 실행 중이라면 한 탭에서 새로고침을 해도 새로운 웹앱을 다운로드 받을 수 없다는 것입니다.

이런 경우에는 Service Worker의 새로운 버전이 로드되었고 설치되었더라도 WAITING state에서 모든 웹앱이 종료될때까지 대기하게 되며, 사용자는 새 버전을 실행할 수 없게 됩니다. 이러한 정책은 탭 간의 앱의 consistency 를 지키는데에는 잘 작동하지만, 웹앱에 버그가 있어 빠르게 핫픽스를 진행해야 하는 상황에서는 유저가 새로고침이라는 직관적인 행동을 하더라도 버그가 있는 기존 웹앱이 계속 로드되는 심각한 문제가 발생하게 됩니다.

PWA를 사용하면 유저에게 새 버전 알림을 진행하고, 탭 간 버전의 consistency도 유지할 수 있습니다.

React PWA 적용 방법

이를 해결하기 위해서는 버그를 해결하는 소프트웨어가 릴리즈된 직후,

  • 새로운 릴리즈가 일어났음을 발견
  • 이 기쁜 소식을 유저에게 알림
  • 동시에 새로운 SW를 백그라운드에서 설치
  • 새로운 SW의 activate
  • 새로운 SW를 사용한 웹앱을 로드하기 위해 새로고침

총 다섯 단계가 필요합니다.


새로운 릴리즈가 일어났음을 발견

이것을 하기 위한 여러 방법이 있는데, 주기적으로 service worker의 새 버전을 체크하거나, React와 같은 SPA에서는 history에서 새로운 location이 업데이트 될 때마다 실행되는 callback 이벤트에서 업데이트를 체크할 수 있습니다. 아래 코드는, 유저가 React SPA에서 새로운 페이지로 내비게이션을 진행할 때, 브라우저에 등록된 (모든) service worker에 대해 업데이트를 수동으로 실행하는 것입니다.

history.listen((location, action) => {
  if (!navigator.serviceWorker) {
    return;
  }
  navigator.serviceWorker.getRegistrations().then(regs =>
    regs.forEach(reg => {
      reg.update().catch(e => {
        // Fetching SW failed.
      });
    })
  );
});

만약 새로운 service worker (새로운 릴리즈) 가 발견되었다면, 해당 이벤트는 serviceWorkerRegistration.ts 내의 registration.onupdatefound 에서 handle 할 수 있습니다.

function registerValidSW(swUrl: string, config?: Config) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      registration.onupdatefound = () => {
        ...
      }
  ...

유저에게 알림

registration.installingWorker 는 현재 설치되고 있는/설치된 SW를 의미하며, 이것의 state가 installed 라면 새로운 SW가 발견된 후 설치까지 완료되어 activate를 기다리고 있는 상태입니다. 유저에게는 이 때 팝업을 띄워줄 수 있습니다. 엘리스에는 다음과 같은 코드가 적용되어 있습니다.

const installingWorker = registration.installing;
  if (installingWorker == null) {
    return;
  }
  installingWorker.onstatechange = () => {
    if (installingWorker.state === 'installed') {
      if (navigator.serviceWorker.controller) {
        doShowSWUpdateToast();
        ...
      }
    ...

새로운 SW의 설치

위 코드에서 보여진 Toast 에서 유저가 새로고침 버튼을 누른다면, 다음 코드로 새로 설치된 service worker를 강제로 activate 시킬 수 있습니다. 아래 코드는, 현재 브라우저에 설치된 service worker 중 상태가 waiting 인 것에 한하여 SKIP_WAITING 메시지를 전송하는 것입니다.

navigator.serviceWorker.getRegistrations().then(regs =>
  regs.forEach(reg => {
    reg?.waiting?.postMessage({ type: 'SKIP_WAITING' });
  })
);

새로 고침

마지막으로 새로운 service worker가 설치되었음을 알리고, 새로고침을 진행하면 유저는 새 버전의 웹앱을 사용할 수 있습니다.


기존 React App에 적용

위 모든 사항은 사용자가 새로운 React App을 CRA 기반으로 새로 작성할 때를 기준으로 만들어졌습니다. 기존 React App 이 있다면 이것을 적용하는 것은 대단히 쉽습니다.

  • service-worker.ts 파일을 복사
  • serviceWorkerRegistration.ts 파일을 복사
  • index.tsx 에 serviceWorkerRegistration.register() 구문을 추가
  • Webpack 설정이 필요하다면 진행, CRA 기반이라면 필요 없음

이러한 비교적 쉬운 단계로 PWA를 설정할 수 있습니다. 다음으로 작성할 마지막 포스트로는 서로 다른 라우팅에 적용한 캐싱 정책에 대해서 알아보겠습니다.

  • #PWA
  • #JavaScript
실습형 플랫폼으로 임직원 디지털 전환 교육에 성공하세요

DX교육은 엘리스

입력해주신 이메일은 회사소개서 발송에만 활용됩니다