DEV NOTE · STUDIO

게임마다 따로 설치되는 PWA: 폴더별 서비스 워커와 허브의 역할 분담

rogagame.com은 사이트 자체도 홈 화면에 설치되고, 그 안의 게임 하나하나도 따로 설치되어 오프라인에서 돌아간다. 한 도메인에 서비스 워커가 여덟 개 있는 셈이다. 서로 캐시를 지우거나 남의 요청을 가로채지 않게 하려고 정한 규칙은 세 가지다. 범위는 자기 폴더로 한정하고, 허브는 게임 폴더 요청을 그냥 통과시키며, 캐시 이름에는 반드시 자기 접두사를 붙인다.

왜 게임마다 따로 설치되게 했나

처음 PWA를 붙인 것은 ORBIT RUSH였다. 홈 화면 설치와 오프라인 플레이가 초기 변경 이력에 이미 들어 있다. 게임 본체가 브라우저 안에서 돌아가는 구조라 셸 파일만 캐시해 두면 네트워크 없이도 실행된다. 이후 게임이 늘면서 선택지는 두 가지였다. 사이트 전체를 하나의 앱으로 묶어 허브 워커가 모든 게임 파일을 캐시하거나, 게임마다 독립된 앱으로 두는 것이다.

우리는 후자를 골랐다. 게임마다 버전 주기가 다르기 때문이다. 같은 날 JJ는 1.4.0까지 올라가고 STARLINK는 1.0.0에 머무를 수 있다. 하나의 캐시로 묶으면 한 게임을 고칠 때마다 사용자는 일곱 게임의 파일을 전부 다시 받아야 한다. 또 ECHO는 Three.js와 후처리 모듈을, JJ는 PixiJS를 들고 있어서 게임마다 오프라인에 필요한 파일 크기 차이가 크다. 좋아하는 게임 하나만 설치하려는 사람에게 다른 게임의 3D 엔진까지 받게 할 이유가 없었다.

그래서 각 게임 폴더에는 자기 manifest.webmanifest(start_url과 scope가 모두 "./")와 자기 sw.js가 있다. 게임 스크립트가 navigator.serviceWorker.register('sw.js')를 상대 경로로 호출하면 워커의 기본 범위는 그 파일이 있는 폴더, 예를 들어 /fourstep/가 된다.

세로 모바일 화면에서 ORBIT RUSH를 플레이하는 모습. 궤도 링과 PERFECT 판정, 13 콤보가 보인다
ORBIT RUSH 모바일 화면. 처음 오프라인 플레이를 붙인 게임이고, 이후 모든 게임이 같은 서비스 워커 틀을 따른다.

허브 워커가 게임 폴더를 건드리지 않는 이유

허브 워커는 루트 /sw.js에 있고 범위가 / 전체다. 브라우저는 요청 주소에 대해 가장 길게 일치하는 범위의 워커를 고르므로, 게임 워커가 이미 설치된 상태라면 /fourstep/ 아래 요청은 게임 워커가 처리한다. 문제는 게임 워커가 아직 없을 때다. 허브에서 처음으로 게임 카드를 누르는 순간, 그 게임 폴더를 맡을 워커는 허브 워커밖에 없다.

허브 워커는 네트워크가 실패하면 허브의 /index.html을 대신 돌려주게 되어 있다. 이 동작이 게임 폴더에도 적용되면 게임 파일이 허브 캐시에 섞여 들어가고, 오프라인에서 게임 주소를 열었을 때 엉뚱하게 허브 화면이 뜰 수 있다. 그래서 허브 워커의 fetch 핸들러는 게임 폴더 경로를 정규식으로 걸러 아예 손대지 않는다.

// only handle hub-level requests; game folders are served by their own workers
if(/^\/(orbit-rush|starlink|flip|slingstar|echo|fourstep|jj)\//.test(url.pathname)) return;

이 목록은 새 게임을 추가할 때마다 직접 고쳐야 한다. JJ를 처음 올렸을 때 이 정규식에 jj를 넣는 커밋이 따로 있었고, 허브 워커 버전도 그때 하나 올렸다. 게임을 내릴 때도 마찬가지라서 ROOFTOP을 제거하면서 정규식에서 빼고 허브 워커 버전을 14로 올렸다. 새 게임 체크리스트에 매니페스트 바로가기와 함께 이 정규식이 들어 있는 이유다. 허브는 자기 셸, 즉 메인 페이지와 회사소개·약관·방침, 공용 CSS·JS, 아이콘만 캐시한다. 글꼴처럼 다른 출처의 요청도 그냥 네트워크로 보낸다.

같은 출처의 캐시 저장소를 나눠 쓰는 법

여기서 놓치기 쉬운 점이 있다. 서비스 워커의 범위는 폴더별로 나뉘지만 Cache Storage는 출처 단위다. FOURSTEP의 워커에서 caches.keys()를 호출하면 JJ와 허브의 캐시 이름까지 전부 보인다. 새 버전이 활성화될 때 "현재 캐시가 아닌 것은 전부 삭제"라고 쓰면 다른 게임의 오프라인 데이터를 지워 버린다.

그래서 모든 워커는 캐시 이름에 자기 접두사를 붙이고, 정리할 때도 자기 접두사만 본다. 게임은 fourstep-1.2.0, jj-1.4.0처럼 폴더 이름과 버전을, 허브는 rogagame-hub-14를 쓴다.

const CACHE='fourstep-'+VERSION;
caches.keys().then(keys=>Promise.all(keys
  .filter(k=>k.startsWith('fourstep-')&&k!==CACHE)
  .map(k=>caches.delete(k))))

설치 단계에서 미리 받는 셸 목록(SHELL)에는 스크립트 주소를 ?v= 버전 쿼리까지 붙여서 적는다. 캐시는 쿼리 문자열까지 포함해 주소를 비교하므로, HTML이 실제로 요청하는 주소와 글자 하나까지 같아야 오프라인에서 일치하는 항목을 찾는다. 이 때문에 버전은 js/version.js, sw.js, index.html 세 곳에서 같아야 하고, CI가 푸시마다 이를 검사한다.

요청 처리 전략은 기본적으로 네트워크 우선이다. 온라인이면 항상 최신 파일을 받아 캐시를 갱신하고, 실패하면 캐시에서 꺼내며, 그것도 없으면 게임의 ./index.html로 돌아간다. 예외는 ECHO의 vendor/ 경로 하나다. Three.js처럼 바뀔 일이 없는 대용량 라이브러리는 캐시 우선으로 읽어 매번 네트워크를 기다리지 않게 했다.

새 버전이 사용자에게 닿기까지

배포 뒤 업데이트 흐름은 이렇다. sw.js는 캐시 헤더가 no-cache라서 브라우저가 페이지를 열 때마다 서버에 확인한다. VERSION 문자열이 바뀌어 파일 내용이 한 바이트라도 달라지면 새 워커가 설치되고, 새 이름의 캐시에 셸을 받는다. 설치가 끝나면 skipWaiting()과 clients.claim()으로 바로 활성화되고 이전 버전 캐시를 지운다.

다만 이미 열려 있는 페이지는 옛 스크립트로 실행 중이다. ORBIT RUSH, FOURSTEP, STARLINK, ECHO는 새 워커가 installed 상태가 되었는데 기존 컨트롤러가 있으면, 즉 업데이트 상황이면 "새 버전이 준비됐어요. 새로고침하면 적용됩니다."라는 안내를 4초 띄운다. 플레이 도중 강제로 새로고침하지 않는 것은 의도한 선택이다. 리듬 게임 한 곡 중간에 화면이 바뀌는 것보다 다음 판에서 적용되는 편이 낫다.

설치 버튼은 브라우저마다 다르게

허브의 설치 안내는 js/site.js 아래쪽에 있다. 크로미움 계열 브라우저는 beforeinstallprompt 이벤트를 주므로, 이를 저장해 두었다가 사용자가 상단의 설치 버튼을 누를 때 prompt()를 호출한다. 이 이벤트가 없는 환경, 특히 iOS 사파리에서는 버튼을 눌러도 바로 설치할 수 없으니 공유 메뉴에서 홈 화면에 추가하는 단계를 보여 주는 안내 창을 연다. iOS 판별에는 사용자 에이전트 외에 MacIntel 플랫폼이면서 터치 포인트가 있는 경우를 함께 본다. 최근 iPad가 데스크톱 사파리처럼 자신을 알리기 때문이다. 이미 display-mode: standalone으로 실행 중이면 "설치됨" 안내를 보여 준다.

허브 매니페스트의 shortcuts에는 게임 일곱 개가 들어 있다. 허브를 설치한 사람은 앱 아이콘을 길게 눌러 원하는 게임으로 바로 들어갈 수 있다. 사이트 전체를 설치하는 사람과 게임 하나만 설치하는 사람 모두를 받는 구조다.

정리

한 도메인에서 여러 PWA를 운영할 때 실제로 부딪힌 것은 범위보다 공유 자원이었다. 워커의 범위는 브라우저가 알아서 나눠 주지만, 캐시 저장소와 첫 방문 시점의 요청은 누군가 명시적으로 양보해야 한다. 접두사 규칙과 허브의 제외 정규식이 그 양보를 코드로 적은 것이다. 남은 과제는 허브 정규식을 손으로 관리하는 부분이다. 게임 목록을 한 곳에서 읽어 정규식과 매니페스트 바로가기를 같이 만들면 새 게임을 추가할 때 빠뜨릴 일이 줄어든다.