AWS 중급 #3 S3: 정적 호스팅, presigned URL
EC2 (#1 ~ #2)가 컴퓨팅 영역이라면, **S3 (Simple Storage Service)**는 AWS의 객체 스토리지 영역입니다. 2006년에 AWS의 첫 서비스로 출시된 가장 오래된 서비스이자, 지금도 가장 많이 쓰이는 서비스 중 하나입니다.
S3는 사실상 “무한대 용량의 글로벌 파일 시스템 (단 디렉터리는 가짜)“입니다. 11 9’s (99.999999999%)의 내구성, GB당 ~$0.023의 가격, 다른 모든 AWS 서비스의 데이터 허브, 이 셋이 S3의 정체입니다.
이 글에선 S3의 구조에서 시작해 정책과 보안, 정적 호스팅, presigned URL, 스토리지 클래스까지 한 흐름으로 정리합니다.
버킷과 객체 #
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 (서울)에 만들면 객체 데이터는 서울 데이터센터 안에 있습니다. 콘솔에 들어가면 어떤 리전에 있는지 보입니다.
리전 간 복제는 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의 보안은 4개의 층이 겹쳐서 동작합니다. 우선순위는 위에서 아래로 갈수록 약해집니다:
1. Public Access Block ← 가장 강력. 차단 결정이 모든 것 위에
2. SCP (Organizations) ← 계정 단위 가드
3. IAM Policy ← 사용자 / 역할 단위
4. Bucket Policy ← 버킷 단위
5. Object ACL ← 객체 단위 (옛 방식, 거의 안 씀)Public Access Block: 가장 먼저 #
**Public Access Block (PAB)**는 버킷이 실수로 공개되는 걸 막는 안전장치. 4가지 옵션:
| 옵션 | 의미 |
|---|---|
| BlockPublicAcls | 새 ACL이 public 못 되게 |
| IgnorePublicAcls | 기존 public ACL 무시 |
| BlockPublicPolicy | 새 버킷 정책이 public 못 되게 |
| RestrictPublicBuckets | 이미 public 인 버킷도 IAM Principal만 접근 |
요즘 모든 새 버킷은 계정 레벨에서 4개 모두 켜기가 기본값입니다. 정적 호스팅처럼 일부러 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와 결합되어 효과를 만듭니다. 둘 다 통과해야 Allow가 적용됩니다 (cross-account의 경우).
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/uploads/*"
}]
}자세한 IAM 설정은 기초 #2에서 다룹니다.
정적 사이트 호스팅 #
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 패턴을 씁니다. #7 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 1개 고정
- 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 }
}]
}운영에 라이프사이클은 거의 필수입니다. 없으면 6개월 뒤 청구서가 무서워집니다.
스토리지 클래스: 비용 항목 #
같은 데이터를 얼마나 자주 / 빨리 꺼내는지에 따라 다른 클래스로 두면 비용이 크게 절약됩니다.
| 클래스 | 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 캐시 + 사용자 도메인 (#7) |
| Lambda | S3 PUT 트리거로 이미지 변환 / 인덱싱 (고급 #3) |
| Athena | S3의 CSV / Parquet / JSON을 SQL로 |
| Glue | S3의 데이터 카탈로그 / ETL |
| CloudTrail / VPC Flow Logs / ALB Logs | 모두 S3에 저장 |
자주 만나는 함정 #
1) 버킷이 의도치 않게 public #
뉴스에 나오는 데이터 유출의 절반은 S3입니다. 새 버킷은 PAB 4개 모두 켠 상태로 시작하고, 정적 호스팅처럼 의도된 용도만 명시적으로 해제합니다.
2) 비용 폭탄 #
- GB당 저장 + 요청 수 + 데이터 전송의 3중 과금
- 특히 **인터넷으로 나가는 트래픽 (Egress)**이 GB당 ~$0.09. 인기 정적 사이트는 이게 큼
- CloudFront와 묶어 Egress 절감 + Edge 캐시 가속 (#7)
3) 작은 파일 수백만 개 #
객체 하나하나 마다 GET / PUT 비용이 있어 작은 파일 수백만 개 패턴은 비용이 의외로 큼. 묶어서 (tar.gz, Parquet) 저장하거나 DynamoDB로 옮기는 게 답.
4) Lifecycle 없이 1년 #
로그 / 임시 파일이 Standard에 그대로. 6개월 뒤 청구서가 폭증. lifecycle은 만든 첫날 같이 설정.
5) Versioning 켜고 잊기 #
Versioning + lifecycle 없음 = 저장 비용이 무한 증가. 켰다면 라이프사이클로 오래된 비현재 버전 정리.
6) presigned URL의 만료가 너무 길다 #
24시간짜리 presigned URL은 사실상 영구 접근입니다. 보통 5~15분, 길어야 1시간으로 둡니다.
7) s3:* 와일드카드 IAM
#
Action: "s3:*" 정책은 위험. 적어도 GetObject / PutObject / ListBucket 같이 명시.
정리 #
이번 글에서 잡은 것:
- S3 = 무한 객체 스토리지. **버킷 (글로벌 유일 이름) + 객체 (key)**의 두 요소만
- 디렉터리는 가짜.
key의 일부일 뿐 - PAB → IAM Policy → Bucket Policy → ACL 순으로 보안 평가
- 새 버킷은 PAB 4개 모두 켜기가 기본값
- 정적 호스팅 = 버킷 + website endpoint + public read 정책. HTTPS / Edge는 #7 CloudFront로
- Presigned URL = 임시 권한 위임. 5~15분, HTTPS만, ContentType 지정
- Versioning + Lifecycle가 한 쌍. 라이프사이클 없이 versioning만 켜면 청구서 폭증
- 스토리지 클래스. Standard / IA / One Zone-IA / Intelligent-Tiering / Glacier 3종
- 함정. public 누설, Egress 비용, 작은 파일, lifecycle 누락, versioning 비용, presigned만료, 와일드카드 IAM
다음: RDS #
객체 영역은 잡았습니다. 이제 관계형 DB 차례입니다.
#4 RDS: 매니지드 DB, 백업, 파라미터 그룹에서는 RDS의 매니지드 모델, 자동 백업과 PITR, Multi-AZ, 파라미터 / 옵션 그룹, 그리고 운영 시 마이너 vs 메이저 업그레이드를 어떻게 다룰지를 정리하겠습니다.