🔥 정적 웹 호스팅과 CloudFront 결합
강의 목차

처음 vite로 React SPA를 빌드해서 S3에 그대로 올렸을 때, 사이트는 떴는데 두 가지에 부딪쳤다. 하나는 HTTPS가 없다는 점이었고, 다른 하나는 /dashboard 경로로 직접 들어가면 404가 뜨는 점이었다. 콘솔에 Static website hosting이라는 옵션이 있는 걸 보고 켰는데, 뭔가 더 필요했다. AWS docs를 다시 읽었더니 한 줄이 답을 줬다.
"Amazon S3 website endpoints do not support HTTPS or access points." (S3 website endpoint는 HTTPS와 access point를 지원하지 않는다)
그제야 전체 윤곽이 분명했다. 정적 사이트를 띄우는 표준 운영은 S3 단독이 아니라 S3 + CloudFront + OAC 한 묶음이고, 그 묶음을 정확히 이해하려면 S3 안에 endpoint가 두 개라는 사실부터 시작해야 한다.

같은 버킷, 두 개의 hostname
S3 버킷 하나는 사실 두 개의 서로 다른 hostname으로 외부에 닿는다. 둘이 같은 객체를 보지만, 성격이 완전히 다르다.
REST API endpoint는 이전에 버킷과 키: 플랫 네임스페이스의 의미에서 다룬 그 endpoint다. 형태는 bucket.s3.region.amazonaws.com (서울 리전이면 my-bucket.s3.ap-northeast-2.amazonaws.com). PUT·POST·DELETE를 받는, 운영용 표준 길이다.
Website endpoint는 다른 hostname이다. bucket-name.s3-website.ap-northeast-2.amazonaws.com (서울 리전 기준). 2011-02-17에 root document와 custom error document 지원이 들어오면서 진짜로 정적 호스팅 모드가 됐다. 이쪽은 GET과 HEAD만 받고, 응답이 다른 길로 가지를 친다.
| 항목 | REST endpoint | Website endpoint |
|---|---|---|
| HTTPS | 지원 | 미지원 (HTTP only) |
| 메서드 | 모두 | GET·HEAD만 |
| 에러 응답 | XML | HTML (custom error document) |
루트 요청 (/) | 객체 키 목록 | IndexDocument 반환 |
| Redirect rules | 없음 | bucket·object 단위 redirect 지원 |
| 권한 | 비공개 가능 (정책으로 풀기) | 공개만 (Block Public Access를 풀어야 함) |

표만 보면 Website endpoint가 정적 사이트에 정확히 맞춘 모드 같다. 하지만 단독 사용은 거의 답이 아니다. HTTPS가 안 되는 점 하나만으로도 2026년 시점에 운영 가능한 사이트가 못 된다. 그리고 공개로 두려면 버킷 정책과 ACL: 공개와 비공개의 경계에서 본 BPA(Block Public Access)를 풀어야 하는데, 이건 운영 표준에서 정확히 거꾸로 가는 결정이다.
그래서 거의 모든 운영 환경에서 정적 사이트는 S3 비공개 REST endpoint 위에 CloudFront를 한 겹 얹는 구조로 정착한다.
CloudFront가 채우는 부족분
CloudFront는 2008-11-18에 14개 edge POP으로 출시한 AWS의 관리형 CDN이다. 이름이 'CDN(Content Delivery Network)'이라 콘텐츠 분산이 본업처럼 보이지만, 정적 사이트 운영의 관점에서 CloudFront는 세 가지 부족분을 한꺼번에 채우는 길이다.
첫째는 HTTPS다. CloudFront는 viewer 쪽 TLS를 자동으로 종료하고, custom domain을 붙일 때 ACM(AWS Certificate Manager)의 무료 인증서를 발급받아 매달 Issued 상태로 자동 갱신한다. ACM 인증서는 글로벌 distribution에서 쓸 때 us-east-1 리전에 발급해야 한다는 한 줄짜리 함정이 있다. 서울 리전에서 다른 서비스를 운영하더라도, CloudFront용 인증서는 us-east-1으로 발급해 가져온다.
둘째는 edge 캐시다. bucket.s3.region.amazonaws.com을 그대로 노출하면 모든 사용자가 서울 리전까지 매번 오고 가야 한다. CloudFront를 끼면 viewer가 가까운 edge POP에 닿고, 거기서 캐시 hit이 나면 origin에 가지 않는다. 글로벌 사용자가 있는 SPA에서 이 차이는 보통 한 자릿수 ms 단위 latency 단축이고, 같은 리전 사용자에게도 TLS handshake가 edge에서 끝나는 것만으로 첫 byte 시간이 줄어든다.
셋째는 origin 보호다. 이 부분에서 OAC가 들어온다.
OAC, 어떻게 동작하는가
OAC와 OAI의 비교는 버킷 정책과 ACL: 공개와 비공개의 경계에서 한 번 짚었다. OAC가 후속이고 신규 default고 OAI는 legacy로 남아 있다는 사실까지. 이번 글에서는 동작 형태만 짚는다.
OAC를 켜면 CloudFront edge가 S3 origin으로 보내는 요청에 SigV4 서명을 추가한다. S3는 들어오는 요청의 서명이 어떤 IAM principal로 서명됐는지를 확인하는데, OAC의 principal은 AWS 서비스 principal인 cloudfront.amazonaws.com이다. AssumeRole이 아니다. service principal이 직접 등장한다.
S3 버킷 쪽에서는 다음 형태의 bucket policy를 적는다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXAMPLE"
}
}
}]
}{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXAMPLE"
}
}
}]
}AWS:SourceArn 조건이 결정적이다. 이게 없으면 다른 AWS 계정의 CloudFront distribution도 같은 service principal로 매칭돼 들어올 수 있다 (이 함정 이름은 confused deputy). 한 distribution을 한 버킷에 묶는 줄을 명시해야 격리를 확보한다.
그리고 OAC에는 Signing behavior라는 옵션이 있다. 세 가지다.
always. 모든 origin 요청에 서명을 추가한다. 콘솔의 권장 default.no-override. viewer 요청에 이미Authorization헤더가 있으면 그대로 통과시키고, 없을 때만 서명을 추가한다. CloudFront를 통해 외부 OAuth 토큰 같은 자격 증명을 origin까지 보내야 하는 경우에 쓰는 모드.never. 서명을 안 한다. S3 버킷이 사실상 공개여야 동작한다. 정적 사이트 운영에서는 거의 쓰지 않는다.
내가 정적 사이트를 띄울 때 고른 건 always다. SPA는 viewer 쪽 인증을 backend API에서 받지 origin S3에서 받지 않으니까, 옆길 통과를 허용할 이유가 없다.

OAC가 OAI를 대체하면서 추가로 풀린 부분 두 가지를 짚는다. 첫째, OAI는 SSE-KMS로 암호화된 객체에 닿지 못했다. OAC는 KMS 키 정책에 kms:Decrypt 권한만 같은 service principal로 주면 그대로 동작한다. 둘째, OAI는 2022년 12월 이후에 새로 출시한 AWS 리전을 지원하지 않는다. OAC는 모든 현재·미래 리전을 다룬다. 같은 OAC가 PUT·DELETE 같은 dynamic 요청도 서명해 주기 때문에 origin이 S3 + Lambda@Edge 조합으로 동적 PUT을 보내는 패턴까지 동일한 길로 그대로 받아 준다.
SPA 캐시 전략, fingerprinting이 invalidation을 대체한다

빌드된 SPA의 산출물은 두 부류로 나뉜다. 한쪽은 매 배포마다 바뀌는 index.html(번들 dispatcher)이고, 다른 쪽은 hash 접두/접미가 붙은 /assets/main.a3b1c2.js 같은 파일이다. 후자는 빌드 결과의 byte가 한 글자라도 달라지면 hash가 다른 값이 되므로, 같은 파일 이름이면 영원히 같은 내용을 보장한다.
이 비대칭이 캐시 헤더의 비대칭에 그대로 닿는다.
| 산출물 | Cache-Control |
|---|---|
index.html | max-age=0, must-revalidate (혹은 no-cache) |
/assets/[hash].* | public, max-age=31536000, immutable |
immutable은 RFC 8246으로 2017-09에 표준화된 directive다. RFC가 정의한 의미는 한 줄짜리 약속이다.
"the origin server will not update the representation of that resource during the freshness lifetime of the response" (이 응답의 freshness lifetime 동안 origin이 표현을 갱신하지 않는다)
브라우저가 이 directive를 보면 새로고침에서도 conditional 요청(If-None-Match 등)을 생략한다. 일반 long max-age 자산은 새로고침에서 304 한 번 왕복이 일어나는데, immutable이면 그 왕복도 없다. Firefox 49+ (2016-09), Safari 11+ (2017-09), Edge 15+에서 directive를 직접 구현했고, Chrome은 directive 자체를 따로 구현하지 않지만 새로고침에서 sub-resource 재검증을 생략하는 동작이 이미 들어 있어 같은 효과가 난다.
CloudFront cache invalidation은 첫 1,000 path/월이 무료고, 그 위로 path 하나당 $0.005를 청구한다. fingerprinting을 안 쓰면 매 배포마다 /*로 통째 invalidate를 돌려야 하는데, fingerprinting을 적용하면 한 배포에 한 path(/index.html 한 줄)만 invalidate하면 끝이다. 한 달 100번 배포해도 100 path. 무료 한도의 1/10이다.
직접 계산해 보자. 20명짜리 팀이 매일 10번 배포하면(= 하루 200번 배포) 한 달 약 6,000번 invalidation 호출이 일어난다. fingerprinting 없이
/*한 줄로 처리하면 한 호출당 1 path니까 6,000 path를 청구하고, 무료 1,000 path를 빼면 5,000 path × $0.005 = 월 $25가 invalidation 단독 비용으로 청구서에 더해 붙는다. 게다가 매번 전체 캐시를 비우는 동작이라 edge POP의 모든 자산이 다시 origin에서 fetch되고, 그게 bytes-out·origin GET 비용으로 따로 돌아온다. fingerprinting을 적용하면 invalidation 호출 수 자체는 비슷하지만(여전히 배포마다/index.html한 줄을 invalidate해 1 path × 6,000회 = 6,000 path), 결정적으로 hash 자산 캐시가 살아 있어 origin 재fetch가 안 일어난다. 그래서 fingerprinting의 진짜 절약은 invalidation path 수가 아니라 bytes-out 청구서 쪽이다. AWS 한도가 path 호출 수 누적이라는 사실을 잘못 이해하면 청구서 산수가 어긋난다.
SPA 라우팅 함정, 403을 /index.html로 매핑
처음 사이트를 띄웠을 때 /dashboard 경로 직접 진입에서 404가 떴다고 적었다. 정확히는 OAC + S3 조합에서는 응답이 403이다. 이유는 두 단계다.
먼저, S3는 디렉토리가 없으니 dashboard라는 키를 가진 객체가 버킷에 없다. 둘째, OAC bucket policy가 s3:ListBucket을 부여하지 않으면(보통 안 부여한다) S3는 missing key를 404가 아니라 403으로 응답한다. 키가 있는데 권한이 없는 건지 키가 없는 건지를 외부에 노출하지 않으려는 보안 디자인.
답은 CloudFront의 Custom Error Response 설정이다. distribution 설정에서 403을 200 /index.html로 매핑하면, CloudFront는 origin이 403을 돌려준 응답을 viewer에게 200 + index.html 본문으로 바꿔서 돌려준다. 그러면 SPA 클라이언트 라우터가 URL을 보고 /dashboard 페이지를 자기가 화면에 띄운다.
이 매핑에는 한 가지 조심할 부분이 있다. JSON API를 같은 distribution 뒤에 둔 환경에서는 그쪽 403이 /index.html로 바뀌어 돌아오면 클라이언트가 HTML을 받고 동작이 멈춘다. 그래서 보통 /api/* 패턴을 별도 cache behavior로 잘라 그쪽에는 custom error response를 안 적용하거나, CloudFront Functions로 prefix 기반 라우팅을 한 줄 넣는다.

'관리형'이 감추는 비용

CloudFront는 관리형 CDN이다. 이 한 단어가 감추는 자동을 풀어 보자.
자동으로 굴러가는 부분은 길다. 750+개 primary edge POP에 자산을 분산 캐시하는 점, viewer 측 TLS handshake가 edge에서 끝나는 점, OAC 서명을 edge에서 origin으로 자동 추가하는 점, gzip·brotli 압축을 자동으로 붙이는 점, HTTP/2와 HTTP/3를 viewer 측에 자동 활성화하는 점.
별도 부담으로 남는 부분도 길다.
- Custom domain의 ACM 인증서는 us-east-1 강제. 다른 리전에서 운영하더라도 CloudFront용 인증서는 us-east-1.
- Data transfer out (DTO) 가격은 viewer location 기준. 서울 사용자가 서울 edge에서 자산을 받으면 ap-northeast-2 가격으로, 미국 사용자가 us-east-1 edge에서 받으면 그쪽 가격으로 청구한다. origin이 어디 있는지와 무관하다.
- Invalidation은 무료 한도(1,000 path/월) 위로 path당 $0.005. fingerprinting을 쓰면 거의 안 닿는 비용이지만, 안 쓰면 빠르게 청구서에 등장한다.
- 관측은 절반만 자동. CloudWatch metric은 distribution 단위로 자동 도착하지만 1분 단위 해상도다. real-time logs(초 단위 access log)는 별도로 stream 설정을 켜야 하고, Kinesis Data Streams 비용이 따로 붙는다.
답이 아닌 케이스
S3 + CloudFront 형식은 가볍지만, 모든 곳에 답이 되진 않는다. 몇 가지 경우에는 다른 길이 더 단단하다.
서버 측 렌더링이 필요한 페이지는 그쪽으로 빼야 한다. Next.js의 dynamic route, SSR 페이지, ISR(Incremental Static Regeneration)을 쓰는 페이지는 정적 산출물이 아니라 매 요청마다 서버가 응답을 만든다. 이 경우는 ALB + EC2/ECS 또는 Vercel·Amplify Hosting의 SSR 컨테이너에서 푸는 게 자연스럽다.
인증된 페이지도 성격이 다르다. CloudFront에 signed URL이나 signed cookie를 붙여서 시간 기반 접근 제어를 거는 패턴도 있지만, 그건 콘텐츠 보호에 더 가깝다. 사용자별 데이터를 그리는 dashboard 같은 페이지는 backend API + JWT 또는 session-based 인증이 본업이고, 정적 산출물 + CloudFront는 그 위에 얹는 viewer-side 캡슐 역할이다.
단일 리전 사용자만 있고 캐시 hit 비율이 낮은 워크로드도 답이 아니다. CloudFront 통과 비용이 직접 ALB로 가는 비용보다 비싸지는 cross-over가 일찍 온다. 한 리전 안에서만 트래픽이 도는데 자산도 매번 invalidate되는 곳이라면 ALB + S3 origin (또는 EFS·EC2 file system)이 더 깔끔하다.
매 분 invalidate가 필요한 콘텐츠도 정적 + CDN이 답이 아니다. 그건 콘텐츠가 정적이 아닌 것이고, 그쪽엔 API 응답 + 짧은 max-age, 또는 SSE/WebSocket이 적합하다.
닫으며
다음에 정적 사이트를 띄울 때 내가 따를 형식은 단순하다. vite로 빌드, dist를 비공개 S3 버킷에 업로드, 그 위에 CloudFront distribution 한 개, origin은 REST endpoint + OAC always, bucket policy는 service principal cloudfront.amazonaws.com + AWS:SourceArn distribution 조건. index.html은 max-age=0, must-revalidate, /assets/[hash].*는 max-age=31536000, immutable. custom error response 403→/index.html 200 한 줄. custom domain은 us-east-1 ACM 인증서.
'정적'이라는 단어가 가벼워 보이지만, 진짜로 가벼운 길 하나를 펴려면 S3·CloudFront·OAC·캐시·라우팅이 한 묶음으로 돌아야 한다는 점을 이번에 분명히 했다. 다음 섹션은 RDS, 같은 AWS 안에서 상태를 가진 자원의 운영 형식이 정적 자산과 어떻게 다른지를 짚는다.










