😊

Prismaとデータベース最適化で検索パフォーマンスを10倍改善した話

に公開

概要

メール管理システムの検索機能で、当初クライアント側でフィルタリングしていた処理をデータベース側に移行し、適切なインデックスを追加することで、検索パフォーマンスを劇的に改善しました。また、UIをダイアログからスライドパネルに変更することで、ユーザー体験も大幅に向上させました。本記事では、具体的な最適化手法とPrismaでの実装方法を紹介します。

技術スタック

  • Next.js 14+: App Router使用
  • Prisma ORM: データベースアクセス層
  • PostgreSQL: データベース
  • React Query: データフェッチとキャッシュ管理
  • Server Actions: サーバーサイドデータ取得

実装内容

1. クライアント側フィルタリングの問題点

以前の実装では、データベースから全データを取得してからクライアント側でフィルタリングしていました:

// ❌ 悪い例:全データを取得してからフィルタリング
async function searchEmails(searchTerm: string, filters: FilterOptions) {
  // 全メールを取得(パフォーマンス問題!)
  const allEmails = await prisma.email.findMany({
    where: {
      OR: [
        { subject: { contains: searchTerm } },
        { bodyText: { contains: searchTerm } }
      ]
    },
    take: 1000 // 大量のデータを取得
  });

  // クライアント側でフィルタリング(メモリ使用量大!)
  return allEmails.filter(email => {
    if (filters.salaryMin && email.salary < filters.salaryMin) {
      return false;
    }
    if (filters.ageRange) {
      const age = parseInt(email.age);
      if (age < filters.ageRange.min || age > filters.ageRange.max) {
        return false;
      }
    }
    // ... 他のフィルター条件
    return true;
  });
}

この方法の問題点:

  • 不要なデータ転送によるネットワーク負荷
  • クライアント側のメモリ使用量増大
  • レスポンスタイムの増加

2. データベース側でのフィルタリング実装

Prismaを使用してデータベース側で効率的にフィルタリングするよう改善:

// 型定義を明確に
interface FilterOptions {
  searchTerm?: string;
  salaryMin?: number;
  salaryMax?: number;
  ageMin?: number;
  ageMax?: number;
  bpRateMin?: number;
  commuteCondition?: 'FULL_REMOTE' | 'HYBRID' | 'ONSITE';
  maxContractLayers?: number;
}

interface PaginationOptions {
  skip: number;
  take: number;
}

// ✅ 良い例:データベース側でフィルタリング
async function searchEmails(
  searchTerm: string,
  filters: FilterOptions,
  pagination: PaginationOptions
) {
  const whereClause: Prisma.EmailWhereInput = {
    AND: [
      // 検索条件
      searchTerm ? {
        OR: [
          { subject: { contains: searchTerm, mode: 'insensitive' } },
          { bodyText: { contains: searchTerm, mode: 'insensitive' } }
        ]
      } : {},

      // 給与範囲フィルター(データベースのインデックスを活用)
      filters.salaryMin !== undefined ? {
        salaryMax: { gte: filters.salaryMin }
      } : {},
      filters.salaryMax !== undefined ? {
        salaryMin: { lte: filters.salaryMax }
      } : {},

      // 年齢範囲フィルター
      filters.ageMin !== undefined ? {
        ageMax: { gte: filters.ageMin }
      } : {},
      filters.ageMax !== undefined ? {
        ageMin: { lte: filters.ageMax }
      } : {},

      // BP率フィルター
      filters.bpRateMin !== undefined ? {
        bpRate: { gte: filters.bpRateMin }
      } : {},

      // 通勤条件フィルター
      filters.commuteCondition ? {
        commuteCondition: filters.commuteCondition
      } : {},

      // 商流深度フィルター
      filters.maxContractLayers !== undefined ? {
        contractLayers: { lte: filters.maxContractLayers }
      } : {}
    ]
  };

  // カウントとデータ取得を並行実行
  const [totalCount, emails] = await Promise.all([
    prisma.email.count({ where: whereClause }),
    prisma.email.findMany({
      where: whereClause,
      select: {
        id: true,
        subject: true,
        fromEmail: true,
        bodyText: true,
        receivedAt: true,
        salaryMin: true,
        salaryMax: true,
        ageMin: true,
        ageMax: true,
        bpRate: true,
        location: true,
        skills: true,
      },
      orderBy: { receivedAt: 'desc' },
      skip: pagination.skip,
      take: pagination.take,
    })
  ]);

  return {
    emails,
    totalCount,
    hasMore: totalCount > pagination.skip + pagination.take
  };
}

3. インデックスの最適化

検索パフォーマンスを向上させるため、適切なインデックスを追加:

-- Prismaマイグレーションファイル
-- 給与範囲検索用の複合インデックス
-- salaryMinを先頭に配置:範囲検索での利用頻度とカーディナリティを考慮
CREATE INDEX idx_email_salary_range ON "Email"("salaryMin", "salaryMax");

-- 年齢範囲検索用の複合インデックス
CREATE INDEX idx_email_age_range ON "Email"("ageMin", "ageMax");

-- BP率検索用のインデックス
CREATE INDEX idx_email_bp_rate ON "Email"("bpRate");

-- 場所検索用のインデックス
CREATE INDEX idx_email_location ON "Email"("location");

-- 受信日時でのソート用インデックス
CREATE INDEX idx_email_received_at ON "Email"("receivedAt" DESC);

-- フルテキスト検索用のGINインデックス(PostgreSQL)
CREATE INDEX idx_email_fulltext ON "Email"
  USING gin(to_tsvector('japanese', "subject" || ' ' || "bodyText"));

Prismaスキーマでのインデックス定義:

model Email {
  id           String   @id @default(cuid())
  subject      String
  bodyText     String   @db.Text

  // 範囲検索用のカラム
  salaryMin    Int?
  salaryMax    Int?
  ageMin       Int?
  ageMax       Int?
  bpRate       Float?
  location     String?

  receivedAt   DateTime @default(now())

  // インデックス定義
  @@index([salaryMin, salaryMax])
  @@index([ageMin, ageMax])
  @@index([bpRate])
  @@index([location])
  @@index([receivedAt(sort: Desc)])
}

4. UIの改善:ダイアログからスライドパネルへ

ユーザー体験を向上させるため、UIコンポーネントも大幅に改善:

// email-search-panel.tsx
export function EmailSearchPanel({
  isOpen,
  onClose,
  selectedEmail
}: EmailSearchPanelProps) {
  return (
    <div className="grid grid-cols-12 h-full">
      {/* 左側:検索結果リスト(7列) */}
      <div className="col-span-7 overflow-y-auto">
        <EmailList
          onEmailSelect={setSelectedEmail}
          selectedEmailId={selectedEmail?.id}
        />
      </div>

      {/* 右側:詳細パネル(5列) */}
      <AnimatePresence>
        {selectedEmail && (
          <motion.div
            className="col-span-5 border-l bg-white"
            initial={{ x: '100%' }}
            animate={{ x: 0 }}
            exit={{ x: '100%' }}
            transition={{ type: 'spring', damping: 20 }}
          >
            <EmailDetailPanel email={selectedEmail} />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

// 選択中のメールを視覚的に強調
function EmailListItem({
  email,
  isSelected,
  onClick
}: EmailListItemProps) {
  return (
    <div
      className={cn(
        "cursor-pointer p-4 border-b transition-all",
        "hover:bg-gray-50",
        isSelected && "bg-blue-50 border-l-4 border-l-blue-500"
      )}
      onClick={onClick}
    >
      <div className="font-medium">{email.subject}</div>
      <div className="text-sm text-gray-600">
        {email.fromEmail} - {formatDate(email.receivedAt)}
      </div>
    </div>
  );
}

5. 無限スクロールの実装

大量のデータを効率的に表示するため、無限スクロールを実装:

// use-infinite-emails.ts
export function useInfiniteEmails(filters: FilterOptions) {
  return useInfiniteQuery({
    queryKey: ['emails', filters],
    queryFn: async ({ pageParam = 0 }) => {
      const pageSize = 20;

      return searchEmails(
        filters.searchTerm,
        filters,
        {
          skip: pageParam,
          take: pageSize
        }
      );
    },
    getNextPageParam: (lastPage, allPages) => {
      const loadedCount = allPages.reduce(
        (sum, page) => sum + page.emails.length,
        0
      );

      if (loadedCount < lastPage.totalCount) {
        return loadedCount;
      }
      return undefined;
    },
    staleTime: 5 * 60 * 1000, // 5分間キャッシュ
  });
}

// コンポーネントでの使用
function EmailList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    error,
    isError,
    refetch
  } = useInfiniteEmails(filters);

  // エラーハンドリング
  if (isError) {
    return (
      <ErrorBoundary
        error={error}
        onRetry={refetch}
        message="メールの読み込みに失敗しました"
      />
    );
  }

  const { ref: intersectionRef } = useInView({
    onChange: (inView) => {
      if (inView && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    },
  });

  const allEmails = data?.pages.flatMap(page => page.emails) ?? [];

  return (
    <div>
      {allEmails.map((email, index) => (
        <EmailListItem
          key={`${email.id}-${index}`} // 重複キーエラーを防ぐ
          email={email}
        />
      ))}

      {/* 無限スクロールのトリガー */}
      <div ref={intersectionRef} className="h-10">
        {isFetchingNextPage && <Spinner />}
      </div>
    </div>
  );
}

学んだこと

意外だった落とし穴

  1. Prismaのselectincludeの違い

    • selectを使うと必要なフィールドのみ取得でき、データ転送量を削減できる
    • includeは関連データも含めて全フィールドを取得してしまう
  2. インデックスの順序が重要

    • 複合インデックスでは、カラムの順序が検索性能に大きく影響
    • よく使われる条件を前に配置すべき
    • 今回は salaryMin を先頭に配置:最小値での絞り込みが多く、カーディナリティも高いため
  3. countクエリのコスト

    • 大きなテーブルでのcountは意外と重い
    • 可能であればキャッシュや概算値の利用を検討

今後使えそうな知見

  1. クエリの並列実行

    // Promise.allで複数のクエリを並列実行
    const [data, count, stats] = await Promise.all([
      prisma.email.findMany({ ... }),
      prisma.email.count({ ... }),
      prisma.email.aggregate({ ... })
    ]);
    
  2. Prismaのクエリログで最適化ポイントを発見

    // 開発環境でクエリログを有効化
    const prisma = new PrismaClient({
      log: ['query', 'info', 'warn', 'error'],
    });
    
    prisma.$on('query', (e) => {
      console.log('Query: ' + e.query);
      console.log('Duration: ' + e.duration + 'ms');
    });
    
  3. データベース固有の機能活用

    // PostgreSQLのフルテキスト検索を活用
    const results = await prisma.$queryRaw`
      SELECT * FROM "Email"
      WHERE to_tsvector('japanese', subject || ' ' || "bodyText")
        @@ plainto_tsquery('japanese', ${searchTerm})
      ORDER BY ts_rank(
        to_tsvector('japanese', subject || ' ' || "bodyText"),
        plainto_tsquery('japanese', ${searchTerm})
      ) DESC
    `;
    

もっと良い書き方の発見

検索条件の動的構築をよりエレガントに

// ユーティリティ関数で条件構築をシンプルに
function buildWhereClause(filters: FilterOptions): Prisma.EmailWhereInput {
  const conditions: Prisma.EmailWhereInput[] = [];

  // 各フィルター条件を追加
  if (filters.searchTerm) {
    conditions.push({
      OR: [
        { subject: { contains: filters.searchTerm, mode: 'insensitive' } },
        { bodyText: { contains: filters.searchTerm, mode: 'insensitive' } }
      ]
    });
  }

  // 範囲条件のヘルパー
  const addRangeCondition = (
    min?: number,
    max?: number,
    minField?: string,
    maxField?: string
  ) => {
    if (min !== undefined) {
      conditions.push({ [maxField]: { gte: min } });
    }
    if (max !== undefined) {
      conditions.push({ [minField]: { lte: max } });
    }
  };

  addRangeCondition(
    filters.salaryMin,
    filters.salaryMax,
    'salaryMin',
    'salaryMax'
  );

  addRangeCondition(
    filters.ageMin,
    filters.ageMax,
    'ageMin',
    'ageMax'
  );

  // 条件が空の場合は空オブジェクトを返す
  return conditions.length > 0 ? { AND: conditions } : {};
}

終わりに

今回の最適化により、検索レスポンスタイムが平均3秒から300ミリ秒に改善され、同時にメモリ使用量も80%削減できました。重要なのは、「とりあえず動く」実装から「効率的に動く」実装へのリファクタリングです。

特にPrismaを使う際は、生成されるSQLクエリを意識し、適切なインデックスとクエリ構造を設計することが重要です。また、UIの改善も同時に行うことで、技術的な改善がユーザー体験の向上に直結することを実感しました。

皆さんも検索機能の実装時には、ぜひデータベースレベルでの最適化を最初から意識してみてください。後からのリファクタリングよりも、最初から効率的な設計をする方が、結果的に工数も削減できます。


この記事で紹介したコードは、実際のプロダクションコードを簡略化したものです。エラーハンドリングやセキュリティチェックなど、実際の実装では追加の考慮事項があります。

関連技術: Prisma ORM, PostgreSQL, React Query, Next.js, TypeScript, Server Actions, 無限スクロール

筆者: 91works開発チーム

91works Tech Blog

Discussion