프로젝트에서 다이얼로그를 닫을 때마다 목록이 깜빡이는 현상이 생겼다. 원인을 추적하다 보니 React Query 캐싱 전략을 제대로 이해하지 못하고 쓰고 있었다는 걸 깨달았고, 그 과정에서 정리한 내용을 공유한다.
enabled + staleTime 조합으로 불필요한 API 호출 줄이기
다이얼로그가 자주 열리고 닫히는 UX라면 쿼리를 아무 조건 없이 실행하는 건 낭비다. 나는 처음에 useQuery를 그냥 컴포넌트 안에 선언해두었는데, 다이얼로그가 열릴 때마다 API가 쏠리는 게 마음에 걸렸다.
해결책은 enabled 조건과 staleTime을 함께 쓰는 것이었다.
const { data } = useQuery({
queryKey: ['beaconList', headerParams.site_id],
queryFn: fetchBeaconList,
enabled: !!open && !!headerParams.site_id, // 다이얼로그 열릴 때만 활성화
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
enabled: !!open && !!headerParams.site_id는 두 가지를 동시에 잡아준다. 다이얼로그가 닫혀 있을 땐 쿼리 자체가 실행되지 않고, site_id가 없는 상태에서도 불필요한 요청이 나가지 않는다.
여기에 staleTime: 1000 * 60 * 5를 추가하면, 다이얼로그를 닫았다가 5분 안에 다시 열었을 때 캐시된 데이터를 그대로 사용하므로 API 호출이 일어나지 않는다. 생각보다 이 두 옵션의 조합이 강력해서 감동받았다.
staleTime과 gcTime, 헷갈리지 않게 구분하기
staleTime과 gcTime은 비슷해 보이지만 역할이 다르다.
- staleTime: 데이터를 "신선하다"고 볼 기간. 이 시간 안에는 재요청을 하지 않는다.
- gcTime: 캐시를 메모리에서 유지하는 기간. 쿼리가 더 이상 사용되지 않아도 이 시간 동안은 메모리에 남아 있는다.
staleTime ≤ gcTime 이어야 자연스럽다.
데이터가 stale 해지기도 전에 캐시가 사라지면 의미가 없으니까.
내가 참여한 WebSocket 기반 프로젝트에서는 아래 원칙을 세웠다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1분 — 중복 요청 방지
gcTime: 1000 * 60 * 5, // 5분 — 메모리 유지
refetchOnWindowFocus: false, // WebSocket이 실시간 처리하므로 불필요
},
},
});
WebSocket이 실시간 업데이트를 담당하기 때문에 REST 쿼리는 staleTime 1분으로 중복 요청을 줄이고, 데이터 변경 시점엔 invalidateQueries로 명시적으로 무효화하는 전략을 썼다. 이 조합이 생각보다 깔끔하게 맞아떨어졌다.
invalidateQueries는 성공 시점에만 실행해야 한다
이게 이번에 가장 핵심적으로 배운 부분이다.
처음엔 다이얼로그를 닫는 핸들러에 invalidateQueries를 넣어두었다.
// ❌ 잘못된 패턴 — 닫기만 해도 무조건 refetch
const handleClose = () => {
queryClient.invalidateQueries({ queryKey: ['beaconList'] });
setOpen(false);
};
이렇게 하면 사용자가 그냥 "취소" 버튼을 눌러 닫아도 목록 데이터가 refetch되면서 깜빡이는 현상이 생긴다. 실제로 변경된 게 없는데 쓸데없이 API가 호출되는 것이다.
올바른 패턴은 이렇다.
// ✅ 올바른 패턴 — 성공했을 때만 invalidate
const handleSubmit = async () => {
try {
await assignBeacon(params);
queryClient.invalidateQueries({ queryKey: ['beaconList'] }); // 성공 시점에만!
setOpen(false);
} catch (e) {
// 에러 처리
}
};
// 단순 닫기 — invalidate 없음
const handleClose = () => {
setOpen(false);
};
invalidateQueries는 "실제로 서버 상태가 바뀌었을 때" 캐시를 무효화하는 용도다. 닫기 버튼은 상태 변경이 아니므로 여기서 invalidate를 실행할 이유가 전혀 없다. 이걸 분리하고 나서 깜빡임이 완전히 사라졌다.
refetchOnMount vs invalidateQueries, 뭘 써야 할까
이 두 가지를 헷갈려서 잘못 쓰는 경우가 많다. 나도 그랬다.
refetchOnMount의 동작 방식
const { data } = useQuery({
queryKey: ['beaconList'],
queryFn: fetchBeaconList,
refetchOnMount: 'always', // mount될 때마다 항상 재조회
});
refetchOnMount: 'always'는 컴포넌트가 mount될 때 실행된다. 핵심은 여기에 있다.
다이얼로그가 열리고 닫혀도 부모 컴포넌트(목록 컴포넌트)가 unmount되지 않는다면, refetchOnMount는 아무 의미가 없다. 이미 mount된 상태이기 때문에 다시 mount 이벤트가 발생하지 않는 것이다.
다이얼로그 열기/닫기
→ 목록 컴포넌트는 계속 mount된 상태 유지
→ refetchOnMount 이벤트 발생 안 함
→ 목록 갱신 안 됨 ❌
invalidateQueries가 정답인 경우
배정 등록 성공
→ invalidateQueries 실행
→ 해당 queryKey 캐시 무효화
→ 다음 렌더링 시 자동 refetch
→ 목록 최신 데이터로 갱신 ✅
정리하면 이렇다.
| 상황 | 적합한 방법 |
|---|---|
| 페이지 이동 후 돌아왔을 때 최신값 보장 | refetchOnMount: 'always' |
| 다이얼로그에서 상태 변경 후 목록 갱신 | invalidateQueries on success |
| 팝업 단순 닫기 | 아무것도 하지 않음 |
refetchOnMount는 페이지 수준의 컴포넌트에서 "이 페이지에 들어올 때마다 최신 데이터를 보여줄게"라는 의도로 써야 한다. 다이얼로그 내부 상태 변경 후 목록 갱신은 반드시 invalidateQueries on success 패턴을 써야 한다.
refetchInterval로 폴링 구현하기
모니터링 페이지처럼 주기적으로 데이터를 갱신해야 하는 경우엔 refetchInterval을 쓰면 된다.
const { data } = useQuery({
queryKey: ['monitoringData'],
queryFn: fetchMonitoringData,
refetchInterval: 5000, // 5초마다 자동 재조회
});
이 한 줄이면 끝이다. TanStack Query가 알아서 5초마다 재조회해주기 때문에 별도의 setInterval 로직을 작성할 필요가 없다. 나는 처음에 useEffect 안에 setInterval로 폴링을 구현했다가 이 방법으로 바꿨는데, 코드가 훨씬 간결해졌다.
Query 훅 설계 패턴 — 필터 분류와 일관성
프로젝트에서 여러 API를 Query 훅으로 캡슐화하다 보니 패턴이 제각각이 되는 문제가 생겼다. 그래서 아래 기준으로 분류해서 통일했다.
필터가 있는 경우 — selectParams / contentParams 분리
interface GetBeaconListParams {
selectParams: {
work_type: string; // 공종 — 선택지가 정해진 필터
job_type: string; // 직종
company: string; // 업체
};
contentParams: {
date: string; // 날짜 — 자유 입력
keyword: string; // 검색어
};
}
export const useBeaconListQuery = (params: GetBeaconListParams) => {
return useQuery({
queryKey: ['beaconList', params],
queryFn: () => fetchBeaconList(params),
enabled: !!headerParams.site_id,
});
};
단순 params 패턴 — 필터가 없는 경우
// selectParams/contentParams 분리 없이 단순하게
const useLogHookListQuery = (params: GetLogHookListParams) => {
return useQuery({
queryKey: ['logHookList', params],
queryFn: () => fetchLogHookList(params),
// user_id/site_id가 없으므로 useHeaderParamsStore 불필요
});
};
export default useLogHookListQuery;
- 외부에서 다양하게 조합해 쓰는 훅은
named export - 단일 목적의 단순한 훅은
default export
이 기준을 팀 내에서 공유하고 나서, 새로운 API를 훅으로 추가할 때 고민하는 시간이 확실히 줄었다.
마치며
React Query를 쓰다 보면 "그냥 되니까" 쓰는 경우가 많은데, 이번에 깜빡임 이슈를 해결하면서 각 옵션의 의도를 제대로 이해하게 됐다.
핵심만 다시 정리하면 이렇다.
enabled+staleTime: 다이얼로그처럼 조건부로 열리는 UI에서 불필요한 API 호출을 막는 조합invalidateQueries: 상태가 실제로 변경됐을 때만, 성공 콜백에서만 실행refetchOnMount: 페이지 mount 시 최신값 보장 용도, 다이얼로그 내부 갱신엔 맞지 않음refetchInterval: 폴링이 필요한 경우 setInterval 없이 한 줄로 해결
옵션 하나하나가 목적이 있다는 걸 알고 쓰면, 의도치 않은 동작에서 훨씬 빠르게 원인을 찾을 수 있다는 걸 느꼈다.