[PWA] CRA가 만들어 준 Service Worker 이해하기

영근·2023년 11월 22일
0

Progressive Web App

목록 보기
1/1
post-thumbnail

Service Worker

2023.11.22.
GeunYeong Kim

Intro

업무 중 PWA를 처음 도입해보았습니다. CRA(Create-React-App)을 사용해 쉽게 구현하였으나, Service Worker에 대한 이해가 부족함을 느껴 정리해봅니다.


Service Worker?

  • 브라우저가 백그라운드에서 실행하는 스크립트로, Javascript 파일 형태입니다.
  • Worker context에서 실행됩니다.
    • DOM에 접근할 수 없고, Javascript 메인 스레드와는 다른 스레드에서 실행됩니다.
    • XHR, Web Storage는 Service Worker 안에서 사용할 수 없습니다.
  • HTTPS에서만 실행됩니다.(보안상의 이유)
  • 프록시 서버와 비슷하게 동작합니다. : 네트워크 요청을 중간에 가로채고, 리소스를 캐싱하는 역할을 합니다. -> 브라우저의 외부에 위치하기 때문입니다.
  • 설치된 서비스 워커들은 여기서 확인 가능합니다. : chrome://serviceworker-internals/

Lifecycle

1. 설치중(installing)

  • ServiceWorkerContainer.register() 메소드로 Service Worker가 등록됩니다.
  • 등록된 후 자바스크립트 파일이 다운로드되고 파싱되고 나면 설치중 상태가 됩니다.
  • 설치 성공 시 설치됨(installed) 상태가 되고, 실패 시 중복(redundant) 상태가 됩니다.

2. 설치됨/대기중(installed/waiting)

  • 서비스 워커가 설치되면 설치됨 상태가 됩니다.
  • 활성화 된 서비스 워커가 없다면 즉시 활성화 중 상태가 되고, 다른 서비스 워커가 앱을 제어하고 있다면 대기중 상태가 됩니다.

3. 활성화중(activating)

  • 서비스 워커가 활성화되기 직전의 상태입니다.
  • 활성화 직전 activate 이벤트가 발생합니다. 이 때 waitUntil() 메서드로 활성화 중 상태로 유지할 수 있습니다.

4. 활성화됨(activated)

  • 서비스 워커가 활성화 된 상태입니다.
  • 서비스 워커가 앱을 제어할 수 있고, 동작 이벤트를 처리할 수 있습니다.

5. 중복(Redundant)

  • 서비스 워커가 등록 중 혹은 설치 중 실패하거나 새로운 버전으로 교체되면 중복 상태가 됩니다.
  • 서비스 워커가 앱에 아무런 영향을 미칠 수 없습니다.

  • 위 이미지에서 기존에 실행되고 있던 ID 622 서비스워커는 ACTIVATED 상태(running)
  • 아래 새로 업데이트될 ID 624 서비스워커는 INSTALLED 상태로 기다리는 중(waiting)
  • 기존 ID 622 서비스워커가 모든 브라우저 / 탭에서 실행이 종료되어야 업데이트 됩니다.
  • 콘솔에서도 아래와 같이 확인 가능합니다.(application - service workers)


CRA 코드 이해하기

serviceWorkerRegistration.ts

: service-worker.js 를 등록하고 이벤트를 동시에 처리하는 파일입니다.

  • register() : service worker 등록하는 function
// service worker 등록
export function register(config?: Config) {
  	// production 환경인지 && 서비스 워커를 지원하는지
	if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
    if (publicUrl.origin !== window.location.origin) {
		// PUBLIC_URL이 다른 origin에 있으면 동작하지 않습니다.
      return;
    }
	// 브라우저 로드가 완료되면
    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
	  // localhost 일 때
      if (isLocalhost) {
        // 1) service worker가 존재하는 지 체크합니다.
        checkValidServiceWorker(swUrl, config);

        // 2) 친절하게 worker/PWA documentation 안내합니다 ^^
        navigator.serviceWorker.ready.then(() => {
          console.log(
            'This web app is being served cache-first by a service ' +
              'worker. To learn more, visit https://cra.link/PWA',
          );
        });
      } else {
        // localhost가 아닐 때 -> 등록합니다.
        registerValidSW(swUrl, config);
      }
    });
  }
}

  • checkValidServiceWorker()
function checkValidServiceWorker(swUrl: string, config?: Config) {
  fetch(swUrl, {
    headers: { 'Service-Worker': 'script' },
  })
    .then((response) => {
      // service worker가 있는지 확인합니다.
      const contentType = response.headers.get('content-type');
	  // 1) 없으면 -> 새로고침 합니다.
      if (
        response.status === 404 ||
        (contentType != null && contentType.indexOf('javascript') === -1)
      ) {
        navigator.serviceWorker.ready.then((registration) => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } 
	  // 2) 있으면 -> 정상적으로 진행합니다.
		else {
        registerValidSW(swUrl, config);
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.',
      );
    });
}

  • registerValidSW() : 유효한 서비스 워커를 등록합니다.(navigator.serviceWorker.register())
function registerValidSW(swUrl: string, config?: Config) {
  // 등록!
  navigator.serviceWorker
    .register(swUrl)
    .then((registration) => {
      // eslint-disable-next-line no-param-reassign
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        if (installingWorker == null) {
          return;
        }
        installingWorker.onstatechange = () => {
		  // installed 상태가 되면
          if (installingWorker.state === 'installed') {
			// 이전 서비스 워커가 아직 일하는 중
            if (navigator.serviceWorker.controller) {
              console.log(
                'New content is available and will be used when all ' +
                  'tabs for this page are closed. See https://cra.link/PWA.',
              );
              // Execute callback
              if (config && config.onUpdate) {
                config.onUpdate(registration);
              }
            } else {
              console.log('Content is cached for offline use.');
              // Execute callback
              if (config && config.onSuccess) {
                config.onSuccess(registration);
              }
            }
          }
        };
      };
    })
    .catch((error) => {
      console.error('Error during service worker registration:', error);
    });
}

  • unregister() : 서비스 워커 사용하지 않을 때
export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready
      .then((registration) => {
        registration.unregister();
      })
      .catch((error) => {
        console.error(error.message);
      });
  }
}

index.tsx

: 위에서 정의한 함수를 사용해 index.tsx에서 등록합니다.

// 서비스 워커를 등록합니다.
serviceWorkerRegistration.register();

// 사용 안할 때는
// serviceWorkerRegistration.unregister();

service-worker.js

: 서비스 워커 🥹 (CRA에서는 기본적으로 Workbox 모듈을 사용합니다.)

declare const self: ServiceWorkerGlobalScope; // Service Worker API 사용

clientsClaim()
// 서비스 워커가 등록되고 설치되자마자 페이지들을 즉시 제어할 수 있도록 합니다.
// top level에서 사용되어야 합니다.(이벤트 핸들러 안에서 사용 불가)

precacheAndRoute(self.__WB_MANIFEST);
// 파라미터로 주어진 path 의 엔트리들을 precache 리스트에 넣어 캐싱을 진행합니다.
// 캐싱이 진행된 path에 대해 라우팅이 일어날 경우 이에 응답합니다.
  • self.__WB_MANIFEST
    • webpack에서 설정한 precache 항목들
    • Webpack 이 service-worker.js 파일을 만들 때 참고하는 placeholder
    • CRA에는 이미 설정이 되어 있습니다.

// registerRoute
// Regex, string, 혹은 함수와 handler를 입력받아 원하는 asset이나 path, 파일에 따라 원하는 방식의 캐싱을 설정합니다.

registerRoute(
  // Return false to exempt requests from being fulfilled by index.html.
  ({ request, url }: { request: Request; url: URL }) => {
    // If this isn't a navigation, skip.
    if (request.mode !== 'navigate') {
      return false;
    }

    // If this is a URL that starts with /_, skip.
    if (url.pathname.startsWith('/_')) {
      return false;
    }

    // If this looks like a URL for a resource, because it contains
    // a file extension, skip.
    if (url.pathname.match(fileExtensionRegexp)) {
      return false;
    }

    // Return true to signal that we want to use the handler.
    return true;
  },
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html'),
);

// precache로 핸들되지 않은 요청에 대해 런타임 캐싱합니다.
registerRoute(
  ({ url }) =>
	// same-origin에서 .png 요청(/public)을 캐싱합니다.
    url.origin === self.location.origin && url.pathname.endsWith('.png'),
  // 캐싱 방식은 변경할 수 있습니다(5가지) - 아래 참고
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
	  // 런타임 캐시가 최대 사이즈에 도달하면, 가장 최근에 사용되지 않은 이미지부터 삭제합니다.
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  }),
);
  • registerRoute 캐싱 방식 5가지

    • StaleWhileRevalidate : 캐싱된 response가 있으면 바로 응답, 아니면 네트워크 요청 fallback이 일어난다. 캐싱된 response로 응답한 뒤에 백그라운드에서 네트워크 요청으로 캐시 업데이트
    • CacheFirst : 캐싱된 response가 있으면 바로 응답, 캐시 업데이트는 하지 않습니다.
    • NetworkFirst : 네트워크 요청 먼저, 실패했을 경우 캐시로 응답
    • NetworkOnly : 캐싱을 전혀 사용하지 않습니다.
    • CacheOnly : 응답은 캐시에서만 받아오는 방식. Precaching이 수동으로 진행되는 경우에만 의미 있는 방식입니다.

  • 콘솔에서 캐싱된 images를 확인할 수 있습니다.(CacheStorage)


// 웹앱에서 skitWaiting을 registration.waiting.postMessage({type: 'SKIP_WAITING'})로 트리거 했을 때
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Reference

0개의 댓글