AWS 중급 #3 S3: 정적 호스팅, presigned URL

8 분 소요

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)로 식별
S3의 모양
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 cli로 업로드 / 다운로드
# 단일 파일
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 ./
Python (boto3)
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개의 층이 겹쳐서 동작합니다. 우선순위는 위에서 아래로 갈수록 약해집니다:

S3 권한의 평가 순서
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인 버킷만 명시적으로 해제합니다.

계정 레벨 PAB 켜기
aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Bucket Policy: JSON 정책 #

Bucket Policy는 버킷에 직접 붙이는 JSON 정책. 누가 (Principal) 무엇을 (Action) 어디에 (Resource) 할 수 있는지 정의.

ALB 로그를 받는 버킷 정책 예
{
  "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"
        }
      }
    }
  ]
}
앱 IAM Role만 읽기 허용
{
  "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의 경우).

앱 IAM Role 정책
{
  "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에서 응답:

S3 website endpoint
http://my-static-site.s3-website-ap-northeast-2.amazonaws.com

공개 접근 허용 #

기본값에선 PAB가 막습니다. 정적 호스팅은 의도된 public이니 다음을 수행합니다:

  1. 버킷 PAB에서 BlockPublicPolicy 두 항목 해제
  2. Bucket Policy로 모두에게 GetObject를 허용합니다:
정적 호스팅용 public read 정책
{
  "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시간 토큰
presigned PUT URL 만들기 (boto3)
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로 presigned URL 사용
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 했을 때 이전 버전이 자동 보존.

Versioning 켜기
aws s3api put-bucket-versioning \
  --bucket my-bucket \
  --versioning-configuration Status=Enabled

켜진 후엔:

  • Delete도 실제로 안 지움. Delete Marker만 추가
  • 이전 버전은 --version-id로 복구 가능
  • 저장 비용은 모든 버전을 합산 ← 함정

Lifecycle: 자동 정리 / 전환 #

오래된 객체를 자동으로 저렴한 클래스로 이동하거나 삭제하는 룰입니다.

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.00361년 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와 다른 서비스 조합 #

같이 자주 쓰는 서비스패턴
CloudFrontS3 + Edge 캐시 + 사용자 도메인 (#7)
LambdaS3 PUT 트리거로 이미지 변환 / 인덱싱 (고급 #3)
AthenaS3의 CSV / Parquet / JSON을 SQL로
GlueS3의 데이터 카탈로그 / 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 메이저 업그레이드를 어떻게 다룰지를 정리하겠습니다.

X