루티(Routie) 서비스에서 캐시 미적용 이슈를 추적하고 해결한 기록입니다.

문제 발생

제가 운영 중인 루티(Routie)에서는 사용자가 페이지를 새로고침하거나 재방문할 때마다
JS, 폰트, 이미지 등 정적 리소스가 매번 네트워크에서 다시 다운로드되는 문제가 있었습니다.

Lighthouse 측정 결과를 살펴보니, 서비스의 정적 리소스에 캐시가 전혀 설정되어 있지 않아 매번 약 2.35MB(2,402KiB) 의 파일이 반복적으로 전송되고 있었습니다.

또한 외부 지도 리소스의 TTL(Time To Live)도 약 6시간으로 짧게 설정되어 있어

효율적인 캐시 정책이 적용되지 않았다

라는 경고가 표시되었습니다.

결과적으로 루티(Routie)의 정적 리소스들은 캐시 없이 매번 새로 요청되고 있었고, 그로 인해 LCP, FCP 등 핵심 웹 성능 지표가 낮게 측정되며 반복적인 네트워크 트래픽 낭비, 로딩 지연, 사용자 데이터 사용량 증가 등 전반적인 성능 저하가 발생하고 있었습니다.

저는 이 문제의 원인을 정확히 분석하고, 정적 리소스 캐시 정책을 직접 설정해 개선해보기로 했습니다.

캐시란?

이 문제의 핵심 원인은 캐시(Cache) 가 제대로 동작하지 않았던 것이었습니다.

캐시는 한 번 받은 데이터를 브라우저, CDN, 프록시, 서버 등의 저장소에 임시로 저장해두었다가, 동일한 요청이 다시 발생할 때 네트워크를 거치지 않고 저장된 데이터를 즉시 제공하는 기술입니다.

즉, 한 번 내려받은 JS, CSS, 이미지, 폰트 등의 정적 리소스를 다시 다운로드하지 않고 재사용함으로써 페이지 로딩 속도를 높이고, 서버 트래픽을 줄이는 역할을 합니다.

이를 통해 다음과 같은 이점을 얻을 수 있습니다.

  • 페이지 로딩 속도 개선
  • 서버 부하 및 네트워크 트래픽 절감
  • 사용자 경험(UX) 향상

캐시의 종류

웹에서 사용되는 캐시는 주로 세 가지로 구분할 수 있습니다.

브라우저 캐시:
사용자의 로컬 디스크에 JS, CSS, 이미지, 폰트 등을 저장해 재방문 시 네트워크 요청 없이 빠르게 재사용합니다.

CDN/프록시 캐시:
여러 사용자가 공유하는 캐시로, 엣지 서버에서 데이터를 분산 저장해 전 세계 어디서나 빠른 응답을 제공합니다.

서버/DB 캐시:
백엔드 서버 내부에서 DB 조회 결과나 동적 데이터를 임시로 보관해 서버 응답 속도를 향상시킵니다.

저는 프론트엔드 개발자로서, 이 중 브라우저 캐시와 CDN 캐시에 초점을 맞춰 문제를 분석했습니다.

브라우저 캐시

브라우저는 페이지를 로드할 때 필요한 JS, CSS, 이미지 등 많은 데이터를 네트워크를 통해 내려받습니다. 이때 브라우저는 페이지 로딩 속도를 향상시키기 위해 해당 리소스들을 로컬 디스크나 메모리에 저장(캐시) 합니다.

이렇게 저장된 리소스는 TTL(Time To Live)이 만료되기 전까지는 네트워크를 거치지 않고 로컬에서 즉시 읽어오게 됩니다. 따라서 한 번 방문한 페이지는 다음 접속 시 훨씬 더 빠르게 로드됩니다.

물론 사용자가 직접 캐시를 삭제하거나 TTL이 만료되면, 브라우저는 다시 네트워크를 통해 리소스를 새로 받아오게 됩니다.

CDN 캐시

CDN 캐시는 사용자의 요청을 원본 서버가 아닌, 사용자와 가까운 엣지 서버(프록시 서버)에서 처리하는 방식을 말합니다. 여기서 프록시 서버는 사용자의 요청을 대신 받아 원본 서버로 전달하고, 응답을 캐시해두었다가 다음 요청이 들어오면 원본에 가지 않고 바로 응답하는 중간 서버입니다.

이 그림에서 CDN은 프록시 서버 역할을 합니다. 사용자의 요청을 대신 받아 원본 서버(미국)에 전달하고, 한 번 받은 응답을 도쿄의 서버에 캐시해 두었다가 같은 요청이 다시 오면 원본까지 가지 않고 바로 응답합니다.

이렇게 되면 서버가 요청을 하는 사용자와 더 가깝기 때문에 CDN이 콘텐츠를 더 빠르게 전달할 수 있습니다.

캐시가 잘 동작하는지 확인하기

그렇다면 요청이 CDN 캐시에서 응답된 것인지, 아니면 원본 서버에서 내려온 것인지 어떻게 구분할 수 있을까요?

이때 참고할 수 있는 지표가 X-Cache 응답 헤더입니다.

X-Cache 값의미
Hit from cloudfront캐시에서 응답
Miss from cloudfront원본 서버에서 가져옴
RefreshHit from cloudfront캐시 만료 전 재검증 후 응답
Error from cloudfront캐시 처리 중 오류

Time to Live(TTL)

TTL은 콘텐츠가 캐시에서 유효하게 유지되는 시간을 의미합니다. 즉, 캐시된 데이터가 원본 서버에서 새 복사본을 가져오기 전까지 얼마 동안 저장되어 있어야 하는지를 결정하는 값입니다.

TTL이 짧으면 자주 새 요청이 발생해 네트워크 낭비가 생기고, 너무 길면 변경된 리소스가 즉시 반영되지 않을 수 있습니다. 개발자는 리소스 특성에 맞게 TTL을 조정해야 합니다.

루티(Routie)의 문제

이제 다시 루티(Routie) 서비스로 돌아와 보겠습니다.

Lighthouse 리포트를 확인해보니 모든 정적 리소스의 TTL이 none으로 표시되어 있었습니다. "그럼 캐시가 안 되었겠네"라고 생각하며 응답 헤더를 열어봤는데 예상 밖의 결과를 확인할 수 있었습니다.

해당 리소스들에 전부 hit from cloudfront라고 찍혀있는 걸 확인 할 수 있었습니다.

캐시가 작동하고 있는데, Lighthouse는 왜 캐시가 없다고 말한걸까요..?

왜 Lighthouse는 ‘TTL none’이라 하고, 응답은 ‘Hit from CloudFront’일까?

처음엔 캐시가 너무 짧게 설정되어서 그런가 싶었지만, 확인 결과 아예 설정되지 않은 상태였습니다. TTL이 0초라면 캐시가 생성되자마자 만료되는 상태인 반면에 none이라면 캐시 유효시간이 아예 설정되지 않아 캐시가 동작하지 않는 상태를 의미합니다.
즉, 이번 경우는 캐시가 아예 적용되지 않았다는 뜻이었습니다.

브라우저 캐시와 CDN 캐시의 차이

원인을 찾아보니까 캐시의 계층 차이 때문에 생긴 현상이였습니다.

CDN 캐시만 생각해보겠습니다. 사용자가 루티 서비스에 접속하면 서비스의 리소스들을 CloudFront 엣지 서버에 요청합니다. CloudFront는 원본인 S3에서 리소스를 가져와서 자체 캐시에 저장하게 되고 다음 요청부터는 CloudFront가 캐시에서 바로 응답합니다. CloudFront는 S3 원본에서 한 번 받아온 리소스를 자체적으로 캐시해두었기 때문에 CDN 수준에서는 Hit으로 표시됩니다.

여기까지는 서버(s3)-CDN(cloudfront)간의 캐시가 잘 동작한 것입니다.

하지만 CloudFront가 브라우저에게 응답을 보낼 때, 응답 헤더에 Cache-Control 과 같은 브라우저 캐시 정책이 포함되어 있지 않았습니다. 이렇게 응답 헤더에 브라우저 캐시 정책을 실어보내지 않으면 브라우저는

이걸 언제까지 저장해야 하는지 모르겠네?

라고 생각해 매번 새로 요청을 보낸다고 합니다.

그리고 Lighthouse는 CloudFront의 Hit 여부는 전혀 고려하지 않고 오직 브라우저 캐시 헤더만 검사를 한다고 합니다. 이 때문에 TTL = none이라고 표시되었던 것이였습니다.

즉, CDN 캐시는 잘 동작했지만 브라우저 캐시가 완전히 비활성화된 상태였던 것입니다.

이 문제를 해결하기 위해서 저는 응답 헤더에 Cache-Control 정책을 설정을 해주기로 했습니다.

Cache-Control

  • HTTP/1.1에서 명시적으로 캐시를 제어하는 헤더입니다.
  • 다양한 값으로 캐시 정책을 다층적으로 설정 가능합니다.
  • 대표 디렉티브:
    • public: 모두 캐시 가능
    • private: 개별 캐시
    • no-cache: 원본 재검증 필수
    • no-store: 아예 저장 금지 (보안, 민감정보 적용)
    • max-age: TTL(초), 일반적으로 86400(1일), 31536000(1년) 등
    • immutable: 변경 없을 때 재검증 불필요

이 외에도 ETag, Last-Modified, Expires와 같은 HTTP 헤더 종류가 있습니다.

응답 헤더에 캐시 정책 적용하기

일단 결과적으로 저는 앱의 진입점이 되는 index.html에는 no-cache를, JS/CSS/이미지/폰트 등의 정적 리소스에는 public, max-age=31536000, immutable(1년)을 설정해주었습니다.

이렇게 캐시 설정을 다르게 해주는 이유는 SPA의 동작 구조 때문입니다.

SPA에서 HTML은 왜 캐시하면 안 될까?

SPA의 index.html은 앱의 진입점이자 JS·CSS 파일을 로드하는 관문입니다.

이 HTML 파일 안에는 <script src="/main.abc123.js"> 와 같이 빌드된 JS 파일의 경로가 포함되어 있습니다.

빌드할 때마다 JS 파일 이름이 해시로 바뀌기 때문에

build 1 → main.a1b2c3.js
build 2 → main.d4e5f6.js

새 버전 배포 후에는 반드시 최신 index.html을 받아와야 새로운 JS를 인식할 수 있습니다.

만약 HTML을 캐시해버리면

사용자는 새 버전 배포 이후에도 로컬 캐시에 있던 옛날 index.html을 그대로 쓰게됩니다. 그 안의 <script src="/main.a1b2c3.js"> 도 예전 파일일 것이고 배포 후에도 옛 버전의 JS가 계속 실행되어 문제가 될 수 있습니다.

반면에 JS/CSS/이미지/폰트는 오래 캐시해도 된다

저는 웹팩 설정에서 빌드 시마다 변경된 청크에 대해 해시값(contenthash)을 갱신하도록 설정해두었습니다.

이 설정 덕분에 JS와 CSS 파일은 빌드될 때마다 파일명에 해시값이 자동으로 붙고, 파일 내용이 수정되면 새로운 해시값이 생성되어 파일 이름 또한 함께 변경됩니다.

output: {
      filename: '[name].[contenthash].js',
      chunkFilename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
      assetModuleFilename: 'assets/[name].[contenthash][ext][query]',
      publicPath: '/',
    },

예를 들어 다음과 같이 빌드 버전이 바뀔 수 있습니다.

main.abc123.js → main.d4e5f6.js

즉, 파일이 수정될 때마다 완전히 새로운 이름으로 배포되기 때문에, 이전 버전의 캐시는 자동으로 무효화되고 브라우저는 새로운 파일을 다시 다운로드하게 됩니다.

따라서 JS, CSS, 이미지, 폰트와 같은 정적 리소스에는 1년 이상의 장기 캐시를 설정하더라도 변경 사항이 즉시 반영되는 안전한 캐시 구조를 유지할 수 있습니다.

이처럼 index.html과 JS/CSS/이미지/폰트의 캐시 정책을 구분해 설정해야 하는 이유는, 각 리소스의 역할과 변경 주기가 다르기 때문입니다. HTML은 항상 최신 버전을 불러와야 하지만, 정적 리소스는 해시를 통한 버전 관리로 안전하게 캐시할 수 있습니다.


이론은 이제 완벽하기 때문에 실제로 프로젝트에 적용해보도록 하겠습니다.

실제 적용 (S3 콘솔 수동 설정)

람다를 통해 s3에 파일을 올릴 때 설정을 해주어도 되고, CloudFront의 응답 헤더 정책을 통해서 설정해주어도 되지만 저는 간단하게 캐시가 잘 되는지 실험해보기 위해서 s3 콘솔에서 수동으로 설정해주도록 하겠습니다.

먼저 index.html의 메타데이터에 캐시 설정을 추가해줍니다. (no-cache)

index.html을 제외한 나머지 정적 리소스들에도 캐시설정을 해주겠습니다 (public, max-age=31536000, immutable)

캐시 설정 적용 후 CloudFront에서 무효화를 수행해 새 캐시 정책이 반영되도록 했습니다.

결과 확인

적용 후 응답 헤더를 확인해보니, 정적 리소스 요청에 Cache-Control 헤더가 정상적으로 포함되어 있습니다.

Lighthouse도 다시 측정해보겠습니다.

외부 지도 리소스를 제외한 모든 캐시 관련 경고가 제거되었고, LCP와 FCP 지표도 눈에 띄게 개선되었습니다. 😋

결론

정리하자면, 기존 루티(Routie) 서비스는 CDN 캐시만 동작하고 브라우저 캐시가 설정되지 않아 매번 동일한 리소스를 다시 다운로드하며 성능 저하가 발생하고 있었습니다.

이에 HTML에는 no-cache, 정적 자산(JS, CSS, 이미지 등)에는 public, max-age=31536000, immutable을 적용해 브라우저 캐시를 활성화했습니다.

그 결과 Lighthouse의 캐시 경고가 모두 해소되었고, 재방문 및 새로고침 시 성능 지표가 향상되었습니다. 🧎‍♀️

마지막으로 외부 지도 리소스는 제공사의 TTL 정책에 따라 여전히 캐시 제한이 있으므로, 추후 이 부분은 개선 방안을 찾아 캐시 정책을 보완할 계획입니다.

https://www.cloudflare.com/ko-kr/learning/cdn/what-is-caching/
https://www.cloudflare.com/ko-kr/learning/cdn/glossary/time-to-live-ttl/
https://developer.mozilla.org/ko/docs/Web/HTTP/Reference/Headers/Cache-Control

profile
공부중

0개의 댓글