콘텐츠로 건너뛰기

Next.js 파일 업로드 구현 가이드|App Router・API Route・S3 대응

카테고리: Next.js·React

Next.js App Router의 등장으로 파일 업로드 구현 패턴이 크게 변했습니다. Pages Router 시대에 사용되던 multer는 App Router의 Route Handlers에서 직접 사용할 수 없으며, 웹 표준 FormData API를 사용한 구현이 주류가 되었습니다. 본 글에서는 App Router의 Server Actions・Route Handlers(route.ts)・클라이언트 측 진행률 표시줄부터 Vercel Blob과 AWS S3로의 업로드까지 체계적으로 설명합니다.

Browser FormData Server Action "use server" Route Handler app/api/.../route.ts Vercel Blob S3 R2 / GCS
그림: Next.js App Router 업로드 흐름 (Server Actions / Route Handlers)

App Router에서 Server Actions를 사용한 파일 업로드

"use server" 지시자가 붙은 Server Actions는 서버 함수로, 폼의 action에 직접 전달할 수 있습니다. JavaScript 없이도 동작하며 프로그레시브 엔핸스먼트와 상성이 좋은 방법입니다.

// app/upload/actions.ts
'use server';

import { writeFile } from 'fs/promises';
import { join } from 'path';

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File;

  if (!file || file.size === 0) {
    return { error: 'ファイルが選択されていません。' };
  }

  // ファイルサイズの検証(10MB 上限)
  const MAX_SIZE = 10 * 1024 * 1024;
  if (file.size > MAX_SIZE) {
    return { error: 'ファイルサイズは10MB以下にしてください。' };
  }

  // MIMEタイプの検証
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
  if (!allowedTypes.includes(file.type)) {
    return { error: '許可されていないファイル形式です。' };
  }

  // Buffer に変換してローカルに保存
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  // ファイル名をサニタイズしてユニークな名前を生成
  const timestamp = Date.now();
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
  const filename = `${timestamp}_${safeName}`;
  const uploadDir = join(process.cwd(), 'public', 'uploads');
  await writeFile(join(uploadDir, filename), buffer);

  return { success: true, filename, url: `/uploads/${filename}` };
}
// app/upload/page.tsx
import { uploadFile } from './actions';

export default function UploadPage() {
  return (
    <form action={uploadFile}>
      <input type="file" name="file" accept="image/*,application/pdf" />
      <button type="submit">アップロード</button>
    </form>
  );
}

Route Handlers(route.ts)에서 FormData 처리

API로 사용하는 경우 app/api/upload/route.ts에 Route Handler를 만듭니다. multer는 불필요하며, request.formData()로 Web 표준 FormData를 얻을 수 있습니다.

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;

    if (!file) {
      return NextResponse.json({ error: 'ファイルが見つかりません。' }, { status: 400 });
    }

    // ファイルサイズ検証
    const MAX_SIZE = 5 * 1024 * 1024; // 5MB
    if (file.size > MAX_SIZE) {
      return NextResponse.json(
        { error: `ファイルサイズは ${MAX_SIZE / 1024 / 1024}MB 以下にしてください。` },
        { status: 413 }
      );
    }

    // MIMEタイプ検証(クライアントの申告値は信頼せず、実際の内容で判定するのが理想)
    const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
    if (!allowedMimes.includes(file.type)) {
      return NextResponse.json({ error: '許可されていないファイル形式です。' }, { status: 415 });
    }

    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);

    // アップロードディレクトリの作成(存在しない場合)
    const uploadDir = join(process.cwd(), 'public', 'uploads');
    if (!existsSync(uploadDir)) {
      await mkdir(uploadDir, { recursive: true });
    }

    // ユニークなファイル名を生成
    const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
    const ext = file.name.split('.').pop();
    const filename = `upload-${uniqueSuffix}.${ext}`;
    await writeFile(join(uploadDir, filename), buffer);

    return NextResponse.json({
      success: true,
      filename,
      url: `/uploads/${filename}`,
      size: file.size,
      type: file.type,
    });
  } catch (error) {
    console.error('Upload error:', error);
    return NextResponse.json({ error: 'アップロード中にエラーが発生しました。' }, { status: 500 });
  }
}

// Vercel のデフォルトは 4.5MB のため、必要に応じて設定を変更
export const config = {
  api: {
    bodyParser: false,
  },
};

클라이언트 측 진행률 표시줄 구현

Fetch API는 표준적으로 업로드 진행률을 가져올 수 없습니다. XMLHttpRequestupload.onprogress 이벤트 또는 ReadableStream을 사용할 수 있습니다.

// components/UploadWithProgress.tsx
'use client';

import { useState } from 'react';

export default function UploadWithProgress() {
  const [progress, setProgress] = useState(0);
  const [uploading, setUploading] = useState(false);
  const [done, setDone] = useState(false);

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

    const formData = new FormData();
    formData.append('file', file);
    setUploading(true);
    setProgress(0);

    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        setProgress(Math.round((event.loaded / event.total) * 100));
      }
    };
    xhr.onload = () => { setUploading(false); setDone(true); };
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  };

  return (
    <div>
      <input type="file" onChange={handleUpload} />
      {uploading && (
        <div>
          <div className="progress-bar" style={{ width: progress + '%' }} />
          <span>{progress}%</span>
        </div>
      )}
      {done && <p>アップロード完了</p>}
    </div>
  );
}

Vercel Blob에 업로드

Vercel 환경에서는 @vercel/blob 패키지를 사용하여 Vercel이 관리하는 객체 스토리지에 직접 업로드할 수 있습니다. Vercel Function의 4.5MB 제한을 우회하기 위해 「클라이언트 업로드」기능도 제공됩니다.

npm install @vercel/blob
// app/api/upload/route.ts(Vercel Blob 使用)
import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get('file') as File;

  if (!file) {
    return NextResponse.json({ error: 'ファイルが見つかりません。' }, { status: 400 });
  }

  // Vercel Blob にアップロード(BLOB_READ_WRITE_TOKEN が必要)
  const blob = await put(file.name, file, {
    access: 'public',       // 'public' or 'private'
    addRandomSuffix: true,  // ファイル名の衝突を防ぐ
  });

  return NextResponse.json({ url: blob.url, size: blob.size });
}

// 大容量ファイルのためにボディサイズ上限を引き上げ(Vercel では効果なし)
export const maxDuration = 60; // 秒
// クライアントアップロード(4.5MB超のファイルを Vercel Blob に直接送信)
// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = (await request.json()) as HandleUploadBody;

  try {
    const jsonResponse = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async (pathname) => ({
        allowedContentTypes: ['image/jpeg', 'image/png', 'application/pdf'],
        maximumSizeInBytes: 50 * 1024 * 1024, // 50MB
      }),
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        console.log('アップロード完了:', blob.url);
      },
    });

    return NextResponse.json(jsonResponse);
  } catch (error) {
    return NextResponse.json({ error: String(error) }, { status: 400 });
  }
}

AWS S3로의 업로드(Presigned URL 방식)

대용량 파일이나 Vercel 이외의 인프라에서는 S3 Presigned URL을 사용하여 클라이언트에서 직접 S3로 업로드하는 방식이 가장 효율적입니다.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// app/api/presigned-url/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextRequest, NextResponse } from 'next/server';

const s3 = 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: NextRequest) {
  const { filename, contentType, size } = await request.json();

  // サーバー側でのバリデーション
  const MAX_SIZE = 100 * 1024 * 1024; // 100MB
  if (size > MAX_SIZE) {
    return NextResponse.json({ error: 'ファイルが大きすぎます。' }, { status: 413 });
  }

  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf', 'application/zip'];
  if (!allowedTypes.includes(contentType)) {
    return NextResponse.json({ error: '許可されていない形式です。' }, { status: 415 });
  }

  const key = `uploads/${Date.now()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_BUCKET_NAME!,
    Key: key,
    ContentType: contentType,
    ContentLength: size,
  });

  // 15分間有効な署名付きURLを生成
  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });

  return NextResponse.json({ presignedUrl, key });
}
// クライアントから S3 に直接アップロード
async function uploadToS3(file: File) {
  // 1. サーバーから Presigned URL を取得
  const res = await fetch('/api/presigned-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      size: file.size,
    }),
  });
  const { presignedUrl, key } = await res.json();

  // 2. S3 に直接 PUT(Vercel Function を経由しないため上限なし)
  const uploadRes = await fetch(presignedUrl, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });

  if (!uploadRes.ok) throw new Error('S3 アップロード失敗');
  return key;
}

next.config.js에서의 설정

Next.js에서는 next.config.js로 파일 업로드와 관련된 설정을 할 수 있습니다. 다만 Vercel에 배포한 경우 vercel.json 측의 설정이 우선됩니다.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 外部画像の許可(S3 のドメインなど)
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.s3.amazonaws.com',
      },
      {
        protocol: 'https',
        hostname: '*.public.blob.vercel-storage.com',
      },
    ],
  },
  // Pages Router の API Route のボディサイズ上限(App Router には無効)
  // App Router では Route Handler に対して個別に設定する
  experimental: {
    serverActions: {
      bodySizeLimit: '10mb', // Server Actions のボディサイズ上限
    },
  },
};

module.exports = nextConfig;

❓ 자주 묻는 질문

Next.js App Router에서 multer를 쓸 수 있나요?
App Router의 Route Handlers(route.ts)에서는 multer가 동작하지 않습니다. 대신 웹 표준 request.formData()로 FormData를 얻고 file.arrayBuffer()로 파일 내용을 읽습니다. multer는 Node.js의 http.IncomingMessage를 전제로 하지만 App Router는 웹 표준 Request 객체를 사용하기 때문입니다.
Vercel에서의 파일 업로드 상한은 얼마인가요?
Vercel Function의 요청 본문 기본 상한은 4.5MB입니다. 이를 넘는 파일은 Vercel Blob의 클라이언트 업로드 기능이나 S3 Presigned URL을 이용한 클라이언트 직접 업로드로 처리합니다. Server Actions에서는 next.config.js의 serverActions.bodySizeLimit로 상한을 바꿀 수 있지만 Vercel 인프라 측 제한은 변하지 않습니다.
Next.js에서 업로드 진행률을 표시하려면?
Fetch API는 표준으로 업로드 진행률을 얻을 수 없으므로 XMLHttpRequest의 xhr.upload.onprogress 이벤트를 사용합니다. 또는 axios 같은 라이브러리를 쓰면 더 간단히 구현할 수 있습니다. S3 Presigned URL을 이용한 직접 업로드에서도 마찬가지로 XHR로 브라우저에서 S3에 PUT 요청을 보내면 진행률 표시가 가능합니다.

이 기사에서 사용할 수 있는 테스트 파일 (무료)