Cloud & Platform Engineering
S3, CloudFront, Terraform으로 정적 호스팅 구성하기
S3 정적 오리진과 CloudFront 배포를 Terraform으로 선언하고 GitHub Actions 배포 흐름까지 연결합니다.
정적 사이트를 배포하는 방법은 많다. Vercel이나 Netlify처럼 애플리케이션 배포에 최적화된 플랫폼을 쓸 수도 있고, AWS에서 S3와 CloudFront를 조합해 직접 구성할 수도 있다. 이 글의 목적은 블로그 호스팅을 반드시 AWS로 옮기자는 것이 아니라, 정적 웹 서비스의 기본 구성 요소를 Infrastructure as Code로 설명 가능한 형태로 만들어보는 것이다.
수동으로 콘솔에서 S3 버킷을 만들고 CloudFront를 붙이면 빠르게 결과를 볼 수 있다. 하지만 같은 구성을 다시 만들거나, 변경 이력을 리뷰하거나, 다른 환경으로 복제하려면 콘솔 클릭만으로는 부족하다. Terraform을 쓰면 버킷, 접근 제어, CDN, 캐시 정책, DNS, 배포 권한을 코드로 남기고 변경 전후를 plan으로 확인할 수 있다.
구조를 먼저 정리하기
정적 호스팅에서 가장 단순한 구조는 S3 버킷을 public으로 열고 index.html을 내려주는 방식이다. 실습에는 좋지만, 실제 서비스에서는 S3를 직접 공개하지 않고 CloudFront를 앞에 두는 구성이 더 안전하고 운영하기 좋다.
User
-> CloudFront distribution
-> Origin Access Control
-> Private S3 bucket
-> Static filesCloudFront가 사용자 진입점이 되면 TLS, 캐시, 압축, 도메인 연결, 에러 페이지, invalidation을 한 곳에서 다룰 수 있다. S3는 원본 저장소 역할에 집중하고, 외부 사용자는 S3 웹사이트 엔드포인트가 아니라 CloudFront 도메인이나 커스텀 도메인으로 접근한다.
중요한 판단은 다음과 같다.
- S3 버킷을 public으로 열지 않을 것
- CloudFront Origin Access Control(OAC)을 통해서만 S3 객체를 읽게 할 것
- SPA나 정적 사이트의 404/403 처리를 CloudFront custom error response로 정리할 것
- 캐시 정책과 invalidation 전략을 배포 방식과 함께 정할 것
- GitHub Actions에서 장기 AWS Access Key 대신 OIDC Role을 사용할 것
Terraform 프로젝트 구조
Terraform 코드는 처음부터 거대한 모듈로 만들 필요는 없다. 작은 프로젝트라면 provider, variables, main, outputs 정도로 시작하고, 반복이 생길 때 모듈을 분리하는 편이 낫다.
이 글의 설명과 동일한 구성은 실행 가능한 Terraform 프로젝트에 정리했다. 예제 조각만 나열하지 않고 변수 검증, 출력, OIDC 배포 역할, GitHub Actions 검증·배포 흐름까지 함께 포함한다.
infra/
providers.tf
variables.tf
main.tf
outputs.tf
versions.tfProvider와 버전은 명시적으로 고정한다.
terraform {
required_version = ">= 1.10.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}변수는 환경마다 달라지는 값과 재사용 가능한 값을 분리한다.
variable "aws_region" {
type = string
default = "ap-northeast-2"
}
variable "project_name" {
type = string
}
variable "domain_name" {
type = string
default = null
}Terraform에서 중요한 파일은 .tfstate다. 이 파일은 실제 인프라와 코드의 연결 상태를 저장한다. 로컬 실습에서는 로컬 state로 시작할 수 있지만, 팀이나 CI에서 함께 다루려면 버전 관리가 켜진 S3 backend와 S3 lockfile을 사용한다. 과거에 사용하던 DynamoDB 기반 잠금은 현재 deprecated 상태다.
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "static-site/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true
}
}state에는 리소스 식별자와 일부 속성이 들어간다. 민감한 값이 state에 남을 수 있으므로 state 저장소 접근 권한은 코드 저장소 권한보다 더 엄격하게 관리해야 한다.
S3 버킷은 private을 기본으로 둔다
S3 버킷은 정적 파일을 담는 원본 저장소다. 이 구성에서는 public access를 막고, CloudFront OAC에서만 읽을 수 있게 한다.
resource "aws_s3_bucket" "site" {
bucket = "${var.project_name}-site"
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "site" {
bucket = aws_s3_bucket.site.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}정적 웹사이트 호스팅 옵션을 켜는 방식도 있지만, CloudFront를 앞에 둘 때는 S3 REST endpoint를 origin으로 두고 OAC를 붙이는 구성이 더 깔끔하다. S3 website endpoint는 OAC와 결합할 수 없기 때문이다.
CloudFront OAC와 버킷 정책
CloudFront Origin Access Control은 CloudFront가 S3에 접근할 때 서명된 요청을 사용하게 한다. 이전에는 OAI(Origin Access Identity)를 많이 썼지만, 새 구성에서는 OAC가 더 자연스럽다.
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.project_name}-oac"
description = "OAC for private S3 origin"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}CloudFront 배포에는 S3 origin, default cache behavior, viewer protocol policy, custom error response 등을 정의한다.
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}CloudFront가 만든 distribution ARN을 조건으로 S3 읽기 권한을 부여한다.
data "aws_iam_policy_document" "site_bucket" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site_bucket.json
}이 구성을 적용하면 사용자가 S3 객체 URL로 직접 접근했을 때는 거부되고, CloudFront를 통해 접근했을 때만 파일이 내려와야 한다.
Terraform 실행 흐름
Terraform은 init, plan, apply 흐름으로 진행한다.
terraform init -upgrade
terraform fmt -recursive
terraform validate
terraform plan -out main.tfplan
terraform apply main.tfplan
terraform outputplan은 단순 미리보기가 아니다. 실제 인프라 변경 전에 무엇이 생성, 수정, 삭제되는지 확인하는 리뷰 지점이다. 특히 CloudFront, Route 53, IAM, S3 policy처럼 장애 영향이 큰 리소스는 plan을 읽는 습관이 중요하다.
삭제도 같은 방식으로 계획을 먼저 본다.
terraform plan -destroy -out destroy.tfplan
terraform apply destroy.tfplanS3 버킷은 객체가 남아 있으면 삭제되지 않을 수 있다. 실습 환경에서는 force_destroy = true를 쓸 수도 있지만, 운영 환경에서는 실수로 원본 데이터를 지우는 위험이 있으므로 신중해야 한다.
GitHub Actions에서 배포하기
정적 사이트 배포는 두 단계로 나뉜다.
- Terraform으로 인프라를 만든다.
- 빌드된 정적 파일을 S3에 업로드하고 CloudFront 캐시를 무효화한다.
GitHub Actions에서 장기 AWS Access Key를 Secret에 넣는 방식은 간단하지만, 키 유출과 회전 문제가 남는다. GitHub OIDC와 AWS IAM Role을 연결하면 워크플로우 실행 시점에 임시 자격 증명을 발급받을 수 있다.
name: deploy-static-site
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ap-northeast-2
- run: aws s3 sync ./out s3://example-site-bucket --delete
- run: |
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/*"Next.js를 정적 export로 배포할 경우 ./out을 업로드할 수 있다. App Router 기능 중 서버 런타임이 필요한 기능을 쓰면 완전 정적 export가 맞지 않을 수 있으므로, 배포 대상과 프레임워크 기능을 함께 확인해야 한다.
캐시 무효화는 비용과 시간이 든다. 모든 배포마다 /*를 무효화하는 방식은 단순하지만, 파일명에 해시가 붙는 정적 자산은 장기 캐시하고 index.html 같은 엔트리 파일만 짧게 캐시하는 전략이 더 효율적일 수 있다.
실제 배포 검증 결과
Terraform plan과 비용을 검토한 뒤 이 프로젝트를 샌드박스 계정에 실제 적용했다. 배포 파이프라인은 장기 Access Key를 사용하지 않는다. GitHub Actions가 OIDC 토큰을 저장소의 main 브랜치로 제한된 AWS 역할의 단기 세션으로 교환하고, 샘플 사이트를 private 버킷에 동기화한 다음 CloudFront invalidation을 생성한다.
계정 식별자와 실제 리소스 이름을 공개하지 않고 다음 항목을 검증했다.
- 별도의 암호화·버전 관리 S3 버킷에 native lockfile 방식으로 Terraform remote state 저장
- 오리진의 직접 public 접근 차단
- private OAC 경로를 통한 CloudFront HTTP 200 응답
- HSTS, frame 차단, MIME sniffing 방지 헤더 적용
- 재실행한 Terraform plan에서 인프라 drift 없음
운영 중 확인할 지점
정적 사이트라고 해서 운영 확인이 사라지는 것은 아니다. 오히려 정적 사이트는 CDN 캐시, 원본 권한, DNS, TLS가 얽혀 있어 문제가 생기면 어느 지점에서 막혔는지 구분해야 한다.
curl -I https://dxxxxx.cloudfront.net/
curl -I https://www.example.com/
aws cloudfront get-distribution --id E1234567890ABC
aws s3 ls s3://example-site-bucket응답 헤더에서는 캐시 상태를 확인할 수 있다.
x-cache: Miss from cloudfront
x-cache: Hit from cloudfrontS3 직접 접근이 차단되는지도 확인한다.
curl -I https://example-site-bucket.s3.ap-northeast-2.amazonaws.com/index.html정상 구성이라면 S3 직접 접근은 거부되고, CloudFront 접근만 성공해야 한다. 만약 S3 직접 접근이 성공한다면 bucket policy, public access block, ACL, website endpoint 사용 여부를 다시 봐야 한다.
Terraform과 Ansible의 역할 구분
인프라 자동화에서 Terraform과 Ansible을 함께 언급하는 경우가 많다. 둘 다 자동화 도구지만 역할은 다르다.
Terraform은 리소스의 desired state를 선언하고 클라우드 API를 통해 인프라를 생성, 수정, 삭제하는 데 강하다. VPC, Subnet, S3, CloudFront, AKS, IAM Role 같은 리소스를 정의한다.
Ansible은 이미 존재하는 서버나 시스템의 설정을 구성하는 데 강하다. Agentless 방식으로 SSH를 통해 관리 노드에 접속하고, YAML Playbook으로 패키지 설치, 설정 파일 배포, 서비스 재시작 같은 작업을 수행한다.
Terraform
- cloud resource provisioning
- network, IAM, storage, cluster
- state file based lifecycle
Ansible
- server configuration
- package install, config file, service restart
- inventory and playbook based automationKubernetes 클러스터를 예로 들면, Terraform으로 Azure VM이나 AKS 클러스터를 만들고, Ansible/Kubespray로 VM 기반 Kubernetes 클러스터를 구성할 수 있다. 관리형 AKS/EKS를 쓴다면 Ansible보다 Terraform과 Helm/GitOps의 비중이 커지고, 직접 VM 위에 클러스터를 만들면 Ansible의 역할이 커진다.
정리
S3와 CloudFront로 정적 사이트를 구성하는 일은 단순해 보이지만, Terraform으로 옮기면 여러 운영 질문이 드러난다. S3 버킷은 public이어야 하는가, CloudFront만 원본에 접근하게 하려면 어떤 policy가 필요한가, state는 어디에 저장할 것인가, 배포 파이프라인은 어떤 권한으로 S3와 CloudFront를 변경할 것인가, 캐시는 언제 무효화할 것인가.
작은 정적 사이트라도 이 질문을 코드로 남기면 인프라 변경이 설명 가능해진다. 콘솔에서 한 번 만든 리소스는 기억에 의존하지만, Terraform으로 선언한 리소스는 리뷰와 재현의 대상이 된다. 이 차이가 Infrastructure as Code를 쓰는 가장 큰 이유다.
공식 참고 문서
시리즈
클라우드 배포와 운영관련 글
Cloud & Platform Engineering
AWS 기반 웹 서비스 인프라 구성과 운영 관점
Route 53, IAM, EC2, S3, CloudFront, ELB, Auto Scaling, CloudWatch를 하나의 웹 서비스 요청 흐름으로 연결해 정리합니다.
Cloud & Platform Engineering
Windows에서 macOS로 Next.js 개발환경 안전하게 이전하기
Git 기준점과 비공개 파일을 보존하면서 Next.js 프로젝트를 Apple Silicon macOS 환경으로 이전하고 검증한 과정을 정리합니다.
Cloud & Platform Engineering
클라우드 네트워크 주소 설계와 라우팅 기초
2진수, CIDR, 서브넷 마스크, 게이트웨이, VNet, Load Balancer 개념을 클라우드 네트워크 설계 흐름으로 연결해 정리합니다.