🍣

大容量ファイルアップロードの実装方法:PresignedURLを使った効率的なアプローチ

に公開

はじめに

CSVファイルアップロード機能を実装していた時のことです。10MBの制限を設けていたら、同僚エンジニアから指摘を受けました。

「容量の大きいファイルもあるから、PresignedURL形式にして」

正直、PresignedURLがよく分かっていませんでした。そもそも「なんでサーバーを経由しないの?」という疑問もありました。今回、徹底的に調査して実装したので、その学びをまとめます。

なぜファイルアップロードが難しいのか

現在のgRPC実装を見てみましょう:

// 現在の実装(gRPC bytes方式)
const checkCSV = async () => {
  const arrayBuffer = await file.arrayBuffer();  // ファイル全体をメモリに
  const csvData = new Uint8Array(arrayBuffer);

  const request = {
    csvData,      // ファイル全体をリクエストに含める
    brandId: brandId,
  };

  await hogeService.checkHogeCSV(request);
};

この実装の問題:

  • メモリ爆発: 100MBのファイル × 10人 = 1GBのメモリ消費
  • サイズ制限: gRPCのデフォルト上限は4MB
  • タイムアウト: 大きなファイルは転送時間がかかる

「なんでサーバーを経由しないの?」という最初の疑問

私が最初に考えていた「普通」の方法

// 直感的に思いつく実装
app.post('/upload', async (req, res) => {
  const file = req.file;  // ファイルを受け取って

  // サーバーからS3へアップロード
  await s3.putObject({
    Bucket: 'my-bucket',
    Key: file.originalname,
    Body: file.buffer,  // メモリに全部載せる😱
  });

  res.json({ success: true });
});

これが「当たり前」だと思っていました。だって:

  • 認証はサーバーでチェック ✓
  • データ処理はサーバーで実行 ✓
  • ファイル保存も...サーバー経由?🤔

PresignedURL方式を知った時の衝撃

「え、サーバーをスキップ?セキュリティは大丈夫?」

でも調べてみると、これが現代の標準的な方法だと分かりました。

なぜサーバー経由は非効率なのか

各アップロード方式の詳細比較

1. 現在のgRPC bytes方式

// Protocol Buffers定義
message CheckHogeCSVRequest {
  bytes csv_data = 1;   // バイナリデータ
  uint64 brand_id = 2;
}
評価項目 内容
メモリ使用 ❌ ファイル全体をメモリに保持
最大サイズ ❌ 実用的には10-20MB程度
実装の複雑さ ⭕ 比較的シンプル
進捗表示 ❌ 不可

2. 従来のmultipart/form-data方式

const formData = new FormData();
formData.append('file', file);
await fetch('/api/upload', { method: 'POST', body: formData });
評価項目 内容
メモリ使用 ❌ サーバー側でファイル処理
最大サイズ ⭕ 設定次第でGB単位も可
実装の複雑さ ⭕ 最もシンプル
進捗表示 🔺 可能だが実装が必要

3. PresignedURL方式

// 1. URLを取得
const { uploadUrl } = await getPresignedUrl();
// 2. S3に直接アップロード
await axios.put(uploadUrl, file, {
  onUploadProgress: (e) => setProgress(e.loaded / e.total * 100)
});
評価項目 内容
メモリ使用 ✅ サーバーメモリ使用なし
最大サイズ ✅ 5GBまで(マルチパートで5TB)
実装の複雑さ 🔺 2段階の処理が必要
進捗表示 ✅ ネイティブサポート

PresignedURLの仕組み(図解)

実装例

バックエンド実装(Node.js + TypeScript)

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

class FileUploadService {
  private s3Client: S3Client;

  constructor() {
    this.s3Client = new S3Client({ region: 'ap-northeast-1' });
  }

  async generatePresignedUrl(userId: string, filename: string, fileSize: number) {
    // 1. セキュリティチェック
    if (fileSize > 500 * 1024 * 1024) {
      throw new Error('ファイルサイズは500MB以下にしてください');
    }

    // 2. ユニークなキーを生成(上書き防止)
    const timestamp = Date.now();
    const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
    const fileKey = `hoges/${userId}/${timestamp}-${sanitizedFilename}`;

    // 3. アップロード用コマンドを作成
    const command = new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: fileKey,
      ContentType: 'text/csv',
      // メタデータを付与
      Metadata: {
        userId,
        originalName: filename,
        uploadedAt: new Date().toISOString(),
      },
    });

    // 4. 署名付きURLを生成(15分間有効)
    const uploadUrl = await getSignedUrl(this.s3Client, command, {
      expiresIn: 900,
    });

    return { uploadUrl, fileKey };
  }
}

// Express エンドポイント
app.post('/api/presigned-url', authenticate, async (req, res) => {
  try {
    const { filename, fileSize } = req.body;
    const service = new FileUploadService();

    const result = await service.generatePresignedUrl(
      req.user.id,
      filename,
      fileSize
    );

    res.json(result);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

フロントエンド実装(React + TypeScript)

import { useState } from 'react';
import axios from 'axios';

// カスタムフック
const useFileUpload = () => {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);

  const uploadFile = async (file: File) => {
    setUploading(true);
    setError(null);

    try {
      // 1. PresignedURLを取得
      const { data } = await axios.post('/api/presigned-url', {
        filename: file.name,
        fileSize: file.size,
      });

      // 2. S3に直接アップロード(進捗付き)
      await axios.put(data.uploadUrl, file, {
        headers: {
          'Content-Type': 'text/csv',
        },
        onUploadProgress: (progressEvent) => {
          const percent = Math.round(
            (progressEvent.loaded * 100) / progressEvent.total!
          );
          setProgress(percent);
        },
      });

      // 3. バックエンドに完了通知
      await axios.post('/api/upload-complete', {
        fileKey: data.fileKey,
      });

      return { success: true, fileKey: data.fileKey };
    } catch (err) {
      const message = err instanceof Error ? err.message : 'アップロードに失敗しました';
      setError(message);
      return { success: false, error: message };
    } finally {
      setUploading(false);
    }
  };

  return { uploadFile, uploading, progress, error };
};

// コンポーネント
export const HogeFileUploader = () => {
  const { uploadFile, uploading, progress, error } = useFileUpload();
  const [selectedFile, setSelectedFile] = useState<File | null>(null);

  const handleSubmit = async () => {
    if (!selectedFile) return;

    const result = await uploadFile(selectedFile);
    if (result.success) {
      alert('アップロード完了!');
      setSelectedFile(null);
    }
  };

  return (
    <div>
      <input
        type="file"
        accept=".csv"
        onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
        disabled={uploading}
      />

      {selectedFile && (
        <div>
          <p>ファイル: {selectedFile.name}</p>
          <p>サイズ: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
        </div>
      )}

      {uploading && (
        <div>
          <div style={{ width: '100%', backgroundColor: '#e0e0e0' }}>
            <div
              style={{
                width: `${progress}%`,
                height: '20px',
                backgroundColor: '#4caf50',
                transition: 'width 0.3s',
              }}
            />
          </div>
          <p>{progress}% アップロード済み</p>
        </div>
      )}

      {error && <p style={{ color: 'red' }}>{error}</p>}

      <button
        onClick={handleSubmit}
        disabled={!selectedFile || uploading}
      >
        {uploading ? 'アップロード中...' : 'アップロード開始'}
      </button>
    </div>
  );
};

セキュリティのベストプラクティス

// ⚠️ よくある間違い
const badExample = {
  Bucket: 'my-bucket',
  Key: req.body.filename,  // 危険!パストラバーサル攻撃の可能性
};

// ✅ セキュアな実装
const secureExample = {
  Bucket: 'my-bucket',
  Key: `users/${userId}/${timestamp}-${sanitize(filename)}`,
  ContentType: 'text/csv',  // 固定して悪意のあるファイルを防ぐ
  ServerSideEncryption: 'AES256',  // 暗号化
  Metadata: {
    'uploaded-by': userId,
    'scanned': 'pending',  // ウイルススキャン状態
  },
};

CORS設定(S3バケット)

{
  "CORSRules": [{
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT"],
    "AllowedOrigins": ["https://yourdomain.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }]
}

いつPresignedURLを使うべきか

✅ PresignedURLが最適なケース

  • ファイルサイズが10MB以上
  • 動画・画像などのメディアファイル
  • 同時アップロード数が多い
  • 進捗表示が必要
  • サーバーリソースが限られている

❌ 従来方式で十分なケース

  • 小さなJSONデータ(< 1MB)
  • リアルタイムな処理が必要
  • ファイル内容の即時検証が必要

まとめ

最初は「なんでサーバーを経由しないの?」と疑問でしたが、調査して実装してみると、PresignedURLの利点が明確になりました:

  1. スケーラビリティ: サーバーリソースを消費しない
  2. パフォーマンス: 大容量ファイルも高速転送
  3. UX向上: 進捗表示でユーザー体験改善
  4. コスト削減: サーバーの負荷軽減

特にコーディネートデータのような大容量CSVファイルを扱う場合、PresignedURL方式は必須です。

実装のポイント

  • 有効期限は短めに(5-15分)
  • ファイル名は必ずサニタイズ
  • エラー時のリトライ処理を実装
  • CORS設定を忘れずに

「サーバーが全てを処理する」という固定観念を捨て、クラウドサービスを活用することで、より効率的なシステムが構築できることを学びました。

Discussion