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>
);
}
学んだこと
意外だった落とし穴
-
Prismaの
select
とinclude
の違い-
select
を使うと必要なフィールドのみ取得でき、データ転送量を削減できる -
include
は関連データも含めて全フィールドを取得してしまう
-
-
インデックスの順序が重要
- 複合インデックスでは、カラムの順序が検索性能に大きく影響
- よく使われる条件を前に配置すべき
- 今回は
salaryMin
を先頭に配置:最小値での絞り込みが多く、カーディナリティも高いため
-
count
クエリのコスト- 大きなテーブルでの
count
は意外と重い - 可能であればキャッシュや概算値の利用を検討
- 大きなテーブルでの
今後使えそうな知見
-
クエリの並列実行
// Promise.allで複数のクエリを並列実行 const [data, count, stats] = await Promise.all([ prisma.email.findMany({ ... }), prisma.email.count({ ... }), prisma.email.aggregate({ ... }) ]);
-
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'); });
-
データベース固有の機能活用
// 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開発チーム
Discussion