프롤로그 - 서버가 중간에서 모든 짐을 옮기고 있었다
처음 파일 업로드를 구현할 때는 간단했다. 사용자가 이미지를 선택하면 서버로 보내고, 서버에서 S3에 올린다. 10MB 정도까지는 완벽하게 작동했다.
그런데 어느 날 사용자가 2GB 동영상을 업로드하려고 했고, 전부 타임아웃이 났다. 로그를 보니 서버 메모리가 급증했고, 네트워크 대역폭도 두 배로 소모되고 있었다. 클라이언트 → 서버 → S3로 데이터가 두 번 이동하는 게 문제였다.
결국 이해했다. 파일 업로드는 "택배 배송"과 같다는 것을. 서버가 집 앞까지 택배를 받아서 다시 창고로 옮기는 게 아니라, 고객이 직접 창고에 배송하게 만들어야 한다는 것을.
Direct Upload: 서버를 거치지 않고 바로 스토리지로
Presigned URL 패턴의 발견
가장 와닿았던 솔루션은 presigned URL이었다. S3가 "임시 출입증"을 만들어주는 방식이다.
// 서버: 임시 업로드 URL을 생성해준다
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async function generateUploadURL(filename: string, contentType: string) {
const s3Client = new S3Client({ region: 'ap-northeast-2' });
const command = new PutObjectCommand({
Bucket: 'my-uploads',
Key: `uploads/${Date.now()}-${filename}`,
ContentType: contentType,
// 파일 크기 제한
ContentLength: undefined,
});
// 15분 동안만 유효한 업로드 URL
const uploadURL = await getSignedUrl(s3Client, command, {
expiresIn: 900
});
return {
uploadURL,
key: command.input.Key,
};
}
흐름은 이렇다:
- 클라이언트가 서버에 "파일 업로드하고 싶어요" 요청
- 서버가 presigned URL을 생성해서 반환 (권한 검증 포함)
- 클라이언트가 그 URL로 직접 S3에 업로드
- 완료되면 서버에 "업로드 완료" 알림
서버는 더 이상 파일 데이터를 다루지 않는다. 마치 택배 기사가 송장만 발급해주고, 실제 물건은 고객이 직접 택배함에 넣는 것처럼.
클라이언트 구현
// 클라이언트: presigned URL로 직접 업로드
async function uploadFile(file: File) {
// 1. 서버에서 업로드 URL 받기
const { uploadURL, key } = await fetch('/api/upload/presign', {
method: 'POST',
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
}).then(r => r.json());
// 2. S3로 직접 업로드 (서버를 거치지 않음!)
const uploadResponse = await fetch(uploadURL, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
// 3. 서버에 완료 알림
await fetch('/api/upload/complete', {
method: 'POST',
body: JSON.stringify({ key }),
});
return key;
}
Chunked Upload: 큰 파일은 조각내서 보낸다
2GB 파일을 한 번에 보내면 네트워크가 끊어졌을 때 처음부터 다시 해야 한다. 마치 트럭으로 짐을 한 번에 옮기다가 도중에 고장나면 모든 짐을 다시 실어야 하는 것처럼.
Multipart Upload는 이 문제를 해결한다. 파일을 여러 조각(chunk)으로 나눠서 각각 업로드하고, 마지막에 합친다.
// Multipart upload 구현
import {
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
} from '@aws-sdk/client-s3';
async function uploadLargeFile(file: File) {
const s3Client = new S3Client({ region: 'ap-northeast-2' });
const chunkSize = 10 * 1024 * 1024; // 10MB per chunk
const chunks = Math.ceil(file.size / chunkSize);
// 1. Multipart upload 시작
const createResponse = await s3Client.send(
new CreateMultipartUploadCommand({
Bucket: 'my-uploads',
Key: `large/${file.name}`,
})
);
const uploadId = createResponse.UploadId!;
const uploadedParts = [];
// 2. 각 chunk를 순차적으로 업로드
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const partResponse = await s3Client.send(
new UploadPartCommand({
Bucket: 'my-uploads',
Key: `large/${file.name}`,
UploadId: uploadId,
PartNumber: i + 1,
Body: chunk,
})
);
uploadedParts.push({
PartNumber: i + 1,
ETag: partResponse.ETag,
});
console.log(`Uploaded part ${i + 1}/${chunks}`);
}
// 3. 모든 조각을 합치기
await s3Client.send(
new CompleteMultipartUploadCommand({
Bucket: 'my-uploads',
Key: `large/${file.name}`,
UploadId: uploadId,
MultipartUpload: {
Parts: uploadedParts,
},
})
);
console.log('Upload complete!');
}
각 chunk는 독립적으로 업로드된다. 하나가 실패하면 그 조각만 다시 보내면 된다. 마치 여러 상자로 나눠서 택배를 보내는 것처럼.
Resume Upload: 중단된 곳부터 다시 시작
네트워크가 불안정한 환경에서는 업로드가 중간에 끊어질 수 있다. tus protocol 개념이 여기서 와닿았다. "어디까지 업로드했는지" 상태를 추적하는 것이다.
// 재시도 가능한 chunked upload
async function resumableUpload(file: File, onProgress?: (percent: number) => void) {
const chunkSize = 5 * 1024 * 1024; // 5MB
const chunks = Math.ceil(file.size / chunkSize);
// localStorage에 업로드 상태 저장
const uploadKey = `upload_${file.name}_${file.size}`;
const savedState = localStorage.getItem(uploadKey);
let completedChunks = savedState ? JSON.parse(savedState) : [];
for (let i = 0; i < chunks; i++) {
// 이미 업로드된 chunk는 건너뛰기
if (completedChunks.includes(i)) {
onProgress?.((completedChunks.length / chunks) * 100);
continue;
}
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
let uploaded = false;
let retries = 0;
// 최대 3번까지 재시도
while (!uploaded && retries < 3) {
try {
await uploadChunk(chunk, i);
completedChunks.push(i);
localStorage.setItem(uploadKey, JSON.stringify(completedChunks));
uploaded = true;
onProgress?.((completedChunks.length / chunks) * 100);
} catch (error) {
retries++;
console.log(`Chunk ${i} failed, retry ${retries}/3`);
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
}
}
if (!uploaded) {
throw new Error(`Failed to upload chunk ${i} after 3 retries`);
}
}
// 업로드 완료 후 상태 제거
localStorage.removeItem(uploadKey);
}
네트워크가 끊어져도 다음에 다시 시작할 수 있다. 마치 게임의 체크포인트처럼.
File Validation: 올려도 되는 파일인지 확인
파일을 받기 전에 검증해야 한다. 클라이언트와 서버 양쪽 모두에서.
// 클라이언트: 업로드 전 검증
function validateFile(file: File) {
// 1. 파일 크기 제한 (100MB)
const maxSize = 100 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('File too large. Max 100MB');
}
// 2. 파일 타입 검증
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type');
}
// 3. 파일 확장자 검증 (MIME type 스푸핑 방지)
const extension = file.name.split('.').pop()?.toLowerCase();
const allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'mp4'];
if (!extension || !allowedExtensions.includes(extension)) {
throw new Error('Invalid file extension');
}
}
// 서버: presigned URL 생성 전 검증
async function validateUploadRequest(filename: string, contentType: string, size: number) {
// 서버에서도 동일한 검증 로직 실행
if (size > 100 * 1024 * 1024) {
throw new Error('File too large');
}
// MIME type과 확장자 일치 여부 확인
const extension = filename.split('.').pop()?.toLowerCase();
const mimeToExt: Record<string, string[]> = {
'image/jpeg': ['jpg', 'jpeg'],
'image/png': ['png'],
'video/mp4': ['mp4'],
};
if (!mimeToExt[contentType]?.includes(extension || '')) {
throw new Error('File extension and MIME type mismatch');
}
}
바이러스 스캔은 업로드 후 비동기로 처리한다. AWS의 경우 Lambda + ClamAV를 사용할 수 있다.
Image Processing: 업로드 후 변환 파이프라인
이미지는 업로드 후 처리가 필요하다. 원본은 그대로 두고, 썸네일과 여러 사이즈를 생성한다.
// S3 업로드 이벤트 → Lambda 트리거
import sharp from 'sharp';
async function processUploadedImage(s3Key: string) {
const s3Client = new S3Client({});
// 1. 원본 이미지 가져오기
const { Body } = await s3Client.send(
new GetObjectCommand({
Bucket: 'my-uploads',
Key: s3Key,
})
);
const imageBuffer = await Body!.transformToByteArray();
// 2. 여러 사이즈로 리사이즈
const sizes = [
{ name: 'thumb', width: 150, height: 150 },
{ name: 'small', width: 400 },
{ name: 'medium', width: 800 },
{ name: 'large', width: 1200 },
];
for (const size of sizes) {
const resized = await sharp(imageBuffer)
.resize(size.width, size.height, {
fit: size.height ? 'cover' : 'inside',
withoutEnlargement: true,
})
.webp({ quality: 85 }) // WebP로 변환
.toBuffer();
const newKey = s3Key.replace(/\.[^.]+$/, `-${size.name}.webp`);
await s3Client.send(
new PutObjectCommand({
Bucket: 'my-uploads',
Key: newKey,
Body: resized,
ContentType: 'image/webp',
})
);
}
// 3. DB에 처리 완료 기록
await db.files.update({
where: { s3Key },
data: {
processed: true,
thumbnailKey: s3Key.replace(/\.[^.]+$/, '-thumb.webp'),
},
});
}
이미지 처리는 CPU 집약적이라 서버리스 함수에서 비동기로 돌리는 게 효율적이다.
Progress Tracking: 사용자에게 진행 상황 보여주기
업로드가 오래 걸리면 사용자는 불안하다. 진행률을 보여줘야 한다.
// XMLHttpRequest로 업로드 진행률 추적
function uploadWithProgress(file: File, url: string, onProgress: (percent: number) => void) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
}
// React 컴포넌트에서 사용
function FileUploader() {
const [progress, setProgress] = useState(0);
const handleUpload = async (file: File) => {
const { uploadURL } = await getPresignedURL(file.name, file.type);
await uploadWithProgress(file, uploadURL, setProgress);
};
return (
<div>
<input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
{progress > 0 && <progress value={progress} max={100} />}
</div>
);
}
Storage 선택: S3 vs R2 vs Supabase Storage
각 스토리지의 특징이 명확했다.
AWS S3
- 가장 안정적이고 기능이 많음
- egress(다운로드) 비용이 비쌈 ($0.09/GB)
- CloudFront CDN과 자연스럽게 통합
Cloudflare R2
- egress 비용이 무료
- S3 호환 API 제공
- Cloudflare CDN과 자동 통합
- 스타트업에게 매력적
Supabase Storage
- PostgreSQL과 통합된 권한 관리 (RLS)
- presigned URL 자동 생성
- 무료 플랜: 1GB 저장소
- 이미지 변환 API 내장 (transform parameter)
// Supabase Storage 사용 예시
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
async function uploadToSupabase(file: File) {
const { data, error } = await supabase.storage
.from('uploads')
.upload(`public/${Date.now()}-${file.name}`, file, {
cacheControl: '3600',
upsert: false,
});
if (error) throw error;
// Public URL (CDN 포함)
const { data: urlData } = supabase.storage
.from('uploads')
.getPublicUrl(data.path);
return urlData.publicUrl;
}
결국 내 선택은 R2 + CDN이었다. egress 비용이 무료라는 게 결정적이었다.
CDN: 업로드된 파일을 빠르게 제공
파일을 S3에 저장했다면, CDN을 앞에 둬야 한다. 전 세계 사용자에게 빠르게 제공하려면.
// CloudFront 배포 설정 예시 (Terraform)
resource "aws_cloudfront_distribution" "uploads_cdn" {
origin {
domain_name = aws_s3_bucket.uploads.bucket_regional_domain_name
origin_id = "S3-uploads"
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.uploads.cloudfront_access_identity_path
}
}
enabled = true
is_ipv6_enabled = true
default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "S3-uploads"
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 86400 # 1 day
max_ttl = 31536000 # 1 year
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
이미지 URL에 쿼리 파라미터로 변환 옵션을 줄 수도 있다.
// Cloudflare Images 스타일 변환
const imageUrl = 'https://cdn.example.com/image.jpg';
const thumbnail = `${imageUrl}?width=300&height=300&fit=cover`;
const webp = `${imageUrl}?format=webp&quality=85`;
Security: 악의적인 업로드 막기
파일 업로드는 보안 취약점이 될 수 있다. 몇 가지 방어책이 필요하다.
1. Rate Limiting
// 사용자당 업로드 제한
const uploadLimiter = new Map<string, number[]>();
function checkRateLimit(userId: string) {
const now = Date.now();
const userUploads = uploadLimiter.get(userId) || [];
// 최근 1시간 내 업로드 기록만 유지
const recentUploads = userUploads.filter(time => now - time < 3600000);
if (recentUploads.length >= 50) {
throw new Error('Too many uploads. Try again later.');
}
recentUploads.push(now);
uploadLimiter.set(userId, recentUploads);
}
2. Content Type 검증
// Magic number로 실제 파일 타입 확인
function verifyFileType(buffer: Buffer): string {
const magicNumbers: Record<string, string> = {
'ffd8ff': 'image/jpeg',
'89504e47': 'image/png',
'47494638': 'image/gif',
'52494646': 'video/webm', // RIFF (WebM/AVI)
};
const header = buffer.slice(0, 4).toString('hex');
for (const [magic, mimeType] of Object.entries(magicNumbers)) {
if (header.startsWith(magic)) {
return mimeType;
}
}
throw new Error('Unknown or invalid file type');
}
3. Signed URLs with Expiry
// presigned URL은 짧은 만료 시간 설정
const uploadURL = await getSignedUrl(s3Client, command, {
expiresIn: 900 // 15분
});
// 한 번만 사용 가능하도록 tracking
await db.uploadTokens.create({
data: {
token: uploadURL.split('?')[1], // query params
userId,
expiresAt: new Date(Date.now() + 900000),
used: false,
},
});
정리 - 파일 업로드 시스템의 핵심
작은 파일 (< 10MB)
- Presigned URL로 직접 업로드
- 서버는 URL만 발급, 파일 데이터는 다루지 않음
큰 파일 (> 100MB)
- Multipart upload로 chunk 단위 전송
- 실패한 chunk만 재시도
- localStorage로 진행 상태 저장
이미지 파일
- 업로드 후 비동기 처리 (Lambda)
- 여러 사이즈 + WebP 변환
- 썸네일 자동 생성
보안
- 클라이언트 + 서버 양쪽 검증
- Magic number로 파일 타입 확인
- Rate limiting으로 abuse 방지
- Presigned URL은 짧은 만료 시간
스토리지 선택
- S3: 안정성, 기능 많음, egress 비용 높음
- R2: egress 무료, S3 호환
- Supabase: RLS 통합, 이미지 변환 내장
결국 파일 업로드 시스템은 "데이터를 어떻게 효율적으로 옮기고, 안전하게 저장하며, 빠르게 제공할 것인가"의 문제였다. 서버를 최대한 거치지 않고, 작업을 분산시키고, 실패에 대비하는 것. 이 세 가지가 핵심이었다.