저희 팀은 지도를 주로 다루는데, 이번에 지도에 데이터를 마커로 시각화하는 작업에서 마커들이 불필요하게 깜빡거리는 현상이 있었고 이를 해결한 내용을 정리했습니다.
지도를 이동하거나 필터값을 변경할 때마다 그에 맞는 최신 데이터를 받아와서 마커로 표시해주는데, 그냥 남아있어도 될 마커까지 다시 리렌더링되는 것을 볼 수 있습니다.
저희팀은 Tanstack Query를 사용해서 서버측 데이터를 관리하고 있고 해당 지도 데이터 또한 useQuery를 사용하여 관리하고 있습니다.
마커가 리렌더링되는 것을 보고 이전에 useMutation으로 낙관적인 업데이트를 구현해본 경험이 생각나서 먼저 "useQuery를 사용하고 있는데 이전 데이터를 캐싱하고 변경되는 새로운 데이터만 업데이트하면 되지 않을까?" 라는 생각이 떠올랐습니다.
먼저 공식문서로 찾아가 관련된 옵션이나 구현 방법이 있는지 찾아보았습니다.
고맙게도 라이브러리에서 제가 원하는 structuralSharing
이라는 옵션을 지원하고 있었습니다.
structural sharing
React Query uses a technique called "structural sharing" to ensure that as many references as possible will be kept intact between re-renders. If data is fetched over the network, usually, you'll get a completely new reference by json parsing the response. However, React Query will keep the original reference if nothing changed in the data. If a subset changed, React Query will keep the unchanged parts and only replace the changed parts.Note: This optimization only works if the queryFn returns JSON compatible data. You can turn it off by setting structuralSharing: false globally or on a per-query basis, or you can implement your own structural sharing by passing a function to it.
tanstack query v5 공식문서 인용
위 글에서 React Query는 구조적 공유라는 기술을 사용하여 다시 렌더링되는 것을 방지한다고 합니다. API 요청을 통해 새로운 데이터를 받아와도 데이터의 변경이 없다면 기존의 참조를 유지합니다.
참조의 변경이 없다면 불필요한 리렌더링을 방지할 수 있을 것입니다.
지금 제가 겪고 있는 문제를 해결할 수 있는 최적의 옵션인 것 같습니다. 이제 옵션을 true
로 변경해서 활성화하면 이 문제를 생각보다 쉽게 해결할 수 있을 것 같습니다.
structuralSharing: boolean | (oldData: unknown | undefined, newData: unknown) => unknown)
Optional
Defaults to true
If set to false, structural sharing between query results will be disabled.
If set to a function, the old and new data values will be passed through this function, which should combine them into resolved data for the query. This way, you can retain references from the old data to improve performance even when that data contains non-serializable values.
tanstack query v5 공식문서 인용
옵션을 보니 무언가 문제가 있습니다. Defaults to true?? 기본적으로 이 옵션은 활성화되어 있다고 합니다.
그렇다면 문제는 저의 코드에 있는 것 같으니 코드를 잠깐 보겠습니다.
데이터를 받아오는데 필요한 파라미터는 필터값과 지도의 boundary가 계산된 값입니다. 그리고 파라미터가 변경될 때마다 최신 데이터를 받아와야 합니다.
export const useGetIncidentMapList = (searchParam) => {
return useQuery({
queryKey: [TOOL.incident.mapList, searchParam],
queryFn: () => getIncidentMapList(searchParam),
select: (data) => data.data.data,
enabled: Boolean(searchParam),
})
}
파라미터가 변경될 때마다 최신 데이터를 요청해야하니 queryKey
에 searchParam
를 넣어 searchParam
이 변경될 때마다 자동으로 새로운 query를 생성하여 새로운 데이터를 받아옵니다.
useEffect(() => {
if (zoom >= 14) {
...
setSearchParams({ ...filterParam, latFrom, lonFrom, latTo, lonTo })
} else {
queryClient.removeQueries([TOOL.incident.mapList, searchParam])
setSearchParams(null)
}
}, [latFrom, lonFrom, latTo, lonTo, filter])
useEffect
를 사용해 파라미터로 사용되는 값들의 변경을 감지하고 새로운 파라미터로 업데이트 해줍니다. 이 setSearchParams
를 트리거로 사용해 새로운 query를 생성하여 데이터를 요청할 수 있게 만들었습니다.
하지만 queryKey
에 파라미터값을 넣어 새로운 query를 생성하는 것이 문제였습니다. 새로운 query가 생성되면서 이전에 조회한 데이터는 접근할 수 없게된 것입니다.
structuralSharing
옵션은 활성화가 되어있지만, 새로운 queryKey
로 계속 새로운 query를 생성하니 비활성화 상태나 다름없습니다.
먼저 structuralSharing
옵션을 사용하기 위해 useQuery에서 사용하는 queryKey
를 일관되게 변경했습니다.
export const useGetIncidentMapList = (params) => {
return useQuery({
queryKey: [TOOL.incident.mapList],
queryFn: () => getIncidentMapList(params),
select: (data) => data.data.data,
enabled: false,
})
}
더 이상 파라미터의 변경에 따라 요청하지 않기 때문에 enabled
옵션도 false
로 변경했습니다. 이제 요청은 파라미터가 아니라 useQuery에서 제공하는 refetch함수를 사용해서 요청할 겁니다.
이제 해당 hook에서 응답받은 데이터는 structuralSharing
옵션이 적용됩니다.
useEffect(() => {
if (zoom >= 14) {
...
setSearchParams({ ...filterParam, latFrom, lonFrom, latTo, lonTo })
refetch()
} else {
queryClient.removeQueries([TOOL.incident.mapList])
setSearchParams(null)
}
}, [latFrom, lonFrom, latTo, lonTo, filter])
기존 useEffect에서 setSearchParams로 파라미터를 업데이트하고 refetch 함수를 사용해 query를 요청했지만, 비동기 동작으로 인해 파라미터가 업데이트되기 전에 query 요청이 발생해 오류가 발생하는 문제가 있었습니다.
결국 useEffect를 하나 더 만들고 searchParams의 변경을 감지해 refetch 함수를 호출하기로 했습니다.
useEffect(() => {
if (searchParam) {
refetch()
}
}, [searchParam])
useEffect를 많이 사용하는 것을 별로 좋아하지 않지만, 비동기 처리에 대한 방법이 마땅치 않아 useEffect를 하나 더 사용하게 되었습니다.
이제 structuralSharing
옵션이 정상적으로 적용되는 것을 확인할 수 있습니다.
이전 요청으로 받은 데이터와 새로운 요청으로 받은 데이터를 비교하고 변경된 부분만 업데이트하여 렌더링 또한 새롭게 추가된 마커와 더 이상 필요 없는 마커만 재조정됩니다.