
S3 + CloudFront: 정적 파일 서빙의 정석
이미지와 정적 파일을 서버에서 직접 서빙하면 트래픽이 몰릴 때 서버가 위험해진다. S3 + CloudFront 조합으로 정적 파일 서빙을 분리하는 방법을 정리했다.

이미지와 정적 파일을 서버에서 직접 서빙하면 트래픽이 몰릴 때 서버가 위험해진다. S3 + CloudFront 조합으로 정적 파일 서빙을 분리하는 방법을 정리했다.
서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

미국 본사 서버에서 영상을 쏘면 버퍼링 때문에 망합니다. Akamai가 만든 '인터넷 배달 지점' 혁명부터, 일관된 해싱(Consistent Hashing), Edge Computing까지 심층 분석합니다.

서버를 직접 사거나 관리하지 마세요. 코드가 실행되는 0.1초만큼만 돈을 내는 클라우드의 혁명. FaaS의 원리부터 Cold Start 문제 해결, 그리고 비용 절감 효과까지.

처음엔 간단했다. Next.js 앱 서버에서 이미지 몇 개 서빙하는 게 뭐가 어렵나 싶었다. /public 폴더에 이미지 넣고, <img src="/images/product.png" /> 쓰면 끝. 작은 규모에서는 잘 돌아간다.
그런데 트래픽이 갑자기 몰리는 상황이 오면 이야기가 달라진다. 이미지 요청이 초당 수백 건씩 들어오면 CPU 사용률이 치솟고, 실제 API 요청들이 타임아웃으로 떨어지기 시작한다. SNS 바이럴이 터지거나 예상치 못한 트래픽 스파이크가 오면 정적 파일 서빙만으로도 서버가 위험해진다는 사례가 있다.
이미지 서빙 때문에 진짜 서비스가 죽는 구조다. 결론은 명확했다. 정적 파일은 애플리케이션 서버가 처리할 일이 아니다. 마치 배달 음식 주문받는 직원이 직접 배달까지 뛰어다니는 격이었다. 역할을 분리해야 했다.
AWS S3에 파일을 올리고 CloudFront로 전 세계에 배포하는 구조로 바꾸면 이 문제가 해결된다. 이 조합이 정석으로 자리잡은 이유가 있었다.
S3는 저장소다. 파일을 던져놓으면 알아서 보관해준다. 내구성 99.999999999%(11 nines)를 보장한다는데, 솔직히 실감은 안 나지만 "파일 잃어버릴 걱정은 하지 마라"는 뜻이다. 용량도 사실상 무제한이다.
CloudFront는 배달망이다. 전 세계 400개 이상의 엣지 로케이션에 파일을 복사해두고, 사용자와 가장 가까운 곳에서 파일을 내려준다. 서울 사용자는 서울 엣지에서, 뉴욕 사용자는 뉴욕 엣지에서 받는다. 지구 반대편 서버까지 갈 필요가 없다.
이 둘을 합치면 "어디서든, 빠르게, 무한히 확장 가능한" 정적 파일 서빙 시스템이 완성된다. 트래픽이 10배 늘어나도 내 서버는 모른다. CloudFront가 다 처리한다.
S3 버킷을 만들 때 가장 큰 실수는 버킷을 public으로 열어놓는 것이다. "파일 공유하려면 public이어야 하지 않나?" 싶지만, 아니다.
# AWS CLI로 버킷 생성
aws s3 mb s3://my-static-files --region us-east-1
# Public Access Block 설정 (CRITICAL)
aws s3api put-public-access-block \
--bucket my-static-files \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
버킷은 완전히 비공개로 유지한다. CloudFront만 접근할 수 있게 하면 된다. 이게 Origin Access Control(OAC)의 핵심이다.
과거엔 Origin Access Identity(OAI)를 썼는데, 이제 AWS가 OAC로 넘어가라고 밀어붙이고 있다. OAC가 더 안전하고 SSE-KMS 암호화도 지원한다.
// S3 Bucket Policy - CloudFront OAC만 허용
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-static-files/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXAMPLEDISTID"
}
}
}
]
}
이 정책을 S3 버킷에 붙이면, 세상 누구도 직접 S3 URL로 파일을 못 받는다. 오직 CloudFront를 통해서만 접근 가능하다. 보안도 보안이지만, 비용 관점에서도 중요하다. 사람들이 CloudFront를 우회해서 S3로 직접 요청하면 데이터 전송 비용이 훨씬 비싸진다.
CloudFront 배포(distribution)를 만들 때 핵심은 캐시 전략이다. 잘못 설정하면 파일이 안 바뀌거나, 비용이 폭발한다.
// Next.js에서 파일 업로드 시 Cache-Control 헤더 설정
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3Client = new S3Client({ region: "us-east-1" });
async function uploadToS3(file: File, key: string) {
const buffer = Buffer.from(await file.arrayBuffer());
// 파일 타입에 따라 캐시 전략 다르게
const cacheControl = key.match(/\.(jpg|png|webp|svg)$/)
? "public, max-age=31536000, immutable" // 1년, 이미지는 변하지 않음
: "public, max-age=3600"; // 1시간, HTML/JSON은 가끔 바뀜
await s3Client.send(
new PutObjectCommand({
Bucket: "my-static-files",
Key: key,
Body: buffer,
ContentType: file.type,
CacheControl: cacheControl,
})
);
}
immutable이 핵심 키워드다. 이미지 파일명에 해시를 붙여서 logo-a3d5f2.png 같은 식으로 관리하면, 파일이 바뀔 때마다 이름이 바뀐다. 브라우저는 "이 파일은 영원히 안 바뀐다(immutable)"고 믿고 디스크 캐시에서 계속 쓴다. 서버 요청 자체가 사라진다.
CloudFront 설정에서도 캐시 동작을 정의한다.
# CloudFormation으로 CloudFront 설정 예시
CacheBehavior:
PathPattern: "images/*"
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
Compress: true
AllowedMethods:
- GET
- HEAD
- OPTIONS
CachingOptimized 정책을 쓰면 CloudFront가 알아서 쿼리스트링, 헤더, 쿠키를 고려해서 캐시한다. 정적 파일에는 보통 이걸로 충분하다.
d111111abcdef8.cloudfront.net 같은 기본 도메인은 못생겼다. cdn.mysite.com 같은 커스텀 도메인을 붙이려면 Route 53과 ACM(AWS Certificate Manager)이 필요하다.
중요한 함정: ACM 인증서는 반드시 us-east-1(버지니아)에서 발급받아야 한다. CloudFront는 글로벌 서비스라 us-east-1 인증서만 인식한다. 다른 리전에서 만들면 안 보인다.
# ACM 인증서 요청 (반드시 us-east-1)
aws acm request-certificate \
--domain-name cdn.mysite.com \
--validation-method DNS \
--region us-east-1
# Route 53에 CNAME 레코드 추가해서 DNS 검증 완료
# CloudFront 배포에 Alternate Domain Name (CNAME) 추가
# Route 53에서 cdn.mysite.com -> CloudFront distribution 연결
이제 https://cdn.mysite.com/images/logo.png 형태로 깔끔하게 접근할 수 있다. HTTPS는 무료고, 인증서 갱신도 자동이다.
파일을 업데이트했는데 CloudFront가 옛날 버전을 계속 서빙하면? 캐시를 무효화(invalidation)해야 한다.
# CloudFront 캐시 무효화
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/images/logo.png" "/css/*"
문제는 비용이다. 매달 첫 1,000개 경로까지는 무료지만, 그 이후는 경로당 $0.005다. /images/* 같은 와일드카드도 1개로 센다. 그런데 매일 배포할 때마다 /* 전체 무효화하면 돈이 새나간다.
해결책은 파일명 버저닝이다. 파일이 바뀔 때마다 이름을 바꾸면 무효화가 필요 없다. Next.js나 Vite는 빌드할 때 자동으로 해시를 붙여준다.
// 수동으로 해시 붙이기 (필요하다면)
import crypto from "crypto";
function getHashedFilename(filename: string, content: Buffer): string {
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
const ext = filename.split(".").pop();
const name = filename.replace(`.${ext}`, "");
return `${name}-${hash}.${ext}`;
}
// logo.png -> logo-a3d5f289.png
이렇게 하면 HTML만 무효화하면 된다. 이미지, CSS, JS는 새 해시 파일명으로 자동 갱신된다.
실제 비용이 얼마나 나올까? S3 + CloudFront 조합은 트래픽이 많아질수록 효율적이다.
예를 들어 이미지 파일 10GB를 저장하고, 월 1TB를 전송한다면:
사실 대부분 비용은 전송량에서 나온다. 스토리지는 거의 공짜 수준이다. 그래서 캐시를 잘 설정해서 불필요한 전송을 줄이는 게 핵심이다.
AWS가 비싸다고 느껴지면 Cloudflare R2를 보라. S3 호환 API를 제공하면서도 egress(데이터 송신) 비용이 0원이다.
# R2 설정 (Wrangler CLI)
npx wrangler r2 bucket create my-static-files
# S3 SDK로 R2 접근 가능
const s3Client = new S3Client({
region: "auto",
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
1TB 전송 기준으로 AWS는 $85, R2는 $0이다. 차이가 극명하다. 단, R2는 읽기 요청당 비용($0.36 per million)이 있긴 한데, 캐시 히트율이 높으면 큰 문제 없다.
트래픽이 많은 서비스라면 R2가 압도적으로 유리하다. AWS에 종속되기 싫다면 더더욱.
수동으로 S3 업로드하고 CloudFront 무효화하는 건 귀찮다. GitHub Actions로 자동화했다.
# .github/workflows/deploy-static.yml
name: Deploy Static Assets
on:
push:
branches: [main]
paths:
- 'public/**'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Sync to S3
run: |
aws s3 sync ./public s3://my-static-files \
--cache-control "public,max-age=31536000,immutable" \
--exclude "*.html" \
--delete
# HTML은 캐시 기간 짧게
aws s3 sync ./public s3://my-static-files \
--cache-control "public,max-age=3600" \
--exclude "*" \
--include "*.html"
- name: Invalidate CloudFront (HTML only)
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*.html" "/index.html"
main 브랜치에 푸시하면 자동으로 S3에 동기화되고, HTML만 무효화된다. 이미지나 JS는 파일명에 해시가 있으니 무효화 불필요.
Public 버킷 만들기: S3 버킷을 public으로 열어두고 CloudFront 없이 직접 서빙. 보안도 문제지만 전송 비용이 3배 이상 비싸다.
전체 캐시 무효화 남발: 배포 때마다 /* 전체 무효화. 비용 폭탄 맞는다. 파일명 버저닝으로 해결.
us-east-1 인증서 깜빡: ACM 인증서를 다른 리전에서 만들어서 CloudFront에 안 보임. 반드시 us-east-1.
캐시 헤더 무시: S3 업로드 시 Cache-Control 안 넣고 기본값 의존. CloudFront가 제대로 캐시 못 함.
Origin으로 S3 Website Endpoint 사용: bucket.s3-website-us-east-1.amazonaws.com 쓰면 OAC 못 쓴다. 반드시 REST API endpoint(bucket.s3.us-east-1.amazonaws.com) 써야 함.
정적 파일 서빙은 애플리케이션 서버가 할 일이 아니다. S3에 던져두고 CloudFront가 전 세계에 뿌리게 하면, 트래픽이 100배 늘어도 내 서버는 모른다.
비용도 생각보다 싸다. 월 수십 달러로 무제한에 가까운 확장성을 산다. 트래픽이 정말 크다면 Cloudflare R2로 egress 비용을 완전히 없앨 수도 있다.
핵심은 캐시 전략이다. 파일명에 해시를 붙여서 immutable하게 만들고, 무효화 비용을 최소화하라. 그러면 이 아키텍처는 거의 완벽에 가깝다.
서버가 죽을 때까지 기다리지 마라. 지금 S3 + CloudFront로 넘어가라.