게시물에 이미지 업로드 기능을 추가하려고 AWS S3를 처음 붙이면 예상보다 훨씬 많은 설정을 건드리게 된다. 버킷 생성, IAM 사용자, 정책, 액세스 키, CORS, 그리고 Mixed Content 에러까지. 이 글은 그 흐름을 개념 중심으로 정리한다
S3 버킷이란
S3(Simple Storage Service)는 객체(Object) 단위로 파일을 저장하는 클라우드 스토리지다. 디렉토리 구조 대신 버킷(Bucket) 안에 객체를 담는 방식으로 동작한다. 버킷은 전 세계에서 고유한 이름을 가져야 하며, 객체마다 고유 URL이 자동으로 생성된다
주요 특징은 다음과 같다. 용량 제한이 사실상 없고, 가용성이 매우 높으며, 인프라 관리가 필요 없다. 비용은 저장 용량과 요청 수 기준으로 과금된다. RDS(데이터베이스) 비용보다 훨씬 저렴하다
버킷 접근 모드는 두 가지다
| 모드 | 동작 |
|---|---|
| Public | URL만 알면 누구나 접근 가능 |
| Private | 인증된 요청만 허용. 임시 접근은 Presigned URL 사용 |
실제 서비스에서는 읽기는 Public, 쓰기는 Private으로 구성하는 방식이 일반적이다. 결제 사용자에게만 파일을 제공해야 한다면 버킷 전체를 Private으로 운영하고 Presigned URL을 발급한다
IAM – 권한을 부여하는 구조
S3 버킷은 수동적인 접근 대상이다. 실제로 버킷에 파일을 올리거나 읽는 주체는 API 서버(EC2)다. 이 주체에게 권한을 부여하는 체계가 IAM(Identity and Access Management)이다
개념을 사원증에 비유하면 이렇다
IAM 사용자 → 사원 (행위 주체, 예: EC2) IAM 롤 → 사원증 (권한 묶음) IAM 정책 → 사원증에 적힌 출입 가능 구역 목록
정책을 먼저 만들고 → 롤에 정책을 연결하고 → EC2에 롤을 부여하는 순서다
정책은 JSON 형식으로 작성한다. 아래는 특정 버킷의 PUT/GET을 허용하는 정책 예시다
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-bucket-name/*"
}
]
}
Resource의 버킷 이름과 경로 패턴(/*)은 반드시 직접 입력해야 한다. AWS가 내 버킷 이름을 알 리 없다
IAM 사용자 생성 순서
- IAM 콘솔에서 정책 생성 → JSON 탭에 정책 입력
- 사용자 생성 → 생성한 정책과 연결
- 사용자 보안 자격증명 탭 → 액세스 키 생성
- 액세스 키와 시크릿 키는 이 화면에서만 확인 가능하다. 반드시 저장해둔다.
액세스 키는 API 서버의 환경 변수나 application-local.yml에 설정한다. application.yml에 직접 넣으면 GitHub에 올라갈 위험이 있다. 반드시 로컬 전용 설정 파일로 분리한다
# application-local.yml (Git에 포함하지 않는다)
cloud:
aws:
credentials:
access-key: YOUR_ACCESS_KEY
secret-key: YOUR_SECRET_KEY
s3:
bucket: my-bucket-name
region:
static: ap-northeast-2
CORS – 왜 설정해야 하는가
문제의 출발점 – CSRF
CORS 설정이 필요한 이유를 이해하려면 CSRF(Cross-Site Request Forgery) 공격을 알아야 한다
CSRF는 사용자가 로그인된 상태를 악용하는 공격이다. 공격자가 만든 악성 페이지에 숨겨진 <form>이 있고, 페이지가 로드되는 순간 이 폼이 자동으로 제출된다. 로그인 상태의 세션 쿠키가 함께 전송되므로, 서버는 정상 요청으로 처리한다
이 공격을 브라우저 수준에서 차단하기 위해 등장한 정책이 SOP(Same Origin Policy)다
SOP – 출처가 같아야 허용
SOP는 출처(Origin)가 다른 리소스 접근을 차단하는 브라우저 정책이다. 출처는 프로토콜 + 도메인 + 포트 조합으로 정의된다
https://www.example.com:443 → 기준 출처 https://www.example.com/path 같은 출처 (포트 동일, 기본 443) http://www.example.com 다른 출처 (프로토콜 다름) https://api.example.com 다른 출처 (서브도메인 다름) https://www.example.com:8080 다른 출처 (포트 다름)
여기서 문제가 생긴다. 프론트(www.example.com)에서 API 서버(api.example.com)로 요청을 보내면 SOP가 차단한다. 이걸 선택적으로 허용하는 메커니즘이 CORS(Cross-Origin Resource Sharing)다.
CORS 설정 방법
S3 콘솔 → 버킷 → 권한 탭 → CORS 편집에서 JSON을 입력한다
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
"AllowedOrigins": ["http://localhost:3000"],
"ExposeHeaders": []
}
]
로컬 개발 환경이라면 AllowedOrigins에 http://localhost:포트번호를 넣는다. 운영 환경 배포 시에는 실제 도메인으로 교체한다
AllowedOrigins를 ["*"]으로 열어두면 모든 출처가 허용되어 보안이 취약해진다. 반드시 허용할 출처를 명시한다
Mixed Content 에러
CORS 설정을 마쳐도 Mixed Content 에러가 뜨는 경우가 있다. 원인은 단순하다. 프론트가 HTTPS인데 API 서버가 HTTP일 때(또는 그 반대일 때) 발생한다
프론트: https://www.example.com (HTTPS)
API: http://api.example.com (HTTP)
← Mixed Content 에러
브라우저는 HTTPS 페이지에서 HTTP 리소스 로딩을 차단한다. 해결 방법은 프론트와 백엔드의 프로토콜을 통일하는 것이다
전체 연동 흐름 정리
[프론트] → 이미지 업로드 요청
↓
[API 서버] → IAM 권한으로 S3에 PutObject
↓
[S3] → 파일 저장 + 고유 URL 생성
↓
[API 서버] → URL을 DB에 저장 후 응답
↓
[프론트] → URL로 이미지 렌더링
새 게시물 작성 시 이미지를 포함하려면 임시 저장(Temp ID) 방식이 필요하다. 게시물 ID가 없으면 S3 저장 경로를 지정할 수 없기 때문이다. 먼저 임시 저장 후 이미지를 업로드하고, 게시물 취소 시 임시 데이터를 삭제하는 로직이 함께 구현되어야 한다
트러블슈팅 체크리스트
에러가 발생했을 때 확인 순서다
403 Forbidden (이미지 로딩 실패)
- 버킷 퍼블릭 액세스 차단 설정 확인
- 버킷 정책의 Resource ARN 경로(
/*) 확인 - 허용된 Action(
s3:GetObject) 확인
CORS 에러
- S3 CORS 설정의
AllowedOrigins확인 - 요청 Origin과 설정값 일치 여부 확인
- API 서버의 Spring Security CORS 설정 확인
업로드 실패
- IAM 사용자의
s3:PutObject권한 확인 - 액세스 키 설정이
application-local.yml에 제대로 주입되었는지 확인 - IntelliJ 실행 프로파일이
local로 지정되어 있는지 확인
정책 편집기 오류 (비ASCII 문자)
- AI가 생성한 JSON을 메모장에 붙여넣었다가 다시 복사한 뒤 입력한다. 보이지 않는 특수문자가 제거된다
AWS 공동 책임 모델
AWS와 사용자는 보안에 대해 각자의 책임 영역을 가진다
| 책임 주체 | 범위 |
|---|---|
| AWS | 클라우드 인프라 자체의 보안 |
| 사용자 | IAM 설정, 네트워크 접근 제어, 애플리케이션 보안 |
IAM 정책을 느슨하게 열어두거나 보안 그룹 설정을 잘못해서 발생한 침해는 사용자 책임이다. 최소 권한 원칙(Least Privilege)을 기본으로 삼는다
한 줄로 요약하면, S3 버킷 연동은 버킷 설정 하나가 아니라 IAM·CORS·프로토콜 일관성을 함께 챙겨야 완성된다