Prisma findUniqueの正しい理解 - バッチング最適化とGraphQLでの注意点
はじめに
Prisma を使ったアプリケーション開発において、データベースからの単一レコード取得にはfindFirst
とfindUnique
という二つの選択肢があります。一見似ているこれらのメソッドですが、実は内部的な動作に大きな違いがあり、特にバッチング最適化において重要な差があります。
この記事では、findUnique
がどのようにクエリを最適化し、パフォーマンスを向上させるのか、そして GraphQL で大量データを扱う際に注意すべき点について、具体的なコード例を交えて解説します。
findFirst
と findUnique
の基本的な違い
findFirst
const user = await prisma.user.findFirst({
where: { email: "example@test.com" },
});
findUnique
const user = await prisma.user.findUnique({
where: { email: "example@test.com" },
});
一見同じように見えますが、findUnique
はユニーク制約(主キーやユニークキー)がある場合にのみ使用でき、重要な違いがあります。
findUnique
の効用
バッチング最適化が適用されるケース
// ✅ Promise.allで並列実行する場合
const userIds = [1, 2, 3, 4, 5];
const users = await Promise.all(
userIds.map((id) =>
prisma.user.findUnique({
where: { id },
})
)
);
生成される SQL(IN 句を使った最適化):
-- Prismaが自動的に1つのクエリに変換
SELECT * FROM "User" WHERE "id" IN (1, 2, 3, 4, 5);
重要な前提:for 文での動作
注意:for 文内での findUnique
の恩恵は受けられないので注意
// ❌ バッチング最適化が適用されないケース
const userIds = [1, 2, 3, 4, 5];
for (const id of userIds) {
const user = await prisma.user.findUnique({
where: { id },
});
}
生成される SQL(各クエリが個別に実行される):
SELECT * FROM "User" WHERE "id" = 1;
SELECT * FROM "User" WHERE "id" = 2;
SELECT * FROM "User" WHERE "id" = 3;
SELECT * FROM "User" WHERE "id" = 4;
SELECT * FROM "User" WHERE "id" = 5;
このように基本的には findUnique
を使用するのをおすすめします。
もちろん GraphQL の ResolveField でも効果は発揮されます。
findFirst()
では N+1 問題が発生します。
@Resolver(Post)
export class PostResolver {
@Query(() => [Post])
async posts(): Promise<Post[]> {
return this.prisma.post.findMany();
}
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User | null> {
// ❌ N+1問題が発生する
return await this.prisma.user.findFirst({
where: { id: post.authorId },
});
}
}
それが findUnique
を使用すると遅延評価されるので N+1 問題は発生せずにまとめてフェッチしてくれます。
@Resolver(Post)
export class PostResolver {
@Query(() => [Post])
async posts(): Promise<Post[]> {
return this.prisma.post.findMany();
}
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User | null> {
// ✅ バッチング最適化が適用される
return await this.prisma.user.findUnique({
where: { id: post.authorId },
});
}
}
ただ、実際にプロダクトに組み込んでユーザー数が増えてきた時には注意点もあります。
次の章で解説します。
GraphQL での大量データフェッチ時の注意点
問題が発生するケース
大量のデータを取得する際、メモリ制約やタイムアウトにより関連データが正しく取得できない場合があります:
// Post.resolver.ts
@Resolver(Post)
export class PostResolver {
@Query(() => [Post])
async posts(): Promise<Post[]> {
// 大量の投稿を取得
return this.prisma.post.findMany({
take: 1000, // 1000件の投稿
});
}
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User | null> {
return await this.prisma.user.findUnique({
where: { id: post.authorId },
});
}
}
この場合に author
は nullable ではないので、大量のデータ処理時にメモリ制約やタイムアウトが発生すると、GraphQL エラーになってしまいます。
ここで焦って応急処置的に findFirst()
にすると、かえってクエリが膨れ上がり DB を圧迫してしまいます。
こういう大量データを扱う時に使用するのが DataLoader
です。
DataLoader
を使った最適化
DataLoader
を使うことで、手動でバッチング処理を実装し、確実に最適化を行えます:
import DataLoader from "dataloader";
@Resolver(Post)
export class PostResolver {
private authorsLoader = new DataLoader<number, User | null>(
async (authorIds) => {
// IN句を使った効率的な取得
const users = await this.prisma.user.findMany({
where: { id: { in: authorIds as number[] } },
});
// ユーザーIDごとにマップ化
const usersMap = new Map<number, User>();
users.forEach((user) => {
usersMap.set(user.id, user);
});
// 各IDに対応するユーザーを返す(見つからない場合はnull)
return authorIds.map((id) => usersMap.get(id) || null);
}
);
@Query(() => [Post])
async posts(): Promise<Post[]> {
return this.prisma.post.findMany({
take: 1000,
});
}
@ResolveField(() => User, { nullable: true })
async author(@Parent() post: Post): Promise<User | null> {
// DataLoaderがバッチングを自動的に処理
return this.authorsLoader.load(post.authorId);
}
}
この実装により、以下のような最適化が行われます:
- バッチング: 複数のリクエストを一度にまとめて処理
- キャッシング: 同一リクエスト内での重複データフェッチを防止
-
確実性:
findUnique
のバッチング最適化に依存せず、確実に最適化を実現
まとめ
findUnique
は以下の条件が揃った時にバッチング最適化の恩恵を受けられます:
- ユニーク制約があるフィールドでの検索
- Promise.all などによる並列実行
- 適度なデータ量でのメモリ制約内での実行
しかし、大量データを扱う GraphQL アプリケーションでは、DataLoader
を使った明示的なバッチング処理の方が安全で確実です。findUnique
の自動最適化は便利ですが、プロダクション環境では予期しない動作を避けるため、重要な部分では DataLoader
の使用を検討することをお勧めします。
Discussion