프론트엔드 최적화로 CloudFront 비용 월 $1,170 절감한 이야기
CDN 비용은 대부분 전송량(Data Transfer Out)이 결정한다. 요청 수가 아니라 한 번에 얼마나 큰 데이터를 보내느냐가 핵심이다.
주간 비용 리포트에서 CloudFront가 전주 대비 28% 올라 있었다. 신규 배포 이후였고, 요청 수는 11% 늘었는데 전송량은 50% 늘었다. 요청당 전송 크기가 비정상적으로 커진 것이다. 원인을 추적해보니 프론트엔드 이미지 처리 방식에 문제가 있었다.
매주 자동으로 생성되는 AWS 비용 리포트를 확인하다가 이상 수치를 발견했다.
| 서비스 | 전주 | 이번 주 | 증감 |
|---|---|---|---|
| CloudFront | $968 | $1,238 | +$270 (+28%) |
주간 $270이면 월로 환산하면 약 $1,170이다. 무시할 수 없는 금액이었다.
CloudFront에 여러 배포판(distribution)이 연결되어 있었다. CloudWatch 지표로 배포판별 전송량을 확인했다.
| 지표 | 변경 전 | 변경 후 | 증감 |
|---|---|---|---|
| 일평균 전송량 | 694GB | 1,046GB | +50.7% |
| 평균 응답 크기 | 205KB | 278KB | +35.6% |
| 요청 수 | - | - | +11.2% |
CDN 도메인 cdn.example.com이 전체 증가분의 대부분을 차지했다. 요청 수는 11.2%만 늘었는데, 평균 응답 크기가 205KB에서 278KB로 35.6% 커졌다. 같은 수의 요청을 처리하는데 더 많은 데이터를 보내고 있었다.
CDN 오리진인 S3 버킷을 확인했다. 해당 기간에 변경된 파일은 5개, 총 12MB에 불과했다. 대용량 파일이 새로 올라간 게 아니었다.
배포 이력을 확인했다. 문제 시점에 프론트엔드 프로젝트에서 카드 목록/상세 페이지가 신규 배포되었다.
1/21 배포: FE-1234 카드 리스트 및 상세페이지 퍼블리싱 및 API 연동
- 67개 파일 변경
- vue-core, vue3-lottie 패키지 추가
새 파일이 S3에 올라간 게 아니라, 기존에 있던 대용량 에셋을 새 페이지에서 참조하기 시작한 것이다. 카드 이미지들은 이미 CDN에 있었지만, 카드 페이지가 없었을 때는 요청 자체가 없었다.
카드 페이지의 프론트엔드 코드를 확인했다. 두 가지 문제가 있었다.
카드 목록 페이지에서 이미지를 렌더링하는 코드:
|
|
loading="lazy" 속성이 없었다. 카드 목록 페이지에 접속하면 37개 카드 이미지(총 7.5MB)가 한 번에 로딩되었다. 사용자가 스크롤하지 않아도 화면 밖의 이미지까지 전부 다운로드한다.
CDN 버킷 전체를 점검했다. 최적화되지 않은 에셋이 대량으로 존재했다.
| 대상 | 파일 수 | 현재 용량 | 문제 |
|---|---|---|---|
| GIF (애니메이션) | 73개 | 870MB | 동영상 포맷 대비 10~50배 비효율 |
| PNG (recruit) | 158개 | 569MB | WebP 미변환 |
| PNG (platform) | 376개 | 690MB | WebP 미변환 |
| 비정상 SVG | 2개 | 31.6MB | 래스터 이미지가 base64로 임베딩 |
| JPG | 242개 | 193MB | WebP 미변환 |
특히 GIF 파일 중 10MB를 초과하는 파일이 17개(750MB) 있었다. GIF는 프레임마다 전체 이미지를 저장하는 포맷이라, 애니메이션 길이가 길어지면 용량이 급격히 커진다.
pie title CDN 에셋 용량 비중
"GIF (870MB)" : 870
"PNG recruit (569MB)" : 569
"PNG platform (690MB)" : 690
"JPG (193MB)" : 193
"비정상 SVG (31.6MB)" : 32
우선순위를 정하고 단계적으로 적용했다.
이미지 컴포넌트를 만들어 lazy loading을 기본 적용했다.
|
|
HTML5 네이티브 loading="lazy" 속성을 사용했다. 브라우저가 뷰포트 근처의 이미지만 로딩하고, 나머지는 스크롤할 때 로딩한다. 별도 라이브러리 없이 브라우저 네이티브 기능만으로 충분했다.
카드 상세 페이지의 슬라이더처럼 즉시 보여야 하는 경우에는 no-lazy prop으로 eager 로딩을 유지했다.
가장 큰 용량 절감 효과를 기대할 수 있는 부분이었다. GIF를 동영상 포맷으로 변환했다.
|
|
| 변환 | 파일 수 | 변환 전 | 변환 후 | 절감 |
|---|---|---|---|---|
| GIF → MP4/WebM | 73개 | 870MB | ~150MB | ~720MB (83%) |
autoplay loop muted playsinline 속성으로 GIF와 동일한 사용자 경험을 유지했다. muted가 없으면 모바일에서 자동 재생이 안 되므로 주의해야 한다.
나머지 이미지를 WebP로 변환했다.
| 변환 | 파일 수 | 변환 전 | 변환 후 | 절감 |
|---|---|---|---|---|
| PNG (recruit) → WebP | 158개 | 569MB | ~170MB | ~400MB (70%) |
| PNG (platform) → WebP | 376개 | 690MB | ~210MB | ~480MB (70%) |
| JPG → WebP | 242개 | 193MB | ~93MB | ~100MB (52%) |
WebP는 PNG 대비 70%, JPG 대비 50% 정도 용량이 줄어든다. 브라우저 호환성도 2026년 기준으로 97% 이상이라 fallback 없이 사용해도 무방했다.
14.5MB, 17.1MB짜리 SVG 파일 2개를 확인했더니, 래스터 이미지가 base64로 임베딩되어 있었다. SVG 안에 PNG 데이터가 통째로 들어 있던 것이다. 래스터 부분을 별도 이미지로 분리하고 SVG는 벡터 데이터만 남겼다.
flowchart LR
Before["변경 전<br/>카드 목록 접속 시<br/>37개 × ~200KB<br/>= 7.5MB/요청"] --> After["변경 후<br/>뷰포트 내 이미지만<br/>~5개 × ~100KB<br/>= ~0.5MB/요청"]
style Before fill:#ef4444,color:#fff
style After fill:#16a34a,color:#fff
| 항목 | 변경 전 | 변경 후 | 절감 |
|---|---|---|---|
| 주간 CloudFront 비용 | $1,238 | ~$970 | ~$270/주 |
| 월간 환산 | ~$5,360 | ~$4,200 | ~$1,170/월 |
Lazy loading만으로도 카드 페이지의 전송량이 크게 줄었고, 이미지 포맷 변환까지 적용하면 전체 CDN 전송량 자체가 줄어드는 효과가 있었다.
| 최적화 | 절감량 |
|---|---|
| GIF → MP4/WebM | ~720MB |
| PNG → WebP | ~880MB |
| JPG → WebP | ~100MB |
| SVG 정리 | ~30MB |
| 합계 | ~1,730MB |
신규 페이지 배포가 CDN 비용에 영향을 줄 수 있다는 건, 배포 후에 비용 지표도 확인해야 한다는 뜻이다. 기능은 정상 동작하지만 비용은 비정상인 경우가 있다.
이번 케이스에서 요청 수는 11%만 늘었다. 요청 수만 보면 정상이다. 하지만 평균 응답 크기가 35% 커지면서 전송량이 50% 늘었다. CDN 비용 분석 시 BytesDownloaded와 평균 응답 크기를 함께 봐야 한다.
S3에 파일을 올리는 시점에 포맷 변환과 리사이징이 되어야 한다. 나중에 일괄 변환하면 기존 URL을 참조하는 코드를 모두 수정해야 하는 문제가 생긴다. 업로드 파이프라인에 이미지 처리를 넣는 게 장기적으로 맞다.
GIF는 256색 제한, 압축 효율 낮음, 용량 거대. 애니메이션이 필요하면 MP4/WebM을 쓰자. <video autoplay loop muted playsinline>으로 GIF와 동일한 UX를 줄 수 있다.