Skip to Content
WebJavaScriptWeb APIs16. Service Worker와 Cache로 오프라인 사용

이번 편의 결과물: 오프라인 상태에서 앱을 새로고침해도 화면과 마지막 메모 목록이 뜹니다. · 다루는 개념: Service Worker 등록, install/activate/fetch 이벤트, CacheStorage로 정적 자산·API 응답 캐시

이 편에서 만드는 파일

notes-app/ ├── public/ │ └── sw.js (+) └── src/ └── main.js (~)

개념 정리

Service Worker의 위치와 역할

Service Worker는 페이지와 네트워크 사이에 끼어드는 프록시 스레드입니다. 페이지가 보내는 모든 네트워크 요청을 가로채, 캐시에서 응답할지 실제 네트워크로 보낼지 결정할 수 있습니다. Web Worker처럼 DOM에 접근할 수 없고, 페이지를 닫아도 브라우저가 필요하면 계속 실행합니다.

세 가지 생명주기 이벤트

이벤트발생 시점이번 편에서 하는 일
install서비스 워커가 처음 등록될 때 한 번CacheStorage를 열어 앱 셸(정적 파일)을 미리 캐시
activate새 서비스 워커가 이전 워커를 대체할 때이전 버전의 캐시를 정리
fetch페이지가 네트워크 요청을 보낼 때마다요청 종류에 따라 캐시 우선 또는 네트워크 우선으로 응답

캐시 전략 두 가지

전략동작 순서적합한 대상
캐시 우선(cache-first)캐시에 있으면 캐시 응답, 없으면 네트워크 요청 후 캐시에 저장거의 안 바뀌는 정적 자산(index.html, main.js, styles.css)
네트워크 우선(network-first)네트워크 요청을 먼저 시도, 실패하면(오프라인) 캐시로 대체자주 바뀌는 API 응답(메모 목록)

정적 자산은 캐시 우선으로 응답 속도를 높이고, 메모 목록처럼 최신 값이 중요한 데이터는 네트워크 우선으로 최신성을 지키되 오프라인일 때만 캐시로 대체합니다.

실습

1. public/sw.js 만들기

public 폴더에 넣은 파일은 Vite가 빌드 시 그대로 루트 경로(/)로 복사합니다. 서비스 워커는 등록 스크립트와 같은 origin, 가급적 루트에 두어야 앱 전체를 제어 범위(scope)로 가질 수 있습니다.

2. 코드 작성

// public/sw.js const CACHE_NAME = 'notes-app-v1'; const APP_SHELL = ['/', '/index.html', '/src/styles.css', '/src/main.js']; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) ); self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all( keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) ) ) ); self.clients.claim(); }); self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') return; const url = new URL(event.request.url); if (url.pathname.startsWith('/notes')) { event.respondWith(networkFirst(event.request)); return; } event.respondWith(cacheFirst(event.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); if (cached) return cached; throw error; } }

install에서 event.waitUntil로 캐시 준비가 끝날 때까지 설치 완료를 미룹니다. self.skipWaiting()은 새 서비스 워커가 이전 워커의 종료를 기다리지 않고 바로 활성화되게 합니다. fetch 핸들러는 요청 경로가 /notes로 시작하면(json-server API) 네트워크 우선, 그 외(정적 자산)는 캐시 우선으로 나눕니다. response.clone()을 쓰는 이유는 응답 본문(body)이 한 번만 읽을 수 있는 스트림이라, 캐시에 저장할 몫과 페이지에 돌려줄 몫을 각각 따로 소비해야 하기 때문입니다.

// src/main.js (등록 부분만 추가) // 04~15편에서 만든 초기화(스토리지 로드, 라우팅, 검색 워커 등)는 그대로 유지합니다. if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').then((registration) => { console.log('서비스 워커 등록 완료:', registration.scope); }).catch((error) => { console.error('서비스 워커 등록 실패:', error); }); }); }

window.load 이후에 등록하는 이유는, 서비스 워커 등록 자체가 초기 페이지 로드 속도에 영향을 주지 않게 하기 위해서입니다.

3. 실행

npm run dev

4. 확인

  • 개발자 도구 Application 탭 → Service Workers에서 activated and is running 상태를 확인한다.
  • Application 탭 → Cache Storage에서 notes-app-v1index.html, main.js, styles.css가 담겼는지 확인한다.
  • 개발자 도구 Network 탭에서 Offline 체크박스를 켠 뒤 페이지를 새로고침해도 화면이 그대로 뜨는지 확인한다.
  • json-server(http://localhost:3001)를 실행한 상태로 메모 목록을 한 번 불러온 뒤 서버를 끄고 새로고침하면, 마지막으로 받은 메모 목록이 캐시에서 그대로 뜬다.

직접 해보기

  1. APP_SHELL 배열에 favicon.ico를 추가해 파비콘도 오프라인에서 뜨게 해봅니다.
  2. CACHE_NAMEnotes-app-v2로 바꾸고 새로고침한 뒤, activate 단계에서 notes-app-v1 캐시가 삭제되는지 Application 탭에서 확인해봅니다.

정답 보기

const APP_SHELL = ['/', '/index.html', '/src/styles.css', '/src/main.js', '/favicon.ico'];

CACHE_NAME을 바꾸면 activate 이벤트의 caches.keys()가 반환하는 캐시 이름 목록에 이전 버전(notes-app-v1)이 여전히 남아 있고, key !== CACHE_NAME 조건에 걸려 삭제됩니다.

자주 하는 실수

증상원인고치는 법
코드를 고쳐도 브라우저에 반영이 안 됨브라우저가 이전 서비스 워커를 캐시해 계속 씀개발 중에는 Application 탭에서 Update on reload를 켜거나 수동으로 Unregister 후 새로고침한다
캐시에 저장한 응답이 비어 있음response를 페이지에 반환한 뒤 같은 객체를 cache.put에도 넘김(스트림 재사용)response.clone()으로 복제본을 만들어 하나는 캐시에, 하나는 반환용으로 쓴다
오프라인에서 API 요청이 그냥 실패함networkFirst에서 catch 없이 fetch만 호출try/catch로 감싸 실패 시 caches.match로 대체 응답을 돌려준다

확인 문제

문제 14지선다
Service Worker의 install 이벤트에서 하는 일로 옳은 것은?
문제 24지선다
캐시 우선(cache-first) 전략이 정적 자산에 적합한 이유로 옳은 것은?
문제 34지선다
networkFirst 함수에서 response.clone()을 호출하는 이유로 옳은 것은?
문제 44지선다
activate 이벤트에서 이전 버전의 캐시를 caches.delete로 지우는 이유로 옳은 것은?

참고 자료

Last updated on