Prologue: 비행기 모드에서도 앱이 돌아간다고?
지하철 안에서 노트 앱을 열었는데 "네트워크에 연결되지 않음"이라는 메시지를 본 적이 있을 것이다. 짜증났다. 내가 만든 앱도 그랬다. 사용자가 오프라인이면 그냥 흰 화면. 끝.
그러다 어떤 앱은 오프라인에서도 멀쩡히 돌아간다는 걸 발견했다. 트위터, 구글 맵, 노션... 이 앱들은 비행기 모드에서도 캐시된 데이터를 보여주고, 나중에 네트워크가 돌아오면 조용히 동기화한다. 마법 같았다.
결국 이건 Service Worker와 똑똑한 캐싱 전략 덕분이었다. 처음엔 어려워 보였지만, 라이프사이클과 캐싱 패턴 몇 가지만 이해하니까 갑자기 명쾌해졌다. 이제 내 앱도 오프라인에서 돌아간다.
Aha! Service Worker는 중간에 끼어드는 프록시였다
Service Worker의 핵심은 이거다. 브라우저와 서버 사이에 끼어들어서 네트워크 요청을 가로채는 JavaScript 파일이다. 일종의 프록시 서버라고 생각하면 된다.
웹페이지가 /api/posts를 요청하면, Service Worker가 중간에서 낚아채서 이렇게 판단한다:
- "이거 캐시에 있네? 캐시 줄게"
- "캐시 없네? 네트워크 요청 보낼게"
- "캐시는 있는데 오래됐네? 일단 캐시 주고 백그라운드에서 새로 받아올게"
이 판단 로직이 바로 캐싱 전략이다. 냉장고 비유를 하자면: Service Worker는 냉장고 앞에 서 있는 집사다. 네가 "우유 줘"라고 하면, 냉장고에 있으면 바로 주고, 없으면 마트 가서 사오고, 유통기한 지났으면 버리고 새로 사온다.
Service Worker 라이프사이클 - 설치, 활성화, 대기
Service Worker는 세 가지 생명주기를 거친다:
// sw.js - Service Worker 파일
const CACHE_VERSION = 'v1';
const CACHE_NAME = `my-app-${CACHE_VERSION}`;
// 1. Install: 처음 등록될 때 실행
self.addEventListener('install', (event) => {
console.log('Service Worker: 설치 중...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 필수 파일들을 미리 캐싱 (프리캐싱)
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html'
]);
})
);
// 기존 SW를 즉시 교체 (선택사항)
self.skipWaiting();
});
// 2. Activate: 설치 후 활성화될 때 실행
self.addEventListener('activate', (event) => {
console.log('Service Worker: 활성화 중...');
event.waitUntil(
caches.keys().then((cacheNames) => {
// 오래된 캐시 삭제
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// 모든 클라이언트를 즉시 제어
return self.clients.claim();
});
// 3. Fetch: 네트워크 요청이 발생할 때마다 실행
self.addEventListener('fetch', (event) => {
console.log('Service Worker: 요청 가로챔', event.request.url);
// 여기서 캐싱 전략 구현
});
처음에 이해 안 됐던 게 skipWaiting()과 clients.claim()이었다. 기본적으로 새 Service Worker는 기존 탭이 모두 닫힐 때까지 대기 상태로 있는다. 하지만 이 두 메서드를 쓰면 즉시 교체된다. 개발할 땐 편하지만, 프로덕션에선 신중해야 한다. 사용자가 앱 쓰는 중에 갑자기 Service Worker가 바뀌면 버그날 수 있다.
메인 앱에서 Service Worker 등록하기
// app.js - 메인 애플리케이션
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker 등록 성공:', registration.scope);
// 업데이트 감지
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 새 버전 있음 - 사용자에게 알림
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Service Worker 등록 실패:', error);
}
});
}
load 이벤트 이후에 등록하는 게 핵심이다. 앱 로딩을 방해하지 않으려고. Service Worker는 백그라운드 작업이니까.
5가지 캐싱 전략과 활용법 더 알아보기
1. Cache First (캐시 우선)
냉장고에 있으면 무조건 냉장고 걸 준다. 없을 때만 마트 간다.
언제 쓰나: 절대 안 바뀌는 정적 파일 (이미지, 폰트, CSS, JS 번들)
self.addEventListener('fetch', (event) => {
const { request } = event;
// 정적 파일만 Cache First 적용
if (request.destination === 'image' ||
request.destination === 'font' ||
request.url.includes('/static/')) {
event.respondWith(
caches.match(request).then((cached) => {
// 캐시 있으면 바로 리턴
if (cached) {
return cached;
}
// 없으면 네트워크 요청 후 캐싱
return fetch(request).then((response) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(request, response.clone());
return response;
});
});
})
);
}
});
처음엔 response.clone()이 이해 안 됐다. Response 객체는 스트림이라 한 번 읽으면 끝이다. 캐시에 저장하려면 복사본이 필요하다. 원본은 사용자한테 주고, 복사본은 캐시에 넣는다.
2. Network First (네트워크 우선)
일단 마트 가본다. 마트 문 닫았으면 냉장고 뒤진다.
언제 쓰나: 최신 데이터가 중요한 API (뉴스 피드, 사용자 프로필, 댓글)
self.addEventListener('fetch', (event) => {
const { request } = event;
// API 요청은 Network First
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
// 성공하면 캐시 업데이트
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
// 네트워크 실패 시 캐시에서 찾기
return caches.match(request).then((cached) => {
return cached || caches.match('/offline.html');
});
})
);
}
});
이 패턴의 와닿은 점은 네트워크 실패가 예외가 아니라 정상 흐름이라는 것이다. 오프라인은 언제든 일어날 수 있다. catch에서 우아하게 처리한다.
3. Stale While Revalidate (캐시 주고 백그라운드 갱신)
냉장고에 있는 거 일단 주고, 동시에 마트 가서 새 거 사온다. 다음번엔 새 거 준다.
언제 쓰나: 약간 오래된 데이터도 괜찮은 경우 (프로필 사진, 설정값, 통계)
self.addEventListener('fetch', (event) => {
const { request } = event;
event.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(request).then((cached) => {
// 네트워크 요청 시작 (백그라운드)
const fetchPromise = fetch(request).then((response) => {
// 새 데이터로 캐시 업데이트
cache.put(request, response.clone());
return response;
});
// 캐시 있으면 즉시 리턴, 없으면 네트워크 대기
return cached || fetchPromise;
});
})
);
});
이 전략의 마법은 속도와 신선도를 동시에 잡는다는 것이다. 사용자는 즉시 화면을 보고, 서버는 조용히 업데이트한다. 인스타그램이 이렇게 동작한다.
4. Network Only (캐싱 안 함)
무조건 마트 간다. 냉장고 무시.
언제 쓰나: 절대 캐시하면 안 되는 요청 (결제, 로그인, 민감한 데이터)
self.addEventListener('fetch', (event) => {
const { request } = event;
// 민감한 요청은 캐싱 절대 금지
if (request.url.includes('/payment') ||
request.url.includes('/auth')) {
event.respondWith(fetch(request));
}
});
간단하지만 중요하다. 캐시해서 좋을 게 없는 요청들이 있다.
5. Cache Only (오프라인 전용)
네트워크 무시. 냉장고만 뒤진다.
언제 쓰나: 완전 오프라인 모드 (게임 앱, 오프라인 문서 뷰어)
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
거의 안 쓴다. 특수한 경우에만.
전략 조합 - 라우트별로 다르게 적용하기
실전에선 URL 패턴에 따라 전략을 섞는다:
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 1. HTML: Network First
if (request.destination === 'document') {
event.respondWith(networkFirst(request));
}
// 2. 정적 파일: Cache First
else if (request.destination === 'image' ||
request.destination === 'font' ||
request.destination === 'style' ||
request.destination === 'script') {
event.respondWith(cacheFirst(request));
}
// 3. API: Stale While Revalidate
else if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
}
// 4. 기본: Network Only
else {
event.respondWith(fetch(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
return cached || caches.match('/offline.html');
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
이렇게 나누니까 각 리소스 특성에 맞는 최적화가 가능해졌다.
Workbox로 간단하게 만들기 파헤치기
직접 Service Worker 짜다 보면 보일러플레이트가 많다. Google의 Workbox 라이브러리를 쓰면 엄청 간단해진다.
Workbox 기본 설정
// sw.js (Workbox 사용)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// 빌드 타임에 생성된 파일들 프리캐싱
precacheAndRoute(self.__WB_MANIFEST);
// 이미지: Cache First + 만료 설정
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60, // 최대 60개
maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
}),
new CacheableResponsePlugin({
statuses: [0, 200], // 캐시할 응답 코드
}),
],
})
);
// API: Stale While Revalidate + 5분 만료
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 5 * 60, // 5분
}),
],
})
);
// HTML: Network First
registerRoute(
({ request }) => request.destination === 'document',
new NetworkFirst({
cacheName: 'pages',
})
);
코드가 절반으로 줄었다. 만료 정책, 캐시 크기 제한, 응답 코드 필터링이 플러그인으로 해결된다.
Vite에서 PWA 설정 (vite-plugin-pwa)
// vite.config.js
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
manifest: {
name: 'My Awesome App',
short_name: 'MyApp',
description: 'An app that works offline',
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60, // 1시간
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
},
},
},
],
},
}),
],
});
Next.js에선 next-pwa 쓰면 된다. 설정이 거의 비슷하다.
PWA 설치 프롬프트와 푸시 알림 한 걸음 더
앱 설치 유도하기
// app.js
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (event) => {
// 브라우저 기본 프롬프트 막기
event.preventDefault();
deferredPrompt = event;
// 커스텀 설치 버튼 보여주기
showInstallButton();
});
function showInstallButton() {
const installButton = document.getElementById('install-button');
installButton.style.display = 'block';
installButton.addEventListener('click', async () => {
if (!deferredPrompt) return;
// 설치 프롬프트 표시
deferredPrompt.prompt();
// 사용자 선택 결과
const { outcome } = await deferredPrompt.userChoice;
console.log(`User response: ${outcome}`);
deferredPrompt = null;
installButton.style.display = 'none';
});
}
// 설치 완료 감지
window.addEventListener('appinstalled', () => {
console.log('PWA 설치 완료!');
trackEvent('pwa_installed');
});
처음엔 이 이벤트가 왜 필요한지 몰랐다. 그냥 브라우저가 알아서 프롬프트 띄우면 되잖아? 하지만 사용자 경험 컨트롤이 핵심이다. 적절한 타이밍에 (예: 앱 3번 방문 후) 설치 유도할 수 있다.
푸시 알림 구독하기
// 푸시 알림 권한 요청 및 구독
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
// 서버에 구독 정보 전송
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
console.log('푸시 구독 완료:', subscription);
} catch (error) {
console.error('푸시 구독 실패:', error);
}
}
// Service Worker에서 푸시 수신
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icon.png',
badge: '/badge.png',
data: {
url: data.url,
},
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// 알림 클릭 처리
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
푸시 알림은 서버 설정도 필요하다. VAPID 키 생성하고, 백엔드에서 web-push 라이브러리 써야 한다. 여기선 프론트엔드만 다뤘다.
Background Sync로 오프라인 폼 제출
// app.js - 폼 제출 시
async function submitForm(data) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
});
} catch (error) {
// 오프라인이면 Background Sync 등록
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-form-submission');
// IndexedDB에 데이터 저장
await saveToIndexedDB('pending-submissions', data);
alert('오프라인 상태입니다. 연결되면 자동으로 제출됩니다.');
}
}
// sw.js - Sync 이벤트 처리
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-form-submission') {
event.waitUntil(
getPendingSubmissions().then((submissions) => {
return Promise.all(
submissions.map((data) =>
fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
}).then(() => {
// 성공하면 IndexedDB에서 삭제
deleteFromIndexedDB('pending-submissions', data.id);
})
)
);
})
);
}
});
Background Sync는 진짜 게임 체인저다. 지하철에서 글 쓰고, 지상 나오면 알아서 업로드된다. 구글 Keep이 이렇게 동작한다.
디버깅과 베스트 프랙티스 뜯어보기
Chrome DevTools에서 디버깅
- Application 탭 → Service Workers: 상태 확인, Unregister, Update
- Cache Storage: 캐시된 파일 목록 확인, 삭제
- Network 탭: "Offline" 체크박스로 오프라인 테스트
- Console:
navigator.serviceWorker.controller확인
내가 자주 쓴 디버깅 팁:
- Bypass for network: 개발 중 Service Worker 비활성화
- Update on reload: 새로고침마다 SW 업데이트 강제
- Clear storage: 캐시 완전 초기화 후 테스트
캐시 버전 관리
const VERSION = 'v2.1.0';
const CACHE_NAME = `my-app-${VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name.startsWith('my-app-') && name !== CACHE_NAME)
.map((name) => {
console.log('오래된 캐시 삭제:', name);
return caches.delete(name);
})
);
})
);
});
버전을 상수로 관리하고, activate 시 오래된 캐시 삭제. 이거 안 하면 캐시가 계속 쌓인다.
주의사항과 함정
- HTTPS 필수: Service Worker는 localhost 아니면 HTTPS에서만 작동
- 스코프 제한:
/app/sw.js는/app/*경로만 제어 가능 - 업데이트 타이밍: SW 파일 1바이트라도 바뀌면 새 버전으로 인식
- 캐시 키: Query string 다르면 다른 캐시 (
/api/posts?page=1vs/api/posts?page=2) - POST 요청: 기본적으로 캐시 안 됨 (해서도 안 됨)
가장 많이 헷갈렸던 게 스코프다. Service Worker 파일 위치가 제어 범위를 결정한다. 루트에 두는 게 보통이다.
Summary: Service Worker는 앱을 네이티브처럼 만든다
Service Worker와 캐싱 전략을 이해하고 나니까, 웹앱과 네이티브 앱의 경계가 흐려졌다. 오프라인 동작, 백그라운드 동기화, 푸시 알림... 이제 다 가능하다.
핵심은 이거였다:
- Service Worker = 네트워크 프록시: 요청을 가로채서 캐시/네트워크 중 선택
- 5가지 전략: Cache First (정적), Network First (API), Stale While Revalidate (둘 다), Network Only (민감), Cache Only (오프라인)
- 라이프사이클: Install (프리캐싱) → Activate (캐시 정리) → Fetch (요청 처리)
- Workbox: 보일러플레이트 제거, 플러그인으로 만료/크기 제한
- PWA 기능: 설치 프롬프트, 푸시 알림, Background Sync
- 디버깅: Chrome DevTools Application 탭, 오프라인 모드 테스트
처음엔 복잡해 보였는데, 결국 "어떤 데이터를 언제 캐시할까?"라는 질문에 대한 답이었다. 이미지는 영원히, API는 5분, HTML은 최신 버전 우선.
이제 내 앱도 비행기 모드에서 돌아간다. 지하철에서 글 쓰고, 지상 나오면 조용히 동기화된다. 사용자는 네트워크 끊긴 걸 눈치채지 못한다. 이게 바로 좋은 UX다.