PreloadResolverを構築してGraphQLのN+1問題に対応した事例
エクサウィザーズは、一般的な DataLoader パターンとは異なり、オブジェクトにデータを直接プリロードする「PreloadResolver」という独自機構を GraphQL API に導入し、N+1 問題を解決した事例を紹介している。
キーポイント
GraphQL の N+1 問題の発生メカニズム
Book リゾルバーが個別に Author を取得する実装では、クエリ数に応じて DB 照会が爆発的に増加し、パフォーマンス劣化の原因となる。
一般的な DataLoader パターンの限界と代替案
DataLoader はバッチ処理で解決できるが、ハナストではプリロードされたデータをオブジェクト内部に保持する「PreloadResolver」を採用している。
PreloadResolver の実装ロジックと安全性
クエリ実行前にスキーマを解析して必要な関連データを一括取得し、Book オブジェクトに埋め込むことで、後続の取得処理で例外を投げる仕組みを実現している。
ネストした N+1 問題への対応
このアプローチは単一の階層だけでなく、Author#location のように深くネストしたデータ構造に対しても同様に適用可能である。
フィールドごとの preload 許可制御
スキーマ分析に基づき特定のパスでのみ preload を実行し、循環参照や不要な読み込みを禁止する設定(`fields` オプション)を実装可能。
再帰的な preload 処理の実現
PreloadResolver の構造をネストさせることで、Book から Author、さらにその関連データへと階層的にデータを事前に読み込む再帰処理が可能になる。
再帰処理によるネスト対応と記述効率
PreloadResolver を再帰的に実装することで、複雑なネスト構造の N+1 問題を冗長性を抑えつつ効率的に解決できる。
影響分析・編集コメントを表示
影響分析
この記事は、GraphQL の N+1 問題解決において DataLoader が唯一の正解ではないことを示しており、アプリケーションの状態管理や型安全性を重視する開発チームにとって有用なアーキテクチャ選択肢を提供しています。特に、データ取得のタイミングとオブジェクトの状態整合性を厳密に制御したい場合に、このプリロードパターンが有効であることが実例を通じて理解できます。
編集コメント
一般的なライブラリ依存の解決策ではなく、自社のドメインモデルに密着した独自の実装パターンを紹介しており、システム設計の自由度を高める良い事例です。ただし、このアプローチはオブジェクトの状態管理コストが増えるため、適用範囲を慎重に見極める必要があります。
エクサウィザーズでハナスト開発チームのTLをしている原です。
ハナストは「音声入力で介護の記録をするアプリ」で、こちらのページでプロダクトの紹介をしています。
hanasuto.carewiz.ai
以前は、ハナストAPIのテストについてこちらの記事で書きました。
techblog.exawizards.com
今回の記事ではハナストのAPIで実践している、PreloadResolverというGraphQLのN+1問題対応の仕組みを紹介します。
まず、GraphQLのN+1問題がどのように発生するかを簡単に説明します。
type Query { books: [Book!]! } type Book { id: ID! author: Author! } type Author { id: ID! name: String! }
というスキーマがあったとします。
ここで、BookResolverが
class BookResolver { async author(book: Book): Promise<Author> { return findAuthorById(book.authorId) } ... }
のような実装になっていたとすると
books { author { name { name } } }
というクエリでデータを取得した時に、Bookの数だけfindAuthorById
findAllAuthorsByIds(books.map((_) => _.authorId))
のような処理でまとめて取得するべきなのですが、そうではなく取得したBookの数だけfind処理が実行されてしまうのがN+1問題です。
DataLoaderを使う場合
GraphQL のN+1問題の対処にはDataLoaderを使うのが一般的です。
DataLoaderを使うと上記の例だと
const authorLoader = async (books: Book[]): Promise<Author[]> => { const authorIds = books.map(books.map((_) => _.authorId)) const authors = await findAllAuthorsByIds(books) const authorMap = arrayToMap(authors) return books.map((book) => authorMap[book.authorId]) }
というDataLoaderを用意して、こちらをBookResolverに関連付けて、Book#author
実際の組み込み方はDataLoaderのライブラリによりますが、大まかな処理はこのようになります。
ハナストGraphQL APIでのN+1問題対策
DataLoaderでも一通りのN+1問題対策はできるのですが、ハナストではPreload Resolverという仕組みを作って、こちらでN+1問題の対策をしています。
PreloadResolverでのN+1問題対策
PreloadResolverを使った仮想コードはこのようになります。
class BookPreloadResolver { async preload( books: Book[], path: string[], info: GraphQLResolveInfo ): { authors: Author[] } { let authors: Author[] = [] // GraphQLスキーマを分析して、Book#authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { authors = await this.preloadAuthor(books) } return { authors } } private async preloadAuthor(books: Book[]): Promise<Author[]> { const authorIds = books.map(books.map((_) => _.authorId)) authors = await findAllAuthorsByIds(authorIds) const authorMap = arrayToMap(authors) books.forEach((book) => { const author = authorMap[book.authorId] book.preloadAuthor(author) }) } ... } class Book { private preloadedAuthor: Author | undefined = undefined preloadAuthor(author: Author): void { this.preloadedAuthor = author } loadAuthor(): Author { if(this.preloadedAuthor) { return this.preloadedAuthor } else { // preloadされていない場合は例外を投げる throw new Error('author is not preloaded.') } } } class BookResolver { author(book: Book): Author { // preload済みのAuthorオブジェクトを取得する return book.loadAuthor() } } class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await bookPreloadResolver.preload(books, ['books'], info) return books } }
PreloadResolverではDataLoaderとは異なり、BookオブジェクトにAuthorをpreloadしています。
参照側はpreload済みのAuthorオブジェクトを返して、preloadされていない場合は例外を投げるようにしています。
GraphQL field毎のpreload可否の設定
以下で、それぞれについて詳しく説明します。
PreloadResolverによるネストしたN+1問題への対処
type Author { location: Location! } type Location { address: String! }
のようにBook#authors
Author#location
class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() const { authors } = await bookPreloadResolver.preload(books, ['books'], info) await authorPreloadResolver.preload(authors, ['books', 'author'], info) return books } } class AuthorPreloadResolver { // locationのpreload処理を実装 } class AuthorResolver { // preload済みのlocation取得処理を実装 }
こうすることでBook#authorだけでなく、Author#Locationもpreloadされます。
GraphQL field毎のpreload可否の設定
type Query { books: [Book!]! authors: [Author!]! } type Book { author: Author! category: Category! } type Author { books: [Book!] } type Category { id: ID! name: String! }
のようなGraphQLスキーマとなっていて、
books { author { name } }
authors { books { category { name } } }
authors { books { author { name } } }
のような循環参照のpreloadは禁止したいというケースがあります。
このような場合、まずPreloadResolverをこのように実装します。
type BookPreloadFields = { author?: AuthorPreloadFields category?: CategoryPreloadFields } type AuthorPreloadFields = { books?: BookPreloadFields } type CategoryPreloadFields = {} class BookPreloadResolver { async books( path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<{ authors: Author[], categories: Category[] }> { let authors: Author[] = [] let categories: Category[] = [] // GraphQLスキーマを分析して、authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { // authorの取得が禁止されていたら例外を投げる if(options.fields.author == undefined) { throw new Error(Book#author preload is forbidden at ${path.join('.')}) } // 許可されていたらauthorをpreloadする authors = await this.preloadAuthor(books) } // GraphQLスキーマを分析してcategoryの取得が必要か判定する if(findFieldInSchema(path, 'category', info)) { // categoryの取得が禁止されていたら例外を投げる if(options.fields.category == undefined) { throw new Error(Book#category preload is forbidden at ${path.join('.')}) } // 許可されていたらcategoryをpreloadする categories = await this.preloadCategory(books) } return { authors, categories } } }
このQueryResolver側では以下のようにPreloadResolverを使います。
class QueryResolver { ... async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.bookPreloadResolver.preload( books, ['books'], info, // books { author { * } category { * } } を許可する { fields: { author: {}, category: {} } } ) return books } async authors(info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() const { books } = await this.authorPreloadResolver.preload(authors, ['authors'], info) await this.bookPreloadResolver.preload( books, ['authors', 'books'], info, { fields: { // authors { books { author { * } } } は禁止する author: undefined, // authors { books { category { * } } } を許可する category: {} } } ) return authors } }
PreloadResolverを使った再帰的なpreload
PreloadResolverを使って再帰的なpreload処理を行うことも可能です。
例えばBookとAuthorを再帰的にpreloadする処理はこのようになります。
class GraphQLPreloadResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, } { } async preloadBook( books: Book[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // booksが空の場合は再帰処理を終了 if(books.length == 0) { return } const { authors } = await this.bookPreloadResolver.preload(books, path, info, options) // 再帰的にauthorsのpreload処理を行う await this.preloadAuthor( authors, path.concat(['author']), info, { fields: options.fields.author ?? {} } ) } async preloadAuthor( authors: Author[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // authorsが空の場合は再帰処理を終了 if(authors.length == 0) { return } const { books } = await this.authorPreloadResolver.preload(authors, path, info, options) // 再帰的にbooksのpreload処理を行う await this.preloadBook( books, path.concat(['author']), info, { fields: options.fields.books ?? {} } ) } }
このGraphQLPreloadResolverを使って
book(id: 1) { author { books { name } } }
books { author { name } }
authors { books { name } }
というpreloadをしようとする場合は、QueryResolverをこのように書きます。
class QueryResolver { constructor( private readonly graphQLPreloadResolver: GraphQLPreloadResolver ) {} async book(args: { id: string }, info: GraphQLResolveInfo): Promise<Book | null> { const book = await findBookById(args.id) if(!book) { return null } await this.graphQLPreloadResolver.preloadBook( [book], ['book'], info, // books { author { books { * } } }を許可する { fields: { author: { books: {} } } } ) return book } async books(_args: {}, info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.graphQLPreloadResolver.preloadBook( books, ['books'], info, // books { author { * } }を許可する { fields: { author: {} } } ) return books } async authors(_args: {}, info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() await this.graphQLPreloadResolver.preloadAuthor( authors, ['authors'], info, // authors { books { *
原文を表示
エクサウィザーズでハナスト開発チームのTLをしている原です。
ハナストは「音声入力で介護の記録をするアプリ」で、こちらのページでプロダクトの紹介をしています。
hanasuto.carewiz.ai
以前は、ハナストAPIのテストについてこちらの記事で書きました。
techblog.exawizards.com
今回の記事ではハナストのAPIで実践している、PrelaodResolverというGraphQLのN+1問題対応の仕組みを紹介します。
まず、GraphQLのN+1問題がどのように発生するかを簡単に説明します。
type Query { books: [Book!]! } type Book { id: ID! author: Author! } type Author { id: ID! name: String! }
というスキーマがあったとします。
ここで、BookResolverが
class BookResolver { async author(book: Book): Promise<Author> { return findAuthorById(book.authorId) } ... }
のような実装になっていたとすると
books { author { name { name } } }
というクエリでデータを取得した時に、Bookの数だけfindAuthorById
findAllAuthorsByIds(books.map((_) => _.authorId))
のような処理でまとめて取得するべきなのですが、そうではなく取得したBookの数だけfind処理が実行されてしまうのがN+1問題です。
DataLoaderを使う場合
GraphQL のN+1問題の対処にはDataLoaderを使うのが一般的です。
DataLoaderを使うと上記の例だと
const authorLoader = async (books: Book[]): Promise<Author[]> => { const authorIds = books.map(books.map((_) => _.authorId)) const authors = await findAllAuthorsByIds(books) const authorMap = arrayToMap(authors) return books.map((book) => authorMap[book.authorId]) }
というDataLoaderを用意して、こちらをBookResolverに関連付けて、Book#author
実際の組み込み方はDataLoaderのライブラリによりますが、大まかな処理はこのようになります。
ハナストGraphQL APIでのN+1問題対策
DataLoaderでも一通りのN+1問題対策はできるのですが、ハナストではPreload Resolverという仕組みを作って、こちらでN+1問題の対策をしています。
PreloadResolverでのN+1問題対策
PreloadResolverを使った仮想コードはこのようになります。
class BookPreloadResolver { async preload( books: Book[], path: string[], info: GraphQLResolveInfo ): { authors: Author[] } { let authors: Author[] = [] // GraphQLスキーマを分析して、Book#authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { authors = await this.preloadAuthor(books) } return { authors } } private async preloadAuthor(books: Book[]): Promise<Author[]> { const authorIds = books.map(books.map((_) => _.authorId)) authors = await findAllAuthorsByIds(authorIds) const authorMap = arrayToMap(authors) books.forEach((book) => { const author = authorMap[book.authorId] book.preloadAuthor(author) }) } ... } class Book { private preloadedAuthor: Author | undefined = undefined preloadAuthor(author: Author): void { this.preloadedAuthor = author } loadAuthor(): Author { if(this.preloadedAuthor) { return this.preloadedAuthor } else { // preloadされていない場合は例外を投げる throw new Error('author is not preloaded.') } } } class BookResolver { author(book: Book): Author { // preload済みのAuthorオブジェクトを取得する return book.loadAuthor() } } class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await bookPreloadResolver.preload(books, ['books'], info) return books } }
PreloadResolverではDataLoaderとは異なり、BookオブジェクトにAuthorをpreloadしています。
参照側はpreload済みのAuthorオブジェクトを返して、preloadされていない場合は例外を投げるようにしています。
GraphQL field毎のpreload可否の設定
以下で、それぞれについて詳しく説明します。
PreloadResolverによるネストしたN+1問題への対処
type Author { location: Location! } type Location { address: String! }
のようにBook#authors
Author#location
class QueryResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, ) { } async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() const { authors } = await bookPreloadResolver.preload(books, ['books'], info) await authorPreloadResolver.preload(authors, ['books', 'author'], info) return books } } class AuthorPreloadResolver { // locationのpreload処理を実装 } class AuthorResolver { // preload済みのlocation取得処理を実装 }
こうすることでBook#authorだけでなく、Author#Locationもpreloadされます。
GraphQL field毎のpreload可否の設定
type Query { books: [Book!]! authors: [Author!]! } type Book { author: Author! category: Category! } type Author { books: [Book!] } type Category { id: ID! name: String! }
のようなGraphQLスキーマとなっていて、
books { author { name } }
authors { books { category { name } } }
authors { books { author { name } } }
のような循環参照のpreloadは禁止したいというケースがあります。
このような場合、まずPreloadResolverをこのように実装します。
type BookPreloadFields = { author?: AuthorPreloadFields category?: CategoryPreloadFields } type AuthorPreloadFields = { books?: BookPreloadFields } type CategoryPreloadFields = {} class BookPreloadResolver { async books( path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<{ authors: Author[], categories: Category[] }> { let authors: Author[] = [] let categories: Category[] = [] // GraphQLスキーマを分析して、authorの取得が必要か判定する if(findFieldInSchema(path, 'author', info)) { // authorの取得が禁止されていたら例外を投げる if(options.fields.author == undefined) { throw new Error(Book#author preload is forbidden at ${path.join('.')}) } // 許可されていたらauthorをpreloadする authors = await this.preloadAuthor(books) } // GraphQLスキーマを分析してcategoryの取得が必要か判定する if(findFieldInSchema(path, 'category', info)) { // categoryの取得が禁止されていたら例外を投げる if(options.fields.category == undefined) { throw new Error(Book#category preload is forbidden at ${path.join('.')}) } // 許可されていたらcategoryをpreloadする categories = await this.preloadCategory(books) } return { authors, categories } } }
このQueryResolver側では以下のようにPreloadResolverを使います。
class QueryResolver { ... async books(info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.bookPreloadResolver.preload( books, ['books'], info, // books { author { * } category { * } } を許可する { fields: { author: {}, category: {} } } ) return books } async authors(info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() const { books } = await this.authorPreloadResolver.preload(authors, ['authors'], info) await this.bookPreloadResolver.preload( books, ['authors', 'books'], info, { fields: { // authors { books { author { * } } } は禁止する author: undefined, // authors { books { category { * } } } を許可する category: {} } } ) return authors } }
PreloadResolverを使った再帰的なpreload
PreloadResolverを使って再帰的なpreload処理を行うことも可能です。
例えばBookとAuthorを再帰的にpreloadする処理はこのようになります。
class GraphQLPreloadResolver { constructor( private readonly bookPreloadResolver: BookPreloadResolver, private readonly authorPreloadResolver: AuthorPreloadResolver, } { } async preloadBook( books: Book[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // booksが空の場合は再帰処理を終了 if(books.length == 0) { return } const { authors } = await this.bookPreloadResolver.preload(books, path, info, options) // 再帰的にauthorsのpreload処理を行う await this.preloadAuthor( authors, path.concat(['author']), info, { fields: options.fields.author ?? {} } ) } async preloadAuthor( authors: Author[], path: string[], info: GraphQLResolveInfo, options: { fields: BookPreloadFields } ): Promise<void> { // authorsが空の場合は再帰処理を終了 if(authors.length == 0) { return } const { books } = await this.authorPreloadResolver.preload(authors, path, info, options) // 再帰的にbooksのpreload処理を行う await this.preloadBook( books, path.concat(['author']), info, { fields: options.fields.books ?? {} } ) } }
このGraphQLPreloadResolverを使って
book(id: 1) { author { books { name } } }
books { author { name } }
authors { books { name } }
というpreloadをしようとする場合は、QueryResolverをこのように書きます。
class QueryResolver { constructor( private readonly graphQLPreloadResolver: GraphQLPreloadResolver ) {} async book(args: { id: string }, info: GraphQLResolveInfo): Promise<Book | null> { const book = await findBookById(args.id) if(!book) { return null } await this.graphQLPreloadResolver.preloadBook( [book], ['book'], info, // books { author { books { * } } }を許可する { fields: { author: { books: {} } } } ) return book } async books(_args: {}, info: GraphQLResolveInfo): Promise<Book[]> { const books = await findAllBooks() await this.graphQLPreloadResolver.preloadBook( books, ['books'], info, // books { author { * } }を許可する { fields: { author: {} } } ) return books } async authors(_args: {}, info: GraphQLResolveInfo): Promise<Author[]> { const authors = await findAllAuthors() await this.graphQLPreloadResolver.preloadAuthor( authors, ['authors'], info, // authors { books { * } }を許可する { fields: { books: {} } } ) return authors } }
再帰処理で実装することで、冗長な処理が少なく記述できているかと思います。
PreloadResolverによるN+1問題対応のメリット&デメリット
個人的にはPreloadResolverには以下のようなメリットがあると思っています。
ネストしたN+1問題に再帰処理で対応できる
field毎のpreloadの許可・禁止を設定しやすい
一方で、DataLoaderで困らない用途であればDataLoaderを使った方が記述量は少なく済むかと思うので、その辺りは用途に応じて使い分けるのが良いかと思います。
今回はハナストのGraphQL APIでのN+1問題への対応としてPreloadResolverというアプローチをご紹介しました。
ハナストチームではGraphQL技術を活用して介護領域での音声AIサービスの開発を行なっており、一緒に働いていただける方を積極的に募集しています。
GraphQL技術を使った社会課題の解決などに少しでも興味がありましたら、ハナストチームおよびエクサウィザーズに是非ご応募ください。
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み