S3와 CloudFront를 이용해 SPA를 배포할 때 캐싱 정책 (feat. Tiered TTLs)

#React#SPA#캐싱#S3#CloudFront#Tiered TTLs

들어가며

React를 배포하는 방식에는 여러 방법이 있다. 그 중, S3와 CloudFront를 이용한 배포 방식이 있다.
S3에 정적 파일(build 폴더)을 저장하고, CDN 역할의 CloudFront가 전 세계에 배치된 엣지 로케이션에서 클라이언트의 요청을 처리하는 프록시 역할을 수행한다.

이때 콘텐츠 전달 속도를 향상하기 위해 HTTP 캐싱을 고려하게 되는데, 새로운 버전으로 업데이트 했을 때 개인 캐시와 공유 캐시에 수정사항을 반영해야 한다.
이 방법을 AWS에서 잘 안내한 가이드1가 있다.

이 글에서는 해당 가이드를 기반으로, SPA를 S3와 CloudFront를 사용해 배포할 때 권장되는 HTTP 캐싱 정책에 대해 정리하려고 한다.

가이드 개요

성능을 위해 캐시 기간을 길게 잡으면, 새로운 버전을 배포해도 사용자의 브라우저나 CDN이 "아직 기존 파일의 유효 기간이 남았다"고 판단하여 옛날 버전의 화면을 계속 보여주게 된다.
반대로 업데이트를 즉각 반영하려고 캐시 기간을 아예 없애거나 매우 짧게 잡으면, 사용자는 매번 서버에서 파일을 새로 받아와야 하므로 사이트 속도가 느려진다.

가이드에서는 이 문제를 해결하기 위해 모든 파일을 똑같이 취급하지 말고, 파일의 성격에 따라 캐시 기간을 계층적으로 나누라고 제안한다.

핵심은 파일 버전 관리(Versioning)다. 요즘의 빌드 도구들은 JS나 CSS 파일 내용이 바뀌면 main.a1b2c3.js 처럼 파일 이름 뒤에 고유한 해시값을 붙여준다. 파일 이름이 바뀐다는 것은 곧 새로운 파일이 생성됨을 의미하므로, 이 파일들은 캐시 기간을 1년 정도로 아주 길게 설정해도 안전하다. 어차피 내용이 바뀌면 이름도 바뀔 것이기 때문이다.

하지만 웹사이트의 입구 역할을 하는 index.html은 이름이 절대 변하지 않는다. 이 파일에는 짧은 캐시 기간(예: 60초)을 설정한다. 사용자가 접속할 때 index.html만 최신인지 짧게 확인하게 하고, 그 안에 적힌 파일 이름들을 통해 나머지 용량이 큰 자산들은 캐시에서 즉시 불러오게 만드는 전략이다.

이 아이디어가 가능한 이유는 HTTP 캐싱은 주로 HTTP Request의 Method와 대상 URI를 최소한의 구성으로 식별하기 때문이다. 2 즉, 파일명이 달라지면 URI가 달라지기 때문에, 파일이 업데이트하게 되면 기존에 저장된 캐시는 무조건 사용되지 않는다.

또한, 이 방식은 HTTP 명세인 RFC 9111: HTTP Caching3을 충실히 따른다.

아키텍처 개요

아래 사진은 AWS에서 SPA를 배포할 때 가장 표준적으로 사용하는 아키텍처의 물리적 흐름이다.
사용자의 요청이 최종 목적지인 S3 버킷에 도달하기까지 거치는 단계와, 그 과정에서 데이터가 어떻게 복사(캐싱)되는지를 이해하는 것이 핵심이다.
배포 아키텍처 다이어그램

전체적인 흐름은 다음과 같다.

  1. 사용자가 브라우저에서 URL을 통해 빌드 파일을 요청한다.
  2. 이 요청은 사용자의 위치와 가장 가까운 위치의 CloudFront 엣지 로케이션에 파일을 요청한다.
  3. 만약 해당 엣지 로케이션에 파일이 존재하지 않다면(Cache Miss) Origin 서버인 S3에 파일을 요청한다.
  4. CloudFront는 S3로부터 전달받은 파일을 클라이언트(브라우저)에게 전달함과 동시에 저장소에 복사본을 저장한다(Shared Cache). 이후 동일한 요청이 들어오면 해당 복사본을 곧바로 응답한다.
  5. 마지막으로 브라우저는 CloudFront에서 전달받은 파일을 렌더링하고 메모리나 디스크에 저장한다(Private Cache). 이후 사용자가 동일한 페이지에 접근할 때 저장해놓은 파일을 이용해 곧바로 렌더링한다.

이 구조에서 중요한 점은 캐싱이 두 계층에 걸쳐 일어난다는 점이다. 따라서 개발자가 파일을 업데이트했을 때, 두 곳의 캐시가 모두 갱신되지 않으면 사용자는 여전히 예전 버전의 웹사이트를 보게 된다.

해당 가이드에서 제안하는 계층형 TTL(Tiered TTLs)은 바로 이 두 가지 캐시 레이어를 어떻게 효율적으로 제어할 것인가에 대한 내용이다.
여기서 TTL은 Age와 같은 개념으로, 캐시된 응답이 CloudFront에 몇 초만큼 저장되었는지를 의미한다.

Tiered TTLs 구현하기

위에서 언급한 것을 실제로 어떻게 구성하고 배포하는지에 대해 알아보자.
핵심은 파일 성격에 따라 S3 객체 메타데이터에 서로 다른 Cache-Control 값을 부여하는 것이다.

1. index.html

  • Cache-Control: public, max-age=60, stale-while-revalidate=2592000
    • max-age=60: 브라우저와 CDN이 딱 60초만 캐싱하게 한다.
    • stale-while-revalidate5: HTTP Caching을 정의하는 RFC 9111과 달리, 별도의 확장 명세인 RFC 5861에서 정의된 지시어다. 만약 60초가 지나 캐시가 만료되었더라도, 백그라운드에서 서버에 새 파일을 확인하는 동안에는 일단 기존 캐시 파일을 사용자에게 즉시 보여주라는 의미다. 2592000초는 30일을 의미한다.

2. JS, CSS 등

  • Cache-Control: public, max-age=31536000, immutable
    • max-age=31536000: 1년동안 캐싱한다.
    • immutable6: RFC 8246에 정의된 이 지시어는, 이 파일은 절대 변하지 않는다는 것을 브라우저에게 알리는 지시어다.

3. CloudFront 캐시 정책

S3에 헤더를 잘 설정했더라도 CloudFront 설정이 이를 무시하면 소용없다.
CloudFront의 캐시 정책이 S3에서 보낸 Cache-Control 헤더를 최소값이나 최대값으로 제한하지 않도록 설정해야 한다.
이를 위해 Managed-CachingOptimized 정책을 적용하면 S3의 설정을 그대로 하는 캐시 정책을 적용할 수 있다.

AWS CLI로 자동화 구성하기

모든 파일에 일일이 헤더를 다는 것은 번거롭기에 자동화가 필요하다.
AWS CLI를 활용하여 CI/CD 파이프라인에서 명령어를 통해 배포할 수 있다.

# Use a short TTL for index.html:
aws s3 cp <sourcedir>/index.html s3://<bucketname> --cache-control 'public,max-age=60,stale-while-revalidate=2592000'

# Use a long TTL for immutable assets, e.g. JavaScript and CSS:
aws s3 cp <sourcedir>/bundle.hash778.js s3://<bucketname> --cache-control 'public,max-age=31536000,immutable'
aws s3 cp <sourcedir>/styles.hash631.css s3://<bucketname> --cache-control 'public,max-age=31536000,immutable'

하지만 이렇게 모든 파일을 직접 한 줄씩 작성하는 것은 너무 비효율적이다. 이때는 s3-spa-upload 라이브러리를 사용하면 지정한 디렉터를 S3에 업로드하고, 위에서 언급한 대로 각 파일에 Cache-Control 헤더를 설정한다.

s3-spa-upload 라이브러리는 SPA를 올바른 Content-Type, Cache-Control와 함께 S3에 업로드해주는 스크립트이다.
이 스크립트는 로컬 SPA의 빌드 디렉터리를 S3에 업로드하여 해당 S3에 있는 파일을 덮어쓴다.

index.html 파일은 마지막에 업로드되며, 참조된 JS/CSS 파일은 먼저 업로드된다. 따라서 배포 중에도 사용자는 항상 정상 작동하는 index.html 파일만 다운로드하게 된다.

s3-spa-upload의 기본 명령어 구문은 다음과 같다.
s3-spa-upload <sourcedir> <s3bucketname>

이때 --delete 옵션을 추가하는 것을 권장한다. (s3-spa-upload dist-dir my-s3-bucket-name --delete)
이 옵션은 새 파일 업로드 후, 기존 파일도 삭제하는 옵션이다. 기존 버전이 무한정 쌓이지 않도록 사용하는 것이 좋다.
다만 S3 버킷 내의 모든 파일을 삭제하기 때문에, 이 점을 유의해야 한다.

SPA 배포 시 계층형 TTL이 효율적인 이유

1. SPA의 일반적인 객체 버전 관리

현대의 빌드 도구(예: vite 등)는 JS나 CSS 파일에 고유한 해시값(예: index-ae387ba8.js)을 붙여 이름을 바꾸되, 모든 파일 경로를 담고 있는 index.html의 이름은 그대로 유지한다.
사용자는 고정된 이름의 index.html을 통해 접속하고, 브라우저는 그 안에 적힌 정보를 읽어 매번 바뀌는 최신 버전의 JS/CSS 파일을 찾는다.

2. 계층형 TTL 전략

위 구조를 바탕으로 다음과 같이 두 계층의 캐시 전략을 세울 수 있다.

  • JS/CSS 파일: public, max-age=31536000, immutable
  • index.html: public, max-age=60, stale-while-revalidate=2592000

이 전략을 사용하면, 별도의 캐시 무효화 작업 없이도 60초 이내에 전 세계 모든 사용자가 새 버전을 보게 된다.
만약 max-age를 0으로 설정하면, 매번 서버에 재검증해야 하므로 서버 부하가 늘어나고, 여러 요청을 하나로 묶어 처리하는 Request Collapsing 기능을 활용할 수 없게 되어 성능이 저하된다.

3. HTTP 조건부 요청 (Conditional Requests)

캐시가 만료되었을 때 브라우저가 서버에 다시 물어보는 과정을 재검증(Revalidation) 또는 조건부 요청(Conditional Requests) 이라고 한다.
S3는 파일을 업로드할 때 자동으로 ETag라는 고유한 식별자를 부여한다.
캐시가 만료된 브라우저는 CloudFront에 요청을 보낼 때, if-none-match 헤더에 자신이 가진 ETag 값을 담아서 보낸다.
서버는 전달받은 ETag 값을 확인하고, 파일이 변하지 않았다면 본문 데이터 없이 304 Not Midified 를 응답한다. 이 응답은 본문 데이터가 없으므로 가벼운 응답이다.
이 응답을 받은 브라우저는 파일이 수정되지 않았음을 검증하게 되고, 결과적으로 적은 데이터 전송량을 통해 성능이 향상된다.

4. s-maxage를 사용하지 않는 이유

s-maxage는 오직 CDN 같은 공유 캐시에만 적용되는 지시어이다.
보통 개인 캐시는 시간을 짧게 잡고, 공유 캐시는 시간을 길게 잡은 뒤, 필요할 때 수동으로 캐시 무효화를 하기 위해 사용한다.
하지만 가이드의 목표는 캐시 무효화 자체를 불필요하게 만드는 전략이므로, 복잡성을 줄이기 위해 별도로 s-maxage를 설정하지 않고 브라우저와 CloudFront가 동일한 전략을 따르게 한다.

stale-while-revalidate 적용 시 주의사항

stale-while-revalidate=n는 캐시가 최대 n초 이내로 Stale 한 상태일 때, 캐시 데이터를 곧바로 응답하면서 백그라운드에서는 원본 서버로 재검증 절차를 진행하는 지시어다.

이 지시어를 활용하면 지연 시간을 극한으로 줄일 수는 있지만, 경우에 따라 반드시 업데이트된 파일(index.html)을 보여줘야 하는 경우가 있을 수 있다.
예를 들어 은행 계좌 잔고나 실시간 재고 데이터 같은 경우가 있을 수 있다.
이 경우에는 stale-while-revalidate를 절대 사용하지 말고, 무조건 원본 서버로 재검증 요청을 보내도록 해야 한다.

마치며

지금까지 AWS에서 제안하는 계층형 TTL에 대해 알아보았다.
이 방식은 캐시 무효화 작업 없이도 코드의 새 버전을 배포할 수 있게 한다.
핵심은 객체의 버전 관리와 HTTP 헤더를 활용한 적절한 캐시 전략의 결합에 있다.

References


Profile picture

모든 강아지가 행복했으면 하는 꿈을 가진 개발자 김동호입니다.
주로 개발 공부, 독서・생각 기록, 유기견 봉사활동 후기 등을 기록하고 있어요.