外部CSVデータ表示でDBを使わない選択肢

## はじめに

外部から定期的に提供されるCSVデータを表示するWebアプリケーションを開発する際、多くの開発者が「CSVを取得→DBに蓄積→表示」というアーキテクチャを最初に思い浮かべるのではないでしょうか。

本記事では、1日1回更新という要件において、
**DBもBEサーバーも不要**という結論に至った経緯と、
Next.js App Routerの実装で遭遇した制限とその解決策について実体験を交えて紹介します。

## 要件と初期検討

### プロジェクト要件
- **データ更新頻度**: 1日1回
- **予算制約**: 運用コストはできる限り抑えたい
- **データソース**: 外部提供のCSVファイル（約6MB）

### 検討したアーキテクチャパターン

#### パターンA: CSV → DB → 表示
```
外部CSV → 定期取得 → PostgreSQL → Web表示
```
**課題**:
- DBホスティング費用（月$15-30程度）
- DBの保守・運用工数
- データ移行処理の実装コスト

#### パターンB: BEサーバー + fs.readFile
```
外部CSV → cron取得 → BEサーバー保存 → fs.readFile → Web表示
```
**課題**:
- BEサーバーの運用費用
- サーバー保守・運用工数

#### パターンC: FEのみ（Next.js App Router）
```
外部CSV → Next.js直接取得・キャッシュ → Web表示
```
**メリット**:
- **追加のインフラコストなし**
- サーバー保守不要
- シンプルな構成

**判断の決め手**: 1日1回更新という低頻度であれば、リアルタイム性よりもコスト効率を重視。
チーム内での検討で「DBは不要では？」という意見が出て、
最終的にパターンCを選択しました。

## Next.js App Routerの基本機能

### App Routerの特徴
Next.js 13以降のApp Routerには、以下のキャッシュ機能が標準で搭載されています：

- **fetchキャッシュ**: `fetch()`の結果を自動キャッシュ
- **静的生成（SSG）**: ビルド時またはオンデマンドでページを生成
- **オンデマンドISR**: APIを使った動的なキャッシュ更新

### revalidateオプション
```typescript
export const revalidate = false; // 完全な静的生成
// または
export const revalidate = 86400; // 24時間後に再生成
```

## 最初の実装: シンプルなfetch + キャッシュ

まずは最もシンプルな実装から始めました。

### ディレクトリ構成
```
/app
  └── page.tsx                   ← トップページ (CSVデータ表示)
/app/api
  └── revalidate/route.ts        ← 再生成API
/lib
  └── fetchCsv.ts                ← CSVダウンロード + パース関数
```

### /lib/fetchCsv.ts
```typescript
import Papa from 'papaparse';

export async function fetchCsv(): Promise<any[]> {
  const res = await fetch('https://people.sc.fsu.edu/~jburkardt/data/csv/hw_200.csv');
  const text = await res.text();

  const { data } = Papa.parse(text, {
    header: true,
    skipEmptyLines: true,
  });

  return data;
}
```

### /app/page.tsx – 静的ページ本体
```typescript
import { fetchCsv } from '@/lib/fetchCsv';

export const revalidate = false; // 完全なSSG（静的生成）

export default async function Page() {
  const data = await fetchCsv();

  return (
    <main>
      <h1>CSVデータ表示</h1>
      <p>データ件数: {data.length}</p>
      <table border={1}>
        <thead>
          <tr>{Object.keys(data[0] || {}).map((key) => <th key={key}>{key}</th>)}</tr>
        </thead>
        <tbody>
          {data.slice(0, 5).map((row, i) => (
            <tr key={i}>
              {Object.values(row).map((val, j) => <td key={j}>{val}</td>)}
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}
```

### /app/api/revalidate/route.ts
```typescript
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const secret = searchParams.get('secret');
  const path = searchParams.get('path') || '/';

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ revalidated: false, message: 'Invalid secret' }, { status: 401 });
  }

  await revalidatePath(path);
  return NextResponse.json({ revalidated: true, path });
}
```

この実装により、1日1回のAPI呼び出しもしくは画面表示時にデータを更新できる仕組みが完成しました。

## 壁にぶつかった: fetchキャッシュの2MB制限

小さなサンプルCSVでのテストは順調でしたが、実際の6MBのCSVファイルでテストした際に衝撃的なエラーに遭遇しました。

```
Error: Failed to set Next.js data cache, items over 2MB can not be cached (6048765 bytes)
```

**Next.jsのfetchキャッシュには2MBという制限**があり、6MBのCSVファイルは直接キャッシュできませんでした。

この制限はドキュメントにも明記されていますが、実際に開発を進めて初めて気づく制約でした。

### 制限の詳細
- fetchキャッシュの上限: **2MB**
- 実際のCSVファイル: **約6MB**
- 制限超過時: キャッシュされず、毎回外部取得が発生

## 解決策: unstable_cacheによる整形後キャッシュ

### unstable_cacheとは
Next.jsが提供するサーバーサイドキャッシュAPIで、fetchキャッシュとは異なる仕組みでデータをキャッシュできます。

**重要な注意点**: 
- APIが`unstable_`プレフィックス付きのため、将来的な変更の可能性があります
- プロダクション使用時は十分な検証が必要です

### 実装方法

```typescript
import { unstable_cache } from 'next/cache';

export const fetchAndParseCsv = unstable_cache(
  async (csvUrl: string, targetDate: Date): Promise<CsvRow[]> => {
    try {
      // 1. fetchによる取得
      const response = await fetch(csvUrl);
      if (!response.ok) {
        throw new Error(`Failed to fetch CSV: ${response.status}`);
      }

      const buffer = await response.arrayBuffer();
      const decodedText = iconv.decode(Buffer.from(buffer), 'Shift_JIS');

      // 2. CSVパース
      const { data, errors } = Papa.parse<CsvRow>(decodedText, {
        header: true,
        skipEmptyLines: true,
        dynamicTyping: false,
      });

      if (errors.length > 0) {
        console.error('CSV parsing errors:', errors);
        return [];
      }

      // 3. 必要な列のみを抽出
      const requiredColumns = [
        '年月日',
        '時刻コード',
        // 他の必要な列...
      ];

      // 4. データをフィルタリングして必要な列のみを抽出
      const filteredData = data
        .filter((row) => row['年月日'] && row['年月日'].trim() !== '')
        .map((row) => {
          const filteredRow: CsvRow = {};
          requiredColumns.forEach((column) => {
            filteredRow[column] = row[column] || '';
          });
          return filteredRow;
        });

      // 5. 対象月のデータのみをフィルタリング
      const targetMonth = targetDate.getMonth();
      const targetYear = targetDate.getFullYear();
      const monthFilteredData = filteredData.filter((row) => {
        const rowDate = new Date(row['年月日'].replace(/\//g, '-'));
        return rowDate.getMonth() === targetMonth && rowDate.getFullYear() === targetYear;
      });

      return monthFilteredData;
    } catch (error) {
      console.error(`Error in fetchAndParseCsv:`, error);
      return [];
    }
  },
  ['csv-data'], // キャッシュキーのプレフィックス
  {
    revalidate: false, // キャッシュの有効期限を無期限に
    tags: ['csv-data'], // キャッシュタグ
  }
);
```

### ポイント

1. **取得後に整形**: 生データを取得してから必要な部分のみを抽出
2. **列の絞り込み**: 6MBの全データから必要な列のみを選択
3. **期間の絞り込み**: 1年分のデータから表示に必要な期間のみを抽出
4. **キャッシュタグ**: 後からキャッシュクリアが可能

この対応により、キャッシュサイズを大幅に削減し、2MB制限を回避できました。

## 運用結果

### 成功実績
- **毎日正しくデータが表示されている**
- オンデマンドISRによる1日1回の自動更新が正常動作
- ページ表示速度も良好（初回生成後はキャッシュから高速表示）

### コスト効果
- DBホスティング費用: **$0**（不要）
- BEサーバー費用: **$0**（不要）
- Vercelの従量課金のみで運用可能

### パフォーマンス
- 初回ビルド時: CSVダウンロード・整形が発生
- 通常アクセス: キャッシュから高速表示
- データ更新時: APIトリガーによる再生成

## まとめ・学んだこと

### アーキテクチャ選択の学び
1. **要件に応じた適切な選択**: 1日1回更新という低頻度であれば、リアルタイム性よりもシンプルさとコスト効率を重視する判断が有効
2. **過剰設計の回避**: DBやBEサーバーが「当然必要」と思い込まず、要件を見直すことで大幅なコスト削減が可能
3. **段階的な実装**: まずシンプルな構成から始めて、制約に応じて調整する開発アプローチの有効性

### Next.js App Routerの実践的な制約
1. **fetchキャッシュの2MB制限**: 大きなデータを扱う場合は事前に把握しておくべき重要な制限
2. **unstable_cacheの活用**: 制限回避の有効な手段だが、APIの安定性には注意が必要
3. **データ整形戦略**: キャッシュ前の適切なデータ整形により、パフォーマンスとコストの両立が可能

### 今後同様のケースに遭遇した場合
- データ更新頻度が低い（1日数回程度）
- コスト効率を重視

という要件であれば、同じアプローチを積極的に検討したいと思います。

一方で、リアルタイム性が重要な場合や、データ量が大幅に増加した場合は、従来のDB + BEサーバー構成への移行も念頭に置いています。

---

この記事が、同様のCSVデータ表示要件を持つ開発者や、アーキテクチャ選択で悩んでいる方の参考になれば幸いです。特にNext.js App Routerのキャッシュ制限については、事前に知っておくことで開発時の手戻りを防げるかもしれません。