TanStack Query의 useSuspenseQueries를 활용한 API 병렬 처리 및 성능 개선

김주현·2024년 9월 15일
0

F1 Info

목록 보기
3/3
post-thumbnail

들어가며

이번 글에서는 ErrorBoundarySuspense를 활용하여 비동기 데이터 로딩 시 발생하는 문제들을 어떻게 해결했는지 설명한다.
처음에는 두 개의 API를 순차적으로 호출하는 방식으로 구현되어 있었는데, 이로 인해 두 번째 API 호출이 완료되기 전까지 첫 번째 데이터가 사용자에게 보여지지 않는 waterfall 문제가 발생하였다. 이를 해결하기 위해 useSuspenseQueries를 사용하여 두 API를 병렬로 처리하고, 데이터가 준비될 때까지 로딩 상태를 유지하는 방법으로 수정하였다.

문제점

기존 코드에서는 각 API 호출이 완료될 때까지 순차적으로 처리되다 보니, 전체 데이터가 불러오기 전 잠깐 빈 화면이 나타나는 문제가 있었는데, 특히 첫 번째 API가 완료된 후에야 두 번째 API를 호출하는 구조가 사용자 경험에 좋지 않다고 생각했다.

기존 코드 구조

각 선수의 포지션을 불러오는 API

import { useQuery } from '@tanstack/react-query'
import axios from 'axios'

export interface PositionInterface {
  date: string
  driver_number: number
  meeting_key: number
  position: number
  session_key: number
}

export interface FetchPositionProps {
  meeting_key: number
  session_key: number
}

export const fetchPosition = async ({ meeting_key, session_key }: FetchPositionProps): Promise<PositionInterface[]> => {
  const response = await axios.get(
    `https://api.openf1.org/v1/position?meeting_key=${meeting_key}&session_key=${session_key}`,
  )
  return response.data
}

export const useFetchPosition = ({ meeting_key, session_key }: FetchPositionProps) => {
  return useQuery<PositionInterface[], Error>({
    queryKey: ['meetings', meeting_key, session_key],
    queryFn: () => fetchPosition({ meeting_key, session_key }),
    enabled: !!meeting_key && !!session_key,
  })
}

각 드라이버의 정보를 불러오는 API

import { useSuspenseQuery } from '@tanstack/react-query'
import axios from 'axios'

export interface DriversInterface {
  broadcast_name: string
  country_code: string
  driver_number: number
  first_name: string
  full_name: string
  headshot_url: string
  last_name: string
  meeting_key: number
  name_acronym: string
  session_key: number
  team_colour: string
  team_name: string
}

export const fetchDrivers = async (session_key: number): Promise<DriversInterface[]> => {
  const response = await axios.get(`https://api.openf1.org/v1/drivers?session_key=${session_key}`)
  return response.data
}

export const useFetchDrivers = (session_key: number) => {
  return useSuspenseQuery<DriversInterface[], Error>({
    queryKey: ['drivers', session_key],
    queryFn: () => fetchDrivers(session_key),
  })
}

포지션과 드라이버를 병합하여 순위를 보여주는 병합 및 필터로직 Function

import { useEffect, useState } from 'react'
import { useFetchDrivers, DriversInterface } from '../Drivers/useDrivers'
import { useFetchPosition, PositionInterface, FetchPositionProps } from '../Position/usePosition'

/** 날짜 기준으로 가장 마지막 값을 기준으로 순위를 찾는 함수 */
const getLatestPositions = (positions: PositionInterface[]) => {
  const latestPositionsMap = positions.reduce(
    (acc, position) => {
      // 누적된 날짜 값보다 새로 들어오는 값이 더 최신이라면.
      if (!acc[position.driver_number] || new Date(acc[position.driver_number].date) < new Date(position.date)) {
        acc[position.driver_number] = position
      }
      return acc
    },
    {} as { [key: number]: PositionInterface },
  )

  return Object.values(latestPositionsMap)
}

export const useFetchDriversWithPosition = ({ meeting_key, session_key }: FetchPositionProps) => {
  const { data: drivers, isSuccess: driversIsSuccess, error: driversError } = useFetchDrivers(session_key)
  const {
    data: positions,
    isSuccess: positionsIsSuccess,
    error: positionsError,
  } = useFetchPosition({ meeting_key, session_key })

  const [mergedData, setMergedData] = useState<(DriversInterface & { position: PositionInterface | undefined })[]>([])

  useEffect(() => {
    if (driversIsSuccess && positionsIsSuccess) {
      const latestPositions = getLatestPositions(positions)
      const merged = drivers.map((driver) => ({
        ...driver,
        position: latestPositions.find((position) => position.driver_number === driver.driver_number),
      }))
      merged.sort((a, b) => (a.position?.position ?? Infinity) - (b.position?.position ?? Infinity))
      setMergedData(merged)
    }
  }, [drivers, positions])

  return { mergedData, isSuccess: driversIsSuccess && positionsIsSuccess, error: driversError || positionsError }
}

문제 발생

위 코드는 API를 각각 호출한 후 데이터를 병합하지만, 두 API 간 waterfall 문제가 발생했다. 첫 번째 API가 완료된 후에야 두 번째 API를 호출하면서 성능 저하가 발생했고, 사용자는 잠시 동안 빈 화면을 볼 수밖에 없었다.

해결 방법: useSuspenseQueries로 병렬 처리

문제를 해결하기 위해 두 API를 병렬로 호출하고, 모든 데이터가 준비될 때까지 로딩 상태를 유지하도록 useSuspenseQueries를 사용했다.

변경된 주요 사항

  • API 호출을 병렬 처리: 기존 코드에서는 useFetchDrivers와 useFetchPosition이 각각 순차적으로 호출, 이를 useSuspenseQueries를 사용해 두 API를 동시에 호출하도록 수정
  • 로딩 상태 유지: Suspense의 fallback을 이용해 전체 데이터가 불러오는 동안 Skeleton UI로 로딩 상태를 표시
  • 성능 개선: 병렬 처리를 통해 데이터를 더 빠르게 불러와 화면에서 잠깐 빈 화면이 나타나는 문제를 해결
import { useMemo } from 'react'
import { useSuspenseQueries } from '@tanstack/react-query'
import { DriversInterface, fetchDrivers } from '../Drivers/useDrivers'
import { fetchPosition, FetchPositionProps, PositionInterface } from '../Position/usePosition'

/** 날짜 기준으로 가장 마지막 값을 기준으로 순위를 찾는 함수 */
const getLatestPositions = (positions: PositionInterface[]) => {
  const latestPositionsMap = positions.reduce(
    (acc, position) => {
      // 누적된 날짜 값보다 새로 들어오는 값이 더 최신이라면.
      if (!acc[position.driver_number] || new Date(acc[position.driver_number].date) < new Date(position.date)) {
        acc[position.driver_number] = position
      }
      return acc
    },
    {} as { [key: number]: PositionInterface },
  )

  return Object.values(latestPositionsMap)
}

export const useFetchDriversWithPosition = ({ meeting_key, session_key }: FetchPositionProps) => {
  const [driversQuery, positionsQuery] = useSuspenseQueries({
    queries: [
      {
        queryKey: ['drivers', session_key],
        queryFn: () => fetchDrivers(session_key),
      },
      {
        queryKey: ['positions', meeting_key, session_key],
        queryFn: () => fetchPosition({ meeting_key, session_key }),
      },
    ],
  })

  const drivers = driversQuery.data as DriversInterface[]
  const positions = positionsQuery.data as PositionInterface[]

  // Merge drivers with their latest positions
  const mergedData = useMemo(() => {
    const latestPositions = getLatestPositions(positions)
    const merged = drivers.map((driver) => ({
      ...driver,
      position: latestPositions.find((position) => position.driver_number === driver.driver_number),
    }))
    merged.sort((a, b) => (a.position?.position ?? Infinity) - (b.position?.position ?? Infinity))
    return merged
  }, [drivers, positions])

  return { mergedData }
}

성능 개선 및 결과

이제 두 API를 병렬로 처리하고, 전체 데이터가 준비될 때까지 로딩 화면을 표시함으로써 사용자 경험을 개선하였다.
waterfall 현상이 제거되어 로딩 시간도 단축되었고, 데이터가 완전히 준비될 때까지 로딩이 표시되어 사용자 경험도 개선되었다.

waterfall 현상이 발생하는 수정이전의 모습


병렬처리를 이용한 수정 후 모습


profile
돈은 목적이 아닌 수단이다.

0개의 댓글