# 桐をWebで蘇らせる ― DataDrawers開発記-第5回
第5回: Turbopack のメモリ制限との遭遇

謎の警告メッセージ
ページネーション方式に変更した後、19万件のデータでテストしていると、突然ターミナルに見慣れない警告が表示された:
⚠ Server is approaching the used memory threshold, restarting...
「え? なんだこれ?」
Next.jsの開発サーバーが自動的に再起動し、進行中だったインポート処理が中断された。
当時はまだこのメッセージの意味を完全には理解していなかった。「メモリが閾値に近づいている? でもNode.jsはまだ1GB程度しか使っていないはず...」
再度実行すると、また同じタイミングで再起動される。何度試しても、19万件付近で必ず中断される。
Turbopack のメモリ監視機能
調べてみると、これはTurbopack(Next.js 16の新しい開発サーバー)の機能だった。
Turbopackは、開発体験を向上させるため、Node.jsプロセスのメモリ使用状況を常時監視している。そして、メモリ使用量が一定の閾値(デフォルトでは約1.5GB前後)に達すると、開発サーバーを自動的に再起動して、メモリリークや不安定な状態を回避する仕組みになっている。
この機能自体は素晴らしい。通常の開発では、ホットリロードを繰り返すことでメモリが徐々に蓄積し、最終的にクラッシュすることがある。それを防ぐための自動再起動は理にかなっている。
しかし、長時間実行されるバックグラウンドジョブにとっては、致命的な障害になる。
当時の実装: APIルート内での処理
当時のコードは、Next.jsのAPIルート内でインポート処理を実行していた:
// app/api/db/sync/route.ts (失敗版)
export async function POST(request: NextRequest) {
const { tableName, tableId } = await request.json();
// ❌ APIルート内で長時間処理
const totalCount = await getTableCount(tableName);
let offset = 0;
while (offset < totalCount) {
const chunk = await fetchTableDataPaginated(tableName, offset, 50000);
await insertTableData(tableId, chunk);
offset += chunk.length;
}
return NextResponse.json({ success: true });
}
この方式の問題点:
-
APIルートはリクエスト単位の処理を想定している
- 数秒〜数十秒で完了する処理が前提
- 124万件のインポートは数分かかる
-
開発サーバーのメモリ閾値に引っかかる
- 19万件(約400MB相当)でTurbopackが再起動
- バックグラウンドジョブが中断される
-
ブラウザのタイムアウト
- フロントエンドは fetch() でAPIを呼んでいる
- 長時間レスポンスがないとタイムアウト
バックグラウンドジョブ方式の試み
「APIルートで直接処理するのがダメなら、バックグラウンドジョブにすればいい」
そう考えて、以下のような実装を試した:
// lib/import-job-processor.ts (失敗版)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function processImportJob(jobData: ImportJobData) {
const { tableName, tableId } = jobData;
console.log(`[ImportJob] 開始: ${tableName}`);
const totalCount = await getTableCount(tableName);
let offset = 0;
while (offset < totalCount) {
const chunk = await fetchTableDataPaginated(tableName, offset, 50000);
await insertTableData(tableId, chunk);
offset += chunk.length;
// 進捗をDBに保存
await prisma.importJob.update({
where: { id: jobData.jobId },
data: {
processedRows: offset,
progress: Math.floor((offset / totalCount) * 100)
}
});
}
console.log(`[ImportJob] 完了: ${tableName}`);
}
そして、APIルートはすぐにレスポンスを返し、バックグラウンドで処理を実行する:
// app/api/jobs/start/route.ts (失敗版)
export async function POST(request: NextRequest) {
const { tableName, tableId } = await request.json();
const jobId = `job_${Date.now()}`;
// ジョブレコードを作成
await prisma.importJob.create({
data: { id: jobId, tableName, status: 'pending' }
});
// ❌ バックグラウンドで実行
processImportJob({ jobId, tableName, tableId }).catch(err => {
console.error('[Job Error]', err);
});
// すぐにレスポンスを返す
return NextResponse.json({ success: true, jobId });
}
フロントエンドは、jobId を使ってポーリングで進捗を確認する仕組みだった:
// components/import-table-dialog.tsx (失敗版)
useEffect(() => {
if (!currentJobId) return;
const pollInterval = setInterval(async () => {
const response = await fetch(`/api/jobs/${currentJobId}`);
const result = await response.json();
if (result.job.status === 'completed') {
clearInterval(pollInterval);
window.location.reload();
}
setJobProgress(result.job.progress);
}, 2000); // 2秒ごとにポーリング
return () => clearInterval(pollInterval);
}, [currentJobId]);
「これで完璧だ」
そう思って実行すると...
[ImportJob] 開始: dbo.指導要録
進捗: 50,000 / 1,240,703 (4%)
進捗: 100,000 / 1,240,703 (8%)
進捗: 150,000 / 1,240,703 (12%)
進捗: 190,000 / 1,240,703 (15%)
⚠ Server is approaching the used memory threshold, restarting...
[開発サーバー再起動]
[ImportJob] 状態: pending のまま
バックグラウンドジョブも、開発サーバーと共に停止してしまった。
「開発サーバー内での大量データ処理は不可能」という結論
この時点で、重要な事実に気づいた:
Turbopack(Next.js開発サーバー)内で、長時間・大量メモリを消費する処理を実行することはできない
理由:
-
メモリ監視による自動再起動
- 閾値に達すると問答無用で再起動
- 設定変更不可(現時点では)
-
開発サーバーは開発用途に最適化されている
- ホットリロード、高速リビルドが目的
- 本番運用のような長時間処理は想定外
-
本番ビルド(
next build+next start)なら問題ない- しかし、開発中はTurbopackを使いたい
- ビルド時間が長くなるのは避けたい
「Turbopack内でのバックグラウンドジョブは無理だ。別のアプローチが必要だ」
アーキテクチャの再設計へ
ここまでの試行錯誤で、以下のことが明確になった:
| 失敗した方法 | 問題点 |
|---|---|
| 全データ一括読み込み | メモリ不足でクラッシュ(90万件) |
| ROW_NUMBER()ページネーション | 77万件で停止(不安定) |
| APIルート内で処理 | ブラウザのタイムアウト、Turbopack再起動 |
| Turbopack内バックグラウンドジョブ | メモリ閾値で自動再起動(19万件) |
「開発サーバーとは完全に独立したプロセスで処理を実行するしかない」
この結論に至った時、頭に浮かんだのが child_process.spawn() だった。
Node.jsの spawn() を使えば、親プロセス(Next.js開発サーバー)から完全に独立した子プロセスを起動できる。そして、detached: true オプションを指定すれば、親が終了しても子プロセスは継続実行される。
「これだ。独立したワーカープロセスで実装しよう」
次回、いよいよ最終的な解決策を実装していく。
教訓
この段階で学んだことは:
-
開発環境と本番環境の違いを意識する
- Turbopackの機能は開発体験向上のため
- 長時間処理は開発サーバーの想定外
-
適切なアーキテクチャを選択する
- APIルート: 短時間のリクエスト/レスポンス
- バックグラウンドジョブ: 長時間の非同期処理
- 独立プロセス: 開発サーバーと分離した重い処理
-
制約を理解し、回避策を考える
- Turbopackのメモリ制限は変更不可
- それなら、Turbopackの外で処理すればいい
「失敗は学びの宝庫だ」
この言葉を実感しながら、次のステップへ進んだ。
Discussion