
Service Worker와 PWA 캐싱 전략 심화
오프라인에서도 앱이 돌아가게 만들고 싶었는데, Service Worker의 캐싱 전략을 제대로 이해하고 나니 가능해졌다.

오프라인에서도 앱이 돌아가게 만들고 싶었는데, Service Worker의 캐싱 전략을 제대로 이해하고 나니 가능해졌다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

지하철 안에서 노트 앱을 열었는데 "네트워크에 연결되지 않음"이라는 메시지를 본 적이 있을 것이다. 짜증났다. 내가 만든 앱도 그랬다. 사용자가 오프라인이면 그냥 흰 화면. 끝.
그러다 어떤 앱은 오프라인에서도 멀쩡히 돌아간다는 걸 발견했다. 트위터, 구글 맵, 노션... 이 앱들은 비행기 모드에서도 캐시된 데이터를 보여주고, 나중에 네트워크가 돌아오면 조용히 동기화한다. 마법 같았다.
결국 이건 Service Worker와 똑똑한 캐싱 전략 덕분이었다. 처음엔 어려워 보였지만, 라이프사이클과 캐싱 패턴 몇 가지만 이해하니까 갑자기 명쾌해졌다. 이제 내 앱도 오프라인에서 돌아간다.
Service Worker의 핵심은 이거다. 브라우저와 서버 사이에 끼어들어서 네트워크 요청을 가로채는 JavaScript 파일이다. 일종의 프록시 서버라고 생각하면 된다.
웹페이지가 /api/posts를 요청하면, 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가 바뀌면 버그날 수 있다.
// 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는 백그라운드 작업이니까.
냉장고에 있으면 무조건 냉장고 걸 준다. 없을 때만 마트 간다.
언제 쓰나: 절대 안 바뀌는 정적 파일 (이미지, 폰트, 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 객체는 스트림이라 한 번 읽으면 끝이다. 캐시에 저장하려면 복사본이 필요하다. 원본은 사용자한테 주고, 복사본은 캐시에 넣는다.
일단 마트 가본다. 마트 문 닫았으면 냉장고 뒤진다.
언제 쓰나: 최신 데이터가 중요한 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에서 우아하게 처리한다.
냉장고에 있는 거 일단 주고, 동시에 마트 가서 새 거 사온다. 다음번엔 새 거 준다.
언제 쓰나: 약간 오래된 데이터도 괜찮은 경우 (프로필 사진, 설정값, 통계)
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;
});
})
);
});
이 전략의 마법은 속도와 신선도를 동시에 잡는다는 것이다. 사용자는 즉시 화면을 보고, 서버는 조용히 업데이트한다. 인스타그램이 이렇게 동작한다.
무조건 마트 간다. 냉장고 무시.
언제 쓰나: 절대 캐시하면 안 되는 요청 (결제, 로그인, 민감한 데이터)
self.addEventListener('fetch', (event) => {
const { request } = event;
// 민감한 요청은 캐싱 절대 금지
if (request.url.includes('/payment') ||
request.url.includes('/auth')) {
event.respondWith(fetch(request));
}
});
간단하지만 중요하다. 캐시해서 좋을 게 없는 요청들이 있다.
네트워크 무시. 냉장고만 뒤진다.
언제 쓰나: 완전 오프라인 모드 (게임 앱, 오프라인 문서 뷰어)
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;
}
이렇게 나누니까 각 리소스 특성에 맞는 최적화가 가능해졌다.
직접 Service Worker 짜다 보면 보일러플레이트가 많다. Google의 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.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 쓰면 된다. 설정이 거의 비슷하다.
// 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 라이브러리 써야 한다. 여기선 프론트엔드만 다뤘다.
// 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이 이렇게 동작한다.
navigator.serviceWorker.controller 확인내가 자주 쓴 디버깅 팁:
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 시 오래된 캐시 삭제. 이거 안 하면 캐시가 계속 쌓인다.
/app/sw.js는 /app/* 경로만 제어 가능/api/posts?page=1 vs /api/posts?page=2)가장 많이 헷갈렸던 게 스코프다. Service Worker 파일 위치가 제어 범위를 결정한다. 루트에 두는 게 보통이다.
Service Worker와 캐싱 전략을 이해하고 나니까, 웹앱과 네이티브 앱의 경계가 흐려졌다. 오프라인 동작, 백그라운드 동기화, 푸시 알림... 이제 다 가능하다.
핵심은 이거였다:
처음엔 복잡해 보였는데, 결국 "어떤 데이터를 언제 캐시할까?"라는 질문에 대한 답이었다. 이미지는 영원히, API는 5분, HTML은 최신 버전 우선.
이제 내 앱도 비행기 모드에서 돌아간다. 지하철에서 글 쓰고, 지상 나오면 조용히 동기화된다. 사용자는 네트워크 끊긴 걸 눈치채지 못한다. 이게 바로 좋은 UX다.