S3 — 정적 호스팅, presigned URL
AWS의 가장 오래된 객체 스토리지 S3. 버킷의 모양과 이름의 글로벌 유일성, 정책과 Public Access Block, 정적 사이트 호스팅, presigned URL, 그리고 스토리지 클래스로 비용을 낮추는 패턴까지 정리합니다.
8장 ~ 9장의 EC2가 컴퓨팅 영역이라면, **S3 (Simple Storage Service)**는 AWS의 객체 스토리지 영역입니다. 2006년에 AWS의 첫 서비스로 출시된 가장 오래된 서비스이자, 지금도 가장 많이 쓰이는 서비스 중 하나입니다.
S3는 사실상 무한대 용량의 글로벌 파일 시스템입니다(단 디렉터리는 가짜입니다). 11 9’s (99.999999999%)의 내구성, GB 당 약 $0.023의 가격, 다른 모든 AWS 서비스의 데이터 허브 — 이 셋이 S3의 정체입니다.
본 챕터에서는 S3의 구조에서 시작해 정책과 보안, 정적 호스팅, presigned URL, 스토리지 클래스까지 한 흐름으로 정리합니다. 여기서 다루는 정적 호스팅은 14장 CloudFront에서 HTTPS와 Edge 캐시를 더해 완성되고, S3 PUT 트리거는 17장 Lambda 기초의 이벤트 처리로 이어집니다.
버킷과 객체 #
S3는 기억할 핵심이 두 가지뿐입니다.
- 버킷 (Bucket) — 객체를 담는 컨테이너입니다. 한 계정 / 리전 단위로 만듭니다.
- 객체 (Object) — 실제 파일입니다. 키(key)로 식별합니다.
my-bucket/ ← 버킷 (이름은 글로벌 유일)
images/
profile/2026/avatar-001.jpg ← 객체 (key = 전체 경로)
profile/2026/avatar-002.jpg
videos/
intro.mp4
index.html디렉터리는 사실 없습니다. 위 그림의 /는 키의 일부입니다. images/profile/2026/avatar-001.jpg가 객체 하나의 전체 키(key)입니다. 콘솔이 / 기준으로 폴더처럼 보여줄 뿐입니다.
버킷 이름의 글로벌 유일성 #
버킷 이름은 전 세계 AWS 계정 모두를 가로질러 유일해야 합니다. my-bucket 같은 평범한 이름은 이미 누군가 가져갔습니다.
my-company-dev-uploads-2026
acme-prod-static-ap-northeast-2규칙은 다음과 같습니다.
- 3~63 자, 소문자 / 숫자 /
-/.를 씁니다. - 점(
.)은 가능하지만 SSL 인증서 와일드카드에서 문제가 되므로 보통-만 씁니다. - IP 주소 형태는 안 되고,
xn--로 시작할 수 없습니다(Punycode). - 대문자와 언더스코어는 안 됩니다.
이름에 환경 / 용도 / 리전 / 회사명을 적어 두면 청구서와 검색이 편합니다.
버킷은 리전 단위 #
버킷 이름은 글로벌 유일이지만, 데이터는 한 리전에 살고 있습니다. ap-northeast-2 (서울)에 만들면 객체 데이터는 서울 데이터센터 안에 있습니다. 콘솔에 들어가면 어떤 리전에 있는지 보입니다. 이 글로벌 이름 / 리전 데이터의 구분은 1장 AWS 입문의 글로벌 서비스 vs 리전 서비스 논의와 같은 맥락입니다.
리전 간 복제는 S3 Replication (CRR, Cross Region Replication)으로 명시적으로 설정합니다.
객체의 핵심 속성 #
객체 하나는 다음을 가집니다.
| 속성 | 역할 |
|---|---|
| Key | 객체의 전체 경로. 버킷 안에서 유일 |
| Body | 실제 데이터 (최대 5TB) |
| Content-Type | 브라우저가 어떻게 처리할지 (image/jpeg, application/json) |
| Metadata | 사용자 정의 헤더 (x-amz-meta-*) |
| ACL | 객체 단위 권한 (요즘은 거의 안 씀, 버킷 정책으로 대체) |
| Storage Class | 스토리지 클래스 (Standard, IA, Glacier 등) |
| Version ID | 버저닝 켰으면 버전 식별자 |
| ETag | 콘텐츠 해시 (대부분 MD5) |
콘솔 / CLI / SDK로 올리기 #
# 단일 파일
aws s3 cp ./image.jpg s3://my-bucket/images/profile/avatar.jpg
# 전체 폴더 동기화
aws s3 sync ./public s3://my-bucket --delete
# Content-Type 명시
aws s3 cp ./index.html s3://my-bucket/ --content-type "text/html; charset=utf-8"
# 다운로드
aws s3 cp s3://my-bucket/data.json ./import boto3
s3 = boto3.client("s3")
s3.upload_file("image.jpg", "my-bucket", "images/avatar.jpg")
s3.download_file("my-bucket", "data.json", "data.json")보안의 4가지 구성 #
S3의 보안은 여러 층이 겹쳐서 동작합니다. 평가 순서는 위에서 아래로 갈수록 약해집니다.
1. Public Access Block ← 가장 강력. 차단 결정이 모든 것 위에
2. SCP (Organizations) ← 계정 단위 가드
3. IAM Policy ← 사용자 / 역할 단위
4. Bucket Policy ← 버킷 단위
5. Object ACL ← 객체 단위 (옛 방식, 거의 안 씀)Public Access Block — 가장 먼저 #
**Public Access Block (PAB)**은 버킷이 실수로 공개되는 것을 막는 안전장치입니다. 네 가지 옵션이 있습니다.
| 옵션 | 의미 |
|---|---|
| BlockPublicAcls | 새 ACL이 public 못 되게 |
| IgnorePublicAcls | 기존 public ACL 무시 |
| BlockPublicPolicy | 새 버킷 정책이 public 못 되게 |
| RestrictPublicBuckets | 이미 public 인 버킷도 IAM Principal만 접근 |
요즘 모든 새 버킷은 계정 레벨에서 네 개 모두 켜는 것이 기본값입니다. 정적 호스팅처럼 일부러 public 인 버킷만 명시적으로 해제합니다.
aws s3control put-public-access-block \
--account-id 123456789012 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueBucket Policy — JSON 정책 #
Bucket Policy는 버킷에 직접 붙이는 JSON 정책입니다. 누가(Principal) 무엇을(Action) 어디에(Resource) 할 수 있는지 정의합니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "logdelivery.elasticloadbalancing.amazonaws.com"
},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-alb-logs/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control"
}
}
}
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/MyAppRole"
},
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
}
]
}IAM Policy #
IAM 사용자 / 역할에 붙이는 정책입니다. Bucket Policy와 결합되어 효과를 만듭니다. cross-account의 경우 둘 다 통과해야 Allow가 적용됩니다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/uploads/*"
}]
}자세한 IAM 설정은 2장 IAM에서 다룹니다.
정적 사이트 호스팅 #
S3는 정적 HTML / CSS / JS를 그냥 호스팅할 수 있습니다. 가장 간단한 정적 사이트 호스팅 방식입니다.
aws s3 website s3://my-static-site/ \
--index-document index.html \
--error-document 404.html이 명령 후 버킷이 다음 URL에서 응답합니다.
http://my-static-site.s3-website-ap-northeast-2.amazonaws.com공개 접근 허용 #
기본값에서는 PAB가 막습니다. 정적 호스팅은 의도된 public 이니 다음을 수행합니다.
- 버킷 PAB에서 BlockPublicPolicy 두 항목을 해제합니다.
- Bucket Policy로 모두에게 GetObject를 허용합니다.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-static-site/*"
}]
}S3 정적 호스팅의 한계 #
S3 만으로는 다음이 안 됩니다.
- HTTPS — S3 website endpoint는 HTTP입니다.
- 커스텀 도메인 + SSL 인증서 — 직접 안 됩니다.
- Edge 캐시 — 전 세계 빠른 응답이 안 됩니다.
그래서 운영에서는 거의 항상 S3 + CloudFront 패턴을 씁니다(14장 CloudFront에서 다룹니다). 그때는 PAB도 다시 켜고 OAC로 CloudFront만 접근을 허용하는 패턴이 됩니다.
Presigned URL — 임시 권한 #
Presigned URL은 이 객체를 N 분 동안만 누구나 다운로드 또는 업로드할 수 있다는 임시 권한을 만드는 방식입니다. 권한이 없는 사용자에게 권한을 잠시 위임하는 패턴입니다.
가장 흔한 사용처는 다음과 같습니다.
- 사용자 프로필 이미지 업로드 — 클라이언트가 직접 S3에 PUT 합니다.
- 결제 영수증 다운로드 — 5분짜리 링크입니다.
- 비공개 비디오 스트리밍 — 1시간 토큰입니다.
import boto3
s3 = boto3.client("s3")
url = s3.generate_presigned_url(
"put_object",
Params={
"Bucket": "my-bucket",
"Key": f"uploads/user-123/{filename}",
"ContentType": "image/jpeg",
},
ExpiresIn=600, # 10 분
)
# 클라이언트는 이 URL 로 PUT 요청curl -X PUT --upload-file ./photo.jpg "<presigned-url>"presigned URL의 보안 #
- URL 자체에 임시 자격증명이 포함되어 있습니다. 그 URL만 있으면 누구나 사용할 수 있습니다.
- 만료 시간이 지나면 자동으로 무효가 됩니다.
- HTTPS만 씁니다. HTTP로 새면 노출됩니다.
- ContentType / Content-Length 같은 조건을 함께 박을 수 있습니다.
POST 폼 vs PUT URL #
업로드 방식은 두 가지입니다.
- PUT URL — 단순합니다. 헤더로 메타데이터를 전달하고, ContentType 한 개를 고정합니다.
- POST 폼 (presigned post) — 복잡합니다. 다중 조건(
content-length-range,starts-with등)으로 더 안전합니다.
큰 규모이거나 중요한 업로드는 POST 폼이 권장됩니다. 단순한 경우는 PUT URL 이면 충분합니다.
버저닝과 라이프사이클 #
Versioning — 객체 이력 #
버킷에 Versioning을 켜면 같은 키에 여러 번 PUT 했을 때 이전 버전이 자동 보존됩니다.
aws s3api put-bucket-versioning \
--bucket my-bucket \
--versioning-configuration Status=Enabled켜진 후에는 다음과 같습니다.
- Delete도 실제로 안 지웁니다. Delete Marker만 추가됩니다.
- 이전 버전은
--version-id로 복구할 수 있습니다. - 저장 비용은 모든 버전을 합산합니다(함정).
Lifecycle — 자동 정리 / 전환 #
오래된 객체를 자동으로 저렴한 클래스로 이동하거나 삭제하는 룰입니다.
{
"Rules": [{
"ID": "ArchiveOldLogs",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER" }
],
"Expiration": { "Days": 365 }
}]
}운영에 라이프사이클은 거의 필수입니다. 없으면 몇 달 뒤 청구서가 무서워집니다.
스토리지 클래스 — 비용 항목 #
같은 데이터를 얼마나 자주, 얼마나 빨리 꺼내는지에 따라 다른 클래스로 두면 비용이 크게 절약됩니다.
| 클래스 | GB/월 | 자주 접근 | 검색 시간 | 역할 |
|---|---|---|---|---|
| Standard | $0.023 | 매일 | 즉시 | 기본값. 핫 데이터 |
| Standard-IA | $0.0125 | 가끔 | 즉시 | 백업, 분석 데이터 |
| One Zone-IA | $0.01 | 가끔, 재생성 가능 | 즉시 | 한 AZ만 — 중요도 낮음 |
| Intelligent-Tiering | 자동 | 패턴 모름 | 즉시 | 접근 빈도가 들쭉날쭉 |
| Glacier Instant Retrieval | $0.004 | 분기 1회 | 즉시 | 아카이브 + 가끔 즉시 필요 |
| Glacier Flexible Retrieval | $0.0036 | 1년 1~2회 | 분~시간 | 일반 아카이브 |
| Glacier Deep Archive | $0.00099 | 거의 안 | 12시간 | 장기 컴플라이언스 |
숫자는
ap-northeast-2기준 대략값입니다. 자세한 것은 공식 가격표를 봅니다.
클래스 결정 가이드 #
이 데이터, 매일 / 매주 보나?
├── YES → Standard
└── NO →
가끔 (월 1회 이하) ?
├── YES → Standard-IA (재생성 가능하면 One Zone-IA)
└── NO →
패턴 예측 가능 ?
├── YES → Glacier 계열
└── NO → Intelligent-Tiering함정 — 클래스 전환 비용 #
Standard에서 IA 같은 전환마다 객체당 약 $0.01의 작은 비용이 듭니다. 객체가 1억 개라면 이 비용이 커집니다. 라이프사이클로 자주 옮기면 안 됩니다. 객체 크기와 빈도를 보고 결정합니다.
S3의 일관성 #
옛날에는 read-after-write 일관성이 약했습니다. 2020년 12월부터 모든 리전에서 strong consistency가 보장됩니다.
- PUT 후 GET이 즉시 가능합니다.
- DELETE 후 LIST가 즉시 반영됩니다.
다만 버전 객체나 메타데이터 변경은 여전히 약간의 시간차가 있을 수 있습니다.
S3와 다른 서비스의 역할 #
| 같이 자주 쓰는 서비스 | 패턴 |
|---|---|
| CloudFront | S3 + Edge 캐시 + 사용자 도메인 (14장) |
| Lambda | S3 PUT 트리거로 이미지 변환 / 인덱싱 (17장) |
| Athena | S3의 CSV / Parquet / JSON을 SQL로 |
| Glue | S3의 데이터 카탈로그 / ETL |
| CloudTrail / VPC Flow Logs / ALB Logs | 모두 S3에 저장 |
자주 만나는 함정 #
- 버킷이 의도치 않게 public — 뉴스에 나오는 데이터 유출의 절반은 S3입니다. 새 버킷은 PAB 네 개를 모두 켠 상태로 시작하고, 정적 호스팅처럼 의도된 경우만 명시 해제합니다.
- 비용 폭탄 — GB 당 저장 + 요청 수 + 데이터 전송의 3중 과금입니다. 특히 인터넷으로 나가는 트래픽(Egress)이 GB 당 약 $0.09 라서 인기 정적 사이트는 이것이 큽니다. CloudFront와 묶어 Egress를 절감하고 Edge 캐시로 가속합니다(14장).
- 작은 파일 수백만 개 — 객체 하나하나마다 GET / PUT 비용이 있어 작은 파일 수백만 개 패턴은 비용이 의외로 큽니다. 묶어서(
tar.gz, Parquet) 저장하거나 다른 저장소로 옮기는 것이 답입니다. - Lifecycle 없이 1년 — 로그나 임시 파일이 Standard에 그대로 남으면 몇 달 뒤 청구서가 폭증합니다. 라이프사이클은 버킷을 만든 첫날 같이 설정합니다.
- Versioning 켜고 잊기 — Versioning을 켜고 lifecycle이 없으면 저장 비용이 무한히 증가합니다. 켰다면 라이프사이클로 오래된 비현재 버전을 정리합니다.
- presigned URL의 만료가 너무 김 — 24시간짜리 presigned URL은 사실상 영구 접근입니다. 보통 5~15분, 길어야 1시간으로 둡니다.
s3:*와일드카드 IAM —Action: "s3:*"정책은 위험합니다. 적어도 GetObject / PutObject / ListBucket 같이 명시합니다.
연습문제 #
- §“S3 권한의 평가 순서"의 다섯 층 중, 정적 호스팅 버킷을 일부러 공개하려면 어느 층을 어떻게 해제해야 하는지 적어 보세요. 그리고 14장 CloudFront의 OAC 패턴에서는 같은 버킷의 PAB를 왜 다시 켜는지 한 줄로 대비해 설명해 보세요.
- presigned PUT URL의
ExpiresIn을 600초로 둔 코드를 보고, 만료가 너무 길 때와 너무 짧을 때 각각 어떤 문제가 생기는지 §“presigned URL의 보안"을 근거로 적어 보세요. - 로그 버킷 하나를 가정하고, 30일 후 Standard-IA, 90일 후 Glacier, 365일 후 삭제하는 Lifecycle 규칙을 직접 작성해 보세요. 이 규칙이 §“비용 폭탄"의 어떤 항목을 줄여 주는지 27장 비용 최적화와 연결해 메모해 두세요.
한 줄 요약: S3는 버킷(글로벌 유일 이름)과 객체(key)의 두 개념만 있는 무한 객체 스토리지이고 디렉터리는 가짜다. 보안은 PAB가 최상위이며 새 버킷은 PAB 네 개를 모두 켠다. 정적 호스팅은 HTTPS와 Edge가 없어 CloudFront와 묶고, presigned URL은 5~15분 임시 권한 위임이며, Versioning은 반드시 Lifecycle과 한 쌍으로 둔다.
다음 챕터 #
객체 영역은 잡았습니다. 다음 11장 RDS에서는 관계형 DB 차례로 넘어갑니다. RDS의 매니지드 모델, 자동 백업과 PITR, Multi-AZ, 파라미터 / 옵션 그룹, 그리고 마이너 vs 메이저 업그레이드를 어떻게 다룰지 정리합니다.