この記事は公開されてから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を持つ。
pages/api/users.ts
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
pages/api/companies/[id].tsx
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]にリクエストを送信する。
- すべてのデータを取得後、ローディング状態を解除し、ユーザーの一覧を表示する。
pages/index.tsx
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件ずつレスポンスが帰ってくるまで待機することになります。
これをやめてみます。
index.tsxのuseEffect部分抜粋
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秒前後くらい?)
ローディングを早く切り上げて遅延ロードする
とはいえローディングが長いのはユーザーにとってストレスに感じることもあるかと思います。
すべてのデータが揃ってから表示できるのが理想ではありますが、今回はユーザー取得後にローディングを切り上げ、
会社名は遅延ロードする実装をしてみます。表示する優先度が低いものがある場合などは使えるかもしれません。
index.tsxのuseEffect部分抜粋
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を導入したりすることによる根本原因の解決ですが
場合によってはコストがかかったり、そこまで優先度が高まらないこともある場合もあるかと思います。
引き出しの一つとして知っておいても良いかもしれません。