PWA 업데이트가 반영되지 않을 때: Service Worker·캐시 교체 점검

핵심 요약
PWA 배포 후 이전 화면이 계속 보일 때 Service Worker의 installing·waiting·active 상태, HTTP 캐시, Cache Storage, 다중 탭과 업데이트 알림 흐름을 순서대로 점검합니다.

검수 범위
Service Worker와 Cache Storage를 사용하는 일반적인 PWA를 대상으로 합니다. 네이티브 앱 업데이트, CDN만의 캐시 문제, Workbox 등 프레임워크별 생성 설정은 원리가 겹치는 부분만 다룹니다.

PWA를 새로 배포했는데 설치된 앱이나 일부 브라우저 탭에서만 이전 화면이 계속 보일 수 있습니다. 이때 “브라우저 캐시”를 하나의 저장소처럼 생각하면 원인을 찾기 어렵습니다. 배포 서버와 CDN, 브라우저의 HTTP 캐시, Service Worker 등록 상태, Cache Storage, 이미 열려 있는 문서가 각각 다른 버전을 들고 있을 수 있기 때문입니다.

해결의 핵심은 사용자에게 사이트 데이터를 모두 지우라고 안내하는 것이 아니라 새 Service Worker가 발견됐는지, 설치됐는지, 기다리는 중인지, 활성화됐지만 페이지가 다시 로드되지 않았는지를 순서대로 구분하는 것입니다.

증상으로 멈춘 단계 찾기

관찰한 증상 가능성이 높은 지점 먼저 확인할 증거
서버의 sw.js 자체가 이전 내용 배포·CDN·HTTP 캐시 직접 요청한 응답 본문, ETag, Cache-Control
새 worker가 installing에서 사라짐 스크립트 구문 오류·precache 실패 Service Worker 콘솔과 install 예외
새 worker가 waiting에 머묾 이전 worker가 제어하는 탭 존재 열린 브라우저 탭과 설치형 PWA 창
새 worker는 active인데 화면은 이전 버전 현재 문서·Cache Storage·fetch 전략 navigator.serviceWorker.controller와 응답 출처
특정 탭만 버전이 다름 탭별 controller 또는 다중 scope 각 탭의 controller scriptURL
사이트 데이터 삭제 후에는 정상 저장 상태 문제는 맞지만 갱신 설계 미해결 삭제 전 registration·cache 목록

1단계: 배포된 버전을 숫자로 확인하기

화면 색이나 문구만 보고 새 버전이라고 판단하지 말고 배포마다 고유한 build ID를 만듭니다. HTML의 meta, 앱 설정 JSON과 Service Worker 파일에 같은 ID를 기록하면 어느 계층이 이전 버전인지 비교할 수 있습니다.

<meta name="app-build" content="2026.09.13-1">
// sw.js
const BUILD_ID = '2026.09.13-1';
const STATIC_CACHE = `myapp-static-${BUILD_ID}`;

운영 로그에는 build ID, Service Worker의 scriptURL, 상태와 현재 controller 유무만 남깁니다. 사용자 토큰이나 Cache Storage의 응답 본문을 수집하지 않습니다. HTML build ID와 worker build ID가 다르면 화면 새로고침 횟수보다 어떤 응답 경로가 낡았는지를 먼저 봅니다.

2단계: DevTools에서 worker 세 상태를 분리하기

Chrome DevTools의 Application 패널에서 Service Workers와 Cache Storage를 함께 확인합니다. 새 worker는 installing, waiting, active 단계를 거칩니다. 현재 페이지를 제어하는 worker와 새로 설치된 worker가 동시에 표시될 수 있습니다.

  • installing: 새 스크립트를 평가하고 install 작업을 수행하는 중입니다.
  • waiting: 설치는 성공했지만 기존 active worker가 제어하는 클라이언트가 남아 있습니다.
  • active: fetch 같은 이벤트를 처리할 수 있는 상태입니다.

개발 중에는 Update, Skip waiting, Update on reload, Bypass for network 기능으로 단계를 재현할 수 있습니다. 다만 이 옵션은 DevTools를 연 개발 환경의 진단 도구입니다. 운영 사용자의 업데이트 흐름이 정상이라는 증거로 대신할 수 없습니다.

3단계: 새 Service Worker 파일이 실제로 내려오는지 확인

registration.update()는 등록된 worker 스크립트를 다시 가져와 현재 스크립트와 비교하고, 내용이 달라졌으면 새 worker 설치를 시도합니다. 이 메서드가 곧바로 페이지를 새로 고치거나 새 worker를 현재 탭의 controller로 바꾸는 것은 아닙니다.

const registration = await navigator.serviceWorker.register('/sw.js', {
  scope: '/',
});

await registration.update();
console.log({
  installing: registration.installing?.state,
  waiting: registration.waiting?.state,
  active: registration.active?.state,
});

Network 패널에서 sw.js 요청의 최종 URL, 상태 코드, 응답 본문과 캐시 관련 헤더를 확인합니다. CDN이 이전 파일을 반환한다면 Cache Storage를 삭제해도 다음 등록에서 다시 이전 스크립트를 받습니다. 반대로 worker 파일은 최신인데 화면 asset만 오래됐다면 fetch 전략과 cache key를 확인할 단계입니다.

4단계: Service Worker 파일과 정적 asset의 HTTP 캐시를 다르게 운영하기

Service Worker 스크립트는 새 배포를 확인해야 하므로 재검증 가능한 정책을 사용하고, 내용 해시가 파일명에 포함된 JS·CSS·이미지는 길게 캐시할 수 있습니다.

리소스 권장 방향 이유
/sw.js Cache-Control: no-cache처럼 재검증 허용 새 worker 발견을 지연시키지 않음
/index.html 짧은 캐시 또는 재검증 새 asset 목록과 업데이트 UI 전달
/assets/app.a81f3.js 긴 max-age와 immutable 내용 변경 시 URL 자체가 바뀜
API 응답 데이터 성격별 별도 정책 앱 셸과 사용자 데이터를 섞지 않음

no-cache는 저장 금지라는 뜻이 아니라 사용 전에 원 서버에 재검증하라는 의미입니다. 모든 파일에 no-store를 붙이거나, 반대로 sw.js에 1년짜리 immutable 정책을 적용하는 식의 일괄 설정은 피합니다. HTTP 캐시와 Service Worker의 Cache Storage는 별도 계층이므로 두 곳의 정책을 함께 기록해야 합니다.

5단계: install 실패를 기존 버전 유지와 구분하기

새 worker의 스크립트가 404이거나 구문 오류가 있거나, install 이벤트의 waitUntil()에 전달한 Promise가 reject되면 새 worker 설치가 실패할 수 있습니다. 이때 기존 active worker는 계속 동작하므로 사용자는 단순히 “업데이트가 안 됐다”고 느낍니다.

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) =>
      cache.addAll([
        '/',
        '/assets/app.a81f3.js',
        '/assets/app.72c4.css',
      ])
    )
  );
});

cache.addAll()에 적은 파일 하나가 배포되지 않았거나 접근이 거절되면 전체 install Promise가 실패할 수 있습니다. Service Worker 콘솔에서 최초 예외를 확인하고, 목록의 각 URL을 새 시크릿 창과 직접 요청으로 검증합니다. 오류를 잡아서 무조건 성공 처리하면 필수 asset이 빠진 worker가 활성화될 수 있으므로 필수 파일과 선택 파일의 실패 정책을 분리합니다.

6단계: waiting은 고장이 아니라 버전 충돌 방지 상태

설치가 끝난 새 worker는 기존 worker가 제어하는 페이지가 모두 종료될 때까지 waiting에 머무는 것이 기본 동작입니다. 일반 새로고침은 새 문서를 여는 순간 기존 문서와 겹치므로 waiting이 바로 풀리지 않을 수 있습니다. 브라우저 탭뿐 아니라 별도 창으로 실행 중인 설치형 PWA도 같은 scope의 클라이언트가 될 수 있습니다.

따라서 waiting worker가 확인되면 두 가지 정책 중 하나를 명시적으로 선택합니다.

  • 사용자가 모든 창을 닫고 다음 실행 때 자연스럽게 새 버전을 받도록 둡니다.
  • “새 버전이 있습니다” 안내를 표시하고 사용자가 동의하면 waiting worker를 활성화한 뒤 한 번만 새로고침합니다.

7단계: 사용자 동의 뒤 skipWaiting과 reload 연결하기

skipWaiting()은 새 worker를 waiting 상태에서 즉시 active로 진행시킵니다. 하지만 이전 JS로 로드된 페이지를 새 worker가 중간부터 제어하면 서로 다른 버전의 asset과 API 규칙이 섞일 수 있습니다. 항상 즉시 실행하기보다 호환성 검토와 업데이트 안내 흐름을 둡니다.

// page.js
function activateUpdate(registration) {
  registration.waiting?.postMessage({ type: 'SKIP_WAITING' });
}

let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (reloading) return;
  reloading = true;
  location.reload();
});
// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

페이지에서는 등록 직후 이미 registration.waiting이 있는 경우와, 실행 중 updatefound가 발생해 installing worker의 상태가 installed로 바뀌는 경우를 모두 처리해야 합니다. 여러 탭에서 동시에 업데이트 버튼을 눌러도 controllerchange가 새로고침 루프를 만들지 않도록 탭별 1회 보호가 필요합니다.

clients.claim()이 새로고침을 대신하지는 않는다

활성화 단계의 clients.claim()은 현재 scope 안에서 아직 worker에 제어되지 않던 페이지를 새 active worker가 제어하도록 요청할 수 있습니다. 이미 로드된 HTML과 JavaScript 자체를 새 버전으로 바꾸는 기능은 아닙니다. 즉시 claim한 뒤에도 화면 코드는 이전 bundle일 수 있으므로 controller 전환과 문서 reload 정책을 별도로 설계합니다.

self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

첫 설치부터 현재 페이지를 제어해야 하는지, 기존 버전 페이지와 새 worker가 함께 동작해도 안전한지를 확인한 경우에만 사용합니다.

8단계: Cache Storage 이름과 삭제 범위를 제한하기

새 배포에서 같은 cache 이름을 계속 쓰면 이전 응답과 새 응답이 섞이기 쉽습니다. 앱 고유 prefix와 build ID를 조합하고 activate 단계에서 소유한 cache만 정리합니다.

const CACHE_PREFIX = 'myapp-static-';
const STATIC_CACHE = `${CACHE_PREFIX}${BUILD_ID}`;

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((name) =>
            name.startsWith(CACHE_PREFIX) && name !== STATIC_CACHE
          )
          .map((name) => caches.delete(name))
      )
    )
  );
});

같은 origin에 다른 서비스나 경로별 PWA가 있으면 prefix 없는 일괄 삭제가 다른 앱의 cache까지 지울 수 있습니다. 또한 즉시 활성화를 허용한다면 이전 화면이 나중에 요청할 asset을 새 worker도 제공할 수 있는지 확인해야 합니다. 호환성이 불확실하면 직전 버전 cache와 서버의 해시 asset을 일정 기간 유지한 뒤 정리합니다.

9단계: fetch 전략 때문에 오래된 응답이 고정되는지 확인

모든 요청에 cache-first를 적용하면 HTML과 API 응답도 첫 방문 버전에 머물 수 있습니다. 리소스 성격에 맞춰 전략을 나눕니다.

  • 해시가 붙은 정적 asset: cache-first가 적합하고 URL 변경으로 버전을 구분합니다.
  • 페이지 navigation: network-first 또는 재검증 정책을 검토합니다.
  • API 데이터: 허용 가능한 오래됨, 개인정보, 오프라인 요구를 기준으로 정합니다.
  • 로그인·결제 응답: 범용 runtime cache에 넣지 않습니다.

Network 패널에서 응답이 Service Worker, memory cache, disk cache, 네트워크 중 어디에서 왔는지 확인합니다. Cache Storage 패널에서는 같은 URL이 여러 버전 cache에 존재하는지 봅니다. query string을 무시하거나 모든 경로에 fallback HTML을 반환하는 규칙도 새 asset 요청을 오래된 문서로 바꿀 수 있으므로 request destination과 URL 조건을 함께 확인합니다.

10단계: 배포 순서와 롤백 가능성 점검

새 HTML이나 worker가 참조하는 해시 asset을 먼저 업로드하고, 그 다음 HTML과 worker를 배포합니다. 이전 버전 클라이언트가 남아 있는 동안에는 기존 해시 asset을 즉시 삭제하지 않습니다. CDN purge도 파일별 캐시 정책과 배포 원자성을 고려해 수행합니다.

  1. 새 해시 asset을 업로드하고 각 URL의 200 응답과 MIME 유형을 확인합니다.
  2. 새 cache 목록을 가진 worker와 HTML을 배포합니다.
  3. 새 worker의 install·waiting·active 전환을 관찰합니다.
  4. 다중 탭과 설치형 PWA에서 업데이트 안내를 시험합니다.
  5. 오류율이 안정된 뒤 이전 서버 asset과 cache 정리 시점을 결정합니다.

롤백할 때도 build ID를 새 값으로 배포해야 브라우저가 worker 스크립트 변화를 인식하기 쉽습니다. 과거 파일을 같은 내용과 cache key로 덮어쓰기만 하면 각 계층의 상태를 구분하기 어렵습니다.

사이트 데이터 삭제는 마지막 진단 수단으로 두기

DevTools의 Clear storage나 사용자 설정의 사이트 데이터 삭제는 registration, Cache Storage와 다른 origin 저장소를 함께 초기화할 수 있습니다. 재현 환경을 복구하는 데는 유용하지만 모든 사용자에게 먼저 안내하면 로그아웃, 오프라인 데이터 손실과 원인 은폐가 생깁니다.

삭제 전 worker 상태, scope, build ID, cache 이름과 실패한 URL을 기록합니다. 삭제 후 정상화됐다면 “사용자 캐시 문제”로 끝내지 말고 새 설치부터 다음 업데이트까지 같은 문제가 다시 생기는지 두 번의 연속 배포로 검증합니다.

배포 전후 재현 테스트

테스트 기대 결과
첫 방문 worker 설치 후 다음 navigation부터 정상 제어
한 탭을 연 채 새 버전 배포 waiting 상태와 업데이트 안내가 일치
두 탭과 설치형 PWA 동시 실행 버전 전환 시점이 예측 가능하고 무한 reload 없음
필수 precache URL 404 새 install은 실패하고 기존 버전은 유지되며 오류 기록
업데이트 동의 controllerchange 후 한 번 reload되어 build ID 일치
오프라인 재실행 약속한 앱 셸만 열리고 민감 데이터는 잘못 cache되지 않음
한 버전 롤백 새 build ID로 안전하게 재설치되고 필요한 asset 존재
다른 경로의 PWA cache prefix와 scope가 서로 침범하지 않음

가장 짧은 진단 순서

  1. HTML과 sw.js의 build ID를 비교합니다.
  2. Network에서 worker 파일의 실제 응답과 HTTP 캐시 헤더를 확인합니다.
  3. Application 패널에서 installing·waiting·active와 현재 controller를 구분합니다.
  4. install 실패라면 최초 예외와 precache URL을 하나씩 확인합니다.
  5. waiting이라면 열린 탭·설치형 PWA와 업데이트 동의 흐름을 확인합니다.
  6. active인데 화면이 오래됐다면 Cache Storage와 fetch 전략을 확인합니다.
  7. 다중 탭, 오프라인, 롤백을 포함해 두 번의 연속 배포로 다시 시험합니다.

이 순서대로 확인하면 CDN, HTTP 캐시, Service Worker 수명주기와 Cache Storage를 한꺼번에 지우지 않고 업데이트가 멈춘 정확한 계층을 고칠 수 있습니다.


검증 기준과 참고 자료

이 글은 2026-09-13에 W3C Service Workers 표준과 MDN, Chrome Developers, web.dev의 공식 문서를 기준으로 worker 업데이트, waiting·active 전환, Cache Storage와 개발자 도구의 동작을 검수했습니다. 브라우저 구현과 사용하는 빌드 도구에 따라 메뉴 이름과 자동 생성 코드는 달라질 수 있습니다.

관련 글