
파일 업로드 시스템: 대용량 파일을 안전하게 처리하기
10MB 이미지 업로드는 됐는데 2GB 동영상은 타임아웃이 났다. 청크 업로드, presigned URL, 그리고 재시도 로직까지 정리한 이야기.

10MB 이미지 업로드는 됐는데 2GB 동영상은 타임아웃이 났다. 청크 업로드, presigned URL, 그리고 재시도 로직까지 정리한 이야기.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

직접 가기 껄끄러울 때 프록시가 대신 갔다 옵니다. 내 정체를 숨기려면 Forward Proxy, 서버를 보호하려면 Reverse Proxy. 같은 대리인인데 누구 편이냐가 다릅니다.

처음 파일 업로드를 구현할 때는 간단했다. 사용자가 이미지를 선택하면 서버로 보내고, 서버에서 S3에 올린다. 10MB 정도까지는 완벽하게 작동했다.
그런데 어느 날 사용자가 2GB 동영상을 업로드하려고 했고, 전부 타임아웃이 났다. 로그를 보니 서버 메모리가 급증했고, 네트워크 대역폭도 두 배로 소모되고 있었다. 클라이언트 → 서버 → S3로 데이터가 두 번 이동하는 게 문제였다.
결국 이해했다. 파일 업로드는 "택배 배송"과 같다는 것을. 서버가 집 앞까지 택배를 받아서 다시 창고로 옮기는 게 아니라, 고객이 직접 창고에 배송하게 만들어야 한다는 것을.
가장 와닿았던 솔루션은 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로 직접 업로드
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;
}
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는 독립적으로 업로드된다. 하나가 실패하면 그 조각만 다시 보내면 된다. 마치 여러 상자로 나눠서 택배를 보내는 것처럼.
네트워크가 불안정한 환경에서는 업로드가 중간에 끊어질 수 있다. 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);
}
네트워크가 끊어져도 다음에 다시 시작할 수 있다. 마치 게임의 체크포인트처럼.
파일을 받기 전에 검증해야 한다. 클라이언트와 서버 양쪽 모두에서.
// 클라이언트: 업로드 전 검증
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를 사용할 수 있다.
이미지는 업로드 후 처리가 필요하다. 원본은 그대로 두고, 썸네일과 여러 사이즈를 생성한다.
// 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 집약적이라 서버리스 함수에서 비동기로 돌리는 게 효율적이다.
업로드가 오래 걸리면 사용자는 불안하다. 진행률을 보여줘야 한다.
// 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>
);
}
각 스토리지의 특징이 명확했다.
AWS S3// 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 비용이 무료라는 게 결정적이었다.
파일을 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`;
파일 업로드는 보안 취약점이 될 수 있다. 몇 가지 방어책이 필요하다.
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,
},
});
결국 파일 업로드 시스템은 "데이터를 어떻게 효율적으로 옮기고, 안전하게 저장하며, 빠르게 제공할 것인가"의 문제였다. 서버를 최대한 거치지 않고, 작업을 분산시키고, 실패에 대비하는 것. 이 세 가지가 핵심이었다.