놀라지 않는 Service workers: 오프라인‑우선 PWAs를 위한 결정적 캐싱
Source: Dev.to
Series:
시작하기 · Part 1 · Part 2 · Part 3 · Part 4 · Part 5 · Part 6 · Part 7 · Part 8 · Part 9 · Part 10
이 게시물은 오픈소스 Pain Tracker 저장소를 기반으로 한 Dev.to 시리즈의 Part 3입니다.
의학적 조언이 아닙니다.
규정 준수 주장도 아닙니다.
이 글은 결정론적 동작과 진실된 경계에 관한 내용입니다.
프라이버시 우선, 감시 없이도 지속될 수 있는 오프라인 헬스 테크를 원한다면, 빌드 후원하기 → https://paintracker.ca/sponsor.
아직 Part 2를 읽지 않으셨다면:
- Part 2: 세 가지 저장 계층 (상태 캐시 vs 오프라인 DB vs 암호화된 금고)
“놀라운” 서비스 워커가 하는 일
서비스 워커 때문에 고생한 적이 있다면 보통 다음 중 하나일 겁니다(아직 고생하지 않았다면 축하합니다—곧 겪게 될 테니까요).
-
배포 후 오래된 HTML이 앱을 깨뜨림
- 브라우저가 오래된
index.html을 계속 제공합니다. - 모듈 그래프가 바뀌면서 청크 404 오류가 발생하거나 화면이 빈 채로 나타납니다.
- 브라우저가 오래된
-
베이스 경로가 실제와 일치하지 않음
/에서는 동작하지만 GitHub Pages의/pain-tracker/아래에서는 깨집니다.- 잘못된 스코프를 등록해 기대한 대로 동작하지 않습니다.
-
캐시가 의도치 않은 데이터 보존이 됨
- 응답을 캐시하려는 의도가 없었습니다.
- “모두”를 캐시하면 쉬워 보이지만…
- 사용자별 페이로드가 캐시에 남아 민감한 앱에서는 문제가 됩니다.
-
업데이트가 혼란스러움
- 새 워커가 설치되지만 활성화되지 않습니다.
- 사용자는 페이지를 새로 고치지 않습니다.
- 현재 어떤 버전이 실행 중인지 알 수 없습니다.
핵심: 브라우저는 정확히 당신이 요구한 대로 동작하고 있지만, 요구를 충분히 신중하게 하지 않은 것입니다.
Pain Tracker의 서비스 워커 철학
- 탐색에 대해 네트워크‑우선 (구식 HTML 방지)
- 정적 자산만 캐시 (스크립트, 스타일, 이미지, 폰트)
- 버전이 지정된 캐시와 활성화 시 정리
offline.html과 매니페스트를 위한 소규모 프리캐시
“오프라인 마법”은 없으며, 임의 API 응답에 대한 런타임 캐시도 없고, SW를 데이터 레이어로 만들려는 시도도 없습니다.
두 개의 서비스‑워커 스크립트가 존재합니다
| 범위 | URL |
|---|---|
| 루트 (일반 배포) | |
GitHub Pages 기본 경로 (/pain-tracker/) |
두 워커 모두에서 가장 중요한 라인은 개념적으로 다음과 같습니다: 탐색은 네트워크‑우선.
public/sw.js에서는 request.mode === 'navigate' 또는 Accept: text/html을 사용해 탐색 요청을 감지합니다. 그런 다음 다음과 같이 동작합니다:
try {
return fetch(request);
} catch (_) {
return caches.match('/offline.html');
}
그게 전부입니다.
왜 이것이 중요한가
캐시‑우선 전략으로 탐색을 캐시하면, 결국 새로운 빌드가 배포되면서 HTML이 새로운 청크 파일명을 가리키게 됩니다. 캐시된 HTML은 여전히 오래된 파일명을 가리키므로 → 청크 404가 발생하고 앱이 “무작위로 깨진” 느낌을 줍니다.
헬스‑관련 PWA에서는 이러한 종류의 실패가 깔끔한 오프라인 메시지보다 훨씬 더 나쁩니다.
무엇을 캐시하는가
Pain Tracker는 동일 출처 GET 요청 중 정적 자산처럼 보이는 것만을 캐시합니다:
- 허용된 경로 접두사:
/assets/,/icons/,/logos/,/screenshots/(GitHub Pages 빌드용/pain-tracker/접두사 포함). - 보수적인 확장자 허용 목록:
.js,.css,.png,.svg,.woff2, …
캐시 흐름
- 요청이 캐시 안에 있으면 → 반환합니다.
- 그렇지 않으면 →
fetch하고, 성공적인200응답을 캐시한 뒤 반환합니다.
결과적인 오프라인 전략
- 첫 방문 이후 셸이 빠르게 로드됩니다.
- 배포 시 캐시된 HTML 때문에 막히는 일이 없습니다.
- 민감한 런타임 응답이 실수로 캐시되지 않습니다.
버전 관리 및 정리
두 워커 모두 버전 문자열을 가지고 있으며, 이를 기반으로 캐시 이름을 생성합니다:
const CACHE_NAME = `pain-tracker-static-v${VERSION}`;
활성화 시 SW는 pain-tracker- 접두사가 붙은 오래된 캐시를 삭제하여 다음과 같은 깨끗한 불변성을 유지합니다:
- SW 버전을 올리면 → 오래된 캐시가 제거됩니다.
- 추측이나 “아마 업데이트될지도” 같은 상황이 없습니다. 업데이트가 되든 안 되든 명확합니다.
GitHub‑Pages‑스타일 워커 (public/pain-tracker/sw.js)
변경되는 점:
- 매니페스트의 프리캐시 URL이
/pain-tracker/manifest.json이 됩니다. - 정적 접두사에
/pain-tracker/assets/가 포함됩니다. - 오프라인 폴백에
/pain-tracker/가 포함됩니다.
이 중복은 “왜 한 환경에서는 오프라인이고 다른 환경에서는 아닌가” 하는 시간을 몇 시간씩 절약해 줍니다.
앱에서 SW 등록
등록 코드는 src/utils/pwa-utils.ts에 있습니다:
// https://github.com/CrisisCore-Systems/pain-tracker/blob/main/src/utils/pwa-utils.ts
핵심 동작
VITE_BASE(설정된 경우) 또는 Vite의BASE_URL에서baseUrl을 계산하고, Vite가 상대 경로 베이스를 반환할 때는location.pathname을 fallback으로 사용합니다.${baseUrl}sw.js를scope: baseUrl옵션으로 등록합니다.updateViaCache: 'none'을 설정해 업데이트 검사를 강제합니다.updatefound리스너를 연결해 새 콘텐츠가 사용 가능할 때 앱이 사용자에게 알릴 수 있게 합니다.
서비스 워커는 활성화 시 SW_READY 메시지를 전송하고, PING에 대해 PONG으로 응답합니다. PWA 매니저는 이를 감지해 window.__pwa_sw_ready = true 로 설정합니다.
실용적인 팁
- “SW가 아직 준비됐나요?”와 같은 불안정한 테스트를 피합니다.
- DevTools에서 간단한 디버그 신호를 제공합니다.
하지 않는 동작 (그리고 그 이유)
- 임의의 fetch를 캐시하지 않음.
- API 응답을 캐시하지 않음.
- 네비게이션을 캐시하지 않음.
- “헬스 데이터를 클라우드에 동기화” 시스템을 구현하지 않음.
이것들은 누락된 기능이 아니라 의도된 경계입니다.
나중에 더 많은 SW 기능(백그라운드 동기화, 오프라인 처리 등)을 추가한다면 새로운 신뢰 경계로 다루세요:
- 캐시에서 허용할 데이터 종류를 결정합니다.
- Cache Storage에 Class A 페이로드를 저장하지 않도록 합니다.
- “잠금 상태”와 “잠금 해제 상태”에서 발생할 수 있는 일을 명확히 정의합니다.
빠른 테스트 체크리스트
- 서비스 워커 스크립트 확인
- DevTools에서
- Application → Service Workers – 범위와 스크립트 URL을 확인합니다.
- Application → Cache Storage –
pain-tracker-static-v…를 찾습니다.
- 오프라인 동작 테스트
- Network → Offline.
- 라우트를 새로 고칩니다.
- 깨진 셸 대신 오프라인 폴백을 받아야 합니다.
다음은?
- Part 4에서는 방어적 파싱(Zod + 스키마‑우선 입력)과 “offline‑first”가 “조용히 쓰레기를 받아들이는” 상황이 되지 않도록 하는 방법을 다룹니다.
Prev: Part 2 — 세 가지 저장 계층
Next: Part 4 — Zod + 방어적 파싱
프로젝트 후원 (주요)
https://paintracker.ca/sponsor
/paintracker.ca/sponsor
레포지토리 별표 달기 (보조):
https://github.com/CrisisCore-Systems/pain-tracker
시리즈 전체를 처음부터 읽기:
link