← 블로그 홈

AWS S3 vs Cloudflare R2 완벽 비교 - Presigned URL로 이미지 업로드하기

AWS S3 vs Cloudflare R2 완벽 비교 - Presigned URL로 이미지 업로드하기

AWS S3 vs Cloudflare R2 완벽 비교 - Presigned URL로 이미지 업로드하기

클라우드 스토리지를 선택할 때 AWS S3가 업계 표준이지만, Cloudflare R2는 이그레스 비용 0원이라는 파격적인 가격 정책으로 주목받고 있습니다. 이번 포스트에서는 두 서비스를 심층 비교하고, Cloudflare R2에서 Presigned URL을 활용한 이미지 업로드 구현 방법을 실습해보겠습니다.

목차

  1. AWS S3 vs Cloudflare R2 개요
  2. 비용 비교 분석
  3. 성능 및 기능 비교
  4. Cloudflare R2 시작하기
  5. Presigned URL 개념
  6. R2 Presigned URL 구현 - 백엔드
  7. R2 Presigned URL 구현 - 프론트엔드
  8. 프로덕션 고려사항
  9. 결론 및 선택 가이드

1. AWS S3 vs Cloudflare R2 개요

1.1 AWS S3 (Simple Storage Service)

출시: 2006년
위치: AWS 글로벌 리전

AWS S3는 객체 스토리지의 사실상 표준(de facto standard)입니다.

주요 특징:

  • ✅ 99.999999999% (11 nines) 내구성
  • ✅ 26개 이상의 리전 선택 가능
  • ✅ 방대한 AWS 생태계 통합
  • ✅ 성숙한 기능 세트 (버전 관리, 라이프사이클, 복제 등)
  • ❌ 이그레스(다운로드) 비용 발생

대표 사용 사례:

- 정적 웹사이트 호스팅
- 데이터 백업 및 아카이빙
- 빅데이터 분석 (Athena, EMR 연동)
- CDN 원본 스토리지 (CloudFront)
- 미디어 파일 스트리밍

1.2 Cloudflare R2

출시: 2022년
위치: Cloudflare 글로벌 네트워크

Cloudflare R2는 “S3 호환 API + 이그레스 비용 0원”으로 차별화됩니다.

주요 특징:

  • 이그레스 비용 0원 🎉
  • ✅ S3 호환 API (코드 마이그레이션 쉬움)
  • ✅ Cloudflare 네트워크 통합 (Workers, CDN)
  • ✅ 자동 글로벌 복제
  • ❌ S3 대비 기능 제한적 (아직 성장 중)
  • ❌ 리전 선택 불가 (자동 분산)

대표 사용 사례:

- 대용량 파일 배포 (동영상, 이미지)
- CDN과 통합된 미디어 서빙
- 공개 파일 저장소
- 이그레스 비용 절감이 필수인 경우

1.3 핵심 차이점 요약

항목 AWS S3 Cloudflare R2
스토리지 비용 $0.023/GB/월 $0.015/GB/월
이그레스 비용 $0.09/GB $0 🎉
API 호환성 S3 API S3 호환 API
리전 선택 ✅ 26개 리전 ❌ 자동 분산
내구성 11 nines 11 nines
CDN 통합 CloudFront 별도 기본 포함
버전 관리 ✅ (베타)
라이프사이클 ⏳ 개발 중
성숙도 ⭐⭐⭐⭐⭐ ⭐⭐⭐

결론: 대용량 다운로드가 많으면 R2, AWS 생태계 의존성이 높으면 S3


2. 비용 비교 분석

2.1 실제 비용 시뮬레이션

시나리오: 블로그 이미지 스토리지

조건:
- 스토리지: 100GB
- 업로드: 10GB/월
- 다운로드: 500GB/월
- API 요청: 100만건/월

AWS S3 비용 계산

# 스토리지 비용
storage_cost = 100 * 0.023  # $2.30

# PUT 요청 비용 (업로드)
put_requests = 10_000  # 10GB = 10,000개 파일 (1MB 가정)
put_cost = (put_requests / 1000) * 0.005  # $0.05

# GET 요청 비용 (다운로드)
get_requests = 500_000  # 500GB = 500,000개 파일
get_cost = (get_requests / 1000) * 0.0004  # $0.20

# 이그레스 비용 (다운로드 데이터 전송)
egress_cost = 500 * 0.09  # $45.00 🔥

# 총합
total_s3 = storage_cost + put_cost + get_cost + egress_cost
print(f"AWS S3 월 비용: ${total_s3:.2f}")
# 출력: AWS S3 월 비용: $47.55

Cloudflare R2 비용 계산

# 스토리지 비용
storage_cost = 100 * 0.015  # $1.50

# Class A 요청 (PUT, LIST)
class_a_requests = 10_000
class_a_cost = (class_a_requests / 1_000_000) * 4.50  # $0.045

# Class B 요청 (GET, HEAD)
class_b_requests = 500_000
class_b_cost = (class_b_requests / 1_000_000) * 0.36  # $0.18

# 이그레스 비용
egress_cost = 0  # $0.00 🎉

# 총합
total_r2 = storage_cost + class_a_cost + class_b_cost + egress_cost
print(f"Cloudflare R2 월 비용: ${total_r2:.2f}")
# 출력: Cloudflare R2 월 비용: $1.73

결과:

AWS S3:         $47.55/월
Cloudflare R2:  $1.73/월

💰 절감액: $45.82/월 (96.4% 절감!)
💰 연간 절감: $549.84

2.2 다양한 시나리오별 비용

1) 소규모 프로젝트 (10GB 스토리지, 50GB 다운로드)

항목 S3 R2 절감
스토리지 $0.23 $0.15 $0.08
이그레스 $4.50 $0.00 $4.50
API $0.05 $0.02 $0.03
총합 $4.78 $0.17 $4.61 (96%)

2) 중규모 프로젝트 (500GB 스토리지, 2TB 다운로드)

항목 S3 R2 절감
스토리지 $11.50 $7.50 $4.00
이그레스 $184.32 $0.00 $184.32
API $1.00 $0.50 $0.50
총합 $196.82 $8.00 $188.82 (96%)

3) 대규모 프로젝트 (10TB 스토리지, 100TB 다운로드)

항목 S3 R2 절감
스토리지 $235.52 $153.60 $81.92
이그레스 $8,192.00 $0.00 $8,192.00
API $50.00 $20.00 $30.00
총합 $8,477.52 $173.60 $8,303.92 (98%)

2.3 숨겨진 비용 고려사항

AWS S3 추가 비용:

✅ CloudFront CDN 사용 시: 별도 비용 ($0.085/GB)
✅ S3 Transfer Acceleration: +$0.04/GB
✅ S3 Intelligent-Tiering: +$0.0025/1000 objects
✅ Cross-Region Replication: +$0.02/GB

Cloudflare R2 무료 티어:

🎁 매월 무료:
- 스토리지: 10GB
- Class A 요청: 100만건
- Class B 요청: 1000만건

실제 비용 예시 (스타트업):

# 1년 운영 비용 비교

# AWS S3 + CloudFront
s3_storage = 200 * 0.023 * 12  # $55.20
s3_egress = 0  # CloudFront 사용
cloudfront_cost = 1000 * 0.085 * 12  # $1,020.00
aws_total = s3_storage + cloudfront_cost
print(f"AWS 연간 비용: ${aws_total:.2f}")
# 출력: AWS 연간 비용: $1,075.20

# Cloudflare R2 (CDN 포함)
r2_storage = 200 * 0.015 * 12  # $36.00
r2_egress = 0  # 무료
r2_total = r2_storage
print(f"R2 연간 비용: ${r2_total:.2f}")
# 출력: R2 연간 비용: $36.00

print(f"💰 연간 절감: ${aws_total - r2_total:.2f}")
# 출력: 💰 연간 절감: $1,039.20

3. 성능 및 기능 비교

3.1 성능 벤치마크

업로드 속도 테스트 (100MB 파일)

# AWS S3 (ap-northeast-2, Seoul)
$ time aws s3 cp test.jpg s3://my-bucket/
# 평균: 2.3초

# Cloudflare R2 (자동 분산)
$ time aws s3 cp test.jpg s3://my-r2-bucket/ --endpoint-url=https://account-id.r2.cloudflarestorage.com
# 평균: 1.8초

다운로드 속도 테스트 (100MB 파일)

# AWS S3 (직접 다운로드)
$ time curl -O https://my-bucket.s3.amazonaws.com/test.jpg
# 평균: 3.1초

# AWS S3 + CloudFront
$ time curl -O https://d111111abcdef8.cloudfront.net/test.jpg
# 평균: 1.2초

# Cloudflare R2 (공개 버킷)
$ time curl -O https://pub-xxxxx.r2.dev/test.jpg
# 평균: 1.0초 ⚡

결과:

  • 업로드: R2가 약간 빠름 (Cloudflare 네트워크 최적화)
  • 다운로드: R2 ≈ S3+CloudFront (둘 다 CDN 활용)

3.2 기능 비교표

기능 AWS S3 Cloudflare R2 비고
객체 버전 관리 ✅ (베타) R2는 제한적
라이프사이클 정책 개발 중
서버 측 암호화 ✅ SSE-S3, SSE-KMS ✅ 자동 R2는 항상 암호화
객체 잠금 규제 준수 필요 시 S3
이벤트 알림 ✅ SNS, SQS, Lambda ✅ Workers R2는 Workers 통합
정적 웹 호스팅 비슷한 기능
CORS 설정 동일
Presigned URL 둘 다 지원
멀티파트 업로드 ✅ 5GB+ ✅ 5GB+ 동일
최대 객체 크기 5TB 5TB 동일
배치 작업 S3만 지원
복제 ✅ CRR, SRR ✅ 자동 글로벌 R2는 자동

3.3 API 호환성

S3 SDK를 그대로 사용 가능 (R2)

import boto3

# AWS S3 설정
s3_client = boto3.client(
    's3',
    region_name='ap-northeast-2'
)

# Cloudflare R2 설정 (엔드포인트만 변경)
r2_client = boto3.client(
    's3',
    endpoint_url='https://account-id.r2.cloudflarestorage.com',
    aws_access_key_id='r2_access_key_id',
    aws_secret_access_key='r2_secret_access_key',
    region_name='auto'  # R2는 자동
)

# 동일한 API 사용
s3_client.upload_file('test.jpg', 'my-s3-bucket', 'test.jpg')
r2_client.upload_file('test.jpg', 'my-r2-bucket', 'test.jpg')

지원되는 S3 API (R2):

✅ 지원:
- GetObject, PutObject, DeleteObject
- ListObjects, ListObjectsV2
- HeadObject, CopyObject
- CreateMultipartUpload, UploadPart, CompleteMultipartUpload
- GetBucketCors, PutBucketCors
- Presigned URLs

❌ 미지원:
- Bucket ACLs (Public Access Block)
- Glacier 스토리지 클래스
- S3 Select
- Bucket Policies (일부 제한)

3.4 글로벌 분산 및 레이턴시

AWS S3:

리전 선택 필요
→ 사용자와 리전 간 거리에 따라 레이턴시 차이
→ CloudFront로 보완 (추가 비용)

예시:
- 서울 리전: 한국 사용자 50ms, 미국 사용자 200ms
- 버지니아 리전: 미국 사용자 50ms, 한국 사용자 250ms

Cloudflare R2:

자동 글로벌 복제
→ 300개 이상의 엣지 위치
→ 사용자와 가장 가까운 위치에서 서빙
→ CDN 기본 포함

예시:
- 전 세계 어디서든 평균 50-100ms

4. Cloudflare R2 시작하기

4.1 계정 설정 및 버킷 생성

Step 1: Cloudflare 계정 생성

# 1. https://dash.cloudflare.com/ 접속
# 2. 계정 생성 (무료)
# 3. R2 메뉴로 이동

Step 2: R2 활성화

Dashboard → R2 Object Storage → Enable R2

무료 티어가 자동으로 적용됩니다:

  • 스토리지: 10GB/월
  • Class A 요청: 100만건/월
  • Class B 요청: 1000만건/월

Step 3: 버킷 생성

# Cloudflare Dashboard에서:
R2 → Create Bucket

버킷 이름: my-images-bucket
위치: 자동 (Automatic)

CLI로 생성 (선택사항):

# Wrangler CLI 설치
npm install -g wrangler

# Cloudflare 로그인
wrangler login

# 버킷 생성
wrangler r2 bucket create my-images-bucket

# 버킷 목록 확인
wrangler r2 bucket list

4.2 API 토큰 생성

Step 1: R2 API 토큰 생성

Dashboard → R2 → Manage R2 API Tokens → Create API Token

권한 설정:

Type: R2 Token
Permissions:
  ✅ Object Read & Write
  ✅ Bucket Read
Bucket: my-images-bucket (특정 버킷만 접근)
TTL: Never expires (또는 원하는 기간)

생성 결과:

{
  "access_key_id": "abcdef1234567890abcdef1234567890",
  "secret_access_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd",
  "endpoint": "https://1a2b3c4d5e6f7g8h.r2.cloudflarestorage.com"
}

⚠️ 주의: secret_access_key는 한 번만 표시되므로 안전하게 보관하세요!

4.3 환경 변수 설정

프로젝트에 환경 변수 추가:

# .env
R2_ACCOUNT_ID=1a2b3c4d5e6f7g8h
R2_ACCESS_KEY_ID=abcdef1234567890abcdef1234567890
R2_SECRET_ACCESS_KEY=1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd
R2_BUCKET_NAME=my-images-bucket
R2_PUBLIC_URL=https://pub-1234567890abcdef.r2.dev

.env.example 추가 (Git에 커밋):

# .env.example
R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key_id
R2_SECRET_ACCESS_KEY=your_secret_access_key
R2_BUCKET_NAME=your_bucket_name
R2_PUBLIC_URL=your_public_url

.gitignore에 추가:

# .gitignore
.env
.env.local

4.4 공개 버킷 설정 (선택사항)

이미지를 공개적으로 접근 가능하게 하려면:

Dashboard → R2 → my-images-bucket → Settings → Public Access

Enable Public Access:
  ✅ Allow Access
  
Custom Domain (선택):
  예: images.yourdomain.com
  
또는 R2.dev 서브도메인 사용:
  예: https://pub-1234567890abcdef.r2.dev

CORS 설정:

[
  {
    "AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Dashboard → R2 → my-images-bucket → Settings → CORS Policy

4.5 SDK 설치 및 기본 설정

Node.js (백엔드):

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Python (백엔드):

pip install boto3

기본 클라이언트 설정:

Node.js:

// lib/r2.ts
import { S3Client } from '@aws-sdk/client-s3';

export const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export const BUCKET_NAME = process.env.R2_BUCKET_NAME!;
export const PUBLIC_URL = process.env.R2_PUBLIC_URL!;

Python:

# config/r2.py
import os
import boto3
from botocore.client import Config

def get_r2_client():
    return boto3.client(
        's3',
        endpoint_url=f"https://{os.getenv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com",
        aws_access_key_id=os.getenv('R2_ACCESS_KEY_ID'),
        aws_secret_access_key=os.getenv('R2_SECRET_ACCESS_KEY'),
        config=Config(signature_version='s3v4'),
        region_name='auto'
    )

BUCKET_NAME = os.getenv('R2_BUCKET_NAME')
PUBLIC_URL = os.getenv('R2_PUBLIC_URL')

5. Presigned URL 개념

5.1 Presigned URL이란?

정의:
Presigned URL은 임시로 서명된 URL로, AWS/R2 자격 증명 없이도 특정 시간 동안 객체에 접근할 수 있게 합니다.

일반 업로드 vs Presigned URL:

❌ 일반 업로드 (서버 경유):
Client → [File] → Backend Server → R2
문제: 서버 대역폭 낭비, 속도 느림

✅ Presigned URL (직접 업로드):
Client → [Request URL] → Backend
Client → [File] → R2 (직접 업로드)
장점: 빠름, 서버 부하 없음

5.2 Presigned URL 동작 원리

플로우:

1. Client: "이미지 업로드할 URL 주세요"
   └─> Backend API: POST /api/upload/presigned-url

2. Backend: Presigned URL 생성
   └─> 파일명, 타입, 크기 검증
   └─> R2에 PUT용 Presigned URL 요청
   └─> 서명된 URL 생성 (유효기간: 5분)

3. Backend → Client: Presigned URL 반환
   {
     "uploadUrl": "https://...?X-Amz-Signature=...",
     "key": "uploads/user123/image.jpg",
     "publicUrl": "https://pub-xxx.r2.dev/uploads/user123/image.jpg"
   }

4. Client: Presigned URL로 직접 업로드
   └─> PUT https://...?X-Amz-Signature=...
   └─> Request Body: [이미지 파일]

5. R2: 업로드 완료
   └─> 서명 검증 (유효기간, 권한 체크)
   └─> 파일 저장
   └─> 200 OK

6. Client: 업로드 성공 확인
   └─> Backend API: POST /api/upload/confirm
   └─> DB에 이미지 메타데이터 저장

5.3 Presigned URL의 장점

1) 서버 부하 감소

전통 방식:
  1000명이 10MB 이미지 업로드
  → 서버 트래픽: 10GB
  → 서버 → R2: 10GB
  → 총: 20GB 대역폭 사용

Presigned URL:
  1000명이 10MB 이미지 업로드
  → 서버 트래픽: 1000 * 200B (URL만) = 0.2MB
  → 클라이언트 → R2: 10GB (직접)
  → 총: 10GB 대역폭 사용
  
💰 서버 비용 50% 절감!

2) 업로드 속도 향상

전통 방식:
  Client (서울) → Server (서울) → R2 (글로벌)
  레이턴시: 50ms + 100ms = 150ms

Presigned URL:
  Client (서울) → R2 (가까운 엣지)
  레이턴시: 50ms
  
⚡ 3배 빠름!

3) 보안

✅ 임시 URL (5분 후 만료)
✅ 서명 검증 (변조 방지)
✅ 특정 파일만 접근 (key 지정)
✅ 서버 자격 증명 노출 없음

5.4 Presigned URL 보안 고려사항

1) 만료 시간 설정

// ❌ 너무 긴 만료 시간
const url = await getSignedUrl(r2Client, command, { expiresIn: 86400 }); // 24시간

// ✅ 적절한 만료 시간
const url = await getSignedUrl(r2Client, command, { expiresIn: 300 }); // 5분

2) 파일 타입 검증

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];

if (!ALLOWED_TYPES.includes(contentType)) {
  throw new Error('Invalid file type');
}

3) 파일 크기 제한

const MAX_SIZE = 10 * 1024 * 1024; // 10MB

if (fileSize > MAX_SIZE) {
  throw new Error('File too large');
}

4) 사용자 인증

// ✅ 로그인한 사용자만 Presigned URL 발급
if (!session.user) {
  return res.status(401).json({ error: 'Unauthorized' });
}

// 사용자별 디렉토리
const key = `uploads/${session.user.id}/${filename}`;

5) Rate Limiting

// 1분에 10개 업로드 제한
const rateLimit = {
  windowMs: 60 * 1000,
  max: 10,
};

6. R2 Presigned URL 구현 - 백엔드

6.1 Node.js + Express 구현

프로젝트 구조:

backend/
├── src/
│   ├── routes/
│   │   └── upload.ts
│   ├── controllers/
│   │   └── uploadController.ts
│   ├── lib/
│   │   └── r2.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   └── rateLimit.ts
│   └── app.ts
├── .env
└── package.json

1) R2 클라이언트 설정 (lib/r2.ts)

import { S3Client } from '@aws-sdk/client-s3';

if (!process.env.R2_ACCOUNT_ID || !process.env.R2_ACCESS_KEY_ID || !process.env.R2_SECRET_ACCESS_KEY) {
  throw new Error('Missing R2 environment variables');
}

export const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
});

export const BUCKET_NAME = process.env.R2_BUCKET_NAME!;
export const PUBLIC_URL = process.env.R2_PUBLIC_URL!;

// 허용 파일 타입
export const ALLOWED_IMAGE_TYPES = [
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/gif',
  'image/svg+xml',
] as const;

// 최대 파일 크기 (10MB)
export const MAX_FILE_SIZE = 10 * 1024 * 1024;

2) 업로드 컨트롤러 (controllers/uploadController.ts)

import { Request, Response } from 'express';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { r2Client, BUCKET_NAME, PUBLIC_URL, ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE } from '../lib/r2';
import crypto from 'crypto';
import path from 'path';

interface PresignedUrlRequest {
  filename: string;
  contentType: string;
  fileSize: number;
}

export const generatePresignedUrl = async (req: Request, res: Response) => {
  try {
    const { filename, contentType, fileSize }: PresignedUrlRequest = req.body;
    const userId = req.user?.id; // 인증 미들웨어에서 주입

    // 1. 입력 검증
    if (!filename || !contentType || !fileSize) {
      return res.status(400).json({ error: 'Missing required fields' });
    }

    // 2. 파일 타입 검증
    if (!ALLOWED_IMAGE_TYPES.includes(contentType as any)) {
      return res.status(400).json({ 
        error: 'Invalid file type',
        allowed: ALLOWED_IMAGE_TYPES 
      });
    }

    // 3. 파일 크기 검증
    if (fileSize > MAX_FILE_SIZE) {
      return res.status(400).json({ 
        error: 'File too large',
        maxSize: MAX_FILE_SIZE,
        receivedSize: fileSize
      });
    }

    // 4. 안전한 파일명 생성
    const ext = path.extname(filename);
    const timestamp = Date.now();
    const randomString = crypto.randomBytes(8).toString('hex');
    const safeFilename = `${timestamp}-${randomString}${ext}`;

    // 5. S3 키 생성 (사용자별 디렉토리)
    const key = `uploads/${userId}/${safeFilename}`;

    // 6. Presigned URL 생성
    const command = new PutObjectCommand({
      Bucket: BUCKET_NAME,
      Key: key,
      ContentType: contentType,
      ContentLength: fileSize,
      // 메타데이터 추가 (선택사항)
      Metadata: {
        'original-filename': filename,
        'uploaded-by': userId,
        'upload-timestamp': timestamp.toString(),
      },
    });

    const uploadUrl = await getSignedUrl(r2Client, command, {
      expiresIn: 300, // 5분
    });

    // 7. 공개 URL 생성
    const publicUrl = `${PUBLIC_URL}/${key}`;

    // 8. 응답
    res.json({
      uploadUrl,
      key,
      publicUrl,
      expiresIn: 300,
    });

  } catch (error) {
    console.error('Presigned URL generation error:', error);
    res.status(500).json({ error: 'Failed to generate upload URL' });
  }
};

// 업로드 확인 (DB 저장)
export const confirmUpload = async (req: Request, res: Response) => {
  try {
    const { key, originalFilename } = req.body;
    const userId = req.user?.id;

    // DB에 이미지 메타데이터 저장
    // await db.images.create({
    //   userId,
    //   key,
    //   originalFilename,
    //   url: `${PUBLIC_URL}/${key}`,
    //   uploadedAt: new Date(),
    // });

    res.json({ success: true, key });
  } catch (error) {
    console.error('Upload confirmation error:', error);
    res.status(500).json({ error: 'Failed to confirm upload' });
  }
};

3) 라우터 (routes/upload.ts)

import express from 'express';
import { generatePresignedUrl, confirmUpload } from '../controllers/uploadController';
import { authenticateUser } from '../middleware/auth';
import { uploadRateLimit } from '../middleware/rateLimit';

const router = express.Router();

// Presigned URL 발급 (인증 필요 + Rate Limit)
router.post(
  '/presigned-url',
  authenticateUser,
  uploadRateLimit,
  generatePresignedUrl
);

// 업로드 확인
router.post(
  '/confirm',
  authenticateUser,
  confirmUpload
);

export default router;

4) 인증 미들웨어 (middleware/auth.ts)

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export const authenticateUser = (req: Request, res: Response, next: NextFunction) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');

    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string };
    req.user = { id: decoded.userId };
    
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

5) Rate Limit 미들웨어 (middleware/rateLimit.ts)

import rateLimit from 'express-rate-limit';

export const uploadRateLimit = rateLimit({
  windowMs: 60 * 1000, // 1분
  max: 10, // 최대 10개 요청
  message: 'Too many upload requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

6) Express 앱 설정 (app.ts)

import express from 'express';
import cors from 'cors';
import uploadRoutes from './routes/upload';

const app = express();

// 미들웨어
app.use(cors({
  origin: process.env.FRONTEND_URL || 'http://localhost:3000',
  credentials: true,
}));
app.use(express.json());

// 라우트
app.use('/api/upload', uploadRoutes);

// 에러 핸들러
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

6.2 Python + Django 구현

프로젝트 구조:

backend/
├── config/
│   ├── settings.py
│   └── r2.py
├── apps/
│   └── uploads/
│       ├── views.py
│       ├── serializers.py
│       ├── models.py
│       └── urls.py
└── requirements.txt

1) R2 클라이언트 설정 (config/r2.py)

import os
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError

# 환경 변수 검증
required_vars = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET_NAME']
for var in required_vars:
    if not os.getenv(var):
        raise ValueError(f'Missing required environment variable: {var}')

def get_r2_client():
    """R2 클라이언트 생성"""
    return boto3.client(
        's3',
        endpoint_url=f"https://{os.getenv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com",
        aws_access_key_id=os.getenv('R2_ACCESS_KEY_ID'),
        aws_secret_access_key=os.getenv('R2_SECRET_ACCESS_KEY'),
        config=Config(signature_version='s3v4'),
        region_name='auto'
    )

BUCKET_NAME = os.getenv('R2_BUCKET_NAME')
PUBLIC_URL = os.getenv('R2_PUBLIC_URL')

# 허용 파일 타입
ALLOWED_IMAGE_TYPES = [
    'image/jpeg',
    'image/png',
    'image/webp',
    'image/gif',
    'image/svg+xml',
]

# 최대 파일 크기 (10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024

2) 시리얼라이저 (apps/uploads/serializers.py)

from rest_framework import serializers
from config.r2 import ALLOWED_IMAGE_TYPES, MAX_FILE_SIZE

class PresignedUrlRequestSerializer(serializers.Serializer):
    filename = serializers.CharField(max_length=255)
    content_type = serializers.CharField(max_length=100)
    file_size = serializers.IntegerField()

    def validate_content_type(self, value):
        if value not in ALLOWED_IMAGE_TYPES:
            raise serializers.ValidationError(
                f'Invalid file type. Allowed types: {", ".join(ALLOWED_IMAGE_TYPES)}'
            )
        return value

    def validate_file_size(self, value):
        if value > MAX_FILE_SIZE:
            raise serializers.ValidationError(
                f'File too large. Maximum size: {MAX_FILE_SIZE} bytes'
            )
        return value

class PresignedUrlResponseSerializer(serializers.Serializer):
    upload_url = serializers.URLField()
    key = serializers.CharField()
    public_url = serializers.URLField()
    expires_in = serializers.IntegerField()

class UploadConfirmSerializer(serializers.Serializer):
    key = serializers.CharField()
    original_filename = serializers.CharField()

3) 뷰 (apps/uploads/views.py)

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle

from config.r2 import get_r2_client, BUCKET_NAME, PUBLIC_URL
from .serializers import (
    PresignedUrlRequestSerializer,
    PresignedUrlResponseSerializer,
    UploadConfirmSerializer
)

import os
import hashlib
import time
from pathlib import Path

class UploadRateThrottle(UserRateThrottle):
    rate = '10/min'  # 1분에 10개

class GeneratePresignedUrlView(APIView):
    permission_classes = [IsAuthenticated]
    throttle_classes = [UploadRateThrottle]

    def post(self, request):
        # 1. 입력 검증
        serializer = PresignedUrlRequestSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        filename = serializer.validated_data['filename']
        content_type = serializer.validated_data['content_type']
        file_size = serializer.validated_data['file_size']
        user_id = str(request.user.id)

        try:
            # 2. 안전한 파일명 생성
            ext = Path(filename).suffix
            timestamp = int(time.time() * 1000)
            random_hash = hashlib.md5(f"{user_id}{timestamp}".encode()).hexdigest()[:8]
            safe_filename = f"{timestamp}-{random_hash}{ext}"

            # 3. S3 키 생성
            key = f"uploads/{user_id}/{safe_filename}"

            # 4. Presigned URL 생성
            r2_client = get_r2_client()
            upload_url = r2_client.generate_presigned_url(
                'put_object',
                Params={
                    'Bucket': BUCKET_NAME,
                    'Key': key,
                    'ContentType': content_type,
                    'ContentLength': file_size,
                    'Metadata': {
                        'original-filename': filename,
                        'uploaded-by': user_id,
                        'upload-timestamp': str(timestamp),
                    }
                },
                ExpiresIn=300  # 5분
            )

            # 5. 공개 URL
            public_url = f"{PUBLIC_URL}/{key}"

            # 6. 응답
            response_data = {
                'upload_url': upload_url,
                'key': key,
                'public_url': public_url,
                'expires_in': 300,
            }

            response_serializer = PresignedUrlResponseSerializer(response_data)
            return Response(response_serializer.data, status=status.HTTP_200_OK)

        except Exception as e:
            print(f"Presigned URL generation error: {e}")
            return Response(
                {'error': 'Failed to generate upload URL'},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR
            )

class ConfirmUploadView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        serializer = UploadConfirmSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        key = serializer.validated_data['key']
        original_filename = serializer.validated_data['original_filename']
        user_id = request.user.id

        try:
            # DB에 이미지 메타데이터 저장
            # Image.objects.create(
            #     user_id=user_id,
            #     key=key,
            #     original_filename=original_filename,
            #     url=f"{PUBLIC_URL}/{key}",
            # )

            return Response({'success': True, 'key': key}, status=status.HTTP_200_OK)

        except Exception as e:
            print(f"Upload confirmation error: {e}")
            return Response(
                {'error': 'Failed to confirm upload'},
                status=status.HTTP_500_INTERNAL_SERVER_ERROR
            )

4) URL 설정 (apps/uploads/urls.py)

from django.urls import path
from .views import GeneratePresignedUrlView, ConfirmUploadView

urlpatterns = [
    path('presigned-url/', GeneratePresignedUrlView.as_view(), name='presigned-url'),
    path('confirm/', ConfirmUploadView.as_view(), name='confirm-upload'),
]

5) 메인 URL 설정 (config/urls.py)

from django.urls import path, include

urlpatterns = [
    # ...
    path('api/upload/', include('apps.uploads.urls')),
]

6) Requirements (requirements.txt)

Django==5.0.0
djangorestframework==3.14.0
boto3==1.34.0
python-dotenv==1.0.0
django-cors-headers==4.3.1

7. R2 Presigned URL 구현 - 프론트엔드

7.1 React + TypeScript 구현

프로젝트 구조:

frontend/
├── components/
│   ├── ImageUpload.tsx
│   └── ImageGallery.tsx
├── hooks/
│   └── useImageUpload.ts
├── services/
│   └── uploadService.ts
├── types/
│   └── upload.ts
└── utils/
    └── fileValidation.ts

1) 타입 정의 (types/upload.ts)

export interface PresignedUrlRequest {
  filename: string;
  contentType: string;
  fileSize: number;
}

export interface PresignedUrlResponse {
  uploadUrl: string;
  key: string;
  publicUrl: string;
  expiresIn: number;
}

export interface UploadConfirmRequest {
  key: string;
  originalFilename: string;
}

export interface UploadProgress {
  loaded: number;
  total: number;
  percentage: number;
}

export interface UploadedImage {
  id: string;
  url: string;
  originalFilename: string;
  uploadedAt: string;
}

2) 파일 검증 유틸 (utils/fileValidation.ts)

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

export const validateImageFile = (file: File): { valid: boolean; error?: string } => {
  // 파일 타입 검증
  if (!ALLOWED_TYPES.includes(file.type)) {
    return {
      valid: false,
      error: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`,
    };
  }

  // 파일 크기 검증
  if (file.size > MAX_SIZE) {
    return {
      valid: false,
      error: `File too large. Max size: ${MAX_SIZE / 1024 / 1024}MB`,
    };
  }

  return { valid: true };
};

export const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 Bytes';
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};

3) 업로드 서비스 (services/uploadService.ts)

import axios from 'axios';
import {
  PresignedUrlRequest,
  PresignedUrlResponse,
  UploadConfirmRequest,
  UploadProgress,
} from '../types/upload';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';

// Axios 인스턴스 (인증 토큰 자동 포함)
const apiClient = axios.create({
  baseURL: API_BASE_URL,
});

apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('authToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export const uploadService = {
  /**
   * 1단계: Presigned URL 요청
   */
  async getPresignedUrl(request: PresignedUrlRequest): Promise<PresignedUrlResponse> {
    const response = await apiClient.post<PresignedUrlResponse>(
      '/api/upload/presigned-url',
      request
    );
    return response.data;
  },

  /**
   * 2단계: R2에 직접 업로드
   */
  async uploadToR2(
    file: File,
    uploadUrl: string,
    onProgress?: (progress: UploadProgress) => void
  ): Promise<void> {
    await axios.put(uploadUrl, file, {
      headers: {
        'Content-Type': file.type,
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          const percentage = Math.round((progressEvent.loaded * 100) / progressEvent.total);
          onProgress({
            loaded: progressEvent.loaded,
            total: progressEvent.total,
            percentage,
          });
        }
      },
    });
  },

  /**
   * 3단계: 업로드 확인 (DB 저장)
   */
  async confirmUpload(request: UploadConfirmRequest): Promise<void> {
    await apiClient.post('/api/upload/confirm', request);
  },

  /**
   * 전체 업로드 플로우
   */
  async uploadImage(
    file: File,
    onProgress?: (progress: UploadProgress) => void
  ): Promise<PresignedUrlResponse> {
    // 1. Presigned URL 요청
    const presignedData = await this.getPresignedUrl({
      filename: file.name,
      contentType: file.type,
      fileSize: file.size,
    });

    // 2. R2에 직접 업로드
    await this.uploadToR2(file, presignedData.uploadUrl, onProgress);

    // 3. 업로드 확인
    await this.confirmUpload({
      key: presignedData.key,
      originalFilename: file.name,
    });

    return presignedData;
  },
};

4) 커스텀 훅 (hooks/useImageUpload.ts)

import { useState, useCallback } from 'react';
import { uploadService } from '../services/uploadService';
import { validateImageFile } from '../utils/fileValidation';
import { PresignedUrlResponse, UploadProgress } from '../types/upload';

export const useImageUpload = () => {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState<UploadProgress | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [uploadedImage, setUploadedImage] = useState<PresignedUrlResponse | null>(null);

  const uploadImage = useCallback(async (file: File) => {
    // 초기화
    setUploading(true);
    setError(null);
    setProgress(null);
    setUploadedImage(null);

    try {
      // 1. 파일 검증
      const validation = validateImageFile(file);
      if (!validation.valid) {
        throw new Error(validation.error);
      }

      // 2. 업로드 실행
      const result = await uploadService.uploadImage(file, setProgress);

      // 3. 성공
      setUploadedImage(result);
      return result;

    } catch (err: any) {
      const errorMessage = err.response?.data?.error || err.message || 'Upload failed';
      setError(errorMessage);
      throw err;

    } finally {
      setUploading(false);
    }
  }, []);

  const reset = useCallback(() => {
    setUploading(false);
    setProgress(null);
    setError(null);
    setUploadedImage(null);
  }, []);

  return {
    uploadImage,
    uploading,
    progress,
    error,
    uploadedImage,
    reset,
  };
};

5) 이미지 업로드 컴포넌트 (components/ImageUpload.tsx)

{% raw %}

'use client';

import React, { useRef, useState } from 'react';
import { useImageUpload } from '../hooks/useImageUpload';
import { formatFileSize } from '../utils/fileValidation';

export const ImageUpload: React.FC = () => {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [preview, setPreview] = useState<string | null>(null);
  const { uploadImage, uploading, progress, error, uploadedImage, reset } = useImageUpload();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // 미리보기 생성
    const reader = new FileReader();
    reader.onloadend = () => {
      setPreview(reader.result as string);
    };
    reader.readAsDataURL(file);
  };

  const handleUpload = async () => {
    const file = fileInputRef.current?.files?.[0];
    if (!file) return;

    try {
      await uploadImage(file);
      alert('업로드 성공!');
    } catch (err) {
      console.error('Upload failed:', err);
    }
  };

  const handleReset = () => {
    reset();
    setPreview(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  return (
    <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
      <h2 className="text-2xl font-bold mb-4">이미지 업로드</h2>

      {/* 파일 선택 */}
      <div className="mb-4">
        <input
          ref={fileInputRef}
          type="file"
          accept="image/jpeg,image/png,image/webp,image/gif"
          onChange={handleFileChange}
          disabled={uploading}
          className="block w-full text-sm text-gray-500
            file:mr-4 file:py-2 file:px-4
            file:rounded-full file:border-0
            file:text-sm file:font-semibold
            file:bg-blue-50 file:text-blue-700
            hover:file:bg-blue-100
            disabled:opacity-50"
        />
      </div>

      {/* 미리보기 */}
      {preview && (
        <div className="mb-4">
          <img
            src={preview}
            alt="Preview"
            className="w-full h-48 object-cover rounded-lg"
          />
        </div>
      )}

      {/* 업로드 진행률 */}
      {uploading && progress && (
        <div className="mb-4">
          <div className="flex justify-between mb-1">
            <span className="text-sm font-medium text-blue-700">업로드 ...</span>
            <span className="text-sm font-medium text-blue-700">{progress.percentage}%</span>
          </div>
          <div className="w-full bg-gray-200 rounded-full h-2.5">
            <div
              className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
              style={{ width: `${progress.percentage}%` }}
            ></div>
          </div>
          <p className="text-xs text-gray-500 mt-1">
            {formatFileSize(progress.loaded)} / {formatFileSize(progress.total)}
          </p>
        </div>
      )}

      {/* 에러 메시지 */}
      {error && (
        <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-sm text-red-600">{error}</p>
        </div>
      )}

      {/* 업로드 성공 */}
      {uploadedImage && (
        <div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
          <p className="text-sm text-green-600 mb-2"> 업로드 성공!</p>
          <div className="flex items-center space-x-2">
            <img
              src={uploadedImage.publicUrl}
              alt="Uploaded"
              className="w-16 h-16 object-cover rounded"
            />
            <div className="flex-1 min-w-0">
              <p className="text-xs text-gray-500 truncate">
                {uploadedImage.key}
              </p>
              <a
                href={uploadedImage.publicUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="text-xs text-blue-600 hover:underline"
              >
                이미지 보기 
              </a>
            </div>
          </div>
        </div>
      )}

      {/* 버튼 */}
      <div className="flex space-x-2">
        <button
          onClick={handleUpload}
          disabled={!preview || uploading}
          className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg
            hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
            transition-colors font-medium"
        >
          {uploading ? '업로드 중...' : '업로드'}
        </button>
        <button
          onClick={handleReset}
          disabled={uploading}
          className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg
            hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed
            transition-colors font-medium"
        >
          초기화
        </button>
      </div>
    </div>
  );
};

{% endraw %}

7.2 드래그 앤 드롭 업로드

고급 업로드 컴포넌트 (components/DragDropUpload.tsx)

{% raw %}

'use client';

import React, { useState, useCallback } from 'react';
import { useImageUpload } from '../hooks/useImageUpload';
import { validateImageFile, formatFileSize } from '../utils/fileValidation';

export const DragDropUpload: React.FC = () => {
  const [isDragging, setIsDragging] = useState(false);
  const { uploadImage, uploading, progress, error, uploadedImage } = useImageUpload();

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(false);
  }, []);

  const handleDrop = useCallback(
    async (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);

      const file = e.dataTransfer.files[0];
      if (!file) return;

      // 파일 검증
      const validation = validateImageFile(file);
      if (!validation.valid) {
        alert(validation.error);
        return;
      }

      // 업로드
      try {
        await uploadImage(file);
      } catch (err) {
        console.error('Upload failed:', err);
      }
    },
    [uploadImage]
  );

  const handleFileSelect = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;

      try {
        await uploadImage(file);
      } catch (err) {
        console.error('Upload failed:', err);
      }
    },
    [uploadImage]
  );

  return (
    <div className="max-w-2xl mx-auto p-6">
      {/* 드래그 앤 드롭 영역 */}
      <div
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
        className={`
          border-2 border-dashed rounded-lg p-12 text-center
          transition-colors duration-200
          ${isDragging 
            ? 'border-blue-500 bg-blue-50' 
            : 'border-gray-300 bg-gray-50'
          }
          ${uploading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
        `}
      >
        {!uploading && !uploadedImage && (
          <>
            <svg
              className="mx-auto h-12 w-12 text-gray-400"
              stroke="currentColor"
              fill="none"
              viewBox="0 0 48 48"
            >
              <path
                d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            <p className="mt-4 text-sm text-gray-600">
              드래그  드롭으로 이미지 업로드
            </p>
            <p className="mt-1 text-xs text-gray-500">
              또는 클릭하여 파일 선택
            </p>
            <p className="mt-2 text-xs text-gray-400">
              PNG, JPG, WEBP, GIF (최대 10MB)
            </p>
            <input
              type="file"
              accept="image/*"
              onChange={handleFileSelect}
              disabled={uploading}
              className="hidden"
              id="file-upload"
            />
            <label
              htmlFor="file-upload"
              className="mt-4 inline-block px-4 py-2 bg-blue-600 text-white rounded-lg cursor-pointer hover:bg-blue-700"
            >
              파일 선택
            </label>
          </>
        )}

        {/* 업로드 중 */}
        {uploading && progress && (
          <div>
            <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
            <p className="mt-4 text-sm text-gray-600">업로드 ... {progress.percentage}%</p>
            <div className="mt-2 w-full bg-gray-200 rounded-full h-2">
              <div
                className="bg-blue-600 h-2 rounded-full transition-all"
                style={{ width: `${progress.percentage}%` }}
              ></div>
            </div>
          </div>
        )}

        {/* 업로드 완료 */}
        {uploadedImage && (
          <div>
            <img
              src={uploadedImage.publicUrl}
              alt="Uploaded"
              className="mx-auto max-h-64 rounded-lg"
            />
            <p className="mt-4 text-sm text-green-600"> 업로드 완료!</p>
            <a
              href={uploadedImage.publicUrl}
              target="_blank"
              rel="noopener noreferrer"
              className="mt-2 inline-block text-sm text-blue-600 hover:underline"
            >
              이미지 보기 
            </a>
          </div>
        )}
      </div>

      {/* 에러 */}
      {error && (
        <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-sm text-red-600">{error}</p>
        </div>
      )}
    </div>
  );
};

{% endraw %}

7.3 다중 이미지 업로드

{% raw %}

'use client';

import React, { useState, useCallback } from 'react';
import { uploadService } from '../services/uploadService';
import { validateImageFile, formatFileSize } from '../utils/fileValidation';
import { PresignedUrlResponse } from '../types/upload';

interface UploadItem {
  id: string;
  file: File;
  status: 'pending' | 'uploading' | 'success' | 'error';
  progress: number;
  result?: PresignedUrlResponse;
  error?: string;
}

export const MultiImageUpload: React.FC = () => {
  const [uploadItems, setUploadItems] = useState<UploadItem[]>([]);

  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    
    const newItems: UploadItem[] = files.map((file) => ({
      id: `${Date.now()}-${Math.random()}`,
      file,
      status: 'pending',
      progress: 0,
    }));

    setUploadItems((prev) => [...prev, ...newItems]);
  }, []);

  const uploadSingleItem = useCallback(async (item: UploadItem) => {
    // 검증
    const validation = validateImageFile(item.file);
    if (!validation.valid) {
      setUploadItems((prev) =>
        prev.map((i) =>
          i.id === item.id
            ? { ...i, status: 'error', error: validation.error }
            : i
        )
      );
      return;
    }

    // 업로드 시작
    setUploadItems((prev) =>
      prev.map((i) => (i.id === item.id ? { ...i, status: 'uploading' } : i))
    );

    try {
      const result = await uploadService.uploadImage(item.file, (progress) => {
        setUploadItems((prev) =>
          prev.map((i) =>
            i.id === item.id ? { ...i, progress: progress.percentage } : i
          )
        );
      });

      setUploadItems((prev) =>
        prev.map((i) =>
          i.id === item.id ? { ...i, status: 'success', result } : i
        )
      );
    } catch (error: any) {
      setUploadItems((prev) =>
        prev.map((i) =>
          i.id === item.id
            ? { ...i, status: 'error', error: error.message }
            : i
        )
      );
    }
  }, []);

  const handleUploadAll = useCallback(async () => {
    const pendingItems = uploadItems.filter((item) => item.status === 'pending');
    
    // 병렬 업로드 (최대 3개씩)
    for (let i = 0; i < pendingItems.length; i += 3) {
      const batch = pendingItems.slice(i, i + 3);
      await Promise.all(batch.map(uploadSingleItem));
    }
  }, [uploadItems, uploadSingleItem]);

  const handleRemove = useCallback((id: string) => {
    setUploadItems((prev) => prev.filter((item) => item.id !== id));
  }, []);

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h2 className="text-2xl font-bold mb-4">다중 이미지 업로드</h2>

      {/* 파일 선택 */}
      <div className="mb-6">
        <input
          type="file"
          accept="image/*"
          multiple
          onChange={handleFileSelect}
          className="block w-full text-sm"
          id="multi-upload"
        />
        <label
          htmlFor="multi-upload"
          className="mt-2 inline-block px-4 py-2 bg-blue-600 text-white rounded cursor-pointer hover:bg-blue-700"
        >
          파일 선택
        </label>
      </div>

      {/* 업로드 버튼 */}
      {uploadItems.length > 0 && (
        <button
          onClick={handleUploadAll}
          disabled={!uploadItems.some((item) => item.status === 'pending')}
          className="mb-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
        >
          전체 업로드 ({uploadItems.filter((i) => i.status === 'pending').length})
        </button>
      )}

      {/* 업로드 목록 */}
      <div className="space-y-4">
        {uploadItems.map((item) => (
          <div
            key={item.id}
            className="flex items-center space-x-4 p-4 border rounded-lg"
          >
            {/* 미리보기 */}
            <img
              src={URL.createObjectURL(item.file)}
              alt={item.file.name}
              className="w-16 h-16 object-cover rounded"
            />

            {/* 정보 */}
            <div className="flex-1">
              <p className="text-sm font-medium">{item.file.name}</p>
              <p className="text-xs text-gray-500">{formatFileSize(item.file.size)}</p>

              {/* 진행률 */}
              {item.status === 'uploading' && (
                <div className="mt-2 w-full bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-600 h-2 rounded-full transition-all"
                    style={{ width: `${item.progress}%` }}
                  ></div>
                </div>
              )}

              {/* 상태 */}
              {item.status === 'success' && (
                <p className="text-xs text-green-600 mt-1"> 완료</p>
              )}
              {item.status === 'error' && (
                <p className="text-xs text-red-600 mt-1"> {item.error}</p>
              )}
            </div>

            {/* 제거 버튼 */}
            <button
              onClick={() => handleRemove(item.id)}
              className="text-red-600 hover:text-red-800"
            >
              
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

8. 프로덕션 고려사항

8.1 이미지 최적화

1) 클라이언트 측 이미지 압축

// utils/imageCompression.ts
import imageCompression from 'browser-image-compression';

export const compressImage = async (file: File): Promise<File> => {
  const options = {
    maxSizeMB: 1,          // 최대 1MB
    maxWidthOrHeight: 1920, // 최대 1920px
    useWebWorker: true,     // 웹 워커 사용 (성능 향상)
  };

  try {
    const compressedFile = await imageCompression(file, options);
    console.log(`압축 전: ${file.size / 1024 / 1024}MB → 압축 후: ${compressedFile.size / 1024 / 1024}MB`);
    return compressedFile;
  } catch (error) {
    console.error('압축 실패:', error);
    return file; // 실패 시 원본 반환
  }
};

// 사용
const handleUpload = async (file: File) => {
  const compressed = await compressImage(file);
  await uploadService.uploadImage(compressed);
};

2) WebP 변환 (Cloudflare Workers)

// workers/image-transform.ts
export default {
  async fetch(request: Request, env: any): Promise<Response> {
    const url = new URL(request.url);
    const imageKey = url.pathname.slice(1);

    // R2에서 이미지 가져오기
    const object = await env.MY_BUCKET.get(imageKey);
    if (!object) {
      return new Response('Not found', { status: 404 });
    }

    // Accept 헤더 확인
    const accept = request.headers.get('Accept') || '';
    const supportsWebP = accept.includes('image/webp');

    if (supportsWebP && !imageKey.endsWith('.webp')) {
      // WebP로 변환 (Cloudflare Image Resizing 사용)
      return fetch(`https://your-domain.com/cdn-cgi/image/format=webp/${imageKey}`);
    }

    // 원본 반환
    return new Response(object.body, {
      headers: {
        'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
        'Cache-Control': 'public, max-age=31536000',
      },
    });
  },
};

{% endraw %}

8.2 보안 강화

1) 이미지 바이러스 스캔 (ClamAV)

# backend/utils/virus_scan.py
import clamd
import tempfile

def scan_file(file_content: bytes) -> bool:
    """
    파일 바이러스 스캔
    Returns: True if safe, False if virus detected
    """
    try:
        # ClamAV 연결
        cd = clamd.ClamdUnixSocket()
        
        # 임시 파일로 스캔
        with tempfile.NamedTemporaryFile() as tmp:
            tmp.write(file_content)
            tmp.flush()
            result = cd.scan(tmp.name)
        
        # 결과 확인
        if result is None:
            return True  # 안전
        
        return False  # 바이러스 감지
    
    except Exception as e:
        print(f"Virus scan error: {e}")
        return False  # 안전하지 않음으로 간주

2) 이미지 메타데이터 제거 (EXIF)

from PIL import Image
import io

def remove_exif(image_bytes: bytes) -> bytes:
    """이미지 EXIF 데이터 제거"""
    image = Image.open(io.BytesIO(image_bytes))
    
    # EXIF 없는 새 이미지 생성
    data = list(image.getdata())
    image_without_exif = Image.new(image.mode, image.size)
    image_without_exif.putdata(data)
    
    # 바이트로 변환
    output = io.BytesIO()
    image_without_exif.save(output, format=image.format)
    return output.getvalue()

3) Content Security Policy (CSP)

// Next.js middleware
import { NextResponse } from 'next/server';

export function middleware(request: Request) {
  const response = NextResponse.next();

  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; img-src 'self' https://pub-*.r2.dev; script-src 'self' 'unsafe-inline';"
  );

  return response;
}

8.3 성능 최적화

1) 이미지 CDN 캐싱

Cloudflare R2 공개 버킷 → 자동 CDN 캐싱
Cache-Control: public, max-age=31536000 (1년)

캐시 무효화:
- 파일명에 타임스탬프 포함 (cache busting)
- 예: image-1234567890.jpg

2) Lazy Loading

// components/OptimizedImage.tsx
import Image from 'next/image';

export const OptimizedImage: React.FC<{ src: string; alt: string }> = ({ src, alt }) => {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      loading="lazy"  // 지연 로딩
      placeholder="blur"  // 블러 플레이스홀더
      blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
    />
  );
};

3) Progressive JPEG

from PIL import Image

def save_progressive_jpeg(image_path: str, output_path: str):
    """Progressive JPEG로 저장"""
    img = Image.open(image_path)
    img.save(output_path, 'JPEG', quality=85, optimize=True, progressive=True)

8.4 모니터링 및 로깅

1) 업로드 로깅

import logging
from datetime import datetime

logger = logging.getLogger(__name__)

def log_upload(user_id: str, key: str, file_size: int, success: bool):
    """업로드 로그 기록"""
    log_data = {
        'timestamp': datetime.utcnow().isoformat(),
        'user_id': user_id,
        'key': key,
        'file_size': file_size,
        'success': success,
    }
    
    if success:
        logger.info(f"Upload successful: {log_data}")
    else:
        logger.error(f"Upload failed: {log_data}")

2) 에러 추적 (Sentry)

import * as Sentry from '@sentry/nextjs';

try {
  await uploadService.uploadImage(file);
} catch (error) {
  Sentry.captureException(error, {
    tags: {
      section: 'image-upload',
    },
    extra: {
      filename: file.name,
      fileSize: file.size,
    },
  });
  throw error;
}

9. 결론 및 선택 가이드

9.1 언제 AWS S3를 선택해야 하나?

✅ S3가 적합한 경우:

  1. AWS 생태계 의존성 ```
    • Lambda, EC2, RDS와 긴밀한 통합 필요
    • AWS Organization으로 멀티 계정 관리
    • CloudFormation/CDK로 인프라 관리 ```
  2. 규제 준수 (Compliance) ```
    • HIPAA, PCI-DSS 인증 필요
    • 특정 리전에 데이터 저장 필수
    • Object Lock (WORM) 필요 ```
  3. 고급 기능 필요 ```
    • S3 Select (SQL 쿼리)
    • Glacier (아카이빙)
    • Batch Operations
    • Replication (CRR, SRR) ```
  4. 이미 AWS 고객 ```
    • Reserved Capacity로 할인
    • AWS Support 계약 보유
    • Savings Plans 활용 ```

9.2 언제 Cloudflare R2를 선택해야 하나?

✅ R2가 적합한 경우:

  1. 이그레스 비용이 큰 경우 ```
    • 대용량 파일 배포 (동영상, 이미지)
    • 다운로드 트래픽 » 스토리지 용량
    • CDN 비용 절감 목표

    예: 1TB 스토리지, 10TB 다운로드 S3 + CloudFront: ~$1,000/월 R2: ~$15/월 (98.5% 절감!) ```

  2. 글로벌 서비스 ```
    • 전 세계 사용자 대상
    • 자동 엣지 복제 필요
    • 낮은 레이턴시 요구 ```
  3. Cloudflare 생태계 활용 ```
    • Workers로 커스텀 로직
    • Pages로 정적 사이트
    • Images로 이미지 변환 ```
  4. 스타트업/사이드 프로젝트 ```
    • 무료 티어 활용 (10GB)
    • 초기 비용 최소화
    • 빠른 프로토타입 ```

9.3 비용 기반 의사결정 가이드

시나리오별 추천:

📊 소규모 (< 100GB, < 500GB 다운로드/월):
→ R2 추천 (무료 티어 활용)

📊 중규모 (100GB-1TB, 1TB-10TB 다운로드/월):
→ R2 추천 (이그레스 비용 절감)

📊 대규모 (> 1TB, > 10TB 다운로드/월):
→ R2 강력 추천 (절감액 수천 달러)

📊 AWS 인프라 의존적:
→ S3 사용 (통합 비용 고려)

📊 규제 준수 필수:
→ S3 사용 (인증 보유)

9.4 마이그레이션 전략

S3 → R2 마이그레이션:

# 1. R2 버킷 생성
wrangler r2 bucket create my-new-bucket

# 2. rclone으로 데이터 복사
rclone copy s3:my-s3-bucket r2:my-new-bucket --progress

# 3. 점진적 전환
# - 새 업로드는 R2로
# - 기존 데이터는 점진적 마이그레이션
# - CloudFront → R2 public URL로 변경

# 4. S3 버킷 삭제 (완전 전환 후)

9.5 하이브리드 전략

최적의 비용 효율:

업로드 빈도 높음 + 다운로드 적음:
→ S3 (저렴한 스토리지 클래스: IA, Glacier)

업로드 적음 + 다운로드 많음:
→ R2 (이그레스 무료)

실전 예시:
- 원본 이미지: S3 Glacier (아카이브)
- 처리된 이미지: R2 (서빙)
- 로그 파일: S3 IA (분석용)

9.6 최종 체크리스트

R2 도입 전 확인사항:

□ S3 호환 API로 충분한가?
□ 특정 리전 선택이 불필요한가?
□ 이그레스 비용이 큰가?
□ AWS 락인을 피하고 싶은가?
□ Cloudflare 생태계를 활용할 의향이 있는가?

5개 중 3개 이상 Yes → R2 추천!

S3 유지 시 확인사항:

□ AWS 생태계 의존도가 높은가?
□ 규제 준수 요구사항이 있는가?
□ S3 고유 기능이 필요한가?
□ 이미 AWS 대규모 할인을 받는가?
□ 운영팀이 AWS에 익숙한가?

5개 중 3개 이상 Yes → S3 유지!

요약

핵심 포인트

  1. 비용 절감: R2는 이그레스 비용 0원으로 대용량 파일 서빙에 최적 (98% 이상 절감 가능)
  2. Presigned URL: 서버 부하 없이 클라이언트가 직접 업로드 (3배 빠름, 50% 비용 절감)
  3. S3 호환: 기존 S3 코드를 엔드포인트만 변경하여 R2로 마이그레이션 가능
  4. 선택 기준:
    • 이그레스 많음 + 글로벌 서비스 → R2
    • AWS 의존성 높음 + 규제 준수 → S3

실전 적용

// 1. 환경 변수 설정
R2_ACCOUNT_ID=your_account_id
R2_ACCESS_KEY_ID=your_access_key
R2_SECRET_ACCESS_KEY=your_secret_key
R2_BUCKET_NAME=your_bucket

// 2. 백엔드: Presigned URL 발급
POST /api/upload/presigned-url
{ "filename": "image.jpg", "contentType": "image/jpeg", "fileSize": 1024000 }

// 3. 프론트엔드: R2에 직접 업로드
PUT <presigned_url>
Body: [이미지 파일]

// 4. 백엔드: 업로드 확인 (DB 저장)
POST /api/upload/confirm
{ "key": "uploads/user123/image.jpg", "originalFilename": "image.jpg" }

다음 단계

  1. ✅ Cloudflare 계정 생성 및 R2 활성화
  2. ✅ 무료 티어로 프로토타입 구현
  3. ✅ Presigned URL 플로우 구현
  4. ✅ 이미지 최적화 (압축, WebP) 적용
  5. ✅ 프로덕션 배포 및 모니터링

행복한 코딩 되세요! 🚀💾


참고 자료

이 글을 공유해보세요!