목차
- 정적 호스팅만 켜면 끝날 줄 알았다
- 403 Forbidden의 진짜 원인은 OAC였다
- ACM 인증서는 반드시 us-east-1에서 발급해라
- SPA 라우팅 처리, 의외로 까다롭다
- GitHub Actions로 자동 배포 붙이기
- 캐시 무효화는 짧게, 똑똑하게
- 비용은 어느 정도 나오나
- 안 다룬 부분
- 언제 이 조합을 쓰고, 언제 다른 걸 봐야 하나
AWS S3 정적 웹사이트 배포는 S3 버킷을 오브젝트 스토리지가 아니라 HTML/CSS/JS를 직접 서빙하는 엔드포인트로 쓰는 방식이다. 여기에 CloudFront를 앞단에 붙여 CDN 캐싱과 HTTPS를 얹는 구성이 사실상 표준이 된 지 오래다. 문서만 보면 "버킷 만들고 정적 호스팅 켜고 CloudFront 연결" 세 줄로 끝나는데, 실제로 해보면 403 Forbidden부터 인증서 리전 오류, 캐시 무효화 누락까지 막히는 지점이 너무 많다.
특히 SPA를 올리는 경우, 라우팅 처리와 캐시 정책이 엮이면서 새로고침할 때마다 404가 뜨는 상황도 흔하다. 이 글은 그 모든 지점을 한 번씩 부숴먹어 본 흔적을 정리한 가이드다. 새벽 두 시에 AccessDenied 메시지 띄워놓고 IAM 정책을 17번 고친 사람이라면 공감할 만한 구간이 한두 군데는 있을 거다.
정적 호스팅만 켜면 끝날 줄 알았다
물론, 처음 S3 정적 웹사이트 배포를 했을 때 흐름은 이랬다. 버킷 만든다 → "이 버킷의 퍼블릭 액세스 차단" 해제 → 정적 웹사이트 호스팅 활성화 → 빌드 결과물 업로드. 그리고 S3가 알려주는 http://my-bucket.s3-website.ap-northeast-2.amazonaws.com 주소를 열었다. 화면이 뜬다. 됐다고 생각했다.
문제는 HTTPS였다. S3 정적 웹사이트 엔드포인트는 HTTPS를 지원하지 않는다. Route 53에 커스텀 도메인 붙이려고 보니, ACM 인증서를 끼울 자리가 없다. 그래서 CloudFront를 앞에 둬야 한다는 사실을 그제야 알았다. CloudFront 배포를 만들고 Origin으로 S3 버킷을 선택했다. 도메인을 열어보니 403 Forbidden. 멘붕이 시작된 지점이다.
첫 번째 함정: Origin을 잘못 선택했다
또한, CloudFront에서 S3 버킷을 Origin으로 추가할 때 두 가지 옵션이 보인다. S3 버킷 자체(예: my-bucket.s3.ap-northeast-2.amazonaws.com)와 S3 웹사이트 엔드포인트(예: my-bucket.s3-website-ap-northeast-2.amazonaws.com)다. 둘은 동작이 완전히 다르다.
| 항목 | S3 REST 엔드포인트 | S3 웹사이트 엔드포인트 |
|---|---|---|
| 기본 인덱스 문서 | 지원 안 됨 | 자동 처리 |
| SPA 라우팅 | 직접 처리 필요 | 에러 문서로 처리 가능 |
| OAC/OAI로 비공개 가능 | 가능 | 불가능 (퍼블릭 필수) |
| HTTPS 직접 지원 | O | X |
| CloudFront 권장 방식 | OAC와 결합 | 비권장 |
대부분의 가이드는 OAC(Origin Access Control)와 결합하는 REST 엔드포인트 방식을 권장한다. 버킷을 완전히 비공개로 두고 CloudFront만 접근하게 만들 수 있기 때문이다. 다만 SPA의 라우팅 처리가 까다로워진다는 단점이 있다. 이 부분은 뒤에서 다시 다룬다.
403 Forbidden의 진짜 원인은 OAC였다
그러나, CloudFront에서 OAC를 생성하고 S3 버킷에 연결했다. 그런데도 403이 안 사라졌다. 한참 헤매다가 깨달은 사실은, OAC를 만드는 것과 S3 버킷 정책을 OAC를 허용하도록 수정하는 것은 별개 작업이라는 점이다.
그래서, 콘솔에서 OAC를 추가하면 "Copy policy" 버튼이 뜬다. 이걸 눌러서 나오는 JSON을 S3 버킷 정책에 붙여넣어야 한다. 무심코 지나쳐서 OAC만 만들고 나면, CloudFront는 "이 버킷에 접근하겠다"고 요청하지만 버킷은 "허가받은 적 없다"고 거절한다. 그 결과가 403이다.
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXXXXXXXX"
}
}
}
]
}
SourceArn에 들어가는 CloudFront 배포 ID는 직접 만든 것으로 교체해야 한다. 여러 배포가 같은 버킷을 바라보는 구조라면 StringEquals 대신 StringLike로 패턴을 넣는 방법도 있다. 그런데 이건 보안상 권장되지 않는다. 명시적으로 ARN을 나열하는 편이 안전하다.
퍼블릭 액세스 차단 해제하지 마라
옛날 가이드 중에는 "버킷의 퍼블릭 액세스 차단 옵션을 다 끄고 시작하라"는 글이 많다. OAC를 쓰는 구조에서는 켜둔 상태가 정상이다. CloudFront가 SigV4로 서명해서 접근하기 때문에 버킷 자체는 비공개여도 된다.
이 부분을 모르고 퍼블릭 액세스를 다 풀어둔 상태로 운영하다가, 보안 점검에서 지적당한 경우를 여러 번 봤다. OAC가 등장하기 전(2022년 8월 이전)에는 OAI(Origin Access Identity)를 썼는데, 그땐 일부 케이스에서 퍼블릭 설정이 필요했다. 지금은 OAC가 SigV4 기반으로 더 깔끔하게 동작한다(출처: AWS Introducing Origin Access Control, 2022-08-25 발표).
ACM 인증서는 반드시 us-east-1에서 발급해라
여기서 한 번 더 막혔다. 도메인을 연결하려고 ACM에서 인증서를 발급받았다. 서울 리전(ap-northeast-2)에서. CloudFront 배포 설정에 들어가서 SSL 인증서 드롭다운을 누르니, 인증서 목록이 비어 있다.
이처럼, CloudFront는 전역 서비스이지만, 사용할 ACM 인증서는 반드시 us-east-1(버지니아 북부) 리전에서 발급된 것만 인식한다. 이건 콘솔에서 친절하게 알려주지 않는다. 드롭다운이 비어 있는 이유를 한참 검색하고 나서야 알았다.
해결은 단순하다. ACM 콘솔 우측 상단의 리전을 us-east-1로 바꾸고, 같은 도메인으로 인증서를 다시 발급받는 거다. DNS 검증을 쓰는 경우 Route 53과 연동되어 있으면 한 번 클릭으로 CNAME 레코드가 자동 추가되니 편하다.
# us-east-1에서 인증서 요청 (도메인 검증은 DNS 방식)
aws acm request-certificate \
--domain-name example.com \
--subject-alternative-names "*.example.com" \
--validation-method DNS \
--region us-east-1
한편, 발급 후 상태가 ISSUED로 바뀌면 CloudFront 드롭다운에서 보인다. 와일드카드(*.example.com)를 같이 넣어두면 서브도메인 추가할 때마다 인증서 새로 안 만들어도 된다. 이건 처음 할 때 안 챙기면 나중에 후회한다.
Route 53 별칭 레코드 설정
예를 들어, 도메인을 CloudFront 배포에 연결할 때는 A 레코드를 별칭(Alias) 으로 만들어야 한다. CNAME으로 만들면 Apex 도메인(example.com)에서는 동작하지 않는다.
| 레코드 유형 | Apex 도메인 (example.com) | 서브도메인 (www.example.com) |
|---|---|---|
| A (별칭) | 가능 | 가능 |
| CNAME | 불가능 (RFC 위반) | 가능 |
| AAAA (별칭) | 가능 (IPv6) | 가능 |
특히, Apex에서 CNAME을 쓰면 DNS 표준 위반이다. Route 53의 별칭 레코드는 이걸 우회하기 위해 만들어진 AWS 전용 기능이다. CloudFront 배포 도메인(dxxxxx.cloudfront.net)을 별칭 대상으로 지정하면 끝난다.
SPA 라우팅 처리, 의외로 까다롭다
따라서, React, Vue, Svelte 같은 SPA를 올리면 새로고침할 때 404가 뜨는 문제가 자주 보고된다. 라우터가 클라이언트에서 처리하는 경로(/dashboard, /users/42)가 S3 버킷에는 실제 파일로 존재하지 않기 때문이다.
S3 웹사이트 엔드포인트 방식을 쓰면 에러 문서 설정으로 index.html을 돌려주게 만들 수 있다. 다만 OAC를 못 쓴다. REST 엔드포인트 + OAC 구조에서는 CloudFront Functions나 Lambda@Edge로 직접 처리해야 한다.
또한, 가장 가벼운 방법은 CloudFront Functions를 쓰는 거다. Viewer Request 단계에서 동작하고, 비용도 거의 무료에 가깝다.
function handler(event) {
var request = event.request;
var uri = request.uri;
// 파일 확장자가 없으면 index.html로 보낸다
if (!uri.includes('.')) {
request.uri = '/index.html';
} else if (uri.endsWith('/')) {
request.uri += 'index.html';
}
return request;
}
이걸 CloudFront 배포의 Behavior에 연결하면 /dashboard로 들어와도 /index.html이 반환되고, React Router가 클라이언트에서 경로를 다시 해석한다. 더 복잡한 리라이팅이 필요하면 Lambda@Edge로 넘어가야 하지만, 일반 SPA라면 CloudFront Functions로 충분하다.
GitHub Actions로 자동 배포 붙이기
특히, 여기까지 했으면 사이트는 뜬다. 단 빌드 결과물을 매번 콘솔에서 업로드하면 의미가 없다. CI를 붙여야 한다.
한편, GitHub Actions를 쓰는 경우, 예전엔 IAM User의 액세스 키를 시크릿에 넣는 방식이 일반적이었다. 지금은 OIDC 기반 IAM Role을 쓰는 게 표준이다. 액세스 키가 유출될 위험을 없애고, 키 로테이션도 신경 안 써도 된다(출처: AWS Configuring OpenID Connect in Amazon Web Services, 작성 시점 기준).
name: Deploy to S3
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: ap-northeast-2
# 빌드 산출물을 S3에 동기화
- name: Sync to S3
run: |
aws s3 sync ./dist s3://my-bucket \
--delete \
--cache-control "public,max-age=31536000,immutable" \
--exclude "index.html" \
--exclude "*.html"
# HTML은 캐시를 짧게 가져간다
- name: Upload HTML with short cache
run: |
aws s3 sync ./dist s3://my-bucket \
--cache-control "public,max-age=0,must-revalidate" \
--exclude "*" \
--include "*.html"
# CloudFront 캐시 무효화
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id EXXXXXXXXXXXX \
--paths "/index.html" "/*.html"
그러나, 이 워크플로의 핵심은 두 번에 나눠서 sync하는 부분이다. 정적 자산(JS, CSS, 이미지)은 파일명에 해시가 붙으니 캐시를 1년으로 길게 가져가고, HTML은 항상 새로 가져오게 만든다. 이렇게 분리하지 않으면 캐시 무효화 비용이 커지거나, 배포 직후에도 옛날 HTML이 보이는 문제가 생긴다.
IAM Role 신뢰 정책
GitHub Actions가 이 Role을 Assume할 수 있게 신뢰 정책을 설정해야 한다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
}
sub 조건은 정확하게 작성해야 한다. repo:my-org/*처럼 와일드카드를 넓게 잡으면, 같은 조직의 다른 레포에서도 Role을 가져갈 수 있게 된다. 브랜치까지 명시해두는 편이 안전하다.
캐시 무효화는 짧게, 똑똑하게
따라서, CloudFront 무효화는 한 달에 경로 1000개까지 무료다(작성 시점 기준, 출처: AWS CloudFront 요금 페이지). 그 이상은 경로당 0.005달러가 붙는다. 작은 사이트라면 신경 안 써도 되는데, 배포가 자주 일어나면 비용이 늘어난다.
피해갈 수 있는 방법은 두 가지다. 하나는 무효화 경로를 좁히는 것. /*로 전체를 날리는 대신, 바뀐 파일만 지정한다. 위 워크플로에서 --paths "/index.html" "/*.html"만 적은 이유다. 정적 자산은 파일명 해시 덕에 무효화 자체가 필요 없다.
그러나, 다른 하나는 캐시 정책을 잘 짜는 것. CloudFront의 Managed-CachingOptimized 정책을 쓰면 기본적으로 캐시 키가 적절하게 설정된다. 직접 만들 거면 쿼리 스트링과 헤더를 어떻게 포함시킬지 신중하게 결정해야 한다. 헤더를 무분별하게 캐시 키에 넣으면 캐시 적중률이 떨어진다.
비용은 어느 정도 나오나
특히, 소규모 정적 사이트라면 월 1달러 이하로 운영 가능하다. 트래픽이 거의 없는 포트폴리오 같은 경우 거의 0달러에 수렴한다. 다음은 작성 시점(2026년 6월) 서울 리전 기준 대략적인 단가다.
| 항목 | 단가 | 비고 |
|---|---|---|
| S3 스토리지 | $0.025/GB | 표준 클래스 |
| S3 GET 요청 | $0.0004/1000건 | OAC 통해도 동일 |
| CloudFront 데이터 전송 (아시아) | $0.114/GB | 처음 10TB |
| CloudFront 요청 | $0.0090/10000건 | HTTPS 기준 |
| ACM 인증서 | 무료 | 퍼블릭 인증서 |
| Route 53 호스팅 영역 | $0.50/월 | 도메인당 |
물론, 월 1만 페이지뷰 정도(이미지 포함 페이지당 1MB 가정)에서 트래픽 비용은 10GB × $0.114 = 약 $1.14 수준이다. Route 53 영역료 $0.50를 더해도 월 $2 안쪽이다. EC2에 띄우는 것과 비교가 안 되게 저렴하다.
안 다룬 부분
반면, WAF 연동, 다중 환경(stage/prod) 분리, 프리뷰 배포 같은 주제는 의도적으로 뺐다. 이걸 다 다루면 글이 너무 길어지고, 각자 환경에 맞춰서 다르게 가는 영역이라 일반화가 어렵다. 특히 WAF는 비용이 갑자기 뛸 수 있어서 별도로 다루는 게 맞다.
그러나, 그리고 Terraform이나 CDK로 IaC 처리하는 방법도 안 적었다. 처음 한 번은 콘솔로 직접 만들어보고 흐름을 이해한 다음에 IaC로 옮기는 편이 학습에 좋다고 본다. 처음부터 Terraform 코드 복사해서 돌리면 어디서 막혔는지 디버깅이 더 어려워지는 경향이 있다.
언제 이 조합을 쓰고, 언제 다른 걸 봐야 하나
S3 + CloudFront 조합은 만능이 아니다. 아래 기준으로 판단하면 된다.
실제로, 이 조합이 맞는 경우: 빌드 결과물이 정적 파일로 떨어지는 SPA(React/Vue/Svelte), 문서 사이트(MkDocs, Docusaurus, VitePress), 랜딩 페이지, 포트폴리오. 트래픽이 급변하더라도 자동으로 스케일되고, 비용은 사용한 만큼만 나간다. 운영 부담이 거의 없다.
다른 걸 봐야 하는 경우: SSR이 필요한 Next.js 앱이라면 Vercel, AWS Amplify Hosting, OpenNext + CloudFront 같은 옵션을 봐야 한다. ISR이나 동적 라우팅이 핵심이면 S3 단독으로는 안 된다. 또 빠르게 PoC만 보여줄 거면 Netlify나 Cloudflare Pages가 설정이 훨씬 적다. 하루 만에 사이트를 띄우고 도메인 연결까지 끝내야 한다면 그쪽이 낫다.
당장 실행으로 옮기려면 세 가지만 챙기면 된다. 첫째, ACM 인증서를 us-east-1에서 발급받아라. 둘째, OAC를 만들고 나면 반드시 S3 버킷 정책에 권한을 추가하라. 셋째, GitHub Actions에서 OIDC Role 방식으로 자격증명을 받고, HTML과 정적 자산을 분리해서 sync하라. 이 세 가지가 가장 자주 막히는 지점이다.
관련 글
- ECS Fargate GitHub Actions runner 설정 — 비싸다는 통념을 다시 보다 – Fargate는 비싸다는 말이 정설처럼 돈다. ephemeral runner 구조에서는 그 통념이 깨지는 지점이 있다. ECS Fargat…
- AWS CodeBuild GitHub Actions 비교 — 비용·성능·설정(2026) – 분당 단가는 비슷하다. 하지만 동시성, IAM 통합, 캐시 전략까지 보면 그림이 달라진다. 두 서비스를 같은 자에 놓고 비교한다.
- GitHub Actions OIDC AWS 연동: 장기 액세스 키 없이 CI/CD 배포 보안화하기 – GitHub Actions OIDC AWS 연동은 워크플로우가 단기 자격증명을 받아 AWS를 다루게 한다. 키 로테이션 알람에서 해방되고 …