architecture2025-04-10·9 min·125/348

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 환경의 제한 사항:

  1. 메모리 제한: 서버리스 함수의 메모리 할당이 제한됨
  2. 파일 시스템 접근 불가: 로컬 파일 시스템에 저장 불가
  3. ** 실행 시간 제한**: 큰 파일 처리 시 타임아웃 발생
  4. 스토리지 비용: 외부 스토리지 서비스 비용 발생

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

  1. 외부 스토리지 활용: Vercel 환경에서는 외부 스토리지 서비스를 사용해야 합니다.
  2. Presigned URL: 클라이언트에서 직접 업로드하면 서버 부하를 줄일 수 있습니다.
  3. 이미지 최적화: 업로드 시 이미지를 최적화하면 저장 공간과 대역폭을 절약할 수 있습니다.
  4. 청크 업로드: 큰 파일은 청크 단위로 나누어 업로드하는 것이 안정적입니다.
  5. 파일 검증: 업로드 전에 파일 유형과 크기를 검증해야 합니다.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.