[Copy Stack] 문제 해결 - 서비스 비활성화 문제

dev2820·2023년 1월 14일
0

프로젝트: Copy Stack

목록 보기
26/28

서비스 워커가 중간에 비활성화 된다.

저번 글에서 동작 테스트를 하면서 발견된 문제가 있었는데, 바로 앱을 켜놓은 상태에서 서비스 워커가 비활성화되며 기능이 작동하지 않는 문제였습니다.

문제 관찰

처음에 문제를 관찰하기 위해 얼만큼의 시간이 지나면 비활성화 되는지 시간을 재보았는데요. 정확히 30초가 지나면 서비스 워커가 비활성화 되었습니다.

서비스 워커는 왜 비활성화 될까?

https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts

MDN에서 Background scripts는 non-persistent(시간이 지나면 idle 모드로 빠짐)이라고 알았지만

Opening a view does not cause the background page to load but does prevent it from closing.

위에서 언급하듯 view가 열려있으면 background는 비활성화되지 않는다고 표현되어 있습니다.

이와 관련해서 Persistent Service Worker와 관련된 키워드로 구글링을 하다가 스택오버플로우에서 해답을 얻을 수 있었습니다.

https://stackoverflow.com/questions/66618136/persistent-service-worker-in-chrome-extension/66618269#66618269

답글 작성자분이 chrome extension의 동작에 대해 자세하게 설명을 해주셔서 어떤 문제인지 바로 파악할 수 있었습니다.

서비스워커는 영구적일 수 없고 브라우저는 특정 시간 후(크롬은 5분) 네트워크 요청 또는 런타임 포트와 같은 SW 연결을 강제로 종료해야 한다. 이러한 요청이나 포트가 열려 있지 않을 때 비활성화 되는 타이머는 30초로 더 짧아진다.

즉 네트워크 요청이 있거나 connect를 이용한 포트를 열었더라도 5분이후엔 서비스 워커로의 연결이 종료되고 포트가 열려있지 않다면 30초 후에 종료된다고 합니다. 여기서 정확히 30초 후에 서비스 워커가 비활성화 되는 이유를 찾을 수 있었습니다.

하지만 View가 열려있는데 백그라운드가 비활성화 되는 것은 MDN의 설명과 일치하지 않습니다.

따라서 30초를 명시한 공식적인 문서는 찾아보려 했지만 찾을 수 없었고 대신 크로미움에 언급된 버그 리포트를 하나 찾을 수 있었습니다.

https://bugs.chromium.org/p/chromium/issues/detail?id=1305369

위 글의 버그 리포트에선 fetch를 사용하는데 30초 후에 서비스 워커가 stopped 되었다는 설명이 있었고 코멘트에서 이것이 버그로 보인다는 설명을 볼 수 있었는데, 아마 같은 종류의 문제이지 않을까 생각합니다.

문제 해결

친절하게도 스택오버플로우 답글 작성자가 몇가지 해결책까지 제시를 해주었는데 그 중 한가지가 port를 열고 5분이 되기 전에 갱신해주는 것입니다.

원래 이를 기반으로 코드를 작성하다 더 좋고 훌륭한 코드를 만나서 첨부하고 설명해보려합니다.

https://stackoverflow.com/questions/72229032/how-to-detect-that-chrome-extension-with-manifest-v3-was-unloaded

// heartbeat.ts
/**
 * reference
 * https://stackoverflow.com/questions/72229032/how-to-detect-that-chrome-extension-with-manifest-v3-was-unloaded
 */

const cycle = 5 * 60 - 1; // 299sec
let state = true;
let portToBackground: chrome.runtime.Port = openPortToBackground();

function openPortToBackground() {
  const port = chrome.runtime.connect();

  const timeout = setTimeout(() => {
    portToBackground = openPortToBackground();
    port.disconnect();
  }, cycle * 1000);

  port.onDisconnect.addListener(() => {
    clearTimeout(timeout);
    state = port !== portToBackground;
  });

  return port;
}

export default {
  isAlive() {
    return state;
  },
};

원본 코드에서 cycle 상수,state 변수와 export default를 추가해 만든 heartbeat 모듈입니다.

코드의 개요는 대략적으로

포트를 연다.
5분이 되기 전에 포트를 갱신하고 기존 포트를 파기하는 타이머를 건다.
타이머에서 기존 포트를 파기하는 경우 onDisconnect에 걸린 리스너가 실행되며 state는 true가 된다. (새 포트가 열려있으므로)
이외의 경로로 포트가 닫혀 onDisconnect가 실행되는 경우 포트를 갱신하지 않았으므로 state가 false가 된다.

입니다.
여기서 state변수의 값을 통해 background로 통하는 포트가 정상적으로 열려있는지 확인할 수 있습니다.

위 코드가 동작하는지 간단한 테스트를 마련해봤습니다.

// heartbeat.test.ts
import { jest, test, expect } from "@jest/globals";

jest.useFakeTimers();
jest.spyOn(window, "setTimeout");

먼저 타이머의 동작을 확인하기 위해 useFakeTimers를 설정하고 setTimeout 함수의 호출에 spy를 달았습니다. (jsdom을 환경을 사용해서 window의 setTimeout을 사용합니다.)

// heartbeat.test.ts
// ...
const sec5 = 5;
const cycle = sec5;
/**
 * 가짜 connect 함수를 만들어줍니다.
 * onDisconnect를 통해 listener를 등록할 수 있고
 * disconnect를 통해 등록된 리스너를 실행할 수 있습니다.
 */
const connect = () => {
  const listeners: Function[] = [];
  const port = {
    onDisconnect: {
      addListener: (cb: Function) => {
        listeners.push(cb);
      },
    },
    disconnect: () => {
      listeners.forEach((l) => l());
    },
  };

  return port;
};

다음 chrome.runtime.connect를 사용할 수 없으니 가짜 connect 함수를 만들어줍니다.

가짜 connect 함수는 onDisconnect.addListener로 동작할 함수를 추가할 수 있고 disconnect 함수로 강제로 연결을 끊을 수 있습니다.

테스트코드에선 5초 주기로 연결을 갱신하도록 코드를 짰습니다.

// heartbeat.test.ts
// ...
let portToBackground: any = null;
let state = true;

function openPortToBackground() {
  const port = connect();

  const timeout = setTimeout(() => {
    portToBackground = openPortToBackground();
    port.disconnect();
  }, cycle * 1000);

  port.onDisconnect.addListener(() => {
    clearTimeout(timeout);
    state = port !== portToBackground;
  });

  return port;
}

이제 이 가짜 connect를 사용하는 가짜 heartbeat모듈을 만들어줍니다. 가짜 connect를 사용한다는 점만 빼면 나머지는 같습니다.

// heartbeat.test.ts
// ...
test("Normal shutdown", () => {
  expect(setTimeout).toHaveBeenCalledTimes(0);
  /**
   * 초기 portToBackground는 null,
   * state는 true인 상태
   */
  expect(portToBackground).toBeNull();
  expect(state).toBe(true);
  /**
   * openPortToBackground가 동작하며 setTimeout이 최초 호출됨
   */
  portToBackground = openPortToBackground();
  expect(setTimeout).toHaveBeenCalledTimes(1);

  /**
   * 현재 동작중인 타이머에 걸려있는 시간만큼만 지나가게 함
   * 이 예제에선 5초 타이머를 걸어놨기 때문에 5초의 시간이 흐른 뒤
   * 의 상황이 될 것임
   */
  jest.runOnlyPendingTimers();

  /**
   * 5초 뒤엔 timeout되며 재귀적으로 updateConnect가 실행될 것 이기때문에
   * 2회 호출이 됨.
   *
   * 다음 새 타이머가 동작하며 5초의 시간 제한을 걸었을 것임
   * 따라서 arguments가 (어떤 함수, sec5 * 1000)이 됨
   */
  expect(setTimeout).toHaveBeenCalledTimes(2);
  expect(setTimeout).toHaveBeenLastCalledWith(
    expect.any(Function),
    sec5 * 1000
  );

  /**
   * onDisconnect가 정상적으로 동작했다면
   * portToBackground에 port가 배정되며 null이 되지 않았을 것임
   */
  expect(portToBackground).not.toBeNull();
  expect(state).toBe(true);

  /**
   * 연결을 종료하며 상태를 초기화함
   */
  portToBackground.disconnect();
  portToBackground = null;
  state = true;
});

먼저 정상적으로 종료되는 경우의 테스트입니다.

// heartbeat.test.ts
// ...

test("Abnormal shutdown", () => {
  /**
   * 이전 테스트에 이어서 setTimeout은 그대로 두 번 호출된 상태
   * state는 true인 상황
   */
  expect(setTimeout).toHaveBeenCalledTimes(2);
  expect(state).toBe(true);
  /**
   * 새 포트를 배정
   */
  portToBackground = openPortToBackground();
  expect(setTimeout).toHaveBeenCalledTimes(3);
  /**
   * 포트를 수동으로 종료 시킴 (비정상 종료 상황)
   */
  portToBackground.disconnect();
  /**
   * 타이머에 걸려있는 시간만큼 시간을 감음
   * 물론 disconnect를 수행하며 타이머가 파기되었기 때문에 이는 동작하지 않음
   */
  jest.runOnlyPendingTimers();

  /**
   * setTimeout이 실행되지 않았음
   * 또한 state가 false가 되어있음
   */
  expect(setTimeout).not.toHaveBeenCalledTimes(4);
  expect(state).toBe(false);
});

다음 중간에 외부에서 disconnect가 호출되어 비정상 종료된 경우의 테스트 케이스입니다.

결과

결과적으로 테스트도 통과했고 빌드해서 실제 크롬 브라우저에서 테스트한 결과도 통과했습니다.

또한 혹시 뷰(popup)이 꺼져도 서비스워커가 동작하는거 아닌가 해서 테스트 해봤는데 30초 뒤에 서비스 워커도 정상적으로 종료되었습니다.

profile
공부,번역하고 정리하는 곳

0개의 댓글