この記事は約 5 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
「コードレビューで気づきやすいが、実装中は見落としやすい」 — そんなパフォーマンスの罠 5 点を整理します。運用中の SaaS 「たすきば Knowledge Relay」 で実際に踏んだ (or 危うかった) アンチパターンと修正例。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
5 つのアンチパターン
| # | 問題 | 影響 |
|---|---|---|
| 1 | 重複 findMany (N+1) |
同じ DB クエリを複数回 |
| 2 | 配列 limit と DB limit の乖離 | DB は全件取得、配列で slice |
| 3 |
useMemo 未適用 |
重い計算が毎レンダー |
| 4 | O(N×M) の双重ループ | データ量に応じて爆発 |
| 5 | Eager fetch | 不要な joined data を取得 |
1. 重複 findMany (N+1 問題)
// ✗ NG: 同じクエリを 2 回
const projects = await prisma.project.findMany({ where: { tenantId } });
const tasksByProject = await Promise.all(
projects.map(p => prisma.task.findMany({ where: { projectId: p.id } }))
);
100 プロジェクトなら 101 クエリ。
// ✓ OK: 1 回の include で取得
const projects = await prisma.project.findMany({
where: { tenantId },
include: { tasks: true },
});
include (or select) で関連データを一度に取得。クエリ数: 1 回。
2. 配列 limit と DB limit の乖離
// ✗ NG: DB から全件取って配列で slice
const allKnowledges = await prisma.knowledge.findMany({ where: { tenantId } });
const top10 = allKnowledges.slice(0, 10);
100,000 件取得して 10 件を返す。転送量・メモリ消費が無駄。
// ✓ OK: DB で limit
const top10 = await prisma.knowledge.findMany({
where: { tenantId },
take: 10,
});
さらに巧妙なバージョン
// ✗ NG: DB で 20 件取得、その後フィルタで絞る
const items = await prisma.knowledge.findMany({
where: { tenantId },
take: 20,
});
const filtered = items.filter(k => k.visibility === 'public').slice(0, 10);
// → public が 5 件しかなければ 5 件しか返らない
// ✓ OK: DB で visibility フィルタ + limit
const top10 = await prisma.knowledge.findMany({
where: { tenantId, visibility: 'public' },
take: 10,
});
「画面に表示したい条件は、すべて DB で適用する」を徹底。
3. useMemo 未適用
// ✗ NG: 毎レンダーで重い計算
function ProjectList({ projects }) {
const sortedProjects = projects.sort((a, b) =>
a.name.localeCompare(b.name, 'ja')
);
}
projects が変わらなくても、毎回 sort が走る。さらに sort() は 破壊的 で副作用も。
// ✓ OK: useMemo + 非破壊的
function ProjectList({ projects }) {
const sortedProjects = useMemo(() =>
[...projects].sort((a, b) =>
a.name.localeCompare(b.name, 'ja')
),
[projects]
);
}
useMemo を使うべきタイミング
| 計算 | useMemo |
|---|---|
| 単純な map / filter | 不要 (軽い) |
| sort / 複雑な変換 | 推奨 |
| 重い計算 (1000+ 件処理) | 必須 |
| 子コンポーネントの key | 推奨 (再レンダー減) |
軽い計算に付けすぎると、メモリ消費が増えます。バランスが大切。
4. O(N×M) の双重ループ
// ✗ NG: O(projects × tasks)
function ProjectsWithTasks({ projects, tasks }) {
return projects.map(p => {
const projectTasks = tasks.filter(t => t.projectId === p.id); // N×M
return <ProjectRow project={p} tasks={projectTasks} />;
});
}
100 プロジェクト × 1000 タスク = 100,000 回比較。
// ✓ OK: Map で O(N+M)
function ProjectsWithTasks({ projects, tasks }) {
const tasksByProjectId = useMemo(() => {
const map = new Map();
for (const task of tasks) {
if (!map.has(task.projectId)) map.set(task.projectId, []);
map.get(task.projectId).push(task);
}
return map;
}, [tasks]);
return projects.map(p => {
const projectTasks = tasksByProjectId.get(p.id) || [];
return <ProjectRow project={p} tasks={projectTasks} />;
});
}
Map を一度作れば、各プロジェクトでの参照は O(1)。合計 O(N + M)。
5. Eager fetch
// ✗ NG: 使わないリレーションも取得
const projects = await prisma.project.findMany({
include: {
tasks: true, // 100 件のタスク
members: true, // 20 件のメンバー
knowledges: true, // 50 件のナレッジ
customer: true,
},
});
// 実際は project.name と project.state しか使わない
// ✓ OK: 使うフィールドだけ select
const projects = await prisma.project.findMany({
select: { id: true, name: true, state: true },
});
| 観点 | 内容 |
|---|---|
| 転送量 | 必要な情報だけ |
| パース時間 | 削減 |
| メモリ | 削減 |
6. ページネーションの併用
リストを表示する場合、必ずページネーションを入れます。
// ✓ OK: cursor-based pagination
const projects = await prisma.project.findMany({
where: { tenantId },
take: 20,
cursor: cursorId ? { id: cursorId } : undefined,
skip: cursorId ? 1 : 0,
orderBy: { createdAt: 'desc' },
});
1 ページ 20 件。次ページは cursor で取得。1000 件の DB でも 20 件しか取らない。
7. 計測する習慣
「遅い気がする」だけでなく、実際に計測。
console.time('listProjects');
const projects = await prisma.project.findMany(...);
console.timeEnd('listProjects'); // → listProjects: 234ms
| Production の計測ツール | 用途 |
|---|---|
| Vercel Analytics / Netlify Analytics | Web Vitals |
Prisma の $on('query')
|
クエリ時間 |
| ブラウザ DevTools Performance | フロント描画 |
数字で見ることで、改善のインパクトが分かります。
8. PR レビューでのチェックリスト
新規 Service / Component の PR レビュー時:
□ N+1 問題 (Promise.all + 個別 query) になっていないか
□ DB limit と表示 limit が一致しているか
□ 重い計算に useMemo が付いているか
□ 双重ループが N×M になっていないか
□ select / include で必要フィールドだけ取得しているか
9. たすきばで実測したチューニング事例
| 画面 | Before | After | 効果 |
|---|---|---|---|
| プロジェクト一覧画面 | 230ms | 80ms | N+1 解消 + select 最適化 |
| 提案エンジン検索 | 450ms | 60ms | HNSW インデックス追加 |
| ApiCallLog 月次集計 | 1500ms | 200ms | インデックス + 集計最適化 |
アンチパターンを解消するだけで、数倍速くなることが多い。
おわりに
| アンチパターン | 修正方針 |
|---|---|
| 重複 findMany | include / select で 1 クエリに |
| limit 乖離 | DB レベルで take + where |
| useMemo 未適用 | 重い計算には useMemo |
| O(N×M) 双重ループ | Map で O(N+M) |
| Eager fetch | select で必要フィールドのみ |
これら 5 点を変更時に自問する習慣で、パフォーマンス問題の大半を予防 できます。
本記事のアンチパターン集は、運用中の SaaS 「たすきば Knowledge Relay」 で実際に直したものです。
👉 たすきば Knowledge Relay — 公式プロダクトページ