- Published on
useInfiniteQuery 도입기 (feat. ag-grid)
- Authors
- Name
- CDD
서론
현재 사내 서비스에서는 그리드 내에서 데이터를 무한 스크롤 형식으로 Fetching
하는 로직이 굉장히 많이 존재합니다. 하지만 방식이 통일 되어있지 않고 버그도 빈번하게 발생합니다. 이런 문제를 인지하고는 있었으나 기능 개발에 몰두한다고 미뤄뒀던 문제를이자 그리드 로직의 통일을 위해서라도 선임 개발자님이 해결해보자고 제안했고, 이후 과정에 대해서 글을 작성해보려고 합니다. 이를 설명하기 위해서는 기존 로직의 문제점부터 설명을 하고 넘어가는 것이 맞다고 생각해서 이를 먼저 설명하고 넘어가려고 합니다.
기존 로직의 문제점
현재 데이터를 Fetch 하여 그리드에 적용시키는 방식
ag-grid
를 사용하는 환경에서는 고질적인 문제가 있었습니다. 바로 데이터를 Fetch
해서 set
해줄 때 rowData
가 새롭게 정의되면서 스크롤이 최상단으로 올라가게 됩니다. 해결책을 찾아보기 전에 기존에 구현해놨던 코드를 보니 ag-grid
내의 함수 중 하나인 applyTransaction()
를 사용했더군요. 이는 ag-grid
내부에서 가지고 있는 데이터의 값을 바꿔주는 목적으로 사용됩니다. 이를 통해 Optimistic
한 업데이트를 구현할 수 있는 것이죠. 아래의 예제 코드를 보시죠.
const Transaction = {
add: [
{employeeId: '4', name: 'Billy', age: 55}
],
update: [
{employeeId: '2', name: 'Bob', age: 23}
],
remove: [
{employeeId: '5'}
]
}
그래서 추가/수정/삭제 로직이 일어날 때마다 refetch
없이 낙관적 업데이트를 진행해왔고, 심지어 무한 스크롤을 구현할 때도 이러한 방식을 사용했습니다. 예시 코드를 하나 준비했습니다.
const onBodyScroll = async (e: BodyScrollEvent) => {
const data = await fetchMoreData();
e.api.applyTransaction({
add: [...data]
})
}
결국 원본 데이터는 ag-grid
에 맡긴 채 저희는 Transaction
만 적용해주고, 진짜 Refetch
가 필요하다면 데이터를 다시 다 날려서 첫 데이터부터 다시 받아오는 방식을 사용하고 있었던 것이죠. 그래서 가능하다면 refetch
를 안하는 쪽으로 코드를 구현했습니다.
기능만 작동하면 되는 거 아니야? 뭐 문제가 있어?
개인적으로는 2가지의 문제가 있다고 생각합니다. 첫 번째는 react query
를 기술 스택으로 삼았음에도 캐싱 데이터를 전혀 활용하고 있지 못한다는 점, 그리고 두 번째는 데이터의 추가/삭제가 일어났을 때 서버와 일관된 데이터를 보여주지 못한다는 점입니다. 하나씩 설명해보려고 합니다.
캐싱 데이터를 전혀 활용하지 못한다니..?
우선적으로 기존 코드를 그대로 들고온 상황인만큼 현재의 데이터 Fetching
방식도 useQuery
를 통해 첫 번째 페이지의 데이터를 받아오고, 이후 데이터는 모두 addTransaction
으로 넣어줍니다. 쿼리키 변동이 잦아 데이터 교체가 빈번하게 이뤄지는 상황에서 정작 캐싱을 할 수 있는 건 첫 번째 페이지 뿐인 것이죠. 좀 더 자세한 예시를 들어 설명 해보겠습니다.
저희 사내 서비스는 한 유저가 여러 그룹에 속할 수 있는데, 그룹마다 다른 데이터를 뿌려줘야 합니다. 그래서 그룹에 대한 정보가 queryKey
의 일부로 들어가있고, 그룹의 변동이 일어날 때마다 queryKey
값이 바뀌게 되면서 새로운 데이터를 Fetching
하게 됩니다.
1. 그룹 1의 데이터 (1-10)페이지 만큼 Fetch
2. 그룹 2 선택
3. 그룹 1 선택
4. 그룹 1의 데이터는 1페이지만 캐싱되어 있음
5. 다시 그룹 1의 (1-10)페이지까지의 데이터를 얻기 위해 계속해서 무한 스크롤을 해야 됨
서버와 일관된 데이터를 보여주지 못한다고..? 그게 무슨 소리야?
물론 완벽하게는 힘들겠지만, 그렇다해도 현재는 완성도가 너무 떨어집니다. 이것도 예시를 들어서 설명해보겠습니다.
- 데이터는 순서대로 1번, 2번, 3번 ...의 ID를 가지고 있다고 가정합니다.
- 한 번의 Fetch 당 데이터를 100개씩 들고온다고 가정합니다.
1. 클라이언트에서 100개의 데이터 중 98번, 99번, 100번 데이터를 삭제 및 낙관적 업데이트
2. 클라이언트의 마지막 데이터는 97번 데이터가 됨
3. 서버에서는 101번, 102번, 103번 데이터가 기존의 98, 99, 100번 데이터의 자리를 대체
4. 클라이언트에서 refetch
5. 서버에서는 104번 데이터부터 203번까지의 데이터를 보내줌
6. 101번 - 103번의 데이터는 어디에..?
결국 아무리 스크롤을 내려도 101번에서 103번까지의 데이터는 새로고침을 하지 않는 이상 찾지 못하게 된다는 것이죠. 이러한 과정이 반복될수록 서버와의 데이터 일관성을 점점 더 잃어갑니다.
useInfiniteQuery를 도입하는 것은 어떨까요?
생각해보니 위의 두 가지 문제점을 완벽히 해결할 수 있을만한 방법이 바로 useInfiniteQuery
를 사용하는 것이었습니다. 사이드 프로젝트에서 한 번 써보기는 했는데, 사내 프로젝트를 위해서는 추가적인 기술 검토가 필요했기에 테스트 프로젝트를 하나 더 만들어서 여러가지 테스트를 해봤습니다.
Fetching
할 때 스크롤 최상단 방어
새롭게 데이터 몇 개월 전의 기억을 더듬어 보니 회사 동료 중 한 명에게 스크롤이 최상단으로 가는 것을 막을 수 있는 방법이 있다고 들어서 관련해서 공식문서를 뒤져보니 AgGridReact
컴포넌트 props
중에 suppressScrollOnNewData
라는 옵션이 있더군요. 이를 true
값으로 변경해주면 무한 스크롤 중에 새로운 데이터가 들어왔을 때 스크롤이 최상단으로 올라가지 않게 되어 캐싱하고 있는 데이터 그대로 그리드에 넣어줄 수 있게 됩니다.
여기서 변수
하지만, 강제로 스크롤을 최상단으로 올려야 하는 경우도 있습니다. 예를 들어 그룹 1에서 400 번째 데이터를 보다가 그룹 2로 갔는데도 그룹 2의 400번째 데이터가 나오면 안되겠죠. 정책 상으로도 첫 번째 데이터부터 보여줘야 해서 추가적인 방법을 찾아야 했습니다. suppressScrollOnNewData
의 값을 토글링 해주는 방법을 생각했었습니다.
# 첫번째 방법
1. 그룹이 변경되면 data가 잠시 undefined / null로 됨
2. 이를 감지하여 토글 시키는 방식
3. data가 캐싱되어 있으면 data는 항상 존재하기에 실패
# 두번째 방법
1. suppressScrollOnNewData를 관리해 줄 State를 하나 만들어줌
2. 그룹이 변경되면 수동으로 State를 토글 시킴
3. 구조가 이상한 것 같음 (state를 바꿔 그리드를 리렌더링 시키고, 이후에 데이터를 Fetching 해야됨)
'querySelector
같은 걸로 요소 찾고 스크롤을 강제로 올려줘야 하나?' 하면서 조금 더 서칭을 해본 결과 ag-grid
내에서 스크롤을 최상단으로 올리는 함수를 지원한다는 것을 알게되었습니다. 그것은 바로 ensureIndexVisible(0, "top")
인데요, 아무리 검색해도 안나오더니 GPT
가 도와줬습니다, (넌 이런 것까지 어떻게 아는거니..?). 이제 스크롤에 대한 문제는 완벽히 해결되었으니 다음 문제를 찾으러 가보죠.
서버와 일관된 데이터를 보여주기 위한 방법
useInfiniteQuery
에 대한 간단한 설명
useInfiniteQuery
의 구조를 한 번 보고 넘어가면 좋을 것 같아 간단하게 코드를 작성해봤습니다.
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
useInfiniteQuery<any, any, any>({
queryKey: ["data", key],
queryFn: async ({ pageParam = 0 }) => {
return await getData({
key: key!,
offset: page,
});
},
enabled: !!key, // key가 있을 때만 활성화
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < PAGE_SIZE) return 0;
return allPages.length * PAGE_SIZE;
},
});
이렇게 코드를 작성하게 된다면 data
에는 어떤 데이터가 들어오게 될까요? 아래와 같습니다.
data = {
pages: [
[data1 - data100], // 첫 번째 페이지
[data101 - data200], // 두 번째 페이지
...
],
pageParams: [0, 100 ...]
}
여기서 제일 중요한 것은 바로 pageParams
인데요, getNextPageParam
함수로 리턴해주는 값이 pageParams
배열에 push
되는 형태로 됩니다. 제가 예시로 적어둔 0, 100, ...
이라는 값은 모두 getNextPageParam
함수에서 리턴해줬던 값인 것이죠.
자, 그럼 이게 왜 중요할까요? 바로 invalidate
에 있습니다. 일반적으로 useQuery
에서 invalidate
를 시키면 당연하게도 한 번 리페칭을 하게 되는데, useInfiniteQuery
에서는 pageParms
에 있는 모든 값들을 활용하여 refetch
로직을 수행합니다. 예를 들어 pageParams
에 [0, 100, 200]
이 있다면 invalidate
를 시켰을 때 0, 100, 200
페이지의 데이터를 모두 refetch
하게 되는 방식입니다.
Optimistic Update 적용
자, 그러면 이제 Optimistic Update
를 고민해봐야 하는데요, 기존에는 applyTransaction
을 사용하여 이를 진행했었죠? 하지만 여기서 의심이 하나 들었습니다. 이것도 풀어서 설명하기는 에매해서 아래에 예시를 하나 만들어봤습니다.
1. 1000개 (1페이지 당 100개의 데이터니까 10페이지)의 데이터를 가지고 있다고 가정합니다.
2. 999번째 데이터의 정보를 수정한 후, applyTransaction을 통해 낙관적 업데이트를 수행합니다.
3. invalidate를 시킵니다. (1000개의 데이터를 다시 받아오겠죠?)
4. fetch는 순차적으로 이뤄져서 첫 번째 페이지부터 두 번째, 세 번째 ... refetch를 진행합니다.
5. 마지막 페이지가 refetch 될 때까지 applyTransaction으로 바꾼 데이터는 보존이 될까?
6. 이론상 캐싱된 데이터가 set 되서 applyTransaction으로 만든 데이터가 날라갈 것 같다는 생각이 듦
그래서 곧바로 테스트 프로젝트에서 테스팅을 해봤는데, 예상대로 applyTransaction
으로 적용한 낙관적 업데이트가 다시 사라져버리더군요. 깨달았습니다, 수동적으로 캐싱된 데이터를 바꿔줘야 하는구나. 다행히도 여기에 대한 해답은 빨리 찾아냈는데, 바로 setQueryData
라는 것입니다. 말 그대로 queryData
자체를 바꿀 수 있는 함수인데요, 사용방법은 아래와 같습니다.
const queryClient = useQueryClient();
const optimisticUpdate = (key) => {
queryClient.setQueryData(["data", key], (oldData: any) => {
if (!oldData) {
return;
}
return {
...oldData,
pages: oldData.pages.map((page: any) =>
page.map((item: any) => {
return item.key === key
? {
...item,
name: "수정된 데이터",
}
: item;
}),
),
};
});
};
첫 번째 파라미터로는 데이터를 관리하고 있는 쿼리의 queryKey
값을 넣어주면, 두 번째로 들어가는 콜백 함수는 해당 쿼리키가 가지고 있는 캐시값을 파라미터로 던져줍니다. 바로 그것이 oldData
가 되는 것이죠. 그러면 이 oldData
를 가공해서 원하는 모양으로 만들어주면 됩니다. 이렇게 하면 refetch
를 해도 Optimistic Update
가 사라지지 않게 되겠죠?
최종적인 Workflow
- useInfiniteQuery를 통해 데이터를 Fetch한다.
- 데이터를 추가, 업데이트, 삭제할 시 setQueryData를 사용하여 직접적으로 캐시 자체를 업데이트 한다 (Optimistic Update).
- invalidateQueries를 통해 전체 데이터를 리프레시 해온다.
다만 invalidate
는 서버에 부하가 갈수도 있는만큼 조심해서 수행해야 할 것 같습니다. 과도하게 말고 데이터의 불일치 문제가 자주 발생하는 상황이 예상되는 케이스 같은 경우(e.g. 데이터 삭제)에만 적용을 해주는 쪽으로 생각해봐야 할 것 같습니다.
마치며
사실 useInfiniteQuery
는 이전에 사이드 프로젝트에서도 사용했었는데, 그때는 사용했던 이유가 다른 페이지로 이동했다가 popState
같은 동작을 수행했을 때 데이터를 다시 처음부터 받아오는 것을 방지하기 위함이었습니다. 당시에는 로직 구현에만 급급했어서 자세한 동작 원리를 이해하지 못했었는데, 이번 기회로 useInfiniteQuery
에 대한 이해도가 높아진 것 같은 느낌이 듭니다. 추가적인 TroubleShoot
이 생긴다면 이후에 글을 추가하는 방향도 고려해보겠습니다.
아마 ag-grid
에서 useInfiniteQuery
를 사용하는 환경인 경우 이 글이 큰 도움이 될 것 같네요. 긴 글 읽어주셔서 감사합니다!