Ray Book
Service Worker와 PWA

Background Sync와 백그라운드 처리

오프라인 작업을 온라인 복귀 시 자동 동기화, Background Sync, Periodic Sync, Background Fetch를 시각화합니다

browserservice-workerofflinesyncpwa

오프라인에서 한 작업은 어떻게 되는가

사용자가 지하철에서 댓글을 작성합니다. "전송" 버튼을 누르는 순간 터널에 진입합니다. 네트워크가 끊겼으므로 fetch는 실패합니다. 사용자는 에러 메시지를 보고, 나중에 다시 시도해야 합니다.

네이티브 앱은 이런 상황을 자연스럽게 처리합니다. 메시지를 로컬에 저장해 두고, 네트워크가 복구되면 자동으로 전송합니다. 사용자는 전송 성공 여부를 기다릴 필요가 없습니다.

Background Sync API 는 웹에서 이 패턴을 구현합니다. 오프라인에서 실패한 작업을 등록해 두면, 브라우저가 네트워크 복구 시점에 SW를 깨워서 작업을 재시도합니다.

Background Sync

1. 사용자 행동온라인

사용자가 댓글을 작성하고 전송 버튼을 누릅니다.

fetch('/api/comments', { method: 'POST', ... })를 호출합니다. 온라인이면 정상적으로 전송됩니다.

동작 원리는 단순합니다.

  1. 페이지에서 sync 이벤트를 등록 합니다 (태그 이름 지정).
  2. 브라우저가 네트워크가 복구되었다고 판단하면 SW의 sync 이벤트를 발생시킵니다.
  3. SW에서 보류 중인 작업을 처리합니다.
// 페이지 코드, 댓글 전송 실패 시 sync 등록
async function postComment(comment) {
  try {
    await fetch("/api/comments", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(comment),
    });
  } catch {
    // 네트워크 실패, IndexedDB에 저장 후 sync 등록
    await saveToOutbox(comment);

    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register("sync-comments");
    // 사용자에게 "온라인 복귀 시 자동 전송됩니다" 표시
  }
}
// sw.js, sync 이벤트 처리
self.addEventListener("sync", (event) => {
  if (event.tag === "sync-comments") {
    event.waitUntil(syncComments());
  }
});

async function syncComments() {
  const comments = await getOutbox(); // IndexedDB에서 보류 중인 댓글 조회

  for (const comment of comments) {
    await fetch("/api/comments", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(comment),
    });
    await removeFromOutbox(comment.id); // 성공하면 outbox에서 삭제
  }
}

핵심 패턴은 outbox 입니다. 실패한 요청을 IndexedDB에 저장하고, sync 이벤트에서 꺼내서 재시도합니다. 성공하면 outbox에서 삭제합니다.

event.waitUntil() 안의 Promise가 거부되면 브라우저가 나중에 다시 시도합니다. 재시도 간격과 횟수는 브라우저가 결정합니다. 영원히 재시도하지는 않고, 일정 횟수 후 포기합니다.

IndexedDB Outbox 패턴

outbox 패턴의 IndexedDB 구현을 살펴봅시다.

// outbox.js, SW와 페이지 양쪽에서 import 가능
const DB_NAME = "app-outbox";
const STORE_NAME = "pending";

function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function saveToOutbox(data) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  tx.objectStore(STORE_NAME).put({ ...data, createdAt: Date.now() });
  return new Promise((resolve) => { tx.oncomplete = resolve; });
}

export async function getOutbox() {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readonly");
  return new Promise((resolve) => {
    const req = tx.objectStore(STORE_NAME).getAll();
    req.onsuccess = () => resolve(req.result);
  });
}

export async function removeFromOutbox(id) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  tx.objectStore(STORE_NAME).delete(id);
}

IndexedDB를 사용하는 이유는 SW에서 접근할 수 있는 유일한 영속 저장소 이기 때문입니다. localStorage는 SW에서 사용할 수 없습니다.

Periodic Background Sync

Background Sync가 "실패한 작업의 재시도"라면, Periodic Background Sync 는 "주기적인 백그라운드 갱신"입니다. 사용자가 앱을 열지 않아도 일정 간격으로 SW가 깨어나 데이터를 갱신합니다.

// 페이지 코드, 주기적 동기화 등록
const registration = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({ name: "periodic-background-sync" });

if (status.state === "granted") {
  await registration.periodicSync.register("update-feed", {
    minInterval: 12 * 60 * 60 * 1000, // 최소 12시간 간격
  });
}
// sw.js
self.addEventListener("periodicsync", (event) => {
  if (event.tag === "update-feed") {
    event.waitUntil(updateFeed());
  }
});

async function updateFeed() {
  const response = await fetch("/api/feed/latest");
  const data = await response.json();
  // IndexedDB에 저장, 다음에 앱을 열면 최신 데이터가 즉시 표시됨
  await saveFeedToDB(data);
}

Periodic Background Sync의 제약은 상당합니다.

  • 브라우저가 간격을 결정 합니다. minInterval은 최소값일 뿐, 브라우저가 더 길게 조정할 수 있습니다.
  • 사이트 참여도(engagement) 에 따라 동작합니다. 자주 방문하는 사이트는 더 짧은 간격으로, 드물게 방문하는 사이트는 더 긴 간격으로 또는 아예 실행하지 않을 수 있습니다.
  • 별도 권한 이 필요합니다. 기본적으로 거부 상태이며, 사이트를 설치(PWA)해야 권한을 얻을 수 있는 브라우저도 있습니다.
  • 브라우저 지원이 제한적 입니다. Chromium 기반 브라우저에서만 지원합니다 (Firefox, Safari 미지원).

뉴스 앱, 팟캐스트 앱, SNS 피드처럼 앱을 열기 전에 최신 콘텐츠가 준비되어 있으면 좋은 경우에 유용합니다.

Background Fetch

일반 fetch는 페이지가 닫히면 중단됩니다. 대용량 파일을 다운로드하다가 탭을 닫으면 처음부터 다시 시작해야 합니다. Background Fetch API 는 이 문제를 해결합니다. 다운로드를 SW에 위임해서, 페이지가 닫혀도 계속 진행합니다.

// 페이지 코드, 대용량 다운로드 시작
const registration = await navigator.serviceWorker.ready;
const bgFetch = await registration.backgroundFetch.fetch(
  "podcast-episode-42",
  ["/media/episode-42-part1.mp3", "/media/episode-42-part2.mp3"],
  {
    title: "에피소드 42 다운로드 중",
    icons: [{ src: "/icons/podcast.png", sizes: "192x192" }],
    downloadTotal: 120 * 1024 * 1024, // 120MB
  }
);

// 진행률 추적
bgFetch.addEventListener("progress", () => {
  const percent = Math.round((bgFetch.downloaded / bgFetch.downloadTotal) * 100);
  updateUI(`${percent}% 완료`);
});
// sw.js, 다운로드 완료 처리
self.addEventListener("backgroundfetchsuccess", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open("podcasts");
      const records = await event.registration.matchAll();
      for (const record of records) {
        await cache.put(record.request, await record.responseReady);
      }
    })()
  );
});

self.addEventListener("backgroundfetchfail", (event) => {
  console.log("다운로드 실패:", event.registration.id);
});

Background Fetch의 특징:

  • 브라우저가 OS 수준의 다운로드 UI 를 제공합니다 (진행률, 일시정지, 취소).
  • 페이지가 닫혀도 다운로드가 계속 됩니다.
  • 네트워크 중단 시 자동 재개 합니다.
  • 완료되면 SW의 backgroundfetchsuccess 이벤트가 발생합니다.

팟캐스트, 오프라인 지도, 대용량 미디어 다운로드에 적합합니다.

점진적 향상

이 글에서 다룬 세 API 모두 브라우저 지원이 제한적입니다.

APIChromeEdgeFirefoxSafari
Background Sync지원지원 (Chromium)미지원미지원
Periodic Background Sync지원지원 (Chromium)미지원미지원
Background Fetch지원지원 (Chromium)미지원미지원

따라서 점진적 향상 (progressive enhancement) 패턴이 필수입니다. API가 있으면 사용하고, 없으면 기존 방식으로 동작합니다.

async function postComment(comment) {
  try {
    await fetch("/api/comments", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(comment),
    });
    showSuccess("댓글이 등록되었습니다.");
  } catch {
    // Background Sync 지원 여부 확인
    const registration = await navigator.serviceWorker?.ready;

    if (registration?.sync) {
      await saveToOutbox(comment);
      await registration.sync.register("sync-comments");
      showInfo("온라인 복귀 시 자동 전송됩니다.");
    } else {
      // 폴백: 사용자에게 재시도 안내
      showError("네트워크 연결을 확인하고 다시 시도해 주세요.");
    }
  }
}

기능이 없어도 앱이 동작해야 합니다. Background Sync는 경험을 개선할 뿐, 앱의 필수 기능이 되어서는 안 됩니다.

다음 단계

Background Sync는 오프라인 작업의 재시도를 자동화합니다. Periodic Background Sync는 주기적 데이터 갱신을, Background Fetch는 대용량 다운로드의 백그라운드 처리를 가능하게 합니다. 세 API 모두 브라우저 지원이 제한적이므로 점진적 향상 패턴이 필수입니다.

다음 글에서는 시리즈의 마지막 주제인 PWA(Progressive Web App) 를 다룹니다. Web App Manifest로 앱을 설치 가능하게 만들고, 설치 프롬프트를 커스텀하고, 네이티브에 가까운 사용자 경험을 구현하는 방법을 살펴봅니다.