フロントエンドからのN+1を緩和するために遅延ロードを考えてみる
こんにちは。サービスGの金谷です。
昨今のWebアプリケーションではフロントエンドからバックエンドAPIにアクセスすることが多いと思います。
この時に発生するフロントエンド側でのN+1問題について考えてみます。
サンプル
ソースコードはこちらに置いています。
Next.jsを使用しているのでAPIはpages/api
ディレクトに配置しています。(API側も簡単に用意できて良いですね。)
動かしたい場合はcloneしてyarn install & yarn dev
で動かせるはずです。
以下のようなケースを考えます。
APIの挙動
- /api/usersにGETリクエストを送信するとユーザーの一覧を取得する。
- ユーザーはid, name, companyIdを持つ。
- /api/companies/[id]にGETリクエストを送信するとidに対応した会社を取得する。
- 会社はid, nameを持つ。
import { NextApiRequest, NextApiResponse } from 'next' type User = { id: number name: string companyId: number } const testUsers: User[] = [ { id: 1, name: '山田太郎', companyId: 1, }, { id: 2, name: '田中二郎', companyId: 3, }, { id: 3, name: '佐藤三郎', companyId: 2, }, { id: 4, name: '鈴木四郎', companyId: 3, }, { id: 5, name: '山本吾郎', companyId: 3, },{ id: 6, name: '山田太郎', companyId: 2, }, { id: 7, name: '田中二郎', companyId: 1, }, { id: 8, name: '佐藤三郎', companyId: 2, }, { id: 9, name: '鈴木四郎', companyId: 3, }, { id: 10, name: '山本吾郎', companyId: 1, }, ] function sleep(sec: number) { return new Promise((resolve) => setTimeout(resolve, sec * 1000)) } const handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => { await sleep(1) res.status(200).json(testUsers) } export default handler
import { NextApiRequest, NextApiResponse } from 'next' type Company = { id: number name: string } const testCompanies: Company[] = [ { id: 1, name: '株式会社その壱', }, { id: 2, name: '株式会社その弐', }, { id: 3, name: '株式会社その参', }, ] const handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => { await sleep(1) const { id } = req.query const company: Company = testCompanies.find( (company) => String(company.id) === id ) if (company) { res.status(200).json(company) return } res.status(404).json({ message: 'Not found.' }) } function sleep(sec: number) { return new Promise((resolve) => setTimeout(resolve, sec * 1000)) } export default handler
フロントエンドの挙動
- 画面を読み込むとローディング状態になる。
- /api/usersにリクエストを送信する。
- ユーザーの情報を取得後、それぞれのユーザーのcompanyIdに紐づく会社データを取得しに/api/companies/[id]にリクエストを送信する。
- すべてのデータを取得後、ローディング状態を解除し、ユーザーの一覧を表示する。
import axios from 'axios' import React, { useEffect, useState } from 'react' type User = { id: number name: string companyId: number companyName: string } type Company = { id: number name: string } export const Home = (): JSX.Element => { const [users, setUsers] = useState<User[]>([]) const [loading, setLoading] = useState<boolean>(false) useEffect(() => { setLoading(true) const fetchData = async () => { const users = (await axios.get<User[]>('http://localhost:3000/api/users')) .data for (const user of users) { const company = ( await axios.get<Company>( `http://localhost:3000/api/companies/${user.companyId}` ) ).data user.companyName = company.name } setUsers(users) setLoading(false) } fetchData() }, []) return ( <> {!loading && ( <table> <thead> <tr> <th>ID</th> <th>名前</th> <th>会社</th> </tr> </thead> <tbody> {users.map((user) => { return ( <tr key={`user${user.id}`}> <td>{user.id}</td> <td>{user.name}</td> <td>{user.companyName}</td> </tr> ) })} </tbody> </table> )} {loading && <div>ロード中...</div>} </> ) } export default Home
動かしてみる
実際に動かしてみます。
10件データを表示するのに10秒くらい掛かっていますね。
わかりやすくするためにAPI側でSleep処理を挟んでいるからなのですが、
/api/users
1回 × 1秒
/api/companies/[id]
10回 × 1秒
ということで計11秒掛かってしまいます。
根本的な修正する方法としてはUsers API側でcompanyNameも一緒に返すようにするのが良いのですが
今回はフロントエンド側でできることにフォーカスしていきます。
for文でawaitするのをやめてみる
会社データの取得にはユーザーの情報が必要になるので、ユーザー一覧取得時にはawaitする必要があるのですが
会社データ取得時のにリクエストを送る際にawaitを使用していると、毎回同期的に通信を行うので1件ずつレスポンスが帰ってくるまで待機することになります。
これをやめてみます。
useEffect(() => { setLoading(true) const fetchData = async () => { const users = (await axios.get<User[]>('http://localhost:3000/api/users')) .data await Promise.all(users.map(async user => { await axios.get<Company>(`http://localhost:3000/api/companies/${user.companyId}`).then(res => { user.companyName = res.data.name }) })) setUsers(users) setLoading(false) } fetchData() }, [])
ポイントとしてはfor文内で処理を待つのではなく、usersをすべてPromiseにmapし、 Promise.allで非同期で実行しています。
先程よりも若干早くなりました。(5秒前後くらい?)
ローディングを早く切り上げて遅延ロードする
とはいえローディングが長いのはユーザーにとってストレスに感じることもあるかと思います。
すべてのデータが揃ってから表示できるのが理想ではありますが、今回はユーザー取得後にローディングを切り上げ、
会社名は遅延ロードする実装をしてみます。表示する優先度が低いものがある場合などは使えるかもしれません。
useEffect(() => { setLoading(true) const fetchData = async () => { const users = (await axios.get<User[]>('http://localhost:3000/api/users')) .data setUsers(users) setLoading(false) await Promise.all(users.map(async user => { await axios.get<Company>(`http://localhost:3000/api/companies/${user.companyId}`).then(res => { user.companyName = res.data.name setUsers([...users]) }) })) } fetchData() }, [])
パラパラと表示されて気持ちいいですね。
最後に
フロントエンドでのN+1問題の緩和について考えました。
理想はバックエンド側の対応だったり、GraphQLを導入したりすることによる根本原因の解決ですが
場合によってはコストがかかったり、そこまで優先度が高まらないこともある場合もあるかと思います。
引き出しの一つとして知っておいても良いかもしれません。