조회 버튼을 연달아 누르면 누른 횟수만큼 요청이 나간다는 제보를 받았습니다.
응답이 몇 초씩 걸리는 무거운 집계 조회라, 다섯 번 누르면 서버가 같은 집계를 다섯 번 실행합니다.
해야 할 일은 분명했어요.
조회 중이면 버튼을 못 누르게 하면 됩니다.
그런데 이 화면은 버튼 하나로 쿼리 세 개를 갱신합니다.
그래서 훅 하나만 봐서는 지금 조회 중인지 알 수 없었습니다.
조회 중인지를 어디서 가져올지 찾다가 useIsFetching을 처음 알게 됐어요.
이 글에는 훅마다 로딩 상태를 꺼내 조합하려다 방향을 바꾼 과정과, 이 방법을 쓰려면 쿼리 키가 어떻게 나뉘어 있어야 하는지를 적었습니다.
버튼 하나가 쿼리 세 개를 갱신하고 있었습니다
필터 바의 조회 버튼 하나가 화면의 데이터 영역 세 곳을 한꺼번에 다시 불러옵니다.
영역마다 쿼리를 따로 씁니다.
flowchart LR B["조회 버튼"] --> S["필터 커밋"] S --> Q1["목록 테이블 쿼리"] S --> Q2["그룹 집계 쿼리"] S --> Q3["추이 차트 쿼리"]
버튼에 걸어 둔 조건은 이것뿐이었습니다.
<Button onClick={() => { filter.submit(); onSubmit?.(); }} disabled={ctx.isDisabled}>
조회
</Button>ctx.isDisabled는 조회 조건에 필요한 메타 정보를 못 불러왔을 때 켜지는 값입니다.
지금 조회 중인지와는 상관이 없는 값이에요.
게다가 이 화면에는 이런 코드가 있었습니다.
// 조건이 같아도 조회 버튼은 항상 다시 조회한다
queryClient.invalidateQueries({ queryKey: REPORT_QUERY_KEY });“같은 조건이어도 무조건 다시 조회”가 의도된 동작이라, 누른 만큼 요청이 새로 나갑니다.
React Query에는 앞선 요청을 취소하는 동작이 있는데, 여기서는 소용이 없습니다.
취소는 queryFn에 signal을 넘겼을 때만 실제로 요청을 끊습니다.
그마저도 이미 서버에 도착한 조회는 그대로 끝까지 실행됩니다.
그래서 클릭 자체를 막아야 했고, 남은 문제는 “조회 중”을 어디서 알아내느냐였습니다.
훅마다 isFetching을 꺼내서 조합하려 했습니다
처음 떠올린 건 익숙한 방법이었습니다.
각 데이터 훅이 useQuery에서 isFetching을 꺼내 리턴하고, 페이지에서 그걸 모아 버튼에 넘기는 거예요.
const { isFetching: isFetchingList } = useReportList();
const { isFetching: isFetchingSummary } = useReportSummary();
const { isFetching: isFetchingTrend } = useReportTrend();
const isFetching = isFetchingList || isFetchingSummary || isFetchingTrend;동작은 합니다.
그런데 두 가지가 걸렸어요.
- 훅 세 개의 리턴값을 모두 고치고, 페이지에서 다시 조합해야 합니다. 버튼 하나 막으려고 손대는 파일이 늘어납니다.
- 나중에 이 화면에 쿼리가 하나 더 붙으면 조합식에도 같이 손을 대야 합니다.
두 번째가 더 신경 쓰였습니다.
조합식에 하나를 빠뜨려도 화면은 멀쩡해 보이고, 버튼을 빠르게 여러 번 누를 때만 조용히 요청이 한 번 더 나갑니다.
그런 클릭은 개발할 때보다 운영에서 훨씬 자주 일어나고요.
쿼리 캐시에 물어보는 useIsFetching을 알게 됐습니다
그러다 useIsFetching을 찾았습니다.
🤔 useIsFetching이란?
개별useQuery의 상태를 읽는 대신, 쿼리 캐시 전체에서 지금 fetching 중인 쿼리가 몇 개인지를 세어 주는 훅입니다. 필터를 넘기면 거기에 매칭되는 쿼리만 셉니다. 불리언 대신 개수를 돌려주기 때문에 보통> 0으로 비교해서 씁니다. (v5 공식 문서)
그래서 페이지에 필요한 건 이 한 줄이 전부였습니다.
const isFetching = useIsFetching({ queryKey: REPORT_QUERY_KEY }) > 0;훅 세 개의 리턴값을 건드릴 필요도, 조합식을 계속 관리할 필요도 없어졌습니다.
이 페이지에는 useQuery 호출이 하나도 없는데, 그래도 로딩 상태를 가져올 수 있습니다.
상태를 들고 있는 훅에 물어보는 대신, 캐시에 “지금 이 조건으로 나간 요청이 있냐”고 물었기 때문입니다.
필터 바에는 prop 하나가 늘었고, 그 값은 그대로 버튼 disabled로 들어갑니다.
<FilterBar store={useReportFilterStore} isFetching={isFetching} onSubmit={handleSubmit} />
// FilterBar 내부
<Button onClick={handleClick} disabled={ctx.isDisabled || isFetching}> // [!code highlight]
조회
</Button>바뀐 건 3파일, +14/-3입니다.
전에는 조회 버튼을 다섯 번 누르면 요청이 다섯 개 나가고 서버도 같은 집계를 다섯 번 실행했는데, 지금은 둘 다 하나입니다.
이게 되려면 쿼리 키가 먼저입니다
한 줄로 끝날 수 있었던 건 제가 넘겨받은 키가 이미 이렇게 나뉘어 있었기 때문입니다.
export const REPORT_QUERY_KEY = ['report'];
export const REPORT_LIST_QUERY_KEY = [...REPORT_QUERY_KEY, 'list'];
export const REPORT_SUMMARY_QUERY_KEY = [...REPORT_QUERY_KEY, 'summary'];
export const REPORT_TREND_QUERY_KEY = [...REPORT_QUERY_KEY, 'trend'];세 쿼리가 전부 ['report']를 앞에 달고 있습니다.
queryKey 필터는 배열을 앞에서부터 맞춰 보기 때문에(프리픽스 매칭), ['report']만 넘기면 세 쿼리가 한 번에 매칭됩니다.
나중에 이 화면에 쿼리가 하나 더 붙어도 같은 프리픽스만 쓰면 따로 손대지 않아도 포함됩니다.
| 넘긴 필터 | fetching 중인지 세는 범위 |
|---|---|
{ queryKey: ['report'] } | ['report', ...]로 시작하는 모든 쿼리 |
{ queryKey: ['report', 'list'] } | 목록 쿼리 하나만 |
| 필터 없음 | 캐시에 있는 모든 쿼리 |
표 마지막 줄 때문에 접은 안이 하나 있습니다.
중간에 “필터 바가 아예 queryKey를 받아서 무효화도 하고 버튼도 막게 할까” 싶었어요.
그런데 이 필터 바는 키를 안 넘기는 다른 화면에서도 같이 쓰는 공용 컴포넌트입니다.
그러면 아무 상관 없는 요청 하나에도 조회 버튼을 못 누르게 됩니다.
그걸 피하려면 아무것도 매칭되지 않는 더미 키 같은 걸 넣어야 하는데, 그게 더 이상해서 접었습니다.
여기 쓴 ['report']는 이미 invalidateQueries에 넘기고 있던 그 상수입니다.
다시 불러오는 범위와 버튼을 막는 범위가 같은 상수를 씁니다.
한쪽만 바뀌어 어긋날 일이 없어요.
쓸 수 있는 자리, 안 막히는 것
| 상황 | 이 방법 |
|---|---|
| 버튼 하나가 쿼리 여러 개를 갱신한다 | ✅ |
컴포넌트가 useQuery를 직접 호출하지 않는다 (전역 헤더 인디케이터 같은) | ✅ |
| 쿼리 키가 프리픽스로 안 나뉘어 있다 | ❌ |
| 이미 서버로 나간 요청을 멈추고 싶다 | ❌ |
마지막 줄이 이 방법의 한계입니다.
버튼을 막아도 직전에 보낸 조회는 서버가 끝까지 실행합니다.
새로고침해도 마찬가지라, 서버에서 타임아웃이나 취소로 다뤄야 하는 일로 보고 이번 범위 밖에 뒀습니다.
캐시에 직접 묻는 게 항상 나은지는 아직 잘 모르겠습니다.
쿼리가 둘뿐이면 훅에서 꺼내는 게 더 짧을 테니까요.
어느 줄부터 캐시 쪽이 나은지는 다음에 비슷한 화면을 만나봐야 알 것 같아요.
참고 자료
- useIsFetching — 캐시에서 fetching 중인 쿼리 개수를 세는 훅
- Filters — Query Filters —
queryKey가 프리픽스로 매칭되는 규칙 - Query Invalidation —
invalidateQueries의 동작과 매칭 범위 - Query Cancellation —
signal을 넘겨야 실제 요청이 끊기는 이유