나닮 캐싱 전략 — TanStack Query staleTime을 어떻게 나눴는가
나닮 프론트엔드에서 캐싱은 "빠르게 보여주기"보다 "언제 새로 가져올지 예측 가능하게 만들기"에 더 가까웠다. 처음에는 전부 기본값으로 두고 시작했는데, 페이지가 늘어나면서 어떤 화면은 너무 자주 요청하고 어떤 화면은 갱신이 늦어지는 문제가 같이 나왔다.
그래서 결국 기준을 하나 정했다. 데이터 성격별로 staleTime을 등급처럼 나누고, 실제 변경은 mutation 이후 invalidateQueries로 맞추는 방식이다.
우리가 먼저 정한 기본값
글로벌 설정은 크게 세 가지다.
staleTime: 5분retry: 1refetchOnWindowFocus: false
여기서 핵심은 refetchOnWindowFocus를 꺼둔 선택이었다. 혹시 이런 경험 있지 않은가? 탭만 잠깐 바꿨다가 돌아왔는데 쿼리가 우르르 다시 날아가서, 네트워크도 쓰고 로딩 깜빡임도 생기는 상황.
나닮은 대부분의 데이터가 "사용자 액션 이후"에만 변한다. 일기 작성, 친구 추가, 결제 같은 것들. 그래서 탭 포커스 복귀마다 재조회하기보다, mutation 성공 시점에 필요한 키만 invalidateQueries 하는 쪽이 더 안정적이었다.
staleTime을 등급으로 나눈 이유
정확히는 모르겠지만, 내 경험상 캐싱이 꼬일 때는 코드가 어려워서가 아니라 "팀 내에서 기준이 없어서"인 경우가 많았다. 그래서 쿼리마다 감으로 숫자 넣지 않게 등급표를 만들었다.
| 등급 | staleTime | 예시 |
|---|---|---|
| 정적 데이터 | Infinity | 구독 플랜, 고정 퀴즈 |
| 드물게 변경 | 30분 | 결제 상태, 추천 도서 |
| 가끔 변경 | 10분 | 페르소나 상태, 친구/통계 |
| 기본 등급 | 5분 | 일기 목록/상세, 알림 |
| 항상 최신 | 0 | 내 프로필(me) |
이렇게 나눠두면 새 API 붙일 때 "이 데이터는 어느 칸인가?"만 먼저 판단하면 된다. 숫자를 외우는 게 아니라 의미를 고르는 방식이라, 유지보수가 훨씬 쉬워졌다.
실제로 자주 헷갈렸던 두 케이스
Infinity를 너무 쉽게 주면 위험하다
처음에 "자주 안 바뀌니까 무한 캐시"를 넓게 적용했다가 한 번 크게 삽질했다. 서버 쪽 데이터가 생각보다 빨리 바뀌는 경우가 있었고, 클라이언트가 이전 값을 오래 붙잡고 있었다.
그래서 지금은 정말로 배포 전까지 거의 안 바뀌는 데이터에만 준다. 구독 플랜이나 고정 퀴즈 같은 것들.
staleTime: 0은 필요한 곳에만
반대로 모든 걸 최신으로 맞추겠다고 0을 남발하면, 캐싱 레이어를 사실상 포기하는 셈이 된다. 현재는 me처럼 "페이지 진입 시 최신이어야 UX가 맞는 값"에만 제한적으로 사용한다.
폴링은 한 군데만, 짧고 명확하게
폴링은 읽지 않은 알림 배지(notificationUnreadCount)에만 60초 주기로 걸어뒀다. Header와 MobileHeader가 같은 queryKey를 공유해서 TanStack Query가 중복 요청을 자동으로 줄여준다.
실시간처럼 보이고 싶다고 폴링을 여기저기 붙이는 건 나중에 감당이 어렵다. 최소 범위로 두고, 정말 필요한 데이터에만 쓰는 게 지금까지는 맞았다.
갱신은 항상 mutation에서 끝낸다
이 프로젝트에서 지키는 룰은 단순하다.
- 데이터 변경은 mutation으로만 수행
- 성공 시 관련 queryKey를
invalidateQueries useEffect + service.xxx()직접 호출로 캐시 우회하지 않기
const mutation = useMutation({
mutationFn: personaService.generate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['personaStatus'] })
queryClient.invalidateQueries({ queryKey: ['myPersona'] })
},
})이 패턴으로 맞추고 나니 "어디서 갱신되는지 모르겠다"는 상황이 많이 줄었다. 상태 관리가 아니라 갱신 경로를 통일한 효과가 컸다.
캐싱하지 않는 API도 있다
로그인, 회원가입, OAuth 콜백, 이메일 인증처럼 1회성 흐름은 캐싱 이점이 거의 없다. 이런 구간까지 캐시에 넣으면 오히려 사고 포인트만 늘어난다.
TIP
"전부 캐싱"보다 "캐싱할 가치가 있는 것만 캐싱"이 운영 단계에서 더 안전했다.
새 쿼리 추가할 때 체크리스트
나닮에서는 새 쿼리 붙일 때 아래 네 가지만 본다.
- 어떤 사용자 액션에서 값이 바뀌는가
- 위 등급표에서 어떤 staleTime이 맞는가
- 변경 mutation의
onSuccess에 invalidate가 들어갔는가 - queryKey 네이밍이 기존 규칙(리스트 복수형, 상세는
[key, id])을 따르는가
이 네 가지를 통과하면 캐시 이슈 대부분은 초기에 막을 수 있었다.
아직 고민 중인 부분
지금 전략은 "대부분의 변경이 사용자 액션 기반"이라는 전제에서 잘 동작한다. 앞으로 백그라운드 이벤트나 서버 주도 업데이트가 늘어나면, 일부 쿼리는 폴링 주기나 WebSocket 기반 동기화를 다시 검토해야 할 수도 있다.
즉, 지금 구성이 정답이라기보다 현재 제품 단계에서의 균형점에 가깝다. 그래도 기준 없이 숫자만 만지던 때보다는 훨씬 덜 흔들린다. 이건 확실하다.
참고한 것들
- TanStack Query 공식 문서 (Important Defaults, Query Invalidation)
- 나닮 프론트엔드
queryClient기본 설정 - 나닮 주요 페이지별 queryKey/staleTime 적용 내역