“개인정보 처리방침을 추가해야 하는데, 어디에 저장하는 게 좋을까요?” 단순한 업무 요청이었다. 처음에는 이미 떠 있는 프론트엔드 서버에 라우팅 페이지 하나를 추가하면 끝날 일이라고 생각했다. 그런데 HTML 한 장 때문에 빌드를 돌리고 서버를 거치는 게 맞나 싶어서, S3를 제대로 들여다보게 됐다.
S3
S3란, Simple Storage Service의 약자로 AWS의 오브젝트 스토리지 서비스이다. 여기서 핵심 개념은 파일 시스템이 아니라 ‘오브젝트 스토리지’라는 점이다. 버킷 안에 키로 파일을 저장한다. images/2026/logo.png 같은 경로도 슬래시가 들어간 하나의 문자열 키다. 콘솔에서 폴더처럼 보이는 건 UI가 접두사(prefix)로 묶어서 보여주는 것뿐이다.
주요 특징은 다음과 같다.
- 내구성: 99.999999999%(9가 11개), 여러 AZ에 자동 복제
- 내구성 ≠ 가용성. 내구성은 데이터가 살아있느냐, 가용성은 지금 접근되느냐다. S3 Standard의 가용성은 설계 목표가 99.99%, SLA 보장은 99.9%로 내구성에 비하면 훨씬 낮다.
- 대신 내가 지운 건 안 지켜준다. DELETE로 날리거나 같은 키에 덮어쓰면 그냥 사라진다. 버저닝이나 Object Lock으로 따로 막아야 한다.
- 용량 무제한 : 단일 오브젝트는 최대 5TB
- 정적 웹 호스팅: HTML/JS/CSS/이미지를 그대로 서빙 가능. 다만 이 기능만으로는 HTTPS가 안 되기 때문에 실무에서는 앞에 CloudFront를 둔다
- 스토리지 클래스: Standard, Infrequent Access, Glacier 등 접근 빈도에 따라 비용 차등
- 스토리지 용량, 요청 수, 데이터 전송 등 여러 축에 따른 비용 청구
버저닝
앞에서 “내가 지운 건 안 지켜준다”고 했다. 버저닝을 켜면 이 얘기가 달라진다. 같은 키에 덮어써도 이전 오브젝트가 사라지지 않고 버전 ID가 붙은 채로 남는다. DELETE를 날려도 실제로 지워지는 게 아니라 ‘삭제 마커’가 맨 위에 얹힐 뿐이다. 그래서 되돌리는 것도 간단하다. 삭제 마커를 지우거나, 원하는 버전을 같은 키로 다시 복사하면 된다.
# 이 키에 어떤 버전이 있는지
aws s3api list-object-versions --bucket my-bucket --prefix privacy/privacy.html
# 이전 버전을 현재 버전으로 되돌리기 (같은 키로 복사)
aws s3api copy-object --bucket my-bucket --key privacy/privacy.html \
--copy-source "my-bucket/privacy/privacy.html?versionId=<이전 버전 ID>"
대신 공짜는 아니다. 이전 버전(noncurrent version)도 현재 버전과 같은 요금으로 저장 용량에 잡힌다. 배포 때마다 수백 개 파일을 덮어쓰는 버킷이라면 금방 불어난다. 그래서 버저닝을 켤 때는 라이프사이클 규칙을 같이 건다. “이전 버전은 30일 지나면 삭제” 같은 식이다.
개인정보 처리방침이라면 이게 실수 방지 이상의 의미가 있다. 개인정보 처리방침 작성지침은 처리방침을 바꿀 때 변경 이력을 남기고, 이용자가 이전 처리방침을 확인할 수 있게 하라고 한다. 버저닝을 켜 두면 이전 파일은 남으니 ‘보관’은 해결된다. 그런데 이용자가 버전 ID로 파일을 열어 볼 수는 없다. 열람용으로는 privacy/2026-08-01.html처럼 시행일을 경로에 박아 두고, 현재 본은 privacy/privacy.html로 두는 쪽이 맞다. 버저닝은 실수 복구용이고, 경로에 붙인 날짜는 열람용이다. 둘은 다른 문제인 것이다.
비용 청구
서비스를 운영하다 보면 가장 궁금한 것 중 하나가 비용이다. S3 요금은 다음 다섯 축으로 청구된다.
- 스토리지 용량 : GB x 저장 시간(월 단위 환산)
- 요청 수 : 1000건당 과금. 쓰기 계열이 읽기 계열보다 약 10배 비쌈
- 데이터 전송(아웃 바운드): 인터넷으로 나가는 트래픽
- 인바운드(업로드)는 무료
- 데이터 검색(retrieval) : IA/Glacier 계열만 해당. 꺼낼 때 GB당 추가 과금
- 관리 기능 : Intelligent-Tiering 모니터링, 인벤토리 등
보통 실제로 비용을 좌우하는 건 3번(전송)이다. 저장 비용보다 트래픽 비용이 몇 배 큰 경우가 흔하다.
S3 → CloudFront로 나가는 전송은 무료이다. CloudFront를 앞에 두면 오리진 전송비가 0이 되고, 캐시 히트되는 만큼 S3 GET 요청 수도 줄어든다. CDN 붙이는 게 성능뿐 아니라 비용 면에서도 이득인 이유이다.
프론트엔드와 S3
1. S3 + CloudFront
관련 글 : AWS로 React 배포 자동화 구축하기 (feat. S3, CloudFront, CodeBuild, CodePipeline)
프론트엔드에서는 주로 S3 + CloudFront 패턴을 가장 많이 사용한다. S3가 오리진(origin) 역할로 빌드 산출물을 보관하고, CloudFront가 그 앞단에서 CDN으로 캐싱/배포한다.
예를 들어 개인정보 처리방침과 같은 정적인 페이지가 있다고 해보자. 이 페이지는 단순 HTML로 구성된 정적인 페이지이다. 이 HTML 파일을 브라우저에서 열면 브라우저가 렌더링을 해서 화면을 사용자에게 보여줄 것이다. 그렇기에 따로 서버가 필요 없다. 이러한 경우, S3에 이 정적인 HTML 파일을 저장하고, CloudFront에서 origin을 파일이 저장된 S3 버킷을 바라보도록 해서 사용자에게 서빙을 할 수가 있다. 이때 버킷을 공개로 열 필요는 없다. CloudFront의 OAC(Origin Access Control)로 CloudFront만 버킷을 읽게 하고, S3의 정적 웹 호스팅 기능은 꺼둔 채로 둔다.
다이어그램으로 표현하면 다음과 같다.
단, 파일을 갈아끼워도 TTL이 남아 있는 동안 엣지는 옛날 파일을 준다. 그래서 재배포할 때는 Invalidation을 같이 걸어야 한다.
그런데 파일을 바꿀 때마다 Invalidation을 거는 게 정답은 아니다. 무효화는 한 달에 1,000 경로까지 무료고 그 뒤로는 경로당 $0.005다. 큰돈은 아니지만, 배포 때마다 /*를 걸면 캐시를 통째로 비우는 셈이라 히트율이 매번 0에서 다시 시작한다.
파일 성격에 따라 캐시 정책을 나누면 무효화가 거의 필요 없어진다.
- HTML:
Cache-Control: max-age=60정도로 짧게. 엣지와 브라우저가 1분만 들고 있다가 다시 물어본다. - 해시가 붙은 JS·CSS(
app.3f2a1c.js):max-age=31536000, immutable. 내용이 바뀌면 파일명이 바뀌니 1년을 캐시해도 된다.
이렇게 나누면 배포는 “에셋 먼저 올리고, HTML을 마지막에 올리기”로 끝난다. HTML이 새 해시를 가리키는 순간부터 사용자는 새 파일을 받는다. 개인정보 처리방침 같은 HTML 한 장짜리 페이지는 애초에 첫 번째 경우라, 짧은 TTL만 줘도 무효화 없이 몇 분 안에 갈아끼워진다.
그럼 그 헤더는 누가 붙이나. S3는 파일을 올릴 때 지정한 메타데이터를 그대로 응답 헤더로 내보낸다. Cache-Control도 Content-Type도 업로드하는 쪽 책임이라는 뜻이다. aws s3 sync는 확장자를 보고 Content-Type을 짐작하는데, 확장자가 없거나 낯선 파일은 binary/octet-stream이 된다. HTML이 화면에 뜨지 않고 다운로드돼 버리는 게 이 경우다. Cache-Control은 짐작조차 안 해주니 직접 넘겨야 한다.
# 해시 붙은 에셋: 1년 캐시
aws s3 sync ./dist s3://my-bucket --exclude "*.html" \
--cache-control "max-age=31536000, immutable"
# HTML: 1분 캐시, Content-Type 명시
aws s3 sync ./dist s3://my-bucket --exclude "*" --include "*.html" \
--cache-control "max-age=60" --content-type "text/html; charset=utf-8"
이미 올라간 파일은 메타데이터만 따로 고칠 방법이 없다. 같은 키로 다시 복사하면서(aws s3 cp + --metadata-directive REPLACE) 새 값을 줘야 한다. 오브젝트 스토리지에서는 메타데이터도 오브젝트의 일부라서 그렇다.
위와 같은 방식의 가장 큰 장점은 ‘서버가 없다’는 것이다. 관리할 프로세스가 없으니 OOM도, 재시작도, 패치도, 오토스케일링 설정도 없다.
- 트래픽 급증에 무관하다. 앱 푸시 한 번에 동시 접속이 10배로 뛰어도 별도 대응이 필요 없다. S3 + CloudFront가 알아서 받아낸다.
- 트래픽이 없으면 스토리지 비용 몇 센트가 전부이다. 유휴 서버 인스턴스를 24시간 띄워 두는 것과 비교하면 차이가 크다.
- 롤백이 곧 파일 복사이다. 이전 버전 아티팩트를 배포 경로로 덮어쓰고 무효화하면 끝이다.
- 공격 표면이 작다. 실행되는 코드가 서버에 없으니 서버 측 취약점이라는 개념 자체가 없다.
HTML 한 장이 아니라 React 앱 전체를 올리면 한 가지가 더 걸린다. /settings로 직접 들어오거나 새로고침하면 403이나 404가 뜬다. S3에는 settings라는 키가 없기 때문이다. 라우팅은 브라우저 안의 JS가 하는 일인데, 그 JS를 실어 나를 index.html조차 못 받은 상태다.
흔한 해법은 CloudFront 커스텀 에러 응답이다. 403/404가 나면 /index.html을 200으로 돌려주게 설정한다. 동작은 하지만 부작용이 있다. 진짜 없는 파일(/assets/old.js)도 200과 함께 HTML이 내려온다. 브라우저는 JS를 기대했는데 HTML을 받고, 콘솔에는 Unexpected token '<'만 남는다. 모니터링에서도 404가 사라진다.
CloudFront Functions로 요청 URL을 고쳐 쓰는 쪽이 낫다. 확장자가 없는 경로만 /index.html로 바꾸고, 파일 요청은 그대로 S3에 보낸다.
function handler(event) {
var request = event.request;
if (request.uri.indexOf('.') === -1) {
request.uri = '/index.html';
}
return request;
}
2. 사용자 업로드 이미지 저장
사용자가 업로드한 이미지를 서버로 보내지 않고, 브라우저에서 S3로 바로 올리는 방식이 있다. 서버 대역폭과 메모리를 아끼고, 대용량 파일에서 타임아웃이 걸리지 않는다는 장점이 있다. 파일 본체가 서버를 안 거치니 동시 업로드가 늘어도 서버를 증설할 일이 없다.
- 브라우저 → 서버: "logo.png, image/png 올릴게요"
- 서버 → 브라우저: presigned URL (유효기간 5분)
- 브라우저 → S3: PUT (파일 본체, 서버 안 거침)
- 브라우저 → 서버: "업로드 끝났어요, 키는 이거"
브라우저가 S3에 직접 PUT하는 구조라 버킷에 CORS 설정이 필요하다. AllowedOrigins에 서비스 도메인, AllowedMethods에 PUT이 없으면 preflight에서 막힌다. presigned URL을 만들 때 넣은 Content-Type과 실제 PUT 헤더가 다르면 403(SignatureDoesNotMatch)이 나는 것도 자주 밟는 함정이다.
큰 파일은 얘기가 다르다. presigned URL로 PUT 한 번에 올릴 수 있는 크기는 5GB까지다. 그 전에 현실적인 벽이 먼저 온다. 수백 MB짜리 동영상을 요청 하나로 올리다 네트워크가 잠깐 끊기면 처음부터 다시다. 그래서 멀티파트 업로드를 쓴다. 파일을 5MB 이상 조각으로 나눠 각각 올리고, 마지막에 “다 올렸으니 합쳐 달라”고 한 번 더 요청한다.
브라우저에서 직접 하려면 서버가 세 번 개입한다.
- 서버:
CreateMultipartUpload로UploadId발급 - 서버: 파트 번호마다 presigned URL 발급 (한 번에 묶어서 줘도 된다)
- 브라우저: 파트를 병렬로 PUT. 응답 헤더의
ETag를 모아 둔다 - 서버:
CompleteMultipartUpload에 파트 번호 + ETag 목록 전달
파트 단위라 진행률을 정확히 보여줄 수 있고, 끊긴 파트만 다시 올리면 된다. 대신 완료 요청을 안 보낸 업로드는 조각인 채로 버킷에 남아 요금이 나간다. 콘솔에서 보이지도 않는다. AbortIncompleteMultipartUpload 라이프사이클 규칙으로 며칠 지난 미완료 업로드를 자동으로 치워야 한다.
3. 비공개 파일 다운로드
업로드와 원리는 같다. 서버가 발급한 presigned URL로 브라우저가 S3에서 직접 내려받는다. 버킷은 비공개로 두고, 누가 받을 수 있는지는 서버가 URL을 발급하는 시점에 판단한다. URL에 만료 시간이 붙어 있으니 링크가 새어 나가도 잠깐만 유효하다. 파일 본체가 서버를 거치지 않는 것도 업로드 때와 같다.
4. 이미지 최적화
S3에 원본을 올려두고, 리사이징/WebP 변환을 엣지에서 처리하는 패턴이다. CloudFront + Lambda@Edge가 ?w=400&f=webp 같은 쿼리를 해석해 변환/캐싱하고, 프론트에서는 next/image의 커스텀 loader로 그 URL을 만들어 쓰는 식이다.
요청이 올 때 변환하는 대신, 올라오는 순간 미리 만들어 두는 쪽도 있다. S3는 오브젝트가 생성될 때 이벤트를 쏠 수 있다. uploads/ 접두사에 PUT이 들어오면 Lambda를 깨워서 썸네일 몇 장을 thumbs/에 저장하는 식이다. 2번 업로드 흐름의 4단계(“업로드 끝났어요”)를 브라우저가 아니라 S3가 대신 알려주는 셈이라, 사용자가 창을 닫아 버려도 후처리는 돈다.
두 방식은 쓰임이 갈린다. 사이즈가 몇 가지로 정해져 있고 업로드가 드물면 미리 만드는 쪽이 단순하다. 화면마다 다른 크기를 요구하고 원본이 많으면 엣지에서 요청 시 변환하고 캐시하는 쪽이 낫다. 안 쓰는 사이즈까지 미리 만들어 두면 그게 다 저장 비용이다.
5. 빌드 아티팩트, 소스맵 보관
next build, vite build를 돌리면 나오는 dist/, .next/static/ 같은 결과물이 아티팩트이다. 이를 따로 저장해두는 가장 큰 이유는 롤백이다. 배포 후 장애가 났을 때 이전 커밋으로 되돌려 CI를 다시 돌리면 5~10분이 걸리고, 그 사이 의존성 버전이 바뀌어 있으면 “그 때 그 빌드”가 재현이 되지 않을 수 있다. 반면 s3://artifacts/web/v1.24.3/에 통째로 올려뒀다면 그걸 배포 경로로 복사하는 데 수십 초면 끝이 난다.
여기에 라이프사이클 규칙을 걸어 30일 지난 건 Glacier로, 90일 지난 건 삭제로 자동 정리한다.
소스맵도 같은 이유로 여기에 둔다. 프로덕션 번들 옆에 .map을 같이 배포하면 소스가 그대로 노출되니, 배포 경로에는 올리지 않고 아티팩트 버킷에만 보관했다가 Sentry 같은 에러 추적 도구에 올리거나 장애 분석할 때 꺼내 쓴다.
우리 회사는 빌드 결과물 전체를 아티팩트로 보관하지는 않고, 이미지·css·js 같은 정적 파일만 S3에 올린다. 이 배포 방식은 다른 글에서 소개하도록 하겠다.
6. 로그 적재 후 Athena 같은 쿼리 엔진으로 분석
S3는 여기서 데이터 레이크의 저장 계층 역할을 한다. 로그를 원본 그대로 쌓아두고, 쿼리 엔진이 그 위에서 읽는 구조이다. 프론트엔드에서 가장 가까운 예는 CloudFront 액세스 로그다. 로그를 S3에 쌓아두면 어떤 정적 파일이 얼마나 요청됐는지, 캐시 히트율이 어떤지 Athena로 바로 조회할 수 있다.
맺으며
단순한 업무 요청 하나에서 시작된 S3 탐구였다. 프론트엔드 서버에 라우팅 페이지를 하나 두는 방법도 하나의 코드베이스에서 관리되고 커밋으로 기록이 남는다는 장점이 있지만, HTML 한 장을 위해 빌드와 서버를 거쳐야 한다. 결국 S3 + CloudFront로 서빙하기로 했다. 파일을 올리고 무효화 한 번 거는 쪽이 훨씬 가벼웠던 것이다. 또한, 버저닝이 필요한 개인정보처리방침의 특징과도 부합했다.
정리하고 나니 S3를 보는 눈이 조금 바뀌었다. 처음엔 “파일 올려두는 곳” 정도로 생각했는데, 파일이 아니라 오브젝트라는 게 계속 따라다녔다. 폴더가 없는 것도, Content-Type을 내가 붙여야 하는 것도, 덮어쓴 파일이 버전으로 남는 것도 전부 같은 이유다. 키 하나에 본체와 메타데이터와 버전이 묶여 있고, S3는 그걸 넣고 꺼내는 API일 뿐인 것이다. 이걸 알고 나면 캐시 헤더나 멀티파트 같은 것들이 따로 외울 게 아니라 당연한 귀결로 보인다.
그리고 S3가 백엔드 일만은 아니라는 것도 알았다. 위에서 본 여섯 가지 중 절반은 프론트엔드가 직접 만진다. presigned URL로 올리고 내려받는 것도, 캐시 헤더를 정하는 것도, 새로고침 404를 잡는 것도 브라우저 쪽 얘기다. 개인정보 처리방침으로 시작해서 생각보다 많은 것들을 알아다. 빌드 결과물을 어떻게 배포하는지에 대해서는 다른 글에서 이어 쓰겠다.