PWA를 만들다 보면 진짜 사람 미치게 하는 순간이 와요. 코드를 고쳐서 새로 배포했는데, 새로고침을 아무리 눌러도 옛날 화면이 그대로인 거예요. 저도 처음엔 배포가 실패한 줄 알고 서버만 몇 번을 다시 올렸어요. 알고 보니 서비스워커(service worker)가 옛 버전을 붙들고 안 놔주고 있던 거였죠. 이게 PWA 초보가 가장 많이 밟는 지뢰예요. 오늘은 서비스워커가 어떻게 업데이트되는지, 왜 옛 버전이 안 사라지는지, 그리고 새 버전을 사용자에게 제때 깔끔하게 넘겨주는 법을 상황별로 풀어볼게요.
39.1 서비스워커는 배포하면 바로 안 바뀌나요?
네, 바로 안 바뀌어요. 이걸 이해하려면 서비스워커의 생애 주기(lifecycle, 태어나서 일하기까지의 단계)를 알아야 해요. 새 sw.js를 배포하면 브라우저는 파일이 한 글자라도 달라졌는지 비교해요. 달라졌으면 새 서비스워커를 install(설치)하죠. 그런데 여기서 끝이 아니에요.
설치가 끝난 새 서비스워커는 곧바로 일을 시작하지 못하고 waiting(대기) 상태로 멈춰 서요. 왜냐면 지금 열려 있는 탭을 아직 옛 서비스워커가 관리하고 있기 때문이에요. 브라우저는 갑자기 관리자를 바꾸면 화면이 꼬일까 봐, 옛 서비스워커가 관리하던 탭이 전부 닫힐 때까지 새것을 대기실에 세워둬요. 그래서 새로고침만으론 안 바뀌는 거예요. 새로고침은 탭을 닫는 게 아니거든요.
39.2 그럼 왜 새로고침해도 옛 버전이 남죠?
바로 이 waiting 때문이에요. 여기가 오늘의 핵심이라 천천히 볼게요. 상황을 그려볼게요. 사용자가 앱 탭을 열어둔 채로 여러분이 새 버전을 배포해요. 사용자가 새로고침을 눌러요. 브라우저는 새 sw.js를 받아 install까지 마쳐요. 하지만 그 탭은 여전히 옛 서비스워커가 관리 중이라, 새것은 waiting에 앉아 있고 화면엔 옛 캐시가 계속 나와요.
진짜로 새 버전을 보려면 사용자가 그 앱의 탭을 전부 완전히 닫았다가 다시 열어야 해요. 새로고침(F5) 말고요. 이 사실을 모르면 배포가 안 먹혔다고 착각하게 돼요. 개발자 도구 Application 탭의 Service Workers 항목을 열어보면 이 상황이 눈에 보여요. 새 워커 옆에 waiting to activate(활성화 대기 중)라고 딱 적혀 있거든요. 이 문구를 본 순간 "아 이거구나" 하게 될 거예요.
39.3 새 버전을 바로 켜려면 skipWaiting이죠?
맞아요. 이 대기 줄을 건너뛰게 하는 명령이 skipWaiting(대기 건너뛰기)이에요. 새 서비스워커의 install 안에서 이걸 부르면, 대기실에 앉지 않고 곧장 활성화로 밀고 들어가요.
self.addEventListener('install', (event) => {
self.skipWaiting();
});
여기에 짝으로 clients.claim(제어권 가져오기)을 활성화 시점에 붙여줘요. skipWaiting으로 새 워커가 활성은 됐어도, 이미 열려 있던 탭들은 여전히 옛 워커가 붙잡고 있을 수 있거든요. claim은 그 탭들의 제어권을 새 워커가 즉시 가져오게 해요.
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
이 activate(활성화) 시점은 옛 캐시를 청소하기에도 딱 좋은 자리예요. 앞 편에서 껍데기 캐시 이름에 버전을 붙였던 거 기억하시죠? 새 워커가 활성화될 때 지금 버전이 아닌 옛 캐시들을 골라 지우면 저장 공간에 낡은 껍데기가 쌓이지 않아요.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) => Promise.all(
names.filter((n) => n !== 'shell-v2').map((n) => caches.delete(n))
))
);
});
caches.keys로 캐시 이름을 전부 훑어서, 지금 버전(shell-v2)이 아닌 것만 caches.delete로 지우는 거예요. 이렇게 활성화 때 옛것을 정리해 두면 껍데기 버전을 올릴 때마다 새 캐시로 자연스럽게 갈아타요.
정리하면, 이 둘을 넣으면 배포 후 새로고침만으로도 새 워커가 화면을 잡아요. 다만 주의할 점이 있어요. skipWaiting은 보고 있던 화면 밑에서 갑자기 관리자를 바꾸는 거라, 옛 코드가 쓰던 캐시와 새 코드가 안 맞으면 그 순간 화면이 어긋날 수 있어요. 그래서 무조건 skipWaiting을 박기보단, 사용자에게 물어보고 바꾸는 방식을 더 안전하게 쳐요.
39.4 사용자한테 물어보고 업데이트하려면요?
실무에서 즐겨 쓰는 점잖은 방식이에요. 새 버전이 대기(waiting)에 들어오면 "새 버전이 있어요, 새로고침할까요?" 같은 알림을 띄우고, 사용자가 누를 때만 교체하는 거죠. 먼저 페이지 쪽에서 새 워커가 대기 상태가 됐는지를 감지해요.
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.addEventListener('updatefound', () => {
const nw = reg.installing;
nw.addEventListener('statechange', () => {
if (nw.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateToast();
}
});
});
});
updatefound는 새 워커가 설치되기 시작할 때 불려요. 그 워커 상태가 installed가 됐고 controller(지금 페이지를 관리 중인 워커)가 이미 있다면, 이건 첫 설치가 아니라 업데이트라는 뜻이에요. 이때 토스트 알림을 띄우는 거죠. 사용자가 알림을 누르면 대기 중인 워커에게 넘어와도 좋다는 신호를 보내요.
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
그러면 sw.js에서 이 메시지를 받아 그때 skipWaiting을 부르고요.
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') self.skipWaiting();
});
마지막 조각이 있어요. 워커가 실제로 교체되면 controllerchange(관리자 바뀜) 이벤트가 페이지에 불려요. 이때 한 번 새로고침해 주면 새 코드로 화면이 깔끔하게 갈아끼워져요.
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
이렇게 하면 사용자가 스스로 원할 때 새 버전으로 넘어가니, 화면이 밑에서 갑자기 바뀌는 사고 없이 매끄럽게 업데이트돼요.
39.5 오늘 정리
서비스워커 업데이트를 정리할게요. 새 sw.js를 배포하면 파일이 달라졌을 때 install되지만, 열린 탭을 옛 워커가 잡고 있어서 새 워커는 waiting에 멈춰요. 그래서 새로고침만으론 옛 버전이 안 사라지고, 탭을 전부 닫았다 열어야 바뀌는 거예요. 이걸 건너뛰려면 install에서 skipWaiting, activate에서 clients.claim을 부르면 되지만, 화면이 밑에서 갑자기 바뀔 위험이 있죠. 그래서 실무에선 updatefound로 새 버전을 감지해 알림을 띄우고, 사용자가 누르면 postMessage로 skipWaiting을 시킨 뒤 controllerchange에서 새로고침하는 방식을 즐겨 써요. 핵심은 딱 하나예요. 서비스워커는 배포 즉시 안 바뀐다, waiting을 이해하면 업데이트가 손에 잡힌다.