Presigned URL 완전 가이드
0. 사전 지식: 이 문서에서 등장하는 기초 개념
이 문서를 읽기 전에 알아두면 좋은 개념들을 먼저 정리한다.
바이너리 (Binary)
컴퓨터는 모든 데이터를 0과 1의 조합으로 저장한다. 이걸 바이너리(이진 데이터)라고 한다.
- 텍스트 파일: 사람이 읽을 수 있는 문자(
hello)를 0과 1로 변환해서 저장 - 바이너리 파일: 이미지, PDF, 영상, 엑셀 등 — 메모장으로 열면
ÿØÿà같은 깨진 문자가 보이는 파일
텍스트 파일 (hello.txt):
저장된 형태 → 01101000 01100101 01101100 01101100 01101111
메모장으로 열면 → "hello" (사람이 읽을 수 있음)
바이너리 파일 (photo.png):
저장된 형태 → 10001001 01010000 01001110 01000111 ...
메모장으로 열면 → "‰PNG..." (사람이 읽을 수 없음)"파일 바이너리가 서버를 거친다" = 이 0과 1 덩어리(원본 파일 데이터)가 서버 메모리에 올라간다는 뜻이다. 100MB 이미지면 서버 메모리를 100MB 차지한다.
바이트 (Byte)와 파일 크기
1 Byte = 8 bit (0과 1 여덟 개)
1 KB (킬로바이트) = 1,024 Byte ← 짧은 텍스트 파일
1 MB (메가바이트) = 1,024 KB ← 일반 사진
1 GB (기가바이트) = 1,024 MB ← 영상 파일코드에서 5 * 1024 * 1024는 5MB를 바이트로 표현한 것이다 (5,242,880 bytes).
HTTP 헤더 (Header)
HTTP는 브라우저와 서버가 통신하는 규약이다. 모든 HTTP 요청/응답에는 헤더와 **본문(body)**이 있다.
[헤더] — 메타 정보 (편지 봉투에 적힌 정보)
Content-Type: image/png ← "이 파일은 PNG 이미지야"
Content-Length: 5242880 ← "크기는 5MB야"
Content-Disposition: attachment ← "다운로드하게 해줘"
[본문(body)] — 실제 데이터 (편지 내용물)
(이미지 바이너리 데이터...)편지에 비유하면:
- 헤더 = 봉투 겉면 (받는 사람, 보내는 사람, 등기/일반 여부)
- 본문 = 봉투 안의 내용물
MIME 타입 (Content-Type)
파일의 종류를 나타내는 표준 표기법. 대분류/소분류 형식이다.
image/png → PNG 이미지
image/jpeg → JPG 이미지
application/pdf → PDF 문서
application/vnd.ms-excel → 엑셀 파일
text/csv → CSV 파일
text/plain → 일반 텍스트
video/mp4 → MP4 영상
audio/mpeg → MP3 음악브라우저는 이 값을 보고 파일을 어떻게 처리할지 결정한다:
image/*→ 화면에 이미지로 표시application/pdf→ PDF 뷰어로 열기- 모르는 타입 → 다운로드 대화상자 표시
FormData
HTML 폼에서 파일을 보낼 때 사용하는 데이터 형식이다. 일반 JSON으로는 파일(바이너리)을 보낼 수 없기 때문에 FormData를 사용한다.
const formData = new FormData()
formData.append('name', '홍길동') // 텍스트 데이터
formData.append('file', imageFile) // 파일 데이터 (바이너리)하나의 FormData 안에 텍스트와 파일을 함께 담을 수 있다. Presigned URL 업로드에서는 인증 필드(텍스트)와 파일(바이너리)을 하나의 FormData에 담아 S3로 보낸다.
Base64 인코딩
바이너리 데이터를 텍스트 문자열로 변환하는 방법이다. 바이너리를 직접 전송할 수 없는 환경(JSON, URL 등)에서 사용한다.
원본 바이너리: 10001001 01010000 01001110
Base64 변환: "iVBORw0KGgo..."Presigned URL의 policy 필드가 Base64로 인코딩되어 있다. 업로드 조건(만료시간, 경로 제한 등)을 JSON으로 만든 뒤, Base64로 변환해서 HTTP 요청에 실을 수 있게 한 것이다.
해시 함수와 HMAC
해시 함수: 어떤 데이터를 넣으면 고정 길이의 값이 나오는 단방향 함수.
"hello" → SHA256 → "2cf24dba5fb0a30e26e83b2ac5b9e29e..."
"hello!" → SHA256 → "ce06092fb948d9ffac7d1a376f7b656d..." ← 한 글자만 달라도 완전히 다른 결과특징:
- 단방향: 결과값에서 원본을 역추출할 수 없다
- 결정적: 같은 입력은 항상 같은 결과
- 눈사태 효과: 입력이 조금만 달라도 결과가 완전히 달라짐
HMAC: 해시 함수 + 비밀키를 조합한 서명 방식.
HMAC-SHA256("비밀키", "데이터") → "서명값"Presigned URL에서는 AWS Secret Key(비밀키)와 policy(데이터)를 HMAC-SHA256으로 서명한다. S3는 같은 Secret Key로 서명을 재계산해서 일치 여부를 확인한다.
엔드포인트 (Endpoint)
API나 서비스에 접근하기 위한 URL 주소를 말한다.
백엔드 엔드포인트: https://api.kodeflo.com/presigned_url/
S3 엔드포인트: https://kodeflo-bucket.s3.ap-northeast-2.amazonaws.com/"S3 엔드포인트로 직접 업로드한다" = 브라우저가 S3의 URL로 직접 HTTP 요청을 보낸다는 뜻이다.
버킷 (Bucket)
S3에서 파일을 저장하는 최상위 폴더(컨테이너). 하나의 버킷 안에 폴더 구조처럼 파일을 정리한다.
kodeflo-bucket/ ← 버킷
├── advertiser_memo/
│ └── AdvertiserMemoImage/
│ └── image/
│ └── a1b2c3_photo.png ← 실제 파일
├── advertiser/
│ └── Advertiser/
│ └── bankbook_copy/
│ └── d4e5f6_통장.pdf
└── cpa/
└── Cpa/
└── main_image/
└── g7h8i9_banner.jpgUUID
Universally Unique Identifier — 전 세계에서 겹치지 않는 고유한 ID 문자열.
예: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"S3 파일 경로에 UUID를 포함시키면 같은 이름의 파일을 올려도 경로가 겹치지 않아 덮어쓰기가 방지된다.
1. 배경: 왜 Presigned URL이 필요한가?
일반적인 파일 업로드의 문제
웹 서비스에서 파일을 저장하려면 스토리지가 필요하다. 대부분의 서비스는 AWS S3(Simple Storage Service) 같은 클라우드 스토리지를 사용한다.
그런데 S3는 AWS 계정의 Access Key + Secret Key로 인증해야 접근할 수 있다. 이 키가 노출되면 버킷의 모든 파일을 읽고/쓰고/삭제할 수 있기 때문에, 절대 클라이언트(브라우저)에 줄 수 없다.
그래서 전통적인 방식은 이렇다:
브라우저 →(파일 전송)→ 백엔드 서버 →(파일 전송)→ AWS S3이 방식의 문제:
| 문제 | 설명 |
|---|---|
| 서버 부하 | 100MB 파일을 10명이 동시에 올리면 서버가 1GB 메모리를 먹는다 |
| 네트워크 비용 2배 | 파일이 브라우저→서버, 서버→S3로 두 번 전송된다 |
| 업로드 시간 2배 | 사용자는 서버→S3 전송이 끝날 때까지 기다려야 한다 |
| 서버 스케일링 복잡 | 파일 I/O 때문에 서버를 늘려야 할 수 있다 |
Presigned URL의 해결 방식
"인증은 백엔드가, 전송은 클라이언트가" 역할을 분리한다.
브라우저 →(파일명만)→ 백엔드 서버 ← 가벼운 JSON (수백 바이트)
백엔드 서버 →(서명 생성)→ 브라우저 ← 임시 허가증 반환
브라우저 →(파일 전송)→ AWS S3 ← 직접 전송 (백엔드 경유 안 함)2. 핵심 개념: 서명(Signature)이란?
비유로 이해하기
회사 창고(S3)에 물건을 넣으려면 사원증(AWS Key)이 필요하다. 하지만 외부 배달원(브라우저)에게 사원증을 줄 수는 없다. 대신 "3번 창고에 오늘 오후 3시까지 박스 1개를 넣어도 된다" 라는 서명된 반입증을 발급한다. 이 반입증은 다른 창고에 쓸 수 없고, 시간이 지나면 무효가 된다.
기술적 원리
- 백엔드가 policy(정책)를 만든다 — "이 경로에, 이 조건으로, 이 시간까지만 업로드 허용"
- AWS Secret Key로 policy를 HMAC-SHA256 서명한다
- 서명 결과(signature)와 policy를 클라이언트에 전달한다
- S3는 요청을 받으면 같은 방식으로 서명을 재계산해서 일치 여부를 검증한다
서명 = HMAC-SHA256(Secret Key, policy)왜 안전한가:
- HMAC은 단방향 함수 — 서명 결과에서 Secret Key를 역추출할 수 없다
- policy에 만료 시간, 경로, 파일 크기 등 조건이 포함되어 있어 범위가 제한된다
- 서명이 탈취되어도 만료 후에는 무용지물이다
3. 업로드 전체 흐름 (코드 기반)
Step 1: Presigned URL 발급 요청
// 클라이언트 → 백엔드
const { fields, url } = await presignedUrlApi.presignedUrlCreate({
data: {
fileName: 'receipt.png', // 파일명
fieldChoice: 'advertiser_memo.AdvertiserMemoImage.image', // 용도 (어디에 쓸 파일인지)
isDownload: false, // 다운로드용 파일인지 여부
},
})요청 파라미터 설명:
| 파라미터 | 역할 | 예시 |
|---|---|---|
fileName | 파일명. S3 경로 생성과 Content-Type 추론에 사용 | 'receipt.png' |
fieldChoice | Django 모델의 어떤 필드에 연결되는 파일인지 지정. 백엔드가 이 값으로 S3 경로를 결정 | 'advertiser.Advertiser.bankbook_copy' |
isDownload | true면 다운로드용 헤더가 설정됨 (아래에서 상세 설명) | false |
백엔드 응답 예시:
{
"url": "https://kodeflo-bucket.s3.ap-northeast-2.amazonaws.com/",
"fields": {
"key": "advertiser_memo/AdvertiserMemoImage/image/a1b2c3d4_receipt.png",
"policy": "eyJleHBpcmF0aW9uIjogIjIwMjYtMDMtMjdUMTI6MDA6MDBaIi...",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "AKIA.../20260327/ap-northeast-2/s3/aws4_request",
"x-amz-date": "20260327T060000Z",
"x-amz-signature": "a1b2c3d4e5f6..."
}
}url: S3 버킷 엔드포인트fields.key: S3에 저장될 파일 경로 (백엔드가fieldChoice기반으로 생성)fields.policy: Base64 인코딩된 업로드 조건 (만료시간, 경로, 크기 제한 등)fields.x-amz-signature: Secret Key로 생성한 서명값
Step 2: FormData 조립
const formData = new FormData()
// 1) 인증 필드들을 먼저 추가
Object.entries(fields).forEach(([k, v]) => formData.append(k, v))
// 2) 파일은 반드시 마지막에 추가 (S3 POST 규칙)
formData.append('file', file)왜 file이 마지막이어야 하는가?
S3 POST 업로드 규약상, file 필드 이후의 모든 필드는 무시된다. 인증 필드가 file 뒤에 오면 S3가 인식하지 못해 업로드가 실패한다.
Step 3: S3에 직접 업로드
await fetch(url, {
method: 'POST',
body: formData,
})이 요청은 브라우저에서 S3로 직접 전송된다. 백엔드 서버를 거치지 않는다.
S3가 하는 일:
fields의x-amz-signature와policy로 서명을 재검증- policy 조건 (만료시간, 경로, 파일크기 등) 충족 여부 확인
- 모두 통과하면
fields.key경로에 파일 저장
Step 4: 업로드된 파일 URL 조합
const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url
const fileUrl = `${baseUrl}/${encodeURI(fields.key)}`
// → "https://kodeflo-bucket.s3.../advertiser_memo/AdvertiserMemoImage/image/a1b2c3d4_receipt.png"이 URL을 코멘트 등록 API 등에 전달하면 백엔드 DB에 S3 경로가 저장된다.
4. fieldChoice: 파일 용도별 경로 분기
fieldChoice는 Django 모델명.필드명 형식으로, 백엔드가 이 값을 기반으로 S3 내 저장 경로를 결정한다.
이미지 파일
| fieldChoice | 용도 | S3 경로 예시 |
|---|---|---|
advertiser_memo.AdvertiserMemoImage.image | 광고주 메모 첨부 이미지 | advertiser_memo/AdvertiserMemoImage/image/... |
cpa.Cpa.main_image | CPA 광고 메인 이미지 | cpa/Cpa/main_image/... |
cpa.Cpa.sns_image | CPA 광고 SNS 이미지 | cpa/Cpa/sns_image/... |
cpa.Cpa.mobile_image | CPA 광고 모바일 이미지 | cpa/Cpa/mobile_image/... |
product.Product.main_image | 상품 메인 이미지 | product/Product/main_image/... |
cps.Cps.logo_image | CPS 로고 이미지 | cps/Cps/logo_image/... |
advertiser.Advertiser.business_license | 사업자등록증 (이미지/PDF) | advertiser/Advertiser/business_license/... |
일반 파일 (비이미지)
| fieldChoice | 용도 | 예상 파일 형식 |
|---|---|---|
advertiser.Advertiser.bankbook_copy | 통장사본 | PDF, 이미지 |
cpa_customer_upload.CpaCustomerUpload.file | 고객 데이터 업로드 | CSV, Excel |
cpa_customer_export.CpaCustomerExport.file | 고객 데이터 내보내기 | CSV, Excel |
코드에서의 MIME 타입 검증
const { name, type } = file
const [mime] = type.split('/') // 'image/png' → 'image'
assertItemOf(
['image', 'audio', 'text', 'video', 'application'] as const,
mime,
)이미지뿐 아니라 다양한 MIME 타입을 지원한다:
| MIME 카테고리 | 예시 파일 | Content-Type |
|---|---|---|
image | PNG, JPG, GIF, WebP | image/png, image/jpeg |
application | PDF, Excel, CSV, ZIP | application/pdf, application/vnd.ms-excel |
text | TXT, CSV | text/plain, text/csv |
video | MP4, WebM | video/mp4 |
audio | MP3, WAV | audio/mpeg |
5. Content-Type과 Content-Disposition
Content-Type
파일의 MIME 타입을 나타내는 HTTP 헤더. 브라우저가 파일을 어떻게 처리할지 결정한다.
Content-Type: image/png → 브라우저가 이미지로 렌더링
Content-Type: application/pdf → 브라우저 내장 PDF 뷰어로 표시
Content-Type: text/csv → 텍스트로 표시하거나 다운로드S3에 파일을 업로드할 때 Content-Type이 함께 저장되며, 나중에 이 파일을 요청(다운로드/조회)할 때 S3가 이 헤더를 응답에 포함한다.
Content-Disposition과 isDownload
// 통장사본 업로드 — 다운로드 용도
await uploadFile({
file,
fieldChoice: 'advertiser.Advertiser.bankbook_copy',
isDownload: true, // ← 다운로드용
})
// 메모 이미지 업로드 — 조회(미리보기) 용도
await uploadFiles([{
file,
fieldChoice: 'advertiser_memo.AdvertiserMemoImage.image',
// isDownload 기본값 false → 조회용
}])isDownload가 하는 일:
| isDownload | S3에 설정되는 헤더 | 브라우저 동작 |
|---|---|---|
false (기본) | Content-Disposition: inline | URL 접근 시 브라우저에서 바로 표시 (이미지 렌더링, PDF 뷰어 등) |
true | Content-Disposition: attachment; filename="파일명" | URL 접근 시 파일 다운로드 대화상자 표시 |
사용 기준:
- 메모 첨부 이미지, 광고 이미지 →
isDownload: false(브라우저에서 미리보기) - 통장사본, 사업자등록증, CSV 엑셀 →
isDownload: true(다운로드받아서 확인)
백엔드는 presigned URL 생성 시 이 값에 따라 S3 메타데이터의 Content-Disposition을 설정한다. 이 설정은 파일이 S3에 저장될 때 함께 기록되어, 이후 해당 URL에 접근할 때마다 적용된다.
6. 보안 모델
policy에 포함되는 제약 조건
{
"expiration": "2026-03-27T06:05:00Z",
"conditions": [
{"bucket": "kodeflo-bucket"},
{"key": "advertiser_memo/AdvertiserMemoImage/image/a1b2c3d4.png"},
{"x-amz-algorithm": "AWS4-HMAC-SHA256"},
["content-length-range", 0, 10485760]
]
}| 조건 | 역할 |
|---|---|
expiration | 이 시간 이후 서명 무효 (보통 5~15분) |
bucket | 지정된 버킷에만 업로드 가능 |
key | 지정된 경로에만 저장 가능 (다른 경로 업로드 불가) |
content-length-range | 파일 크기 제한 (예: 최대 10MB) |
공격 시나리오별 방어
| 공격 | 방어 |
|---|---|
| 서명 탈취 후 재사용 | 만료시간이 짧아 시간 지나면 무효 |
| 다른 경로에 파일 업로드 | key 조건이 고정되어 있어 지정된 경로만 가능 |
| 대용량 파일로 스토리지 공격 | content-length-range로 크기 제한 |
| 서명에서 Secret Key 추출 | HMAC-SHA256은 단방향이라 역추출 불가능 |
| 업로드된 파일 URL로 다른 파일 덮어쓰기 | key에 UUID가 포함되어 경로 예측 불가 |
7. 전체 아키텍처 비교
Before: 서버 중계 방식
After: Presigned URL 방식
| 비교 항목 | 서버 중계 | Presigned URL |
|---|---|---|
| 백엔드 메모리 사용 | 파일 크기만큼 | 거의 0 |
| 네트워크 전송 횟수 | 2번 (브라우저→서버→S3) | 1번 (브라우저→S3) |
| 업로드 시간 | 느림 (2번 전송) | 빠름 (1번 전송) |
| 서버 스케일링 | 파일 I/O 때문에 복잡 | 파일 무관, API만 처리 |
| 대용량 파일 | 서버 타임아웃/메모리 위험 | S3가 알아서 처리 |
| 보안 | AWS Key가 서버에만 존재 (안전) | AWS Key가 서버에만 존재 (동일) |
| 구현 복잡도 | 단순 | 약간 복잡 (서명 로직) |
8. 실제 사용 예시 정리
이미지 업로드 (미리보기용)
// 코멘트 이미지 — 브라우저에서 바로 볼 수 있어야 함
const result = await uploadFiles([
{
file: imageFile,
fieldChoice: 'advertiser_memo.AdvertiserMemoImage.image',
// isDownload: false (기본값)
},
])
const imageUrl = result.fulfilled[0].url
// → <img src={imageUrl} /> 로 렌더링 가능문서 업로드 (다운로드용)
// 통장사본 — 다운로드받아서 확인해야 함
const { url } = await uploadFile({
file: bankbookPdf,
fieldChoice: 'advertiser.Advertiser.bankbook_copy',
isDownload: true,
})
// → 이 URL 클릭 시 브라우저가 파일 다운로드 대화상자를 띄움복수 파일 업로드
// uploadFiles는 내부적으로 Promise.allSettled를 사용
const inputs = files.map((f) => ({
file: f.file,
fieldChoice: 'advertiser_memo.AdvertiserMemoImage.image' as const,
}))
const result = await uploadFiles(inputs)
result.fulfilled // 성공한 파일들: { url, name, file }[]
result.rejected // 실패한 파일들uploadFiles는 각 파일에 대해 독립적으로 presigned URL을 발급받고 업로드한다. 하나가 실패해도 나머지는 정상 업로드된다.