Previewlocked to source

프로젝트에서 다이얼로그를 닫을 때마다 목록이 깜빡이는 현상이 생겼습니다. 원인을 추적하다 보니 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 헷갈리지 않게 구분하기

staleTimegcTime은 비슷해 보이지만 역할이 다릅니다.

  • 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 없이 한 줄로 해결

옵션 하나하나가 목적이 있다는 걸 알고 쓰면 의도치 않은 동작에서 훨씬 빠르게 원인을 찾을 수 있다는 걸 느꼈습니다.

postsReact Query 캐싱 전략과 invalidateQueries 올바른 사용 패턴.md
010203040506070809101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
> 프로젝트에서 다이얼로그를 닫을 때마다 목록이 깜빡이는 현상이 생겼습니다. 원인을 추적하다 보니 React Query 캐싱 전략을 제대로 이해하지 못하고 쓰고 있었다는 걸 깨달았고 그 과정에서 정리한 내용을 공유합니다.
# enabled + staleTime 조합으로 불필요한 API 호출 줄이기
다이얼로그가 자주 열리고 닫히는 UX라면 쿼리를 아무 조건 없이 실행하는 건 낭비입니다. 저는 처음에 `useQuery`를 그냥 컴포넌트 안에 선언해두었는데 다이얼로그가 열릴 때마다 API가 쏠리는 게 마음에 걸렸습니다.
해결책은 `enabled` 조건과 `staleTime`을 함께 쓰는 것이었습니다.
```typescript
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 기반 프로젝트에서는 아래 원칙을 세웠습니다.
```typescript
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`를 넣어두었습니다.
```typescript
// ❌ 잘못된 패턴 — 닫기만 해도 무조건 refetch
const handleClose = () => {
queryClient.invalidateQueries({ queryKey: ['beaconList'] });
setOpen(false);
};
```
이렇게 하면 사용자가 그냥 "취소" 버튼을 눌러 닫아도 목록 데이터가 refetch되면서 깜빡이는 현상이 생깁니다. 실제로 변경된 게 없는데 쓸데없이 API가 호출되는 것입니다.
올바른 패턴은 이렇습니다.
```typescript
// ✅ 올바른 패턴 — 성공했을 때만 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의 동작 방식
```typescript
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`을 쓰면 됩니다.
```typescript
const { data } = useQuery({
queryKey: ['monitoringData'],
queryFn: fetchMonitoringData,
refetchInterval: 5000, // 5초마다 자동 재조회
});
```
이 한 줄이면 끝입니다. TanStack Query가 알아서 5초마다 재조회해주기 때문에 별도의 `setInterval` 로직을 작성할 필요가 없습니다. 저는 처음에 `useEffect` 안에 `setInterval`로 폴링을 구현했다가 이 방법으로 바꿨는데 코드가 훨씬 간결해졌습니다.
---
# Query 훅 설계 패턴 — 필터 분류와 일관성
프로젝트에서 여러 API를 Query 훅으로 캡슐화하다 보니 패턴이 제각각이 되는 문제가 생겼습니다. 그래서 아래 기준으로 분류해서 통일했습니다.
**필터가 있는 경우 — selectParams / contentParams 분리**
```typescript
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 패턴 — 필터가 없는 경우**
```typescript
// 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 없이 한 줄로 해결
옵션 하나하나가 목적이 있다는 걸 알고 쓰면 의도치 않은 동작에서 훨씬 빠르게 원인을 찾을 수 있다는 걸 느꼈습니다.
hov_i [main] ⚡