← 글 목록

업무 사례

Service Worker와 Cache Storage로 정적 리소스 캐싱하기

WebView 화면에서 이미지, 폰트, 3D 모델 같은 정적 리소스를 사용하면서 같은 파일을 반복해서 요청하는 문제가 있었습니다.

특히 3D 모델은 일반 이미지보다 파일 크기가 커서 화면에 다시 진입할 때마다 네트워크 요청이 발생하면 로딩 시간이 눈에 띄게 길어졌습니다.

브라우저 HTTP 캐시만으로는 WebView와 운영체제에 따라 동작을 정확하게 확인하기 어려웠습니다.

같은 파일은 재사용하되, 내용이 바뀌면 새 파일을 받아야 했습니다. 서비스 워커가 페이지를 제어하지 않는 iOS WebView에서도 이 동작이 필요했습니다.

파일 내용으로 리비전(Revision)을 만들고 Service Worker와 Window에서 같은 기준으로 CacheStorage를 관리하도록 했습니다. 캐시에 문제가 생기면 네트워크 요청을 유지하고, 응답이 어디에서 왔는지도 확인할 수 있도록 구성했습니다.

전체 구조는 다음과 같습니다.

Build
  ↓
정적 리소스 목록 확인
  ↓
파일별 Content Hash 생성
  ↓
Service Worker + Manifest 생성
  ↓
배포
  ↓
Cache Storage 우선 조회
  ↓
없으면 Network 요청 후 저장

1. HTTP 캐시만 사용했을 때의 문제

처음에는 배포 시 파일 종류에 따라 Cache-Control을 적용했습니다.

HTML은 항상 최신 상태를 확인하고, 정적 파일은 오래 캐시하도록 구성했습니다.

HTML
→ must-revalidate

CSS / JavaScript / Image / Font / Model
→ max-age + immutable

파일명에 해시가 포함된 JavaScript와 CSS는 긴 캐시를 적용해도 문제가 없습니다.

app.a1b2c3.js
app.d4e5f6.js

파일 내용이 바뀌면 URL도 함께 변경되기 때문입니다.

하지만 이미지나 3D 모델처럼 같은 URL을 유지하는 파일은 문제가 달랐습니다.

/models/character.glb

파일 내용이 변경돼도 URL이 같으면 브라우저가 이전 응답을 계속 사용할 수 있습니다.

CDN 캐시를 삭제해도 이미 사용자 기기에 저장된 브라우저 캐시는 별도로 남을 수 있었습니다.

2. 캐시 계층 구분

정적 리소스 요청에는 여러 캐시 계층이 존재합니다.

캐시위치갱신 방법
CDN Cache서버와 사용자 사이배포 후 Invalidation
HTTP Cache브라우저 내부Cache-Control
Cache StorageService Worker 또는 WindowRevision 기반 직접 관리

CDN 캐시를 비웠다고 브라우저 CacheStorage까지 제거되는 것은 아닙니다.

반대로 CacheStorage를 비워도 CDN에 오래된 파일이 남아 있으면 다시 이전 파일을 받을 수 있습니다.

따라서 배포와 브라우저 캐시를 함께 관리해야 했습니다.

배포 시
→ CDN Cache 갱신

브라우저 실행 시
→ Revision에 맞지 않는 Cache Storage 제거

3. 빌드 결과물에서 Revision 생성

정적 리소스 목록을 개발자가 직접 관리하면 파일 추가를 누락하기 쉽습니다.

빌드가 끝난 뒤 실제 결과물 디렉터리를 탐색하도록 했습니다.

const cacheableDirectories = [
  'assets',
  'fonts',
  'images',
  'models',
]

각 파일의 내용을 읽어 해시를 생성했습니다.

const revision = createHash('sha256')
  .update(fileContents)
  .digest('hex')
  .slice(0, 16)

생성되는 목록은 다음과 같은 형태입니다.

{
  "/assets/app.js": "1a2b3c4d5e6f",
  "/images/banner.png": "2b3c4d5e6f7a",
  "/models/character.glb": "3c4d5e6f7a8b"
}

파일명이 같아도 내용이 변경되면 Revision이 달라집니다.

/models/character.glb?v1
→ revision: abc123

/models/character.glb?v2
→ revision: def456

이 목록을 이용해 Service Worker와 Cache Manifest를 함께 생성했습니다.

4. Revision을 캐시 키에 포함

실제 리소스 URL에 쿼리 매개변수를 붙이면 CDN이나 기존 링크에 영향을 줄 수 있습니다.

네트워크 요청 URL은 그대로 유지하고 CacheStorage 내부의 키에만 Revision을 추가했습니다.

function createCacheKey(url, revision) {
  const cacheUrl = new URL(url)

  cacheUrl.searchParams.set(
    '__asset_revision',
    revision,
  )

  return new Request(cacheUrl.href)
}

실제 네트워크 요청은 다음 URL을 사용합니다.

/models/character.glb

CacheStorage에는 다음 키로 저장됩니다.

/models/character.glb?__asset_revision=abc123

파일 내용이 변경되면 동일한 경로라도 캐시 키가 달라집니다.

이전
/models/character.glb?__asset_revision=abc123

변경 후
/models/character.glb?__asset_revision=def456

기존 파일과 새로운 파일이 같은 캐시 항목을 공유하지 않도록 했습니다.

5. 캐시 우선 전략

Service Worker에서는 요청된 파일이 Manifest에 포함돼 있는지 확인했습니다.

const revision = assetRevisions[url.pathname]

if (!revision) {
  return
}

관리 대상인 경우 CacheStorage를 먼저 확인합니다.

async function cacheFirst(request, revision) {
  const cache = await caches.open(cacheName)
  const cacheKey = createCacheKey(
    request.url,
    revision,
  )

  const cachedResponse = await cache.match(cacheKey)

  if (cachedResponse) {
    return cachedResponse
  }

  const networkResponse = await fetch(request, {
    cache: 'no-store',
  })

  if (networkResponse.ok) {
    await cache.put(
      cacheKey,
      networkResponse.clone(),
    )
  }

  return networkResponse
}

처음 요청할 때는 네트워크에서 파일을 받고 CacheStorage에 저장합니다.

두 번째 요청부터는 같은 Revision의 응답을 CacheStorage에서 가져옵니다.

첫 번째 요청
Cache Miss
→ Network
→ Cache Storage 저장

두 번째 요청
Cache Hit
→ Cache Storage 반환

네트워크 요청에는 no-store를 사용했습니다.

Revision이 변경돼 새 파일을 요청하는 상황에서 이전 HTTP 캐시가 끼어들지 않도록 하기 위해서입니다.

6. 모든 요청을 가로채지 않은 이유

Service Worker가 모든 GET 요청을 캐시하면 API 응답이나 사용자 데이터까지 저장할 수 있습니다.

다음 조건을 모두 만족하는 요청만 처리했습니다.

GET 요청
같은 Origin
Query Parameter 없음
Manifest에 등록된 경로
Range 요청이 아님

코드에서는 다음과 같이 확인했습니다.

if (request.method !== 'GET') {
  return
}

if (request.headers.has('range')) {
  return
}

if (url.origin !== self.location.origin) {
  return
}

if (url.search !== '') {
  return
}

if (!assetRevisions[url.pathname]) {
  return
}

API 응답과 외부 리소스는 기존 네트워크 흐름을 그대로 사용합니다.

캐시 범위를 정적 파일 목록으로 제한하면서 Service Worker가 다른 요청에 영향을 주지 않도록 했습니다.

7. 오래된 캐시 항목 제거

Revision이 변경될 때마다 새로운 캐시 키가 생성되므로 이전 데이터가 계속 남을 수 있습니다.

Service Worker가 활성화될 때 현재 Manifest에 없는 캐시 항목을 제거했습니다.

async function removeOutdatedEntries() {
  const cache = await caches.open(cacheName)

  const validKeys = new Set(
    Object.entries(assetRevisions).map(
      ([path, revision]) =>
        createCacheKey(
          new URL(path, self.location.origin),
          revision,
        ).url,
    ),
  )

  const cachedRequests = await cache.keys()

  await Promise.all(
    cachedRequests
      .filter(request => !validKeys.has(request.url))
      .map(request => cache.delete(request)),
  )
}

Service Worker의 activate 단계에서 정리를 실행했습니다.

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

새 Service Worker가 활성화되면 현재 배포 결과에 없는 Revision을 자동으로 삭제합니다.

8. Service Worker 갱신

Service Worker 파일 자체가 오래 캐시되면 새로운 Manifest와 캐시 정책이 적용되지 않습니다.

등록할 때 Service Worker Script Cache를 사용하지 않도록 설정했습니다.

await navigator.serviceWorker.register('/sw.js', {
  scope: '/',
  updateViaCache: 'none',
})

이미 활성화된 Registration이 있으면 갱신도 요청했습니다.

if (registration.active) {
  await registration.update()
}

개발 환경에서는 Service Worker가 이전 빌드 파일을 반환하면 디버깅하기 어려우므로 운영 빌드에서만 등록했습니다.

if (import.meta.env.PROD) {
  registerStaticAssetServiceWorker()
}

배포 시 sw.js와 Manifest에는 항상 재검증이 필요한 캐시 헤더를 적용했습니다.

sw.js
static-asset-cache-manifest.json

→ max-age=0
→ must-revalidate

정적 리소스는 오래 캐시하되 캐시 정책을 결정하는 파일은 항상 최신인지 확인하는 구조입니다.

9. Service Worker가 첫 화면을 제어하지 못하는 문제

Service Worker를 등록했다고 현재 화면이 즉시 Service Worker의 제어를 받는 것은 아닙니다.

첫 등록 시점에는 다음과 같은 상태가 될 수 있습니다.

navigator.serviceWorker.controller === null

이 경우 다음 페이지 진입이나 새로고침 전까지 Fetch Event를 거치지 않을 수 있습니다.

Service Worker 등록 여부만 확인하면 캐시가 동작한다고 잘못 판단할 수 있으므로 실제 Controller 존재 여부를 확인했습니다.

function isServiceWorkerControlled() {
  return Boolean(
    navigator.serviceWorker?.controller,
  )
}

Controller가 있으면 Service Worker Cache를 사용합니다.

Service Worker Controller 있음
→ Service Worker가 요청 처리

Controller가 없으면 Window의 CacheStorage를 직접 사용하는 대체 경로로 전환했습니다.

Service Worker Controller 없음
→ Window Cache Storage 직접 확인

10. iOS WebView용 CacheStorage 보완

일부 iOS WebView 환경에서는 Service Worker 지원 여부와 제어 시점이 일정하지 않았습니다.

모델을 다시 요청하는 상황에서 Service Worker만으로 캐시 적중 여부를 보장하기 어려웠습니다.

그래서 Service Worker와 동일한 Revision Manifest를 Window에서도 읽을 수 있도록 분리했습니다.

{
  "cacheName": "static-assets-v1",
  "revisionParam": "__asset_revision",
  "revisions": {
    "/models/character.glb": "abc123"
  }
}

Service Worker의 Controller가 없으면 Window CacheStorage를 직접 조회합니다.

const cache = await window.caches.open(
  manifest.cacheName,
)

const cachedResponse = await cache.match(cacheKey)

if (cachedResponse) {
  return cachedResponse
}

캐시에 없다면 네트워크에서 가져온 뒤 저장합니다.

const response = await fetch(assetUrl, {
  cache: 'no-store',
  mode: 'same-origin',
})

await cache.put(
  cacheKey,
  response.clone(),
)

두 캐시 경로가 동시에 같은 요청을 처리하지 않도록 Controller 여부로 분기했습니다.

Service Worker 제어 중
→ Window Cache Storage 우회

Service Worker 제어하지 않음
→ Window Cache Storage 직접 사용

Service Worker를 사용할 수 없는 환경에서도 같은 Revision 규칙을 유지할 수 있게 됐습니다.

11. 캐시 오류는 화면 오류로 처리하지 않기

CacheStorage는 브라우저 정책이나 저장 공간에 따라 실패할 수 있습니다.

캐시를 성능 개선 기능으로 사용하되 서비스 실행을 위한 필수 조건으로 만들지는 않았습니다.

try {
  const cachedResponse = await cache.match(cacheKey)

  if (cachedResponse) {
    return cachedResponse
  }
} catch {
  return fetch(assetUrl)
}

CacheStorage 생성, 조회, 저장 중 오류가 발생하면 일반 네트워크 요청으로 전환했습니다.

Cache 사용 가능
→ Cache First

Cache 사용 불가
→ Network Fallback

캐시 저장에 실패했다는 이유로 3D 모델이나 이미지가 표시되지 않는 상황을 방지했습니다.

12. 캐시 적중 여부 확인

일반적인 Fetch 응답만으로는 다음 중 어떤 경로에서 응답했는지 구분하기 어렵습니다.

  • Service Worker CacheStorage
  • Window CacheStorage
  • Browser HTTP 캐시
  • CDN 또는 네트워크

Service Worker가 반환하는 응답에는 디버깅용 헤더를 추가했습니다.

X-Cache-Source: service-worker
X-Asset-Revision: abc123

모델 로더에서는 응답 헤더와 Resource Timing을 함께 확인했습니다.

const entries =
  performance.getEntriesByName(assetUrl)

const resource = entries.at(-1)

다음 정보를 기준으로 캐시 사용 여부를 분류했습니다.

Service Worker 응답 Header
Cache Storage 직접 조회 결과
Resource Timing의 transferSize
Network 요청 횟수
적용된 Revision

캐시를 구현하는 것뿐 아니라 실제 WebView에서 캐시가 사용되고 있는지 확인할 수 있도록 했습니다.

13. 배포 대상 감지 문제

Service Worker와 Manifest를 생성해도 해당 애플리케이션이 배포 대상으로 감지되지 않으면 실제 서버에는 반영되지 않습니다.

배포 워크플로는 변경된 앱을 찾기 위해 기준 커밋과 현재 커밋을 비교하고 있었습니다.

처음에는 Pull Request의 Head SHA를 사용했습니다.

TURBO_SCM_HEAD: pull_request.head.sha

하지만 배포 워크플로가 실행되는 시점의 실제 결과물은 병합 커밋 기준이었습니다.

변경 감지 기준
→ PR Head Commit

실제 배포 대상
→ Merge Commit

기준이 달라지면서 병합 과정에서 만들어진 변경이 배포 대상 계산에 포함되지 않을 수 있었습니다.

워크플로에 Checkout된 커밋과 변경 감지에 사용된 SHA를 비교해 원인을 확인했습니다.

Head 기준을 실제 병합 커밋으로 변경했습니다.

TURBO_SCM_HEAD: pull_request.merge_commit_sha

Service Worker 코드가 저장소에는 반영됐지만 배포 결과물에는 없는 상황을 방지할 수 있었습니다.

14. CDN 캐시 갱신

새로운 파일이 업로드돼도 CDN에 기존 응답이 남아 있으면 Service Worker가 이전 파일을 받을 수 있습니다.

배포가 끝난 뒤 CDN 경로를 무효화했습니다.

파일 업로드
→ Service Worker와 Manifest 업로드
→ CDN Invalidation

정적 파일의 URL이 유지되는 구조이므로 최종적으로 전체 경로를 갱신하도록 변경했습니다.

/*

브라우저 CacheStorage는 Revision으로 구분하고, CDN은 배포 시점의 캐시 무효화로 갱신하도록 역할을 나눴습니다.

정리

정적 리소스 캐시는 하나의 설정만으로 해결되지 않았습니다.

배포
→ 파일별 Cache-Control 설정
→ Service Worker와 Manifest 업로드
→ CDN Cache 무효화

브라우저
→ Revision 확인
→ Cache Storage 조회
→ 없으면 Network 요청
→ 오래된 Revision 제거

iOS WebView
→ Service Worker Controller 확인
→ 미제어 상태라면 Window Cache Storage 사용

파일 내용이 바뀌면 리비전을 바꾸고, 같은 파일은 CacheStorage에서 재사용하도록 했습니다. Service Worker가 페이지를 제어하지 못하면 Window에서 같은 캐시를 조회하고, 캐시 자체에 문제가 있으면 네트워크 요청으로 이어지게 했습니다.

배포할 때는 Service Worker와 CDN 캐시도 갱신했습니다. 응답 출처를 기록해 어떤 경로에서 파일을 재사용했는지 확인할 수 있게 됐습니다.

후속 보완과 재사용 검증

앞에서 설명한 Window CacheStorage 코드는 cache miss가 발생하면 no-store로 응답을 가져옵니다.

이후에는 이 부분을 force-cache로 변경했습니다. Window CacheStorage에 응답이 없더라도 기존 HTTP 캐시를 활용할 수 있도록 보완한 것입니다.

아래 검증 결과에는 이 변경도 포함돼 있습니다. 따라서 앞의 코드와 동일한 구현 단계에서 측정한 결과는 아닙니다.

실제 iOS 재진입 로그

같은 기기와 같은 모델 revision을 기준으로, 첫 관측과 새 페이지로 재진입했을 때의 로그 한 쌍을 비교했습니다.

항목첫 관측재진입
모델을 위한 JS fetch 호출1회0회
Window CacheStoragemiss 후 저장hit

재진입 시 Service Worker 제어 상태는 false였고, 응답의 backend는 Window CacheStorage로 기록됐습니다. Service Worker가 페이지를 제어하지 않는 상태에서도 저장된 모델 Response를 재사용한 것을 확인했습니다.

다만 첫 관측에서 실제 네트워크 왕복이 발생했는지는 확정할 수 없었습니다. 여기서 확인한 결과는 모델 fetch 호출 1회 → 0회입니다. 네트워크 요청이나 전송량이 같은 비율로 줄었다는 의미는 아닙니다.

로컬 전후 재현

로컬에서는 다음 조건을 동일하게 맞춘 뒤 변경 전후 소스로 모델을 다시 로드했습니다.

  • Service Worker가 페이지를 제어하지 않는 상태
  • HTTP 캐시 probe 결과가 miss인 상태
  • CacheStorage를 사용할 수 있는 상태

모델을 다시 로드할 때의 JS fetch 호출은 2회 → 0회로 줄었습니다.

이전의 두 호출에는 only-if-cached probe가 포함돼 있었습니다. 따라서 이 수치가 실제 네트워크 왕복 두 번의 감소를 뜻하지는 않습니다. manifest 요청도 모델 fetch 횟수와 별도로 구분했습니다.

실제 로그는 cold/warm 한 쌍을 비교한 결과입니다. 배포 전후의 전체 사용자 성능을 비교한 것은 아닙니다.

모델 응답을 가져온 뒤의 Three.js parse·render 시간, 실제 transfer bytes, 모든 iOS 환경의 캐시 적중률은 이번 검증에 포함하지 않았습니다.