Vercel에서 파일 업로드 처리
Vercel 환경에서 파일 업로드를 처리하는 다양한 방법과 최적화 전략을 알아봅니다.
Vercel에서 파일 업로드 처리
Introduction
Vercel의 서버리스 환경에서 파일 업로드를 처리하는 것은 일반적인 웹 서버와 다른 접근이 필요합니다. 이 글에서는 Vercel에서 파일 업로드를 처리하는 다양한 방법과 각 방법의 장단점을 살펴보겠습니다.
Environment
- Vercel 프로젝트 (Next.js 14+)
- Vercel Blob Storage
- Cloudflare R2
- AWS S3
Problem
Vercel 서버리스 함수에서 파일 업로드 시 발생하는 문제:
// 이 코드는 Vercel에서 작동하지 않을 수 있습니다
export async function POST(request) {
const formData = await request.formData();
const file = formData.get('file');
// 서버리스 함수의 메모리 제한으로 인해 큰 파일 처리 실패
const buffer = Buffer.from(await file.arrayBuffer());
// 파일 저장 실패 (파일 시스템 접근 불가)
require('fs').writeFileSync(`/uploads/${file.name}`, buffer);
return Response.json({ success: true });
}Analysis
Vercel 환경의 제한 사항:
- 메모리 제한: 서버리스 함수의 메모리 할당이 제한됨
- 파일 시스템 접근 불가: 로컬 파일 시스템에 저장 불가
- ** 실행 시간 제한**: 큰 파일 처리 시 타임아웃 발생
- 스토리지 비용: 외부 스토리지 서비스 비용 발생
Solution
1. Vercel Blob Storage 사용
// app/api/upload/route.js
import { put } from '@vercel/blob';
import { NextResponse } from 'next/server';
export async function POST(request) {
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
const blob = await put(file.name, file, {
access: 'public',
contentType: file.type
});
return NextResponse.json({
url: blob.url,
pathname: blob.pathname,
size: blob.size
});
}2. 클라이언트 업로드 (Presigned URL)
// app/api/presigned-url/route.js
import { NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
export async function POST(request) {
const { filename, contentType } = await request.json();
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: `uploads/${Date.now()}-${filename}`,
ContentType: contentType
});
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 300
});
return NextResponse.json({ signedUrl });
}// 클라이언트 코드
async function uploadFile(file) {
// Presigned URL 획득
const response = await fetch('/api/presigned-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type
})
});
const { signedUrl } = await response.json();
// S3에 직접 업로드
await fetch(signedUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type
}
});
return signedUrl.split('?')[0];
}3. 청크 단위 업로드
// lib/chunked-upload.js
export class ChunkedUploader {
constructor(chunkSize = 5 * 1024 * 1024) {
this.chunkSize = chunkSize;
}
async upload(file, onProgress) {
const totalChunks = Math.ceil(file.size / this.chunkSize);
let uploadedChunks = 0;
for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, file.size);
const chunk = file.slice(start, end);
await this.uploadChunk(chunk, i, file.name);
uploadedChunks++;
onProgress((uploadedChunks / totalChunks) * 100);
}
}
async uploadChunk(chunk, index, filename) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index.toString());
formData.append('filename', filename);
const response = await fetch('/api/upload-chunk', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Chunk ${index} upload failed`);
}
}
}4. 이미지 최적화 업로드
// lib/image-optimizer.js
import sharp from 'sharp';
export async function optimizeImage(file, options = {}) {
const {
maxWidth = 1920,
maxHeight = 1080,
quality = 80,
format = 'webp'
} = options;
const buffer = Buffer.from(await file.arrayBuffer());
const optimized = await sharp(buffer)
.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
})
.toFormat(format, { quality })
.toBuffer();
return new Blob([optimized], { type: `image/${format}` });
}
// app/api/upload-image/route.js
import { put } from '@vercel/blob';
import { NextResponse } from 'next/server';
import { optimizeImage } from '@/lib/image-optimizer';
export async function POST(request) {
const formData = await request.formData();
const file = formData.get('file');
const optimized = await optimizeImage(file, {
maxWidth: 1920,
quality: 80,
format: 'webp'
});
const blob = await put(`images/${Date.now()}.webp`, optimized, {
access: 'public',
contentType: 'image/webp'
});
return NextResponse.json({ url: blob.url });
}5. 업로드 프로그레스 표시
// components/FileUploader.jsx
'use client';
import { useState, useRef } from 'react';
export default function FileUploader() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);
const fileInputRef = useRef(null);
const handleUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
setUploading(true);
setProgress(0);
setError(null);
try {
// Presigned URL 획득
const response = await fetch('/api/presigned-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type
})
});
const { signedUrl } = await response.json();
// 업로드 진행률 추적
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
setProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error('Upload failed'));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('PUT', signedUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
console.log('Upload successful');
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
{uploading && (
{Math.round(progress)}%
)}
{error && {error}}
);
}6. 파일 검증
// lib/file-validator.js
const ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'application/pdf'
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function validateFile(file) {
const errors = [];
if (!ALLOWED_TYPES.includes(file.type)) {
errors.push(`File type ${file.type} is not allowed`);
}
if (file.size > MAX_FILE_SIZE) {
errors.push(`File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`);
}
return {
valid: errors.length === 0,
errors
};
}Lessons Learned
- 외부 스토리지 활용: Vercel 환경에서는 외부 스토리지 서비스를 사용해야 합니다.
- Presigned URL: 클라이언트에서 직접 업로드하면 서버 부하를 줄일 수 있습니다.
- 이미지 최적화: 업로드 시 이미지를 최적화하면 저장 공간과 대역폭을 절약할 수 있습니다.
- 청크 업로드: 큰 파일은 청크 단위로 나누어 업로드하는 것이 안정적입니다.
- 파일 검증: 업로드 전에 파일 유형과 크기를 검증해야 합니다.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.