PWA(프로그레시브 웹 앱)를 처음 만들면 다들 감탄하는 순간이 있어요. 두 번째 방문부터 화면이 하고 순식간에 뜨는 거예요. 네트워크가 느려도, 심지어 잠깐 끊겨도 뼈대만큼은 바로 그려지죠. 이 마법의 정체가 바로 앱 셸(app shell, 앱의 껍데기) 아키텍처예요. 저도 처음엔 무슨 특별한 프레임워크가 있는 줄 알았는데, 알고 보니 서비스워커(service worker, 브라우저 뒤에서 도는 작은 스크립트)로 껍데기를 미리 캐시에 저장해 두는 아주 단순한 발상이더라고요. 오늘은 이 앱 셸을 어떻게 나누고, 어떻게 캐시에 박아두고, 어떻게 꺼내 쓰는지를 상황별로 하나씩 풀어볼게요.

37.1 앱 셸이 대체 뭔가요?

앱 셸은 말 그대로 앱의 껍데기예요. 화면을 열 때마다 항상 똑같이 보이는 부분들, 그러니까 상단 헤더, 왼쪽 네비게이션, 로고, 하단 바 같은 뼈대가 셸이에요. 반대로 게시글 목록이나 사용자별 데이터처럼 매번 달라지는 내용은 셸이 아니라 콘텐츠죠.


왜 이렇게 나누냐면요, 뼈대는 거의 안 변하니까 한 번 캐시에 저장해 두면 매번 서버에서 새로 받을 필요가 없어요. 방문할 때마다 헤더 HTML과 CSS를 다시 내려받는 건 낭비잖아요. 껍데기를 캐시에서 즉시 그리고, 그 안의 빈 콘텐츠 영역만 나중에 채우면 네이티브 앱처럼 즉시 뜨는 느낌이 나요. 사용자 눈엔 로딩 스피너 대신 익숙한 레이아웃이 먼저 보이니 체감 속도가 확 달라지죠.

37.2 껍데기를 언제 캐시에 넣어두죠?

정답은 서비스워커의 install(설치) 이벤트예요. 서비스워커는 처음 등록될 때 딱 한 번 install 이벤트를 거치는데, 이때 셸 파일들을 미리 캐시에 쓸어담는 걸 프리캐시(precache, 미리 캐싱)라고 불러요.


먼저 페이지에서 서비스워커를 등록하는 코드부터 볼게요.


if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}


이제 sw.js 안에서 install 때 껍데기를 담아요. caches.open으로 캐시 저장소를 하나 열고, addAll로 파일 목록을 통째로 넣어요.


const SHELL = 'shell-v1';
const FILES = ['/', '/index.html', '/app.css', '/app.js', '/logo.svg'];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL).then((cache) => cache.addAll(FILES))
);
});


여기서 event.waitUntil이 중요해요. 이걸 씌워두면 브라우저가 파일을 다 담을 때까지 설치를 완료로 치지 않아요. 즉 껍데기가 완전히 저장되기 전에 서비스워커가 활성화돼서 어정쩡하게 도는 사고를 막아주죠. addAll은 목록 중 하나라도 못 받으면 통째로 실패하니, 여기엔 확실히 존재하는 껍데기 파일만 넣는 게 안전해요.


캐시 이름을 shell-v1처럼 버전을 붙여 지은 것도 이유가 있어요. 나중에 껍데기 파일을 고쳐서 새로 배포하면, 이름을 shell-v2로 올려 새 캐시에 새 껍데기를 담고 옛 shell-v1은 통째로 지우면 되거든요. 이름이 같으면 옛 파일이 캐시에 남아 섞일 수 있는데, 버전을 바꾸면 새 캐시와 옛 캐시가 깔끔하게 분리돼요. 이 껍데기 버전 올리기가 바로 PWA 업데이트의 출발점이에요.

37.3 캐시에 있으면 어떻게 꺼내 쓰나요?

이제 저장한 껍데기를 꺼내 쓸 차례예요. 브라우저가 파일을 요청할 때마다 서비스워커의 fetch(요청 가로채기) 이벤트가 불려요. 여기서 캐시에 있으면 캐시를 주고, 없으면 네트워크로 간다는 전략을 쓰는데, 이걸 캐시 우선(cache-first)이라고 해요.


self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});


caches.match는 요청과 짝이 맞는 파일이 캐시에 있는지 뒤져봐요. 있으면 그걸 그대로 응답으로 주고, 없으면(cached가 없으면) fetch로 서버에서 받아오죠. 껍데기 파일들은 install 때 이미 담아놨으니 캐시에서 바로 나와요.


브라우저에서 이걸 확인하는 방법도 알려드릴게요. 개발자 도구의 Network(네트워크) 탭을 열고 새로고침하면, 껍데기 파일들 옆에 크기 대신 from ServiceWorker라고 뜨는 걸 볼 수 있어요. 서버까지 안 가고 캐시에서 나왔다는 증거예요. 심지어 Offline(오프라인) 체크박스를 켜고 새로고침해도 껍데기는 멀쩡히 그려져요. 이게 앱 셸의 힘이죠.

37.4 콘텐츠는 껍데기랑 어떻게 나눠 다루죠?

여기서 초보들이 자주 헷갈려요. 껍데기를 캐시 우선으로 박아뒀는데, 게시글 데이터까지 캐시 우선으로 주면 낡은 내용이 계속 보이는 문제가 생겨요. 그래서 껍데기와 콘텐츠는 전략을 달리 가져가야 해요.


껍데기는 방금처럼 캐시 우선으로, 하지만 자주 바뀌는 API 데이터는 네트워크 우선(network-first)으로 다뤄요. 즉 최신을 먼저 시도하고, 실패하면 그때 캐시로 넘어가는 거죠. 요청 주소를 보고 갈래를 나눠요.


self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
} else {
event.respondWith(
caches.match(event.request).then((c) => c || fetch(event.request))
);
}
});


/api/로 시작하는 데이터 요청은 fetch를 먼저 하고, 네트워크가 끊겨 실패하면 catch로 넘어가 캐시를 꺼내요. 반대로 껍데기 파일은 캐시 우선 그대로죠. 한 가지 더, 싱글 페이지 앱이라면 주소를 직접 입력해 들어오는 화면 이동 요청(event.request.mode가 navigate인 경우)엔 항상 캐시된 index.html을 돌려줘야 껍데기가 뜨고, 그 뒤에 자바스크립트가 콘텐츠를 채워요. 껍데기는 뼈대라 오래 캐시해도 되고, 콘텐츠는 신선도가 생명이라 네트워크를 먼저 본다, 이 감각만 잡으면 돼요.

37.5 오늘 정리

앱 셸 아키텍처를 정리할게요. 앱 셸은 헤더, 네비, 로고처럼 매번 똑같은 껍데기이고, 게시글 같은 콘텐츠와 분리해서 다뤄요. 껍데기는 서비스워커 installcaches.openaddAll로 미리 담아두고, event.waitUntil로 다 담길 때까지 설치를 붙잡아요. 꺼낼 땐 fetch 이벤트에서 caches.match로 캐시를 먼저 보는 캐시 우선 전략을 쓰면 오프라인에서도 껍데기가 즉시 떠요. 대신 자주 바뀌는 API는 네트워크 우선으로 갈래를 나눠서 낡은 데이터가 굳는 걸 막고요. 핵심은 딱 하나예요. 안 변하는 껍데기는 캐시에서 즉시, 변하는 콘텐츠는 네트워크에서 신선하게. 이 분리가 PWA의 빠른 첫인상을 만들어요.