フロントエンドからのN+1を緩和するために遅延ロードを考えてみる

フロントエンドからAPIを呼んだ時に発生するN+1を緩和することを考えてみます。
2021.05.27

こんにちは。サービス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を導入したりすることによる根本原因の解決ですが

場合によってはコストがかかったり、そこまで優先度が高まらないこともある場合もあるかと思います。

引き出しの一つとして知っておいても良いかもしれません。