이번 글에서는 ErrorBoundary
와 Suspense
를 활용하여 비동기 데이터 로딩 시 발생하는 문제들을 어떻게 해결했는지 설명한다.
처음에는 두 개의 API를 순차적으로 호출하는 방식으로 구현되어 있었는데, 이로 인해 두 번째 API 호출이 완료되기 전까지 첫 번째 데이터가 사용자에게 보여지지 않는 waterfall 문제가 발생하였다. 이를 해결하기 위해 useSuspenseQueries
를 사용하여 두 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,
})
}
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),
})
}
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를 호출하면서 성능 저하가 발생했고, 사용자는 잠시 동안 빈 화면을 볼 수밖에 없었다.
문제를 해결하기 위해 두 API를 병렬로 호출하고, 모든 데이터가 준비될 때까지 로딩 상태를 유지하도록 useSuspenseQueries
를 사용했다.
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 현상이 제거되어 로딩 시간도 단축되었고, 데이터가 완전히 준비될 때까지 로딩이 표시되어 사용자 경험도 개선되었다.