1. 개요
헬스장에는 지하가 많다. 운동 일지를 쓰는 앱이 지하에서 안 되면 쓸모가 반이다. 그래서 오프라인을 진지하게 만들기 시작했는데, 첫 설계를 뒤집은 질문은 “오프라인이 되나” 가 아니라 “남의 폰에 내 식단 사진이 남나” 였다. 이 글은 서비스워커 캐시 정책, IndexedDB 사본, outbox 큐, 그리고 오프라인과는 무관해 보였지만 함께 잡은 뒤로가기 경합 버그를 다룬다.
2. 핵심 내용
2-1. 서비스워커는 /api/* 를 캐시하지 않는다
PWA 오프라인의 가장 쉬운 길은 서비스워커의 런타임 캐시다. /api/log/2026-09-28 응답을 캐시하면 오프라인에서도 그날 일지가 보인다. 그런데 서비스워커 캐시는 브라우저 프로필에 남는다. 로그아웃해도 남는다. 공용 태블릿(센터 키오스크)에서 다음 사람이 같은 URL 을 열면 캐시가 먼저 응답한다.
그래서 /api/* 는 전부 NetworkOnly 다. 예외는 개인정보가 없는 것만 허용했다.
- 운동 카탈로그 상세·이름 조회 첫 페이지: stale-while-revalidate
- 자체 저장소의 운동 이미지: CacheFirst
- 내비게이션·RSC 페이로드: NetworkFirst, 5초 타임아웃 후
/offline폴백
Turnstile 과 Cloudflare beacon 요청은 서비스워커가 아예 개입하지 않는다. fetch 리스너에서 stopImmediatePropagation 으로 빠진다. iOS Safari 에서 서비스워커가 이 요청을 가로채면 위젯 스크립트가 20초 가까이 blocked 상태로 걸리는 현상이 있었다.
2-2. 오프라인 검색은 IndexedDB 사본으로
그러면 오프라인에서 운동을 어떻게 검색하나. 서비스워커 캐시가 아니라 앱이 직접 관리하는 IndexedDB 사본 이다.
- 운동 카탈로그 전체(설명·영상 제외)를 하루 한 번 받아 저장한다. 크기가 작고 개인정보가 없다.
- 식품은 전체가 아니라 내 것만: 최근 50건, 즐겨찾기, 나만의 음식, 자주 고른 것. 한 시간마다 갱신.
- 사용자별로 격리하고, 로그아웃하면 지운다. 이게 서비스워커 캐시와의 결정적 차이다. 앱이 생명주기를 통제한다.
검색 훅은 온라인이면 서버를, 오프라인이거나 서버가 실패하면 사본을 본다. 사용자 입장에서는 같은 검색창이다.
2-3. 쓰기는 outbox 큐
오프라인에서 일지를 저장하면 outbox 큐에 넣는다. 온라인으로 돌아오면 순서대로 flush 한다. React Query 의 networkMode: offlineFirst 와 persist(IndexedDB) 를 함께 쓰고, 앱 버전이 바뀌면 persist 캐시를 버린다.
멱등성이 여기서 중요해진다. flush 중에 네트워크가 다시 끊기면 같은 요청이 두 번 갈 수 있다. axios 인터셉터가 모든 쓰기 요청에 Idempotency-Key 를 자동으로 붙이고, 백엔드가 같은 키의 재요청을 첫 응답으로 돌려준다. 멱등성 글에서 다룬 개념이 여기서 실제로 쓰였다.
2-4. 비공개 자산은 서명 URL 로, 그리고 재저장 회귀
식단 사진은 비공개다. 오브젝트 스토리지 앞의 게이트웨이가 서명 없는 /private/ 접근을 403 으로 끊고, 백엔드가 15분짜리 presigned URL 을 응답에 싣는다. 응답에는 Cache-Control: private, no-store 를 붙인다. 공개 리소스는 반대로 1년 immutable 이다.
이 변경이 회귀를 하나 만들었다. 목록 응답에 서명 URL 을 실었더니, 사진이 있는 일지를 다시 저장하면 거부 됐다. 클라이언트가 받은 URL(쿼리가 붙은 서명 URL)을 그대로 되돌려 보냈고, 서버는 그 문자열을 객체 키로 해석했다. 응답 형태를 바꾸는 변경은 “읽고 → 다시 쓰는” 왕복을 테스트해야 한다. 읽기만 테스트하면 통과한다.
2-5. 뒤로가기 sentinel 이 화면 이동을 취소했다
오프라인과 무관한 버그지만 같은 시기에 잡았고, 원인 분석 방식이 같아서 함께 적는다.
증상: 삭제 확인 다이얼로그에서 “삭제” 를 누르면 삭제는 되는데 화면이 그대로다. e2e 는 toHaveURL 타임아웃. 이슈에는 “콜드 빌드의 하이드레이션 타이밍 문제” 라고 적혀 있었다.
DoEatFit 은 모바일에서 하드웨어 뒤로가기로 오버레이를 닫게 하려고 히스토리에 sentinel 항목을 하나 밀어 둔다. 다이얼로그가 닫히면 그 sentinel 을 history.back() 으로 거둔다. 문제는 이 회수가 비동기 라는 것. 다이얼로그가 닫히고 → 뮤테이션 응답이 오고 → 호출부가 router.push() 를 부르는 사이에 회수의 popstate 가 끼어든다. Next 라우터는 RSC 를 받는 도중 popstate 를 만나면 진행 중인 이동을 취소한다.
다이얼로그 닫힘 ─┐
├─ history.back() 예약 (비동기)
뮤테이션 응답 ───┤
├─ router.push('/list') ← RSC 요청 시작
popstate 도착 ───┘ ← 라우터: 진행 중 이동 취소
콜드 빌드에서 자주 보인 건 RSC 응답이 느려 경합 창이 넓어졌기 때문이지, 콜드가 원인이 아니다. 경합은 웜에서도 있다.
해법은 settleOverlayHistory() 다. 예약된 회수가 있으면 앞당겨 실행하고, 이미 back 이 나갔으면 그 popstate 가 도착할 때 resolve 한다. 다이얼로그를 닫은 뒤 이동하기 전에 이걸 await 한다. 기다리는 대상은 시간이 아니라 사건 이다. “느려서 못 따라갔다” 로 보고 대기 시간을 늘리면 영영 못 고친다.
3. 마무리
요약
- 서비스워커 캐시는 로그아웃해도 남는다.
/api/*는 NetworkOnly, 예외는 개인정보 없는 것만.- 오프라인 데이터는 앱이 생명주기를 통제하는 IndexedDB 사본으로. 로그아웃 시 삭제.
- 오프라인 쓰기는 outbox + 멱등키. 재전송을 전제로 설계한다.
- 응답 형태를 바꾸면 “읽고 다시 쓰는” 왕복을 테스트한다.
- 경합은 시간이 아니라 사건을 기다려서 푼다.
마지막 편은 테스트가 거짓말하는 순간이다.