35.1 오프라인에서 누른 "전송"은 어디로 갈까요?
지하철에서 댓글을 쓰고 전송 버튼을 눌렀는데 하필 신호가 끊겼어요. 보통이면 "전송 실패" 문구가 뜨고, 사용자는 정거장 지날 때까지 버튼을 계속 눌러야 하죠. 꽤 짜증나는 경험이에요. 백그라운드 동기화(Background Sync, 백그라운드 동기화)는 이 문제를 우아하게 풀어줘요.
아이디어는 이래요. 지금 인터넷이 없으면 요청을 일단 저장해두고, "네트워크가 돌아오면 대신 보내줘"라고 브라우저에 예약을 걸어요. 그러면 사용자가 앱을 닫고 폰을 주머니에 넣어도, 신호가 잡히는 순간 브라우저가 알아서 그 요청을 서버로 쏴줘요. 사용자는 아무것도 안 했는데 댓글이 등록돼 있는 거죠.
35.2 SyncManager가 대체 뭔가요?
이 예약을 담당하는 게 SyncManager(동기화 매니저)예요. 서비스워커 등록 객체(registration) 안에 sync라는 이름으로 들어 있어요. 페이지 쪽에서 이렇게 예약을 걸죠.
const reg = await navigator.serviceWorker.ready;
await reg.sync.register('send-comment');
reg.sync.register에 넘긴 'send-comment'는 이 예약에 붙인 이름(태그)이에요. 나중에 서비스워커가 "아, send-comment 예약이 실행됐구나" 하고 알아보는 꼬리표죠. 같은 태그로 여러 번 등록해도 예약은 하나로 합쳐져요. 그러니 중복 걱정 없이 마음껏 불러도 돼요.
주의할 점 하나. SyncManager는 아직 모든 브라우저가 지원하진 않아요. 그래서 쓰기 전에 있는지 먼저 확인하고, 없으면 그냥 바로 보내는 대비책을 두는 게 실무예요.
35.3 예약을 걸기 전에 무엇을 저장해둘까요?
브라우저가 나중에 요청을 대신 보내려면, "무엇을 보낼지"가 어딘가 저장돼 있어야 해요. 예약 태그에는 데이터를 담을 수 없거든요. 그래서 보통 IndexedDB(브라우저 안의 작은 데이터베이스)에 보낼 내용을 먼저 넣어둬요.
async function queueComment(text) {
await saveToDB('outbox', { text, at: Date.now() });
const reg = await navigator.serviceWorker.ready;
if ('sync' in reg) {
await reg.sync.register('send-comment');
} else {
await sendNow(text); // 대비책: 바로 보내기
}
}
흐름을 읽어볼게요. 먼저 댓글을 outbox(보낼 편지함)라는 저장소에 넣어요. 그다음 sync가 지원되면 예약을 걸고, 안 되면 곧바로 전송을 시도하죠. 사용자 눈엔 어느 쪽이든 "전송됨"으로 보이니, 경험이 끊기지 않아요.
35.4 네트워크가 돌아오면 무슨 일이 일어나나요?
이제 진짜 재미있는 부분이에요. 신호가 잡히면 브라우저는 서비스워커를 깨워서 sync 이벤트를 발생시켜요. 앱이 닫혀 있어도, 화면이 꺼져 있어도 상관없어요.
self.addEventListener('sync', (e) => {
if (e.tag === 'send-comment') {
e.waitUntil(flushOutbox());
}
});
e.tag로 어떤 예약이 실행됐는지 확인하고, 맞으면 flushOutbox(편지함 비우기)를 돌려요. 여기서 waitUntil이 정말 중요해요. "이 작업 끝날 때까지 서비스워커를 잠들게 하지 마"라고 붙잡는 거거든요. 이걸 빼먹으면 전송 도중에 워커가 꺼져서 요청이 반쯤 가다 말 수 있어요.
더 좋은 점은 재시도예요. flushOutbox가 실패해서 waitUntil의 프로미스가 거부되면, 브라우저가 잠시 뒤에 sync 이벤트를 다시 일으켜줘요. 한 번 놓쳐도 알아서 또 시도하는 거죠.
35.5 편지함은 어떻게 비우나요?
flushOutbox 안을 들여다볼게요. 저장해둔 요청을 하나씩 꺼내 서버로 보내고, 성공한 것만 지우는 구조예요.
async function flushOutbox() {
const items = await readAll('outbox');
for (const item of items) {
const res = await fetch('/api/comment', {
method: 'POST',
body: JSON.stringify({ text: item.text })
});
if (res.ok) {
await removeFromDB('outbox', item.id);
} else {
throw new Error('보내기 실패');
}
}
}
포인트는 성공한 것만 지운다는 거예요. 서버가 잘 받았다는 res.ok를 확인하고 나서야 저장소에서 빼요. 중간에 하나라도 실패하면 throw로 에러를 던지는데, 이게 waitUntil로 전해져서 브라우저가 나중에 재시도하게 만들어요. 아직 안 지워진 항목은 편지함에 그대로 남아 있으니, 다음 sync 때 다시 시도되고요.
한 가지 조심할 게 있어요. 재시도가 반복되다 보면 같은 댓글이 두 번 등록될 위험이 있어요. 서버가 요청은 받았는데 응답이 오다가 끊긴 경우죠. 그래서 각 항목에 고유 번호(예: item.id)를 실어 보내고, 서버가 "이미 받은 번호면 무시"하도록 짜두면 안전해요. 이런 방식을 멱등(idempotent, 여러 번 보내도 결과가 같음)하게 만든다고 표현하는데, 백그라운드 동기화에선 거의 필수 습관이에요.
35.6 주기적으로 새로 받아오고 싶으면요?
보내는 것 말고 받아오는 동기화도 있어요. 뉴스나 날씨를 하루에 몇 번씩 미리 받아두고 싶을 때죠. 이건 Periodic Background Sync(주기적 백그라운드 동기화)라고 불러요.
const reg = await navigator.serviceWorker.ready;
if ('periodicSync' in reg) {
await reg.periodicSync.register('refresh-news', {
minInterval: 24 * 60 * 60 * 1000
});
}
minInterval은 "최소 이 간격은 두고 실행해줘"라는 뜻이에요. 위 값은 하루예요. 서비스워커에선 periodicsync 이벤트로 받고요. 다만 이 기능은 설치된 PWA이면서 사용자가 자주 여는 앱에만 브라우저가 허락해줘요. 배터리와 데이터를 아끼려는 안전장치죠. 그러니 "되면 좋고 아니어도 그만"인 보조 기능으로 쓰는 게 마음 편해요.
35.7 백그라운드 동기화, 어떻게 정리할까요?
흐름을 한 줄로 이으면 이래요. 사용자가 오프라인에서 전송을 누르면, 데이터를 IndexedDB에 저장하고 sync 예약을 걸어요. 신호가 돌아오면 브라우저가 서비스워커를 깨워 sync 이벤트를 주고, waitUntil로 붙잡은 채 편지함을 비워요. 실패하면 알아서 재시도하고요.
덕분에 사용자는 네트워크 상태를 신경 쓸 필요가 없어요. 그냥 누르면, 지금 되든 나중에 되든 결국 전송되니까요. 이게 앱 같은 웹의 진짜 힘이에요.
확인은 개발자 도구에서 해요. Application 탭의 Service Workers와 Background Sync 항목에서 예약이 잡혔는지, 실행됐는지 볼 수 있어요. Offline 체크박스로 끊었다가 다시 켜보면, 저장해둔 요청이 스르륵 나가는 걸 눈으로 확인할 수 있죠. 한 번 만들어보면 감이 확 잡힐 거예요.