Next.js App Router で DataLoader を使って N+1 問題を解決する


Next.js の Route Hanlders を使用したデータフェッチについて、前回こんな記事を書きました。今回は、DataLoader を使用して N+1 問題の解決方法にフォーカスした記事を書いてみたいと思います。
前回の記事に興味がある方はぜひ以下のリンクから読んでみてください!

API エンドポイントの設計

まず、小さく明確な責務を持つ API エンドポイントを設計します:

// app/api/posts/route.ts - 投稿一覧の取得
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET() {
  const posts = await prisma.post.findMany({
    select: {
      id: true,
      title: true,
      content: true,
      createdAt: true,
      authorId: true
    }
  });
  
  return NextResponse.json(posts);
}

// app/api/posts/[id]/route.ts - 特定の投稿の取得
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const post = await prisma.post.findUnique({
    where: { id: params.id },
    select: {
      id: true,
      title: true,
      content: true,
      createdAt: true,
      authorId: true
    }
  });
  
  if (!post) {
    return new NextResponse('Post not found', { status: 404 });
  }
  
  return NextResponse.json(post);
}

// app/api/posts/[id]/comments/route.ts - 特定の投稿のコメント取得
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const comments = await prisma.comment.findMany({
    where: { postId: params.id },
    select: {
      id: true,
      content: true,
      createdAt: true,
      authorId: true
    }
  });
  
  return NextResponse.json(comments);
}

Leaf Fetch と DataLoader の組み合わせ

Next.js のコンポーネントで、必要なデータのみを取得します。

// app/posts/page.tsx
import { PostList } from '@/components/PostList';

export default async function PostsPage() {
  // 投稿一覧のみを取得
  const posts = await fetch('/api/posts', {
    next: { revalidate: 60 }
  }).then(res => res.json());
  
  return <PostList posts={posts} />;
}

// components/PostList.tsx
import { PostItem } from './PostItem';

export function PostList({ posts }) {
  return (
    <div className="space-y-4">
      {posts.map(post => (
        <PostItem key={post.id} post={post} />
      ))}
    </div>
  );
}

// components/PostItem.tsx
import { Comments } from './Comments';

export function PostItem({ post }) {
  return (
    <article className="p-4 border rounded">
      <h2>{post.title}</h2>
      <p>{post.content}</p>
      <Comments postId={post.id} />
    </article>
  );
}

// components/Comments.tsx
import { createCommentsLoader } from '@/lib/loaders';

export async function Comments({ postId }) {
  const loader = createCommentsLoader();
  const comments = await loader.load(postId);
  
  return (
    <div className="mt-4">
      {comments.map(comment => (
        <div key={comment.id} className="p-2 border-t">
          {comment.content}
        </div>
      ))}
    </div>
  );
}

DataLoader の実装

各エンドポイントに対応する DataLoader を実装します。

// lib/loaders.ts
import DataLoader from 'dataloader';

export function createCommentsLoader() {
  return new DataLoader(async (postIds: string[]) => {
    // 各投稿のコメントを個別に取得
    const commentsPromises = postIds.map(postId =>
      fetch(`/api/posts/${postId}/comments`).then(res => res.json())
    );
    
    // すべてのコメントを並列で取得
    return Promise.all(commentsPromises);
  });
}

エラーハンドリング

各エンドポイントで適切なエラーハンドリングを実装します。

// app/api/posts/[id]/comments/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const comments = await prisma.comment.findMany({
      where: { postId: params.id },
      select: {
        id: true,
        content: true,
        createdAt: true,
        authorId: true
      }
    });
    
    return NextResponse.json(comments);
  } catch (error) {
    console.error(`Error fetching comments for post ${params.id}:`, error);
    return new NextResponse('Internal Server Error', { status: 500 });
  }
}

パフォーマンスの最適化

1. 適切なキャッシュ戦略

// app/posts/page.tsx
const posts = await fetch('/api/posts', {
  next: {
    revalidate: 60,
    tags: ['posts']
  }
}).then(res => res.json());

2. 必要なフィールドのみの取得

// app/api/posts/route.ts
const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    content: true,
    createdAt: true
  },
  orderBy: {
    createdAt: 'desc'
  },
  take: 20
});

このアプローチの利点

  1. 関心の分離

    • 各エンドポイントが単一の責務を持つ

    • データの過剰取得を防ぐ

  2. パフォーマンス

    • 必要なデータのみを取得

    • DataLoader によるバッチ処理

  3. スケーラビリティ

    • 機能追加が容易

    • キャッシュ戦略の柔軟な実装

注意点

  1. エンドポイント設計

    • 単一責務の原則に従う

    • 過度な細分化を避ける

  2. データ取得

    • 必要最小限のデータのみを取得

    • N+1 問題への注意

  3. エラーハンドリング

    • 適切なエラー応答

    • クライアントサイドでの対応

いいなと思ったら応援しよう!