발단: "왜 폰이랑 PC가 달라요?"
학습 앱을 만들고 배포했다. 단일 HTML 파일, 외부 의존성 제로, Cloudflare Pages에 올려두면 그걸로 끝. 완벽했다.
그런데 사용자가 물었다.
"여러 디바이스에서 학습을 한건데, 학습 진행 사항을 동기화할 수 없어요?"
아. localStorage는 브라우저마다 완전히 분리된다. PC에서 챕터 3까지 학습했는데 모바일을 열면 처음부터다. 이건 앱이 아니라 기억상실증 환자다.
선택지를 줄여나가는 과정
동기화 구현 방법은 여러 가지다.
- Firebase / Supabase: 계정 기반, 제대로 된 auth. 근데 외부 서비스 의존성이 생기고, 지금 앱 철학(zero deps)과 맞지 않는다.
- Export/Import: 파일 다운받아서 다른 기기에서 업로드. 구현은 쉬운데... 솔직히 아무도 안 한다.
- Cloudflare Workers + KV: 이미 Cloudflare Pages 쓰고 있으니 같은 플랫폼. Workers는 엣지 함수, KV는 글로벌 분산 키-값 저장소.
세 번째가 답이었다. 같은 생태계, 무료 한도 충분, 구현 단순.
식별자는 이메일로 결정했다. 별도 회원가입 없이 "이메일 = 내 계정"이라는 직관적인 UX. 보안? 소수 사용자의 학습 앱에선 충분한 수준이다.
구현: 생각보다 단순했다
Worker 코드는 50줄이면 충분하다.
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/sync\/([a-f0-9]{64})$/);
if (!match) return json({ error: 'Not found' }, 404);
const key = match[1]; // SHA-256(email)
if (request.method === 'GET') {
const raw = await env.ECODOM_SYNC.get(key);
return json({ data: raw ? JSON.parse(raw) : null });
}
if (request.method === 'POST') {
const body = await request.json();
await env.ECODOM_SYNC.put(key, JSON.stringify(body));
return json({ ok: true });
}
}
};
클라이언트에선 이메일을 SHA-256으로 해시해서 키로 쓴다. 이메일 평문을 서버에 보내지 않아도 되고, URL에 노출되지 않는다.
async function hashEmail(email) {
const buf = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(email.trim().toLowerCase())
);
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
crypto.subtle은 브라우저 내장 API라 외부 라이브러리 불필요.
첫 번째 함정: Service Worker 캐시
배포 완료. 브라우저에서 열었더니... 아무것도 안 바뀌었다.
당연하다. PWA는 Service Worker가 모든 리소스를 캐시한다. Cache-Control: no-store를 눌러도 SW 캐시는 별개다. 개발자도구의 "Disable cache" 체크박스도 SW 캐시엔 적용 안 된다.
해결책은 단순하다: 캐시 버전 번호를 올린다.
const CACHE_NAME = 'ecodom-v4'; // v3 → v4
새 버전의 SW가 설치되면 이전 캐시를 삭제한다. 사용자는 강제 새로고침(Cmd+Shift+R) 또는 Application → Service Workers → Unregister 후 새로고침.
교훈: PWA 업데이트는 배포 후 캐시 버전 변경이 필수다. CI/CD에 자동화하면 더 좋다.
두 번째 함정: 탭 닫는 순간 fetch가 죽는다
자동 동기화를 구현했다. 로직은 완벽해 보였다.
- 앱 열기 →
autoSyncPull() - 탭 닫기/이탈 →
autoSyncPush()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') autoSyncPush();
});
그런데 PC는 50%, 모바일은 38%가 표시됐다. 데이터가 안 올라간 것이다.
원인은 명확하다. visibilitychange → hidden이 발생하는 순간 브라우저는 페이지를 정리하기 시작한다. 비동기 fetch가 완료되기 전에 프로세스가 종료된다.
해결책 1: keepalive: true
const res = await fetch(SYNC_WORKER_URL + '/sync/' + hash, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true, // 페이지 종료 후에도 요청 완료 보장
});
keepalive: true는 페이지가 unload된 후에도 브라우저가 요청을 완료하도록 보장한다. 단, body 크기 제한이 64KB이므로 큰 데이터는 주의.
해결책 2: pagehide 이벤트 추가
window.addEventListener('pagehide', autoSyncPushImmediate);
visibilitychange는 탭 전환 시 발생하지만, iOS Safari에서 앱을 종료하거나 다른 앱으로 넘어갈 때는 pagehide가 더 신뢰할 수 있다. 두 이벤트를 모두 listen해야 모바일 환경을 제대로 커버한다.
교훈: PWA에서 "앱 닫기" 시점의 동작은
visibilitychange만으론 부족하다.pagehide+keepalive조합이 필수다.
세 번째 발견: 닫을 때 말고, 변경될 때 push하라
그래도 여전히 갭이 있었다. PC에서 학습 중인데, 탭을 안 닫으면 push가 안 된다.
발상을 바꿨다. 탭을 닫을 때가 아니라 데이터가 변경될 때마다 push하면 된다.
function saveProgress() {
localStorage.setItem(STORAGE_KEYS.progress, JSON.stringify(state.progress));
autoSyncPush(); // 여기!
}
function saveQuiz() {
localStorage.setItem(STORAGE_KEYS.quiz, JSON.stringify(state.quizScores));
autoSyncPush();
}
autoSyncPush는 800ms 디바운스가 걸려 있어서 연속 저장 시 한 번만 요청한다.
이제 흐름이 깔끔해졌다:
섹션 완료 → saveProgress() → 800ms 후 push
퀴즈 완료 → saveQuiz() → 800ms 후 push
다른 기기에서 앱 열기 → autoSyncPull() → 최신 데이터 반영
충돌 처리: merge 전략
두 기기에서 동시에 학습하면 어떻게 되나? 더 많이 학습한 쪽이 이겨야 한다.
function mergeData(local, remote) {
// progress: 완료된 섹션은 유지 (OR 병합)
const progress = Object.assign({}, remote.progress || {});
for (const ch in local.progress) {
for (const sec in local.progress[ch]) {
if (local.progress[ch][sec]) progress[ch][sec] = true;
}
}
// quizScores: 높은 점수 유지
// leitner: 높은 박스 번호 유지 (더 많이 복습한 쪽)
// streak: 높은 카운트 유지
return { progress, quizScores, leitner, streak };
}
학습 앱에서 "퇴보"는 없다. 이미 완료한 섹션이 미완료로 돌아가거나, 높은 퀴즈 점수가 낮아지면 안 된다.
최종 아키텍처
[디바이스 A] [Cloudflare KV] [디바이스 B]
localStorage ──saveProgress()──▶ push(keepalive) ──────────▶ pull(앱 시작)
key: SHA256(email)
◀───────────────── merge(local, remote) ◀──────
Worker 1개, KV 네임스페이스 1개, 추가 서버 없음. 배포는 wrangler deploy 한 줄.
핵심 요약
| 상황 | 해결책 |
|---|---|
| SW 캐시로 업데이트 안 됨 | CACHE_NAME 버전 올리기 |
| 탭 닫을 때 fetch 실패 | keepalive: true + pagehide 이벤트 |
| 모바일 iOS sync 누락 | pagehide 추가 (visibilitychange 불충분) |
| 실시간 동기화 지연 | 데이터 변경 즉시 push (저장 함수에 훅) |
| 충돌 처리 | merge 전략 (OR/max 기반) |
백엔드 없이도 실용적인 실시간 동기화는 충분히 가능하다. Cloudflare Workers + KV의 무료 한도는 개인/소규모 앱에선 걱정할 필요가 없다.
정적 사이트라서 뭔가 못 한다는 건 점점 옛말이 되고 있다.