NestJSとPrismaでバックエンド開発を爆速に。N+1問題とトランザクションを全部解決しよう

技術Tips・学び
いいね!もらえると 励みになります。

現場で直面した「APIが遅い」「データが壊れる」問題、どう乗り越えた?

takuma
takuma

バックエンド開発の初期、私は「APIが遅い」「データがたまに壊れる」という壁に何度もぶつかりました。

一体何が原因で、どうすればこの問題を解決できるのか?ここでは、その具体的な体験談と、私がどのように問題と向き合い、解決に導いたのかを共有したいと思います。

「なぜAPIがこんなに遅い!?」あの日のレスポンスタイム地獄

私がバックエンド開発に本格的に触れ始めた頃、ある既存APIのパフォーマンス改善に取り組む機会がありました。

そのAPIは、ユーザーに紐づく複数のプロジェクト情報とそのプロジェクトに紐づくタスクリストを返すという、ごく一般的なものでした。しかし、開発環境で叩いてみると、レスポンスに平気で2秒、ひどい時には5秒以上もかかっていました。フロントエンドでリッチなUIを作っても、肝心のデータ取得がこんなに遅いとユーザー体験は最悪です。

「え、これって普通なの…?まさか、DBがおかしいとか?」

当時の私は、APIのボトルネックがどこにあるのか全く分かっていませんでした。フロントエンドの経験はあっても、バックエンドの深い部分、特にデータベースとのやり取りに関しては知識が乏しかったんです。

Prismaの導入とN+1問題との出会い

パフォーマンスが課題のAPIを調査する中で、私はPrismaと出会いました。

Prismaは、型安全なデータベースアクセスを提供するORM(Object-Relational Mapper)で、TypeScriptとの相性が抜群です。スキーマ定義からDBモデルを自動生成し、データベース操作を直感的かつ安全に行えるのが魅力ですよね。

まず、データ取得の基盤を安定させるために、既存のSQLクエリをPrismaに置き換えました。Prismaのコードは非常に読みやすく、型定義のおかげで「どんなデータが返ってくるか」が明確になり、フロントエンド開発者としても非常に助けられました。

しかし、Prismaに置き換えたものの、APIのレスポンスタイムはほとんど改善されませんでした。 なぜだ!?と頭を抱えながら、私はNestJSのロギングを詳細に見てみることにしました。すると、ある恐ろしい事実が判明したんです。

# NestJSのログ(一部抜粋)
[Nest] 12345 - 07/10/2025, 8:00:01 PM LOG [query] SELECT "id", "name" FROM "users" WHERE "id" = 'user_abc'
[Nest] 12345 - 07/10/2025, 8:00:01 PM LOG [query] SELECT "id", "title" FROM "projects" WHERE "userId" = 'user_abc'
[Nest] 12345 - 07/10/2025, 8:00:02 PM LOG [query] SELECT "id", "description" FROM "tasks" WHERE "projectId" = 'project_001'
[Nest] 12345 - 07/10/2025, 8:00:02 PM LOG [query] SELECT "id", "description" FROM "tasks" WHERE "projectId" = 'project_002'
[Nest] 12345 - 07/10/2025, 8:00:02 PM LOG [query] SELECT "id", "description" FROM "tasks" WHERE "projectId" = 'project_003'
# ...この後に、プロジェクト数に応じた大量のSELECTクエリが続く...

そう、N+1問題 です。ユーザーに関連するプロジェクトをN個取得するたびに、それぞれのプロジェクトに紐づくタスクを個別にデータベースに問い合わせていたんです。

もしユーザーが100個のプロジェクトを持っていたら、単純計算で1(ユーザー)+1(プロジェクト一覧)+100(各プロジェクトのタスク)=102回のデータベースクエリが発行されていることになります。これではAPIが遅くなるのも当然です。

N+1問題の具体的な解決策|includeselectを使いこなす

Prismaでは、このN+1問題をスマートに解決するための機能が提供されています。それが includeselect です。

includeによる関連データの一括取得

include を使うと、リレーション先のデータを親クエリと一緒に一括で取得できます。これにより、個別のクエリ発行を劇的に減らすことができます。

// 悪い例:N+1問題が発生
// まずユーザーを取得
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return null;

// 各プロジェクトのタスクをループ内で取得
const projectsWithTasks = await Promise.all(user.projects.map(async (project) => {
  const tasks = await prisma.task.findMany({ where: { projectId: project.id } }); // ★ここにN+1発生
  return { ...project, tasks };
}));
// 良い例:`include`でN+1問題を解決
// ユーザー、関連するプロジェクト、さらにプロジェクトに紐づくタスクを1回のクエリで取得
const userWithProjectsAndTasks = await this.prisma.user.findUnique({
  where: { id: userId },
  include: {
    projects: { // ユーザーに紐づくプロジェクトを取得
      include: {
        tasks: true // プロジェクトに紐づくタスクも同時に取得
      }
    }
  }
});

この include を使うことで、データベースへのアクセス回数が激減し、APIのレスポンスタイムが劇的に改善されました。私の経験では、2秒以上かかっていたAPIが、なんと0.1秒台にまで短縮されたんです!

これには本当に感動しました。フロントエンドからの視点だと「なんでこんな遅いんだろう」くらいにしか思わなかったんですが、バックエンドの奥深さに触れた瞬間でした。

selectによる取得フィールドの最適化

include は非常に便利ですが、注意点もあります。include: true とすると、リレーション先のモデルの全てのフィールドを取得してしまいます。もし不要なフィールドまで取得していると、ネットワーク転送量やメモリ消費が増え、パフォーマンスに悪影響を与える可能性があります。

ここで役立つのが select です。

// `select`を使って必要なフィールドだけ取得し、データ転送量を最適化
const userWithSelectedData = await this.prisma.user.findUnique({
  where: { id: userId },
  select: {
    id: true,
    name: true,
    projects: {
      select: { // プロジェクトのidとname、そしてタスクのidとtitleだけを取得
        id: true,
        name: true,
        tasks: {
          select: {
            id: true,
            title: true
          }
        }
      }
    }
  }
});
```

select を使うことで、本当に必要なデータだけをデータベースから取得し、ネットワーク転送量やメモリ消費を最小限に抑えることができます。大規模なデータや多数のリレーションを持つ場合、この select の活用は非常に重要になります。

データベーストランザクションでデータ整合性を守る

takuma
takuma

APIのパフォーマンス改善で一息ついた私を、次に襲ったのは「データの整合性」という、より恐ろしい問題でした。

ある日、ユーザーが「商品の購入」と「在庫の更新」を同時に行う機能で、ごく稀に「商品が購入されたのに在庫が減っていない」という不具合が報告されたんです。

これは本当に肝を冷やしました。決済関連のデータ不整合は、ユーザーからの信頼を失うだけでなく、会社の損失に直結するからです。原因を調査すると、複数のデータベース操作が同時に実行された際に、片方だけが成功し、もう片方が失敗するという「中途半端な状態」が発生していました。

トランザクションの必要性:アトミックな操作の保証

この問題の解決策が、データベーストランザクションでした。

トランザクションとは、複数のデータベース操作(例: INSERT, UPDATE, DELETE)を一つの論理的な単位として扱い、その単位内の全ての操作が成功するか、さもなければ全ての操作が失敗(ロールバック)して元の状態に戻ることを保証する仕組みです。

これにより、データが「部分的に更新された」という中途半端な状態になるのを防ぎ、常にデータの整合性を保つことができます。

Prismaの$transactionで堅牢なデータ整合性を実現

Prismaでは、このトランザクションを非常に直感的に扱うことができます。特に便利なのが $transaction メソッドです。

// NestJSのサービス層でのトランザクション実装例

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; // PrismaServiceを注入

@Injectable()
export class ProductService {
  constructor(private prisma: PrismaService) {}

  async purchaseProduct(userId: string, productId: number, quantity: number) {
    // トランザクション内で複数のDB操作をアトミックに実行
    return await this.prisma.$transaction(async (tx) => {
      // 1. 商品の在庫を確認
      const product = await tx.product.findUnique({
        where: { id: productId },
      });

      if (!product || product.stock < quantity) {
        throw new Error('在庫が不足しています、または商品が見つかりません。');
      }

      // 2. 在庫を減らす
      const updatedProduct = await tx.product.update({
        where: { id: productId },
        data: { stock: product.stock - quantity },
      });

      // 3. 購入履歴を作成
      const purchase = await tx.purchase.create({
        data: {
          userId: userId,
          productId: productId,
          quantity: quantity,
          totalPrice: product.price * quantity,
        },
      });

      // ここまでの全ての操作が成功すればコミットされる
      // 途中でエラーが発生すれば全てロールバックされる

      return { updatedProduct, purchase };
    });
  }
}

この this.prisma.$transaction(async (tx) => { ... }) のブロック内で実行される全てのデータベース操作 (tx.product.findUnique, tx.product.update, tx.purchase.create) は、全てが成功するか、さもなければ全てがロールバックされます。これにより、「購入されたのに在庫が減っていない」のようなデータ不整合を根本的に防ぐことができるんです。

Prisma Client Extensionsを活用したトランザクションの共通化(応用)

さらに高度な使い方として、Prisma Client Extensions を使うと、よく使うトランザクション処理を共通化し、再利用可能なメソッドとしてPrismaクライアントに追加できます。

// prisma/client.ts (Prismaクライアント拡張の例)
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient().$extends({
  // トランザクションをラップする共通メソッドを定義
  // 例えば、共通のロギングやエラーハンドリングを仕込める
  client: {
    $tx: async (callback: (tx: any) => Promise): Promise => {
      return await prisma.$transaction(async (tx) => {
        try {
          const result = await callback(tx);
          return result;
        } catch (error) {
          console.error("Transaction failed:", error);
          throw error; // エラーを再スローしてロールバックをトリガー
        }
      });
    },
  },
});

このように拡張しておけば、サービス層では this.prisma.$tx(async (tx) => { ... }) のように、よりシンプルにトランザクションを呼び出すことができるようになります。これは、複雑なバックエンドアプリケーションを開発する上で、コードの保守性と可読性を高める重要なテクニックです。

データベース分離レベルの基礎知識

トランザクションを深く理解するためには、分離レベル(Isolation Level)の基礎知識も重要です。これは、複数のトランザクションが同時に実行される際に、どれくらい互いの変更が見えるようにするか、というデータベースのルールです。

  • Read Committed: 一般的なデフォルト。コミットされたデータだけが見える。
  • Serializable: 最も厳格。データが完全にロックされ、並行性が犠牲になるが、データ不整合は起きない。

Prismaの $transaction は通常、データベースのデフォルト分離レベルを使用しますが、必要に応じてPrisma Client Extensionsなどを介して分離レベルを指定することも可能です。これにより、アプリケーションの要件に応じた最適なデータ整合性を確保できます。

バックエンド開発の実践Tips

takuma
takuma

バックエンドは、ユーザーには直接見えない部分だからこそ、地道な改善と堅牢な設計が求められます。

N+1問題の解決も、トランザクション管理も、最初は「面倒だな」と感じるかもしれません。でも、これがAPIのパフォーマンス向上や、データの信頼性、ひいてはサービス全体の品質に直結するんです。

私自身、これらの学びを通して、単に動くものを作るだけでなく、「なぜ、どう作るべきか」という深い思考力が身についたと実感しています。

N+1問題やトランザクション管理は、バックエンド開発のほんの一部に過ぎませんが、APIのパフォーマンスとデータの整合性という、最もクリティカルな側面に直結します。

  • パフォーマンス計測の習慣化: APIのレスポンスタイムを継続的に計測し、ボトルネックを特定する癖をつけましょう。(NestJSのInterceptorでレスポンスタイムをロギングするなど)
  • 開発初期からのデータモデリングの重要性: Prisma Schemaを慎重に設計することで、後々のパフォーマンス問題やリレーションの複雑化を防げます。
  • エラーハンドリングとロールバックの徹底: トランザクション内でエラーが発生した際に、必ずロールバックされるようにエラーを適切にスローしましょう。
  • ロギングの活用: Prismaのクエリロギングを有効にして、実際に発行されているSQLクエリを確認する習慣をつけることが、N-1問題の発見に繋がります。
// main.ts (Prismaクエリロギングの有効化)
import { NestFactory } from '@nestjs/core';
import { AppModule } => './app.module';
import { PrismaClient } from '@prisma/client';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Prismaのロギングを有効にする
  const prisma = new PrismaClient({
    log: [
      { emit: 'event', level: 'query' },
      { emit: 'event', level: 'info' },
      { emit: 'event', level: 'warn' },
      { emit: 'event', level: 'error' },
    ],
  });

  // クエリログのリスナーを設定
  prisma.$on('query', (e) => {
    console.log('Query: ' + e.query);
    console.log('Params: ' + e.params);
    console.log('Duration: ' + e.duration + 'ms');
  });

  // アプリケーションにPrismaClientをプロバイドする方法は別途設定が必要
  // (例: PrismaServiceを作成してAppModuleにインポート)

  await app.listen(3000);
}
bootstrap();

このロギング設定をしておけば、開発中にターミナルで実際にどんなSQLが発行されているかが見えるので、N+1問題が発生しているかどうかが一目瞭然になります。

まとめ:バックエンドの深みは「見えない部分」にこそ宿る

私が現場で体験したN+1問題との格闘や、データ不整合からトランザクションの重要性を学んだ経験は、フロントエンド中心だった私の視点を大きく変えてくれました。

バックエンドは、ユーザーが直接触れることはない「見えない部分」です。しかし、その「見えない部分」の設計や実装が、APIの応答速度、データの正確性、そしてサービス全体の信頼性を決定づけます。

単にAPIを叩いてデータを表示するだけでなく、「なぜこのAPIは遅いのか?」「どうすればデータの整合性を保証できるのか?」といった問いに向き合うことで、エンジニアとしての幅と深みが増していきます。

これらの学びは、将来的にテックリードやプロダクトマネージャーを目指す上でも、技術的な判断を下すための確固たる基盤となります。パフォーマンスと堅牢性を両立させるバックエンド開発のスキルは、どんな現場でも重宝されるはずです。

ぜひ皆さんも、バックエンドの「見えない部分」の探求に挑戦してみてください。その先に、きっとエンジニアとしての大きな成長と、サービスを支える喜びが待っています。

いいね!もらえると 励みになります。

コメント