게임마다 따로 설치되는 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/가 된다.

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