본문 바로가기
캐시 버스팅실무 경험

배포 후 변경되지 않는 이미지의 원인 분석과 캐시 버스팅 적용

7분실무 경험티스토리에서 옮긴 글

이미지를 교체해 배포했는데도 사용자 화면에는 이전 이미지가 계속 노출되는 문제가 발생했습니다. 브라우저를 새로고침해도 그대로이고, 다른 PC에서도 한동안 이전 이미지가 표시되는 상황이었습니다.

배포 이후에는 Akamai Purge API를 호출해 CDN 캐시를 항상 무효화하고 있었기 때문에 처음에는 CDN 캐시 문제라고 생각했습니다. 하지만 원인은 예상과 달리 브라우저 캐시에 있었습니다.

이번 글에서는 Purge를 수행했음에도 이미지가 변경되지 않았던 원인을 분석하고, 캐시 버스팅을 적용해 해결한 과정과 Akamai CDN에서도 정상적으로 동작하는지 검증한 내용을 정리해 보겠습니다.

Purge를 했는데도 왜 이미지가 변경되지 않았을까?

기존에는 공용 스프라이트 이미지를 아래와 같이 고정된 URL로 참조하고 있었습니다.

html
<img src="/images/gnb.webp" />

이미지를 교체한 뒤에는 Akamai Purge API를 호출해 CDN 캐시를 무효화했습니다.

하지만 일부 환경에서는 이전 이미지가 계속 노출되었습니다.

처음에는 Purge가 정상적으로 수행되지 않았거나 CDN 캐시가 남아 있는 것으로 생각했습니다. 하지만 Chrome DevTools를 확인하면서 원인을 찾을 수 있었습니다.

브라우저 캐시를 확인해 보기

먼저 이미지 요청의 응답 헤더를 확인했습니다.

응답에는 Cache-Control 헤더가 존재하지 않았습니다.

HTTP 캐싱에서는 Cache-Control이나 Expires와 같이 명시적인 캐시 정책이 없는 경우, 브라우저는 Last-Modified나 ETag 등의 정보를 참고해 휴리스틱 캐싱(Heuristic Caching) 을 수행할 수 있습니다.

즉, 명시적인 만료 정책이 없더라도 브라우저는 자체적인 기준에 따라 리소스를 캐시 할 수 있습니다.

이미지 요청의 Response Headers
그림 1. 이미지 요청의 Response Headers
  • Cache-Control 헤더가 없는 모습
  • Last-Modified
  • ETag
  • Date

하지만 결정적인 힌트는 Network 탭에서 발견했습니다.

text
200 OK (from disk cache)

처음에는 단순히 200 응답이라고 생각했지만, from disk cache는 브라우저가 네트워크 요청 자체를 보내지 않았다는 의미였습니다.

즉, Akamai Purge API를 통해 CDN 캐시를 제거했더라도 브라우저는 이미 로컬 디스크에 저장해 둔 이미지를 그대로 사용하고 있었던 것입니다.

Chrome DevTools Network 탭에서 200 OK (from disk cache)가 표시되는 화면
그림 2. Chrome DevTools Network 탭에서 200 OK (from disk cache)가 표시되는 화면

여기서 원인이 명확해졌습니다.

브라우저는 URL 전체를 캐시 키(Cache Key) 로 사용합니다.

예를 들어, gnb.webp 와 gnb.webp?v=1 은 서로 다른 리소스로 인식합니다.

반대로 URL이 동일하면 파일 내용이 변경되더라도 기존 캐시를 그대로 사용합니다.

결국 Purge만으로는 브라우저 캐시를 무효화할 수 없었고, 브라우저가 새로운 리소스로 인식하도록 URL 자체를 변경할 필요가 있었습니다.

해결 방법 - 타임스탬프 기반 캐시 버스팅

캐시 버스팅 방법을 리서치해 보니, 가장 일반적으로 사용되는 방식은 파일명에 콘텐츠 해시(Content Hash)를 포함하는 방법이었습니다.

예를 들어, logo.a1b2c3.svg처럼 파일명 자체에 해시를 포함하면 파일 내용이 변경될 때만 새로운 URL이 생성됩니다. 변경되지 않은 파일은 기존 캐시를 그대로 활용할 수 있어 캐시 효율이 가장 뛰어난 방식입니다.

하지만 이번에 문제가 되었던 이미지는 번들러가 관리하는 리소스가 아니라 별도로 관리되는 공용 이미지였기 때문에, 콘텐츠 해시를 자동으로 적용하기 어려운 환경이었습니다.

그래서 이번에는 빌드 시점의 타임스탬프를 쿼리스트링으로 추가하는 방식을 선택했습니다.

먼저 이미지 URL을 생성하는 fileUrl 헬퍼를 만들었습니다.

ts
export const fileUrl = (path: string) => `/images${path}?v=${__ASSET_VERSION__}`;

__ASSET_VERSION__은 Vite의 define 옵션을 이용해 빌드 시점의 타임스탬프(Date.now())를 주입하도록 설정했습니다.

js
export default defineConfig({
  define: {
    __ASSET_VERSION__: JSON.stringify(Date.now()),
  },
});

기존에는 고정된 URL을 사용했다면,

jsx
// Before
<img src="/images/gnb.webp" />

// After
<img src={fileUrl('/gnb.webp')} />

이제는 빌드될 때마다 새로운 타임스탬프가 URL에 추가되므로 브라우저는 새로운 리소스로 인식하고 최신 이미지를 다시 다운로드합니다.

이번 방식은 모든 이미지에 일괄 적용하지 않았습니다.

차량 제조사 로고나 새로고침 아이콘처럼 변경 가능성이 거의 없는 이미지는 캐시를 유지하는 편이 더 효율적입니다. 반면 GNB 이미지나 배너처럼 배포 과정에서 변경될 가능성이 있는 이미지는 사용자가 최신 이미지를 즉시 받아보는 것이 중요했습니다.

따라서 배포 시 변경될 가능성이 있는 공용 이미지에만 타임스탬프 기반 캐시 버스팅을 적용하고, 변경 가능성이 낮은 이미지는 기존 캐시를 그대로 활용하도록 적용 범위를 제한했습니다.

이 방식은 콘텐츠 해시처럼 변경된 파일만 캐시를 무효화하는 방식은 아니지만, 구현이 단순하면서도 실제 문제가 발생했던 이미지에만 적용할 수 있어 불필요한 재다운로드를 최소화하면서도 최신 이미지가 즉시 반영되도록 개선할 수 있었습니다.

Akamai CDN에서도 정상적으로 동작할까?

브라우저 캐시 문제는 해결되었지만 한 가지를 더 확인해야 했습니다.

이미지 앞단에는 Akamai CDN이 있었기 때문에, 타임스탬프가 포함된 새로운 URL을 Akamai도 별개의 객체로 인식하는지 확인할 필요가 있었습니다.

먼저 Chrome DevTools에서 Disable cache를 활성화한 뒤 강력 새로고침을 수행했습니다. 브라우저 캐시를 우회하여 실제 네트워크 요청이 발생하도록 만든 것입니다.

응답 헤더를 확인해 보니 Server-Timing에 다음과 같은 값이 표시되었습니다.

text
Hit from child

이는 해당 URL이 이미 Akamai 캐시에 존재하며, CDN이 캐시 된 이미지를 반환했다는 의미입니다.

Disable cache를 활성화한 뒤 이미지를 요청했을 때 Server-Timing: Hit from child가 표시되는 화면

하지만 이것만으로는 충분하지 않았습니다.

이미 실제 서비스에서 사용 중인 URL이었기 때문에, 이전 요청으로 인해 캐시에 저장되어 있었을 가능성이 있기 때문입니다.

정말 확인하고 싶었던 것은 한 번도 요청되지 않은 새로운 타임스탬프를 사용했을 때도 별개의 객체로 인식하는지였습니다.

이를 확인하기 위해 curl로 새로운 쿼리스트링을 가진 URL을 직접 요청했습니다.

bash
curl -sI "https://.../gnb.webp?v=1872007758"

응답 결과는 다음과 같았습니다.

text
Server-Timing: Miss from child, Miss from parent

이는 Akamai 캐시에 해당 객체가 존재하지 않아 오리진 서버까지 요청이 전달되었다는 의미입니다.

만약 Akamai가 쿼리스트링을 캐시 키에 포함하지 않았다면 기존 캐시를 그대로 사용했을 것이므로 Hit가 반환되어야 합니다.

하지만 새로운 ?v= 값을 별개의 객체로 인식해 Miss가 발생한 것을 확인했고, 이를 통해 쿼리스트링이 Akamai의 캐시 키에 포함되어 정상적으로 캐시 버스팅이 동작한다는 것을 확인할 수 있었습니다.

새로운 ?v= 값을 사용해 curl을 실행했을 때 Miss from child, Miss from parent가 표시되는 화면
그림 3. 새로운 ?v= 값을 사용해 curl을 실행했을 때 Miss from child, Miss from parent가 표시되는 화면

느낀 점

처음에는 Akamai CDN 캐시가 원인이라고 생각했습니다. 하지만 브라우저의 Network 탭을 확인하면서 from disk cache를 발견했고, 실제 원인은 브라우저 캐시라는 것을 알게 되었습니다.

이번 경험을 통해 브라우저 캐시가 어떤 기준으로 동작하는지, Cache-Control이 없는 경우 휴리스틱 캐싱이 적용될 수 있다는 점, 그리고 URL을 변경하는 방식으로 캐시 버스팅을 적용할 수 있다는 점을 새롭게 알게 되었습니다.

문제를 해결하는 것뿐만 아니라 캐시 동작 원리까지 이해할 수 있었던 좋은 경험이었다고 생각합니다.

  • #Browser Cache
  • #Cache Busting
  • #CDN
  • #브라우저 캐시
  • #이미지 캐시 버스팅
  • #캐시 버스팅
  • #프론트엔드

실무 경험의 다른 글