33.1 캐시 전략이 왜 필요한가요?
PWA(Progressive Web App, 앱처럼 동작하는 웹)를 만들다 보면 서비스워커(service worker, 브라우저 뒤에서 도는 작은 스크립트)를 만나게 돼요. 이 친구가 특별한 이유는, 웹페이지가 보내는 모든 네트워크 요청을 중간에서 가로챌 수 있다는 거예요.
요청을 가로챈다는 건 곧 "이 요청은 네트워크로 보낼까, 아니면 저장해둔 걸 줄까"를 우리가 정할 수 있다는 뜻이에요. 이 판단 규칙을 캐시 전략(cache strategy, 캐시 전략)이라고 불러요. 전략을 어떻게 짜느냐에 따라 앱이 번개처럼 빠를 수도, 인터넷이 끊겨도 멀쩡할 수도 있어요.
대표 전략은 크게 세 가지예요. 캐시 우선(cache-first), 네트워크 우선(network-first), 그리고 둘 다 쓰기(stale-while-revalidate)죠. 하나씩 천천히 볼게요.
33.2 캐시는 대체 어디에 저장되나요?
전략을 짜기 전에 저장 창고부터 알아야겠죠. 브라우저는 Cache API(캐시 저장소 인터페이스)라는 걸 제공해요. 요청(request)과 응답(response)을 짝지어 통째로 보관하는 창고라고 보면 돼요.
창고는 이름으로 열어요. caches.open으로 원하는 이름의 캐시를 열고, put으로 넣고, match로 꺼내요.
상황: 응답 하나를 캐시에 넣었다가 다시 꺼내요.
const cache = await caches.open('v1');
await cache.put('/style.css', response);
const hit = await cache.match('/style.css');
match는 저장된 게 있으면 응답을, 없으면 undefined를 돌려줘요. 이 "있으면 응답, 없으면 빈손" 동작이 모든 전략의 출발점이에요.
보통 처음 캐시를 채우는 건 서비스워커의 install(설치) 이벤트에서 해요. 앱의 뼈대가 되는 파일들을 미리 한꺼번에 저장해두는 거죠.
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open('v1').then((c) => c.addAll([
'/', '/index.html', '/style.css', '/app.js'
]))
);
});
waitUntil은 "이 작업 끝날 때까지 설치를 끝내지 마"라고 브라우저에 부탁하는 거예요. addAll은 목록의 파일을 전부 받아서 캐시에 넣어주고요.
한 가지 안심할 점은, 이렇게 저장한 캐시는 브라우저를 껐다 켜도 그대로 남아 있다는 거예요. 잠깐 쓰고 사라지는 메모리와 달리 Cache API의 내용은 디스크에 저장되거든요. 그래서 어제 방문한 페이지의 CSS가 오늘도 캐시에서 곧바로 나오는 거예요.
33.3 캐시 먼저 볼까요, 네트워크 먼저 볼까요?
이제 요청을 가로채는 fetch(요청) 이벤트로 가볼게요. 여기서 respondWith로 "이 응답을 대신 줘"라고 답하면 돼요.
캐시 우선 전략은 이름 그대로예요. 캐시부터 뒤지고, 있으면 그걸 바로 주고, 없을 때만 네트워크로 나가요.
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((hit) => {
return hit || fetch(e.request);
})
);
});
결과는 어떻게 될까요? CSS나 로고 이미지처럼 잘 안 바뀌는 파일은 두 번째 방문부터 네트워크를 아예 안 타요. 저장소에서 바로 꺼내니 화면이 눈 깜짝할 새에 떠요. 심지어 인터넷이 끊겨도 캐시에 있으면 멀쩡히 나오고요.
단점도 분명해요. 서버에서 파일을 고쳐도 캐시에 옛날 게 남아 있으면 계속 옛날 걸 보여줘요. 그래서 자주 바뀌는 데이터엔 안 맞아요.
33.4 최신 데이터가 중요할 땐 어떻게 하죠?
뉴스 목록이나 댓글처럼 항상 최신이어야 하는 건 반대로 가야겠죠. 네트워크 우선 전략은 일단 네트워크로 나가고, 실패했을 때만 캐시를 꺼내요.
self.addEventListener('fetch', (e) => {
e.respondWith(
fetch(e.request)
.then((res) => {
const copy = res.clone();
caches.open('v1').then((c) => c.put(e.request, copy));
return res;
})
.catch(() => caches.match(e.request))
);
});
여기서 res.clone()이 눈에 띄죠. 응답의 본문(body)은 한 번만 읽을 수 있어요. 캐시에 넣으면서 화면에도 주려면 복사본이 필요해요. 이걸 빼먹으면 "이미 읽은 응답"이라며 에러가 나니 꼭 챙겨요.
결과는 이래요. 인터넷이 되면 늘 서버의 최신 데이터를 보여주고, 방금 받은 걸 캐시에 몰래 저장해둬요. 그러다 지하철에서 신호가 끊기면 catch가 발동해 마지막으로 저장해둔 화면을 대신 띄워줘요. 최신은 아니어도 빈 화면보단 훨씬 낫죠.
33.5 빠르면서 최신일 순 없을까요?
욕심 같아선 캐시처럼 빠르면서 네트워크처럼 최신이면 좋겠죠. 그 절충안이 stale-while-revalidate(오래된 걸 주면서 뒤에서 갱신)예요. 이름이 길지만 동작은 단순해요.
일단 캐시에 있는 걸 지금 바로 보여줘요. 그리고 동시에 네트워크로도 요청을 보내서, 새로 받은 걸 다음번을 위해 캐시에 갱신해둬요.
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.open('v1').then((c) =>
c.match(e.request).then((hit) => {
const fresh = fetch(e.request).then((res) => {
c.put(e.request, res.clone());
return res;
});
return hit || fresh;
})
)
);
});
결과가 재밌어요. 화면은 캐시 덕에 즉시 뜨는데, 그사이 새 데이터가 조용히 저장돼서 다음 방문 땐 갱신된 게 보여요. 아바타 이미지나 프로필처럼 "살짝 옛날이어도 괜찮지만 결국은 최신이면 좋은" 것들에 딱이에요.
33.6 그래서 뭘 언제 쓰면 되나요?
세 전략을 한 줄로 정리해볼게요. 캐시 우선은 로고, CSS, 폰트처럼 잘 안 바뀌는 정적 파일에 써요. 빠르고 오프라인에도 강하죠. 네트워크 우선은 뉴스, 잔액, 댓글처럼 최신이 생명인 데이터에 쓰고요. stale-while-revalidate는 그 중간, 즉시 보여주되 서서히 최신화되면 되는 것들에 맞아요.
정답이 하나로 정해진 게 아니에요. 한 앱 안에서도 파일 종류마다 전략을 섞어 써요. 로고엔 캐시 우선, 게시글엔 네트워크 우선, 이런 식으로요. 요청 주소(URL)를 보고 갈래를 나누면 돼요.
한 가지 덧붙이면, 어떤 전략을 쓰든 캐시 이름에 버전을 붙여 관리하는 습관을 들이세요. 배포할 때마다 새 이름을 쓰면 옛 캐시와 섞이지 않아 훨씬 깔끔하고, 낡은 파일을 골라 지우기도 쉬워요.
머리로만 읽으면 금방 헷갈려요. 작은 서비스워커 파일 하나 만들어서 caches.match와 fetch를 직접 조합해보세요. 개발자 도구의 Network 탭에서 "이 요청이 캐시에서 왔는지 네트워크에서 왔는지"를 눈으로 확인하는 순간, 세 전략의 차이가 확 와닿을 거예요.