👏

CSVファイルアップロード機能の実装パターン - フロントエンドからバックエンドへのデータ転送アーキテクチャ

に公開

はじめに

Web開発において、CSVファイルのアップロード機能は頻繁に実装する要件の1つです。「管理画面からマスタデータを一括登録したい」「ユーザーデータを一括インポートしたい」など、様々な場面で必要になります。

本記事では、CSVファイルをフロントエンドからバックエンドに転送する際の実装パターンを整理し、それぞれのメリット・デメリットを解説します。

前提条件

  • フロントエンド: React
  • バックエンド: Go
  • 想定ファイルサイズ: 数KB〜数十MB
  • CSVフォーマット例:
name,code,type,min_value,max_value
ItemA,001,TypeX,1,10
ItemB,002,TypeY,5,20

実装パターン一覧

1. 直接アップロード方式

1-1. RESTful API (multipart/form-data)

最もシンプルで一般的な実装方法です。
フロントエンド実装

const uploadCSV = async (file, metadata) => {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('organizationId', metadata.organizationId);
  
  const response = await fetch('/api/import/csv', {
    method: 'POST',
    body: formData,
    // Content-Typeは自動設定されるので指定不要
  });
  
  return response.json();
};

バックエンド実装(Go)

func handleCSVUpload(w http.ResponseWriter, r *http.Request) {
    // ファイルサイズ制限
    r.ParseMultipartForm(10 << 20) // 10MB
    
    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    defer file.Close()
    
    orgID := r.FormValue("organizationId")
    
    // CSVパース処理
    reader := csv.NewReader(file)
    records, err := reader.ReadAll()
    // ... 処理続行
}

メリット
✅ 実装がシンプル
✅ ブラウザの標準機能を活用
✅ アップロード進捗の追跡が可能

デメリット
❌ 大容量ファイルでタイムアウトの可能性
❌ メモリ使用量が大きい

1-2. gRPC Binary転送

Protocol Buffersを使用した型安全な実装方法です。
Proto定義

syntax = "proto3";

service DataImportService {
  rpc ImportData(ImportDataRequest) returns (ImportDataResponse);
}

message ImportDataRequest {
  int64 organization_id = 1;
  bytes file_content = 2;
  string filename = 3;
}

message ImportDataResponse {
  bool success = 1;
  repeated ImportError errors = 2;
}

フロントエンド実装

const uploadCSV = async (file, organizationId) => {
  const arrayBuffer = await file.arrayBuffer();
  
  const response = await grpcClient.importData({
    organizationId: organizationId,
    fileContent: new Uint8Array(arrayBuffer),
    filename: file.name
  });
  
  return response;
};

メリット
✅ 型安全性が高い
✅ エラーハンドリングが明確

デメリット
❌ デフォルトで4MBのメッセージサイズ制限
❌ Base64エンコードによるサイズ増加(約33%)

1-3. 構造化データ転送

CSVをフロントエンドでパースし、構造化データとして送信する方法です。
Proto定義

message ImportRecordsRequest {
  int64 organization_id = 1;
  repeated Record records = 2;
}

message Record {
  string name = 1;
  string code = 2;
  string type = 3;
  string min_value = 4;
  string max_value = 5;
}

フロントエンド実装

import Papa from 'papaparse';

const uploadCSV = async (file, organizationId) => {
  // CSVをパース
  const parseResult = await new Promise((resolve) => {
    Papa.parse(file, {
      header: true,
      complete: resolve,
      encoding: 'UTF-8'
    });
  });
  
  // データ構造に変換
  const records = parseResult.data.map(row => ({
    name: row.name,
    code: row.code,
    type: row.type,
    minValue: row.min_value,
    maxValue: row.max_value
  }));
  
  // gRPC送信
  return await grpcClient.importRecords({
    organizationId: organizationId,
    records: records
  });
};

メリット

✅ フロントエンドで事前バリデーション可能
✅ 転送データサイズが小さい
✅ 型安全性が非常に高い

デメリット

❌ フロントエンドにパースロジックが必要
❌ 大容量ファイルでブラウザメモリを圧迫

2. 事前アップロード方式

2-1. Presigned URL方式(S3/GCS経由)

クラウドストレージのPresigned URLを使用して、クライアントから直接アップロードする方法です。
実装フロー

const uploadViaPresignedUrl = async (file, organizationId) => {
  // 1. バックエンドからPresigned URLを取得
  const { uploadUrl, objectKey } = await api.getPresignedUploadUrl({
    filename: file.name,
    contentType: file.type,
    organizationId: organizationId
  });
  
  // 2. クラウドストレージに直接アップロード
  await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type
    }
  });
  
  // 3. バックエンドに処理を依頼
  const result = await api.processUploadedFile({
    objectKey: objectKey,
    organizationId: organizationId
  });
  
  return result;
};

バックエンド実装(Presigned URL生成)

func getPresignedUploadURL(ctx context.Context, req *GetURLRequest) (*GetURLResponse, error) {
    objectKey := fmt.Sprintf("uploads/%s/%s", req.OrganizationID, uuid.New().String())
    
    // AWS S3の例
    presignClient := s3.NewPresignClient(s3Client)
    request, err := presignClient.PresignPutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String("my-bucket"),
        Key:    aws.String(objectKey),
    }, s3.WithPresignExpires(15*time.Minute))
    
    if err != nil {
        return nil, err
    }
    
    return &GetURLResponse{
        UploadURL: request.URL,
        ObjectKey: objectKey,
    }, nil
}

メリット
✅ 大容量ファイル対応
✅ サーバー負荷軽減
✅ リトライが容易
✅ 非同期処理との相性が良い

デメリット
❌ 実装が複雑
❌ クラウドストレージのコスト

2-2. チャンクアップロード

ファイルを分割してアップロードする方法です。
フロントエンド実装

const CHUNK_SIZE = 1024 * 1024; // 1MB

const uploadInChunks = async (file, organizationId) => {
  const sessionId = generateSessionId();
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
  
  // 各チャンクをアップロード
  for (let i = 0; i < totalChunks; i++) {
    const start = i * CHUNK_SIZE;
    const end = Math.min(start + CHUNK_SIZE, file.size);
    const chunk = file.slice(start, end);
    
    await api.uploadChunk({
      sessionId: sessionId,
      chunkIndex: i,
      totalChunks: totalChunks,
      data: await chunk.arrayBuffer()
    });
    
    // 進捗更新
    onProgress((i + 1) / totalChunks * 100);
  }
  
  // 最終処理
  return await api.finalizeUpload({
    sessionId: sessionId,
    organizationId: organizationId
  });
};

メリット
✅ 再開可能なアップロード
✅ 正確な進捗表示
✅ メモリ効率が良い

デメリット
❌ 実装が複雑
❌ 一時ファイルの管理が必要

3. ストリーミング方式

3-1. WebSocket

双方向通信を活用する方法です。

const uploadViaWebSocket = (file, organizationId) => {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket('wss://api.example.com/ws/upload');
    const reader = new FileReader();
    
    ws.onopen = () => {
      // メタデータ送信
      ws.send(JSON.stringify({
        type: 'init',
        filename: file.name,
        size: file.size,
        organizationId: organizationId
      }));
    };
    
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      if (message.type === 'complete') {
        resolve(message.result);
        ws.close();
      }
    };
    
    // ファイルをチャンクで読み込み送信
    let offset = 0;
    const chunkSize = 64 * 1024; // 64KB
    
    const readNextChunk = () => {
      const slice = file.slice(offset, offset + chunkSize);
      reader.readAsArrayBuffer(slice);
    };
    
    reader.onload = (e) => {
      ws.send(e.target.result);
      offset += chunkSize;
      
      if (offset < file.size) {
        readNextChunk();
      } else {
        ws.send(JSON.stringify({ type: 'eof' }));
      }
    };
    
    readNextChunk();
  });
};

メリット
✅ リアルタイム進捗
✅ 双方向通信
✅ 低レイテンシ

デメリット
❌ 接続管理が複雑
❌ エラーハンドリングが難しい

パターン選択のフローチャート

実装選択の指針

ケース1: 小規模な管理画面での利用

推奨: RESTful API (multipart/form-data)
理由: シンプルで十分

ケース2: 型安全性を重視した開発

推奨: 構造化データ転送(gRPC)
理由: コンパイル時のエラー検出

ケース3: 大容量ファイルを扱うサービス

推奨: Presigned URL方式
理由: スケーラビリティとコスト効率

ケース4: UXを重視したサービス

推奨: チャンクアップロード or WebSocket
理由: 進捗表示と再開可能性

まとめ

CSVファイルのアップロード機能は、要件に応じて適切な実装パターンを選択することが重要です。

  • シンプルさ重視: REST multipart/form-data
  • 型安全性重視: gRPC構造化データ
  • 大容量対応: Presigned URL方式
  • UX重視: チャンクアップロード

それぞれのパターンにはトレードオフがあるため、プロジェクトの特性、チームのスキルセット、既存のインフラを考慮して選択しましょう。

Discussion