Skip to content

오프라인 사용 지원 — 비행기 모드 검은 화면에서 내 기록 기능 전체 오프라인화까지 (2026-09-03)

  • 기간: 2026-09-03 (세션 1, 오너 보고 "지금 비행기모드로 앱 켜면 처음 스플래시 화면만 한 30초 유지되다가 검은 화면으로 넘어가버리는데, 오프라인 환경에서 어떻게 쓸 수 있게 할 수 있을까" → "오프라인 사용범위는 일단 앱 전체 기능으로 하고 싶은데 가능할까? 저장은 로컬이 되었다가, 다시 연결되었을때 한번에 서버 업로드하고")
  • 랜딩: PR #1178(Phase 1~6, db66d0d0) — 마이그레이션·엣지 함수 없음(서버 무변경). Vercel 배포로 웹은 즉시 반영, iOS는 오너 Mac 빌드 1회 필요(Info.plist 앱 전용 도메인 선언 + 9/2 오프라인 안내 페이지가 같은 빌드에 실림)
  • 설계서: 이슈 #1173 본문·댓글 1("예상 효과·개선사항" 절 포함)
  • 정본: public/sw.js(화면 코드 캐시 규칙) · src/react/services/supabaseAuth.ts readPersistedAuthSessionPresence(오프라인 계정 판정) · src/react/services/ownDataReplica.ts + barbelicRepository.ts callScreenRpc(기기 사본) · src/react/services/ownDataReplicationJob.ts(복제 작업) · src/react/services/pendingWorkoutSaves.ts queuePendingWorkoutMutation(합치기 규칙) · src/react/services/pendingWorkoutMutationFlush.ts(종류별 업로드·충돌 규칙 D1)
  • 도구: 없음(레포 안 코드만)
  • 게이트: tests/react/offlineServiceWorker.test.mjs · authDegradedBootstrap.test.mjs(네이티브 계정 판정) · ownDataReplica.test.mjs · ownDataReplicationJob.test.mjs · pendingWorkoutMutationQueue.test.mjs · pendingWorkoutMutationFlush.test.mjs · calendarPendingMutationOverlay.test.mjs · controllerBoundaries.test.mjs(수정·삭제 큐 경계로 개정) · 브라우저 e2e CASE-029(수정·삭제 큐 왕복)
  • 버그리포트: bug-report/bug-068-20260903.md
  • 계약: 없음(새 계약 문서 없이 코드 주석·이 기록이 규칙을 설명)

Phase 현황

Phase내용상태
Phase 1서비스 워커로 화면 코드 캐시(첫 페이지·스크립트·글꼴) + iOS 앱 전용 도메인 선언✅ PR #1178
Phase 2앱에서 오프라인 계정 판정(로그인 때 남긴 계정 id) → 저장본 먼저 그리기✅ PR #1178
Phase 3내 기록 기기 복제 = 관문 사본 하나(IndexedDB) + 온라인 유휴 시 달·상세 복제 작업✅ PR #1178
Phase 4쓰기 큐 확장(수정·삭제·계획 저장·계획 삭제) + 합치기 규칙 + 나중 저장 우선(D1)✅ PR #1178
Phase 5오프라인 상태 줄·"동기화 후 반영" 표시✅ PR #1178
Phase 6검증(단위·게이트·e2e CASE-029)·랜딩✅ PR #1178

1. 배경

바벨릭 앱(iOS·안드로이드)은 껍데기(WebView)가 켤 때마다 인터넷에서 www.barbelic.com을 받아와 실행하는 구조다. 앱 안에 화면 코드가 없으니 비행기 모드에서는 첫 페이지 자체를 못 받고, 네이티브 부팅 커버는 웹의 "그렸다" 신호를 기다리다 20초 뒤 내려가 빈 WebView(검은 화면)만 남았다. 9월 2일 랜딩된 "서버에 연결할 수 없어요" 안내 페이지(#1136)는 앱 재빌드 뒤에만 들어가고, 들어가도 안내일 뿐 앱을 쓸 수는 없었다.

한 겹 더: 앱은 로그인 세션을 메모리에만 두고 기기 보관소(Keychain)에는 갱신 토큰만 저장해, 켤 때마다 인증 서버에 토큰 교환을 요청했다. 오프라인이면 "누구 계정인지"를 모르는 상태가 되어 #436(2026-08-18)에서 만든 "마지막으로 본 홈을 저장본으로 먼저 그리기"도 웹(사파리)에서만 동작했다. 코드에는 "앱 쪽은 로컬 번들 Phase 3에서"라는 미완 메모가 남아 있었다.

2. 문제 제기

화면 코드가 기기에 없다

원격 로드 구조(ios/App/App/MainViewController.swift serverURL, android/.../MainActivity.kt setServerUrl). 서비스 워커·내장 번들 둘 다 없음. 비행기 모드 = 실행할 코드 0.

앱이 오프라인에서 계정을 판정하지 못한다

readPersistedAuthSessionPresence가 네이티브 런타임에서는 무조건 indeterminate를 돌려줘, 저장본 먼저 그리기(degradedReady) 경로에 들어가지 못했다.

상세 데이터가 기기에 없다

홈·달력 1달·기록 개요·그룹 목록만 기기 캐시(localStorage·IndexedDB)에 있었고, 기록 상세·종목별 기록·다른 달은 메모리 캐시뿐이라 재부팅 뒤 오프라인에서는 볼 수 없었다.

오프라인 쓰기는 "완료 저장 1종"만

대기 큐(#924)는 새 완료 기록 생성만 담았다. 수정·삭제는 requireOnlineCompletedWorkoutMutation이 오프라인에서 즉시 막았고, 계획 저장·삭제도 온라인 전용이었다.

3. 해결 방안

원칙 (오너 결정, 2026-09-03)

  • D1 두 기기 충돌 규칙: 나중에 저장한 쪽이 이김. 큐가 올라갈 때 개정번호가 달라도 최신 개정번호로 다시 보낸다.
  • D2 오프라인 저장분의 통계: "동기화 후 반영" 표시만. 앱에서 근사 계산하지 않는다.
  • D3 친구·그룹 쓰기: 큐에서 뺀다. 오프라인에서는 마지막 본 화면만.
  • D4 범위: Phase 3(기기 복제)까지 한 트랙, PR·CI·머지 1회.
  • 오너 질문(#1143 사본 충돌 재발 우려)에 대한 원칙: 사본을 하나 더 만드는 것이 아니라 **서버 응답 원문 사본 하나(관문)**로 모으고, 쓰기 조립은 서버 응답형(detail·receipt)에서만, 임시 id는 서버 id로 승격하지 않는다.

접근

방식판정
A. 서비스 워커로 화면 코드 캐시 — 원격 로드 유지, 배포는 웹만채택
B. 웹 코드를 앱에 내장 — 웹 수정마다 앱스토어 제출, 인증·보안 검사·저장소 주소 전부 변경보류
C. 원격 실패 시 내장본 대체 — 두 주소의 데이터 분리기각
기기 복제: 스토어별 IndexedDB 재작성기각 — 사본이 늘어 #1143류 재발 위험
기기 복제: RPC 관문(callScreenRpc)에서 응답 원문 사본 1부 — 화면·스토어·쓰기 조립은 온라인과 같은 경로채택

4. 적용한 내용

Phase 1 — 서비스 워커 (#1178)

  • public/sw.js: 첫 페이지(/)는 서버 먼저·6초 안에 응답 없거나 실패면 저장본, 다른 경로 문서는 실패 시 /offline.html 저장본, /assets/*(해시 불변)·아이콘·글꼴·매니페스트·글꼴 CDN은 저장본 먼저. 서버 API·인증·/api/*·/admin/*·워커 자신·POST는 손대지 않는다. 캐시 버전 상수로 옛 저장소 정리.
  • src/react/vite/offlineServiceWorker.ts + main.tsx: 프로덕션·https·앱 루트에서만 load 뒤 등록. vercel.json/sw.js no-cache.
  • iOS: Info.plist WKAppBoundDomains(xcconfig BARBELIC_APP_BOUND_DOMAIN·BARBELIC_SUPABASE_AUTH_DOMAIN) + capacitor.config.json ios.limitsNavigationsToAppBoundDomains: true — WKWebView는 이 선언이 있어야 서비스 워커를 허용한다.

Phase 2 — 오프라인 계정 판정 (#1178)

  • supabaseAuth.ts: 로그인·세션 복원 때 barbelic:auth-owner:v1{userId, supabaseUrl}을 남기고(비밀 아님), 네이티브 부팅의 readPersistedAuthSessionPresence가 이를 읽어 present로 판정 → 기존 degradedReady 경로. 로그아웃·계정 삭제 두 길에서 지운다. 키가 없으면(구 설치) 종전대로 Keychain 복원.
  • 네트워크 실패로 끝난 Keychain 복원 약속을 캐시에 남기지 않도록 고쳐, 연결이 돌아오면 부팅 재시도(최대 30초 간격)가 실제로 복원한다(종전엔 앱을 다시 켜야 했다).

Phase 3 — 내 기록 기기 복제 (#1178)

  • ownDataReplica.ts: IndexedDB barbelic-own-data-replica, 키 = 계정|RPC|정렬된 인자. 대상 = 내 기록 계열 읽기 RPC 15종(홈·연간 활동·달력 월/일/범위·기록 상세·계획 상세·로그표·PR 개요·볼륨·종목별 4종). 피드·검색·카탈로그·쓰기 RPC 제외(D3).
  • barbelicRepository.ts callScreenRpc: 검증 통과 응답을 사본에 저장(유휴 배치). 실패가 네트워크·5xx·시간 초과이면 사본을 같은 어댑터로 재검증해 반환(4xx·검증 실패는 대체하지 않음, 깨진 사본은 축출). 완료 기록·계획 삭제 성공 시 그 상세 사본 축출. 로그아웃·계정 삭제 시 계정 사본 전부 삭제.
  • ownDataReplicationJob.ts + ownDataReplicationController.ts: 부팅 데이터 준비·온라인·워밍 뒤 12초부터 유휴 시간에 이번 달부터 거슬러 달 요약을 받고(사본 있는 오래된 달은 건너뜀, 빈 달 6연속이면 중단, 한 번에 12달·상세 60건), 기기에 없는 기록 상세만 하나씩 받는다. 진행 표식은 localStorage(완료 뒤 6시간 내 재실행 안 함, 미완이면 이어서).

Phase 4 — 쓰기 큐 확장 (#1178)

  • pendingWorkoutSaves.ts: 행에 kind(생성·수정·삭제·계획 저장·계획 삭제)·targetId·dates. queuePendingWorkoutMutation 합치기 규칙 — 미업로드 생성 기록 수정 → 수정 내용으로 생성 하나(새 식별자·해시), 미업로드 생성 삭제 → 둘 다 버림, 서버 기록 두 번 수정 → 마지막 하나, 수정 후 삭제 → 삭제 하나, 계획도 동일. 구 행(kind 없음)은 생성 행.
  • pendingWorkoutMutationFlush.ts: 종류별 업로드. 수정 행이 40001/LG409면 서버 최신 개정번호로 줄 번호표를 떼고 재전송(D1), 삭제 행은 최신 개정번호로 재삭제·이미 없으면 성공, 계획은 updatedAt으로 동일. 재전송은 completedWorkoutCommands를 거친다(완료 기록 쓰기는 명령 모듈만 서버 호출한다는 경계 유지).
  • workoutWriteController.ts: 수정·삭제·계획 수정·계획 삭제가 서버에 못 닿으면(오프라인 판정 또는 전송 실패) 조립된 요청을 큐에 넣고 화면에는 결과를 그린다(수정은 syncPending 표시, 삭제는 즉시 제거). 미업로드 기록의 수정은 생성 행에 합친다. 새 계획 생성은 서버 id가 필요해 종전대로 온라인 전용.
  • calendarViewSelector.ts: 큐의 수정·삭제 행을 달력 목록·상세 색인 위에 덮어그린다.

Phase 5 — 표시 (#1178)

  • features/connectivity/OfflineStatusBinding.tsx: 브라우저 오프라인이거나 사본으로 응답한 뒤 45초 안이면 화면 위쪽에 "오프라인 · 이 기기에 저장된 기록을 보여드려요 · 변경 N건은 연결되면 자동으로 올라가요" 줄(data-lg-view="connectivity.offline-bar").
  • 하루 상세·일지 카드: 큐에 든 수정은 "동기화 후 반영", 미업로드 생성은 종전 "동기화 대기".

주요 결정과 그 근거

  • 사본을 관문 한 곳에 두고 "저장은 원문, 검증은 읽을 때, 실패 = 축출" 규칙을 부팅 캐시(#1016)에서 그대로 가져왔다 — 스토어별 재작성보다 회귀 면적이 작고 #1143 원칙과 충돌하지 않는다.
  • 충돌 재전송은 줄 번호표를 떼고 보낸다 — 다른 기기가 줄을 바꿨을 수 있어 옛 번호표는 믿을 수 없고, 번호표 없는 요청은 서버가 줄을 새로 만드는 정상 경로다.

작업 중 드러난 것

  • Keychain 복원 실패 약속이 캐시돼 재시도가 즉시 같은 실패를 돌려주던 잠복 결함(Phase 2에서 수리).
  • controllerBoundaries 게이트가 "완료 기록 쓰기는 명령 모듈만 서버를 부른다"를 잠그고 있어, 큐 업로드도 retryUpdate/retryDelete 통로로 명령 모듈을 지나게 했다.
  • 감사 명부: 명부에 없는 테스트 파일은 신고하면 안 되고, 같은 파일은 기존 항목에 이어 적는다.
  • 오너 Docker Desktop 크래시(AF_UNIX 잔존 소켓, 메모리 기록된 함정)를 같은 세션에서 절차대로 복구했다(두 폴더 동시 이름 변경).
  • CI만 4회 실패한 단위 테스트 1건(오프라인 수정 큐 적재): 로컬(Node 24)은 통과, CI(Node 22)만 실패. 원인은 Node 22의 tsx 로더가 .mjs 테스트 파일과 .ts 소스가 같은 .js 지정자로 가져온 모듈을 서로 다른 인스턴스로 적재하는 것 — 테스트가 큐 모듈의 전역 변수(기본 저장소)를 바꿔도 컨트롤러 쪽 인스턴스에는 닿지 않아 IndexedDB 저장소로 떨어졌다. Node 22를 npx node@22.13.0으로 로컬 재현한 뒤, 테스트 전용 전역 덮어쓰기 함수를 없애고 큐 저장소를 컨트롤러 env로 주입하도록 바꿨다(오류 클래스 판정도 같은 이유로 instanceof 대신 이름·형태). 교훈: 테스트가 소스 모듈의 전역 상태를 바꾸는 방식은 쓰지 않는다 — 의존성은 env·인자로 주입한다.

5. 적용 결과

항목결과
오프라인 부팅(앱)검은 화면 → 저장본 홈 (자동 증거: 단위 테스트·게이트. 실기기는 오너 Mac 빌드 뒤 관찰 가능)
첫 페이지 캐시 규칙단위 테스트 7건 통과
네이티브 계정 판정단위 테스트(present·타 프로젝트·깨진 값·정리 배선) 통과
기기 사본단위 테스트 6건(키·왕복·대상·대체 조건·정리·관문 배선) 통과
복제 작업단위 테스트 6건 통과
큐 합치기·업로드·충돌단위 테스트 11건 통과
로컬 게이트 npm run check통과(Windows CRLF 전용 기존 실패 tabSwitchLatencyProbe 1건 제외)
브라우저 e2e CASE-029통과 — Repository checks run 33735473251(migration-smoke 잡, 브라우저 행동 케이스). 이전 회차에서도 e2e 잡은 통과, 실패는 단위 테스트 1건(아래 "작업 중 드러난 것")
미검증실기기 비행기 모드 관찰(서비스 워커·앱 전용 도메인은 Mac 빌드 뒤), 오프라인 통계 표시의 시각 품질

6. 이번 개선으로 향상된 것

비행기 모드에서 앱이 뜬다

한 번 온라인으로 접속한 기기는 화면 코드와 계정, 마지막 홈을 기기에 갖고 있어 서버 없이 부팅한다.

내 기록을 오프라인에서 본다

복제 작업이 받아 둔 달·상세와, 온라인일 때 본 모든 내 기록 화면이 사본으로 남아 서버 없이 열린다.

오프라인에서 기록을 만들고 고치고 지운다

큐가 생성·수정·삭제·계획 수정·계획 삭제를 순서대로 보관하고, 연결되면 한 번에 올린다. 충돌은 나중 저장 우선.

구조적으로 남는 것

관문 사본(원문·읽을 때 검증·축출) 규칙, 큐 합치기 규칙과 종류별 업로드 규칙, "완료 기록 쓰기는 명령 모듈만" 경계에 큐 재전송 통로, e2e CASE-029.

남은 것

  • 새 계획 생성·계획 채택/해제·커스텀 종목 등록의 오프라인 큐(서버 id 발급 필요) — 별도 이슈 후보.
  • 다른 기기에서 고친 기록의 사본은 온라인에서 다시 열기 전까지 옛 내용(복제 작업은 "없는 상세"만 받는다).
  • 오프라인 저장분의 통계는 동기화 후 반영(D2). 친구·그룹·서버 통계는 오프라인에서 마지막 본 화면(D3).
  • iOS 앱 전용 도메인 선언은 WebView 안에서 다른 도메인으로 이동하는 경로가 있으면 막힌다 — Mac 빌드 뒤 소셜 로그인·약관 페이지 동작 확인이 필요하다(정보로 기록).