[트러블 슈팅] HTTP 캐싱

·2025년 5월 18일
0

React

목록 보기
13/13
post-thumbnail

바야흐로 목요일, 평소와 같이 배포를 했는데 갑자기 사용자들에게 앱이 흰 화면으로 보인다는 이슈가 인입이 되었다😱
S3 + CloudFront + Next.js 웹뷰 환경에서 캐싱 문제 트러블슈팅한 이야기!! 두두등장

최근 프론트엔드 배포 시스템을 AWS S3와 CloudFront 조합으로 이전한 뒤, 캐싱 문제로 사용자들에게 흰 화면이 발생하는 심각한 이슈를 겪었다.
웹뷰 환경에서는 작은 캐시 이슈도 치명적으로 작용한다는 것을 이번 경험을 통해 절실히 느꼈다.

이 글에서는 문제를 어떻게 분석하고 해결해갔는지 과정을 정리해보았다.

문제 상황: 평소처럼 배포했는데 흰 화면?

어느 날, 앱 고객센터로부터 "앱을 열었더니 흰 화면만 보여요"라는 제보가 들어왔다.

우리는 이전에도 배포를 여러 번 해왔고, 별다른 문제가 없었기 때문에 처음엔 뭔가 일시적인 문제겠거니 했다.

하지만 직접 웹에서 열어보니 콘솔에 아래와 같은 에러가 있었다.

_app.js?ts=1747359952985:498 Uncaught SyntaxError: Invalid or unexpected token

이후 새로고침을 하면 정상적으로 화면이 로딩되었다.

이 증상은 일부 사용자에게만 발생했고, 새로고침을 하지 않은 상태에서 내부 팝업 등을 통해 라우팅할 때 주로 발생했다.


원인 분석: 캐시된 오래된 JS 파일과 최신 HTML의 충돌

콘솔 에러를 본 뒤, 네트워크 탭을 확인해봤다.

에러가 나는 시점에는 main-7b9a라는 오래된 JS 파일이 로딩되고 있었고, 정상일 때는 main-f1ef이라는 최신 JS 파일이 로딩되었다.

즉,

  • 사용자의 브라우저에는 이전 배포 시점의 JS 파일이 캐시되어 있었고
  • HTML은 새로운 버전으로 불러오면서 버전이 불일치하게 되었다.
  • 이 때문에 구버전 JS로 최신 HTML을 해석하려다 에러가 발생한 것이다.

환경 변화: 왜 갑자기 이런 문제가 생겼을까?

기존에는 프론트엔드를 다른 방식으로 배포하고 있었지만, 최근 S3 + CloudFront로 전환한 이후부터 이런 문제가 생기기 시작했다.

이 환경에서는 브라우저 캐싱이 더욱 강력하게 동작하기 때문에, 의도하지 않으면 캐시가 쉽게 무효화되지 않는다.


처음 시도한 해결 방법: 캐싱 정책 변경

우리는 HTTP 캐싱을 문제의 원인으로 보고, 배포 워크플로우의 aws s3 sync 명령에 캐시 무효화 정책을 추가했다.

적용한 캐시 정책은 다음과 같다:


# _next/static/* → 캐시 X
aws s3 sync out/_next/static s3://.../_next/static \
  --cache-control "no-cache, no-store, must-revalidate"

# *.html → 캐시 X
aws s3 sync out/ s3://... \
  --include "*.html" \
  --cache-control "no-cache, no-store, must-revalidate"

# 나머지 정적 자산 → immutable 캐시 (1년)
aws s3 sync out/ s3://... \
  --exclude "_next/static/*" \
  --exclude "*.html" \
  --cache-control "max-age=31536000, public, immutable

CloudFront invalidation도 함께 설정하여, 모든 경로에 대해 캐시를 무효화하도록 했다.

aws cloudfront create-invalidation --paths "/*"

그런데 문제는 해결되지 않았다

우리는 완벽하게 캐시 정책을 적용했다고 생각했지만, 여전히 사용자에게 흰 화면이 간헐적으로 나타났다.

원인은 생각보다 단순한 곳에 있었다.


원인 재발견: -include / -exclude 옵션의 맹점

S3에 파일을 업로드할 때 aws s3 sync는 한 번의 명령에 대해 하나의 --cache-control만 적용할 수 있다.

그런데 우리는 아래처럼 --include "*.html" 같은 옵션을 쓰고 있었다.

aws s3 sync out/ s3://... \
  --include "*.html" \
  --cache-control "no-cache, no-store, must-revalidate"

문제는, --include 옵션이 있다고 해서 해당 파일만 cache-control이 설정되는 게 아니라는 점이다.

정확히는:

  • -include대상 파일을 필터링하는 역할만 하고
  • 그 외 모든 파일도 함께 업로드되지만, 동일한 cache-control이 적용된다

그래서 의도치 않게 .js나 이미지 파일에도 no-cache가 적용되었거나, 반대로 .htmlimmutable이 적용되어 있었을 수도 있다.

📌 참고 레퍼런스:

aws s3 sync 공식 문서


두 번째 접근: 서비스 워커를 직접 구현

캐시 문제를 사용자 단에서 최대한 자동으로 해결하기 위해, 서비스워커를 직접 구현해보기로 했다. 기존에는 서비스워커가 존재하지 않았기 때문에, 이슈가 발생했을 때 사용자가 강제 새로고침을 하지 않으면 캐싱된 오래된 리소스를 계속 불러오는 문제가 있었다.

서비스워커의 역할

이번 이슈를 해결하기 위한 서비스워커는 다음과 같은 목적을 갖는다:

  • 정적 리소스의 최신화 감지 및 캐시 삭제
  • 오프라인 환경 대응
  • 에러 발생 시 자동으로 캐시 정리 시도
  • 명시적인 캐시 삭제 API 제공

주요 구현 내용

  1. 설치 시 사전 캐싱 (install)

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

    배포 시 정의된 정적 파일들을 미리 캐싱하여, 앱 로딩 시 일부 파일들이 빠르게 불러올 수 있도록 하고 skipWaiting()을 통해 새 버전이 즉시 활성화될 수 있도록 했다.

  2. 이전 버전 캐시 삭제 (activate)

    self.addEventListener('activate', (event) => {
      event.waitUntil(
        caches.keys().then((cacheNames) =>
          Promise.all(
            cacheNames.map((cacheName) => {
              if (cacheName !== CACHE_NAME) return caches.delete(cacheName);
            })
          )
        )
      );
      self.clients.claim();
    });
    

    새로운 캐시 이름이 등록되면, 이전 버전의 캐시를 자동으로 제거함으로써 충돌을 방지했다.

  3. 네트워크-우선 or 캐시-우선 전략 (fetch)

    • _next/static, .js 등은 네트워크 우선
    • 나머지 리소스는 캐시 우선

    이는 최신 JavaScript 코드가 반드시 반영되도록 하기 위해서다.

  4. 스크립트 로딩 실패 시 자동 캐시 삭제

    HTML 내에 <script>로 다음과 같은 처리를 추가했다:

    window.addEventListener('error', function(event) {
      if (event.target?.src?.includes('_next/static')) {
        window.clearCaches();
      }
    }, true);
    

    Next.js의 정적 JS 파일 로딩이 실패했을 경우 자동으로 캐시를 삭제하고 새로고침을 유도한다.

  5. 수동 캐시 정리용 메시지 이벤트 (message)

    self.addEventListener('message', (event) => {
      if (event.data?.type === 'CLEAR_CACHES') {
        caches.keys().then((keys) => Promise.all(keys.map(caches.delete)));
      }
    });
    

    앱 내에서 serviceWorker.controller.postMessage({ type: 'CLEAR_CACHES' })로 호출하여 명시적으로 캐시 정리를 요청할 수 있다.

  6. URL 파라미터 or 로컬 스토리지를 이용한 캐시 무효화

    앱이 열릴 때 다음과 같이 처리했다:

    const forceRefresh = urlParams.get('force_refresh') || localStorage.getItem('refreshCache');
    

    URL에 ?force_refresh=true가 붙어있거나, 로컬 스토리지에 특정 플래그가 있을 경우 자동으로 캐시와 쿠키를 삭제하고 새로고침을 수행한다.

한계

  • Service Worker는 최신 브라우저에서만 동작한다.
  • 웹뷰 환경마다 서비스워커 지원 범위가 다르기 때문에 일부 디바이스에서는 완벽하게 동작하지 않는다.
  • 사용자가 앱을 완전히 종료하지 않는 이상, 캐시 정리 후 적용이 지연될 수 있다.

그래서 팀원과 논의한 결과, 서비스워커만으로는 완전한 해결이 어렵다고 판단하여 앱 단에서도 웹뷰 초기 로딩 시 캐시 삭제 처리를 추가로 구현했다.


병행 접근: 앱 웹뷰 단에서 캐시 삭제 처리

팀원은 앱단에서 웹뷰를 초기화할 때 캐시를 삭제하는 방식으로 대응했다.

이는 대부분의 이슈를 해결할 수 있었고, 서비스 워커 방식과 병행하면서 흰 화면 문제는 거의 사라졌다. 하지만 캐싱에 대한 이점을 상쇄하는 해결방법이기 때문에 지양해야하는 해결법이다.


결론: 캐시는 의도하지 않으면 무서운 존재다

이번 경험을 통해,

  • S3 + CloudFront의 캐시 정책은 매우 정확하게 컨트롤해야 하며
  • Next.js의 아키텍처에 맞춰 HTML과 JS의 버전 동기화를 신경 써야 하고
  • 웹뷰 환경에서는 브라우저 캐시 문제가 더 치명적이라는 걸 배웠다.

서비스 워커나 앱단 캐시 삭제는 보완책일 뿐, 근본적으로는 HTTP 캐시 설정과 빌드 방식을 꼼꼼히 점검하는 게 정답이었다.


향후 고려할 수 있는 개선 방안

  • CloudFront의 캐시 정책을 Lambda@Edge나 Response Header Policy로 명확히 설정
  • Next.js에서 appDir 또는 serverActions를 사용해 더 동적인 빌드 구조 도입

마무리

이번 경험을 통해 배포 환경과 캐시 정책의 디테일이 얼마나 중요한지 깨달았다.

단순히 "잘 되겠지"라는 안일한 배포보다는, 작은 설정 하나에도 정확한 이해와 실험이 필요하다는 걸 다시금 확인했다.

누군가 비슷한 문제를 겪는다면 이 글이 도움이 되길 바란다.

profile
중요한 건 꺾여도 다시 일어서는 마음

0개의 댓글