3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で踏みやすいパフォーマンス・アンチパターン 5 点 — Prisma N+1 から useMemo 漏れまで

3
Posted at

この記事は約 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 — 公式プロダクトページ

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?