[PWA] 서비스 워커의 생명 주기와 캐시 관리

Chani·2022년 7월 14일
6
post-thumbnail

본 포스팅은 아래 링크의 만들면서 배우는 프로그레시브 웹 앱 책을 보며 공부한 내용을 스스로 정리한 것 입니다.

만들면서 배우는 프로그레시브 웹 앱

이번 포스팅부터 사용할 실습 코드는 포스팅 맨 아래 링크에 첨부해두었습니다.

0. 서비스워커의 생명주기를 알아보기 전에…

서비스 워커에 코드를 다음과 같이 작성하고 실행을 시켜보자

self.addEventListener("install", function () {
  console.log("install");
});

self.addEventListener("activate", function () {
  console.log("activate");
});

self.addEventListener("fetch", function (event) {
  console.log("hi");
  if (event.request.url.includes("bootstrap")) {
    console.log("Fetch request for");
    event.respondWith(
      new Response(
        ".hotel-slogan { background: green!important; } nav{display:none}",
        { headers: { "Content-Type": "text/css" } }
      )
    );
  }
});

첫번째 실행에는 install, activate가 콘솔에 찍혔고, 그 이후에 등록이 된 것을 확인 할 수 있다.

그런데 fetch 요청에 대한 것은 하나도 실행되지 않았는데, 이 상태에서 새로고침을 한번 눌러보자

우리의 페이지 스타일이 바뀌었고 콘솔도 잔뜩 찍힌것을 볼 수 있다.

서비스 워커가 fetch 이벤트를 받기 위해 새로고침을 한 번 더 해야했던 이유는 무엇일까?

1. 서비스워커의 생명주기 (Life Cycle)

페이지가 새로운 서비스 워커를 등록하려면 여러 단계의 상태를 거쳐야 한다.

설치 중 (Installing)

navigator.serviceWorker.register 를 사용하여 새로운 서비스 워커를 등록할 때, 자바스크립트가 다운로드되고, 파싱되고 나면, 서비스 워커설치 중 상태에 들어가게 된다.

설치가 성공적으로 이루어지면, 설치됨 상태가 되고, 설치 중 에러가 발생하면, 페이지를 새로고침하여 서비스 워커를 다시 등록하거나, 그렇지 않으면 중복 상태로 빠져버리게 된다.

Install 이벤트 콜백 내에서 waitUntil 함수를 사용하여 설치 중 상태를 연장 할 수 있다.

프로미스가 리졸브 되면 그때 설치 됨 상태가 되고, 설치 과정이 실패하면 중복 상태로 빠지게 된다.

설치됨/대기중 (Installed/waiting)

서비스 워커가 성공적으로 설치되면, 설치됨 상태로 넘어가게 되고, 현재 활성화 되어있는 다른 서비스 워커가 앱을 제어하고 있지 않으면, 바로 활성화 중 상태로 전환된다.

앱을 제어하고 있는 경우에는 대기 중 상태가 유지 된다.

활성화 중 (Activating)

서비스 워커가 활성화되어 앱을 제어하기 전, activate 이벤트가 발생한다.

설치 중 상태와 비슷하게, 활성화 중 상태 또한 waitUntil 함수를 사용하여 호출을 연장 할 수 있다.

활성화 됨 (Activated)

서비스 워커가 활성화 되면 페이지를 제어하고, fetch 이벤트와 같은 동작 이벤트를 받을 준비가 된다.

서비스 워커는 페이지 로딩이 시작하기 전에만 페이지 제어 권한을 가져올 수 있다. 즉, 서비스 워커가 활성화 되기 전에 로딩이 시작된 페이지는 서비스 워커가 제어할 수 없다.

중복 (Redunant)

서비스 워커가 등록중, 설치 중 실패하거나 새로운 버전으로 교체되면 중복 상태가 된다.

이 상태의 서비스 워커는 앱에 아무런 영향을 미치지 못한다.

0번에서 어떤 일이 일어났을까?

사용자가 처음 사이트에 방문하면 앱은 서비스워커를 등록합니다. 이때 install 이벤트가 발생하게 되고, 처음 방문했기 때문에 제어 중인 서비스 워커가 없어서 바로 activate 이벤트가 발생하게 된다. 마지막으로 서비스 워커는 활성화 됨 상태로 들어가 페이지를 제어할 준비가 되게 된다.

하지만 서비스 워커가 설치되는 동안 페이지는 이미 로딩과 렌더링을 시작했다. 서비스 워커가 활성화 된다고 하더라도, 이미 로딩과 렌더링을 시작한 페이지는 제어할 수는 없다. 즉, 페이지를 다시 새로고침해야 페이지 로딩 전에 이미 설치 된 서비스 워커가 활성화될 수 있다.

2. 서비스 워커의 수명과 waitUntil

서비스 워커가 성공적으로 설치 되고 활성화 되었다면, 서비스 워커는 브라우저 탭이나 윈도우 창에 묶여있지 않고 언제든 이벤트에 응답 할 수 있기때문에 서비스워커가 늘 돌고 있는걸까?

당연히 그렇지 않다. 그렇게 된다면 서비스 워커를 많이 등록할수록 성능이 급격히 떨어지게 될 것이다.

서비스 워커의 수명은 서비스 워커가 처리하는 이벤트와 직접적으로 연관되어 있다. 서비스 워커 범위 내에서 이벤트가 발생한다면 서비스 워커는 활성화 되고 이벤트를 처리한 후 종료된다.

다시말해 사용자가 사이트를 방문할 때, 서비스 워커가 시작되고, 페이지에서 이벤트 처리를 완료하는 즉시 종료가 된다. 다른 이벤트가 나중에 들어온다면 서비스 워커는 다시 시작되고 완료되는 즉시 종료된다.

self.addEventListener("push", function(){
  fetch("/updates").then(function(response){
    return self.registeration.showNotification(response.text);
  });
});

위 코드를 함께 확인해보자.

push 이벤트가 발생하면, 서버로부터 업데이트를 가져오기 위해 시도하고, 응답을 받게 되면 업데이트를 사용자에게 알리게 되는 코드이다.

하지만 이 코드에는 문제가 있다.

업데이트를 확인하기 위해 fetch 요청이 비동기적으로 진행되는 동안 이벤트 리스너 코드의 실행이 종료된다.

fetch에서는 pending을 즉시 리턴하고, resolve가 되면 callback function을 실행하도록 태스크 큐에 넣지만 fetch를 리턴하면서 이미 리스너 자체가 끝나서 사라져있기때문에 업데이트를 표시할 주체가 사라지게 되고 따라서 정상적으로 작동하지 못하게 된다.

브라우저가 서버스 워커 작업이 완료될 때까지 기다리게 하려면 waitUntil을 사용하면 된다.

서비스 워커의 수명은 실행 중인 이벤트 리스너 코드와 직접적으로 연관되어 있다.

waitUntil을 사용하면 필요한 작업이 완료될 때까지 이벤트 리스너 코드를 연장하여 서비스 워커가 종료되는 것을 방지할 수 있다.

self.addEventListener("push", function (event) {
  event.waitUntil(
    fetch("/updates").then(function () {
      return self.registration.showNotification("New Update");
    })
  );
}); 

위 예제 코드는 waitUntil을 호출하여 프로미스가 resolve 되거나 reject 될 때까지 push 이벤트 리스너가 종료되지 않도록 하게되고, 따라서 서비스 워커의 수명도 함께 연장된다.

3. 서비스 워커 업데이트 하기

코드를 아래와 같이 바꾸고 새로고침을 눌러보자

self.addEventListener("install", function () {
  console.log("install");
});

self.addEventListener("activate", function () {
  console.log("activate");
});

self.addEventListener("fetch", function (event) {
  console.log("hi");
  if (event.request.url.includes("bootstrap")) {
    console.log("Fetch request for");
    event.respondWith(
      new Response(
        ".hotel-slogan { background: red!important; } nav{display:none}",
        { headers: { "Content-Type": "text/css" } }
      )
    );
  }
});

아무리 눌러도 초록색인 배경이 빨간색으로 바뀌지 않는다.. 왜그러는 걸까?

크롬의 개발자 도구에서 서비스 워커가 어떻게 돌아가고 있는지 확인해보자.

아무리 새로고침을 눌러도 대기중인 #370 서비스 워커activated 되지 않는다.

background: green#369 서비스 워커가 계속 활성화 중이기 때문이다.

페이지를 새로고침 하게 되면 해당 페이지는 서비스 워커 스크립트에 대한 업데이트가 있는지 확인하고, 업데이트가 있다면 새로운 서비스 워커를 설치(install) 하지만 바로 교체되지 않고 대기중 (waiting) 상태에 남게 된다.

기존의 서비스 워커의 범위에 해당하는 모든 탭과 윈도우 창이 종료되거나, 범위를 벗어난 새로운 페이지로 이동 할 때까지 새로운 서비스 워커는 대기 중 상태를 유지하게 된다.

활성화된 서비스 워커가 제어하는 페이지가 더 이상 열려 있지 않을때에만, 활성화되어 있던 이전의 서비스 워커가 중복 상태가 되고 새 서비스 워커가 활성화 되게 된다.

탭을 닫고 다시 열거나 다른 사이트에 접속했다가 뒤로가기를 누르면 이제서야 배경 색이 바뀌는 것을 확인 할 수 있다. (서비스 워커도 #370이 활성화 된 것도 확인 할 수 있다.)

4. 캐시 관리 및 이전 캐시 제거

var CACHE_NAME = "gih-cache-v2";
var CACHED_URLS = [
  "/index-offline.html",
  "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css",
  "/css/gih-offline.css",
  "/img/jumbo-background-sm.jpg",
  "/img/logo-header.png",
];

self.addEventListener("install", function (event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function (cache) {
      return cache.addAll(CACHED_URLS);
    })
  );
});

self.addEventListener("fetch", function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request).then(function (response) {
        if (response) {
          return response;
        } else if (event.request.headers.get("accept").includes("text/html")) {
          return caches.match("/index-offline.html");
        }
      });
    })
  );
});

다음과 같은 코드를 한번 살펴보자.

맨 첫번째 줄처럼 캐시명에 버전 넘버를 달고 파일이 변경 될 때마다 버전 숫자를 증가시키는 방식으로 캐시를 관리한다고 하면 다음 두가지 이점이 있다.

  1. 활성화된 서비스 워커를 새로운 서비스 워커로 바꿔 설치해야 함을 알 수 있다.

  2. 각 버전에 서비스 워커에 해당하는 별도의 캐시를 생성 할 수 있다.

    서비스 워커가 업데이트 되어 캐시 해주는 파일이 달라졌기 때문에 이전의 캐시 내역을 모두 덮어버린다고 하면 예상치 못한 오류를 만났을때 대처가 힘들 수 있다.

각 서비스워커 마다 캐시가 있는게 바람직하다고는 하지만 생성만 해줄 뿐 오래된 캐시를 삭제해주지 않는다면 공간이 부족해져 버리는 것은 시간문제일 것이다.

이를 해결하기 위해 다음 두 함수에 대해 알아보자

caches.delete(cacheName) : 첫 번째 인수로 캐시명을 받고 해당 캐시를 삭제한다.

caches.keys() : 접근 가능한 모든 캐시의 이름을 받아온다. 캐시명 배열을 resolve 하는 프로미스를 반환한다.

오래된 캐시는 중복 상태에 빠진 서비스 워커에 해당하는 캐시라고 생각해도 된다. 왜냐하면 한번 중복 상태에 빠진 서비스워커는 돌아올수 없기 때문이다.

따라서 우리는 새로운 서비스 워커를 설치하면 그에 해당하는 캐시를 생성 할 것이고, 해당 서비스 워커가 활성화 되면 기존에 활성화 되었던 서비스 워커에 대한 캐시는 지우도록 캐시를 관리할 예정이다.

위에 적어둔 코드는 새로운 서비스 워커를 설치하면 그에 해당하는 캐시를 잘 생성해주고 있기 때문에 활성화 되면 기존에 활성화 되었던 서비스 워커에 대한 캐시를 지우도록 하는 로직만 추가해주면 된다.

self.addEventListener("activate", function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames.map(function (cacheName) {
          if (CACHE_NAME !== cacheName && cacheName.startsWith("gih-cache")) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

위 코드를 살펴보면 서비스 워커가 activate 상태에 들어가기 전에 모든 캐시를 돌면서 현재 cacheName과 다른 cacheName을 가진 캐시(이전 버전의 캐시)를 찾아서 삭제한다. 단, 서비스 워커와 상관이 없는 완전히 다른 곳에서 생성한 캐시를 삭제하지 않도록 해당 캐시명이 gih-cache로 시작하는지 확인해준다.

5. 캐시 관리 개선하기

완벽할 것 같은 위의 캐시 관리도 실은 부족한 점이 남아있다.

캐시를 생성할 때 항상 응답이 같을 수 밖에 없는 캐시, bootstrap 같은 요청에 대한 캐시는 항상 응답이 같을텐데 매번 새롭게 캐싱을 해주고 있다.

지정된 요청에 대한 캐싱은 이전 캐시 기록을 쓸 수는 없을까?

const CACHE_NAME = "gih-cache-v6";

const immutableRequests = [
  "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css",
];

const mutableRequests = [
  "/index-offline.html",
  "/css/gih-offline.css",
  "/img/jumbo-background-sm.jpg",
  "/img/logo-header.png",
];

self.addEventListener("install", function (event) {
  event.waitUntil(
    caches.open(CACHE_NAME).then(function (cache) {
      const newImmutableRequests = [];
      return Promise.all(
        immutableRequests.map(function (url) {
          return caches.match(url).then(function (response) {
            if (response) {
              return cache.put(url, response);
            } else {
              newImmutableRequests.push(url);
              return Promise.resolve();
            }
          });
        })
      ).then(function () {
        return cache.addAll(newImmutableRequests.concat(mutableRequests));
      });
    })
  );
});

다음과 같은 코드를 살펴보면 모든 immutable한 request에 대해 만약 캐시에 있다면 해당 정보를 캐시에 put으로 바로 넣어주고 없다면 처음 들어온 immutable한 요청이기 때문에 newImmutable 배열에 넣고 마지막에 newImmutableRequests 배열mutableRequests 배열concat으로 합쳐서 캐시에 넣어준다.

이렇게 하면 이전 캐시의 리소스까지 더 완벽하게 사용할 수 있게 된다.

실습 코드 링크

profile
프론트엔드에 스며드는 중 🌊

2개의 댓글

comment-user-thumbnail
2022년 7월 16일

굳.

1개의 답글