본문으로 건너뛰기

Presigned URL 완전 가이드

·21 min read

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.jpg

UUID

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개를 넣어도 된다" 라는 서명된 반입증을 발급한다. 이 반입증은 다른 창고에 쓸 수 없고, 시간이 지나면 무효가 된다.

기술적 원리

  1. 백엔드가 policy(정책)를 만든다 — "이 경로에, 이 조건으로, 이 시간까지만 업로드 허용"
  2. AWS Secret Key로 policy를 HMAC-SHA256 서명한다
  3. 서명 결과(signature)와 policy를 클라이언트에 전달한다
  4. 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'
fieldChoiceDjango 모델의 어떤 필드에 연결되는 파일인지 지정. 백엔드가 이 값으로 S3 경로를 결정'advertiser.Advertiser.bankbook_copy'
isDownloadtrue면 다운로드용 헤더가 설정됨 (아래에서 상세 설명)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가 하는 일:

  1. fieldsx-amz-signaturepolicy로 서명을 재검증
  2. policy 조건 (만료시간, 경로, 파일크기 등) 충족 여부 확인
  3. 모두 통과하면 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: 파일 용도별 경로 분기

fieldChoiceDjango 모델명.필드명 형식으로, 백엔드가 이 값을 기반으로 S3 내 저장 경로를 결정한다.

이미지 파일

fieldChoice용도S3 경로 예시
advertiser_memo.AdvertiserMemoImage.image광고주 메모 첨부 이미지advertiser_memo/AdvertiserMemoImage/image/...
cpa.Cpa.main_imageCPA 광고 메인 이미지cpa/Cpa/main_image/...
cpa.Cpa.sns_imageCPA 광고 SNS 이미지cpa/Cpa/sns_image/...
cpa.Cpa.mobile_imageCPA 광고 모바일 이미지cpa/Cpa/mobile_image/...
product.Product.main_image상품 메인 이미지product/Product/main_image/...
cps.Cps.logo_imageCPS 로고 이미지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
imagePNG, JPG, GIF, WebPimage/png, image/jpeg
applicationPDF, Excel, CSV, ZIPapplication/pdf, application/vnd.ms-excel
textTXT, CSVtext/plain, text/csv
videoMP4, WebMvideo/mp4
audioMP3, WAVaudio/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가 하는 일:

isDownloadS3에 설정되는 헤더브라우저 동작
false (기본)Content-Disposition: inlineURL 접근 시 브라우저에서 바로 표시 (이미지 렌더링, PDF 뷰어 등)
trueContent-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을 발급받고 업로드한다. 하나가 실패해도 나머지는 정상 업로드된다.