버그 하나를 추적하다 발견한 TanStack Query 안티패턴

버그 하나를 추적하다 발견한 TanStack Query 안티패턴

Engineering

May 25, 2026

버그를 추적하다가, 1년 반 넘게 지나치고 있던 TanStack Query 안티패턴을 발견했습니다.
결과적으로 처음 보고된 버그의 직접 원인은 따로 있었습니다.

하지만 이 추적 덕분에, 그렇지 않았다면 한참 더 지나서야 발견했을 구조적인 문제까지 함께 수정하게 됐습니다.
디버깅 과정 자체도 꽤 흥미로웠고, 그 과정에서 다시 확인하게 된 TanStack Query의 동작 방식과 교훈들을 정리해두고 싶어 글을 남깁니다.


처음 보고된 문제

튜터 페이지의 수업 완료 리스트(ClassFinishedList)에서 총 개수(totalCount)가 0으로 나온다는 현상이 한 튜터로부터 보고됐습니다.

문제가 되는 페이지를 살펴보니 몇 가지 이상한 점이 있었습니다.

  • 네트워크 응답은 정상이었습니다 (count: 22)
  • 리스트 항목(items)은 잘 렌더링되고 있었습니다
  • 하지만 화면 상단의 총 개수만 0으로 표시됐습니다
  • 새로고침하면 0 → 22 → 0으로 깜박였습니다
  • 로컬에서는 재현되지 않았고, 특정 사용자 환경에서만 발생했습니다

디버깅 과정

1단계 — queryKey 충돌 확인

가장 먼저 의심한 건 queryKey 구조였습니다.

당시 코드베이스에서는 querykey를 체계적으로 관리하고 있지 않았고, 문자열 기반 키를 화면 단위로 흩어져서 사용하는 경우가 많았습니다. 그러다 보니 서로 다른 훅이 의도치 않게 같은 키를 공유하는 상황도 종종 있었습니다.

실제로 확인해보니 useQueryuseInfiniteQuery가 동일한 queryKey를 사용하고 있었습니다.

["slotReservations", params]; // useQuery에서도, useInfiniteQuery에서도 사용 중

문제는 두 훅의 data shape가 완전히 다르다는 점이었습니다.

  • useQuery → 일반 리스트 데이터
  • useInfiniteQuery → pages 구조를 가진 infinite 데이터

이 상태에서 같은 키를 공유하면, 나중에 마운트된 쪽이 기존 캐시를 덮어쓰게 됩니다. 캐시 구조가 예상과 다르게 바뀌면서 UI가 깨지는 문제로 이어질 수 있기 때문에 우선 키를 분리했습니다.

["slotReservationsInfinite", params];

결과적으로 이번 이슈의 직접 원인은 아니었지만, 충분히 위험한 구조였습니다. 이 일을 계기로 querykey를 흩어진 문자열로 관리하지 않고, TanStack Query v5의 queryOptions 기반 query factory 패턴으로 정리하게 됐습니다.

2단계 — 캐시 상태 직접 확인

키를 분리해도 이슈는 그대로였습니다. 문제는 제 로컬에서는 재현되지 않았고, 특정 사용자 환경에서만 발생하고 있었습니다. 우선 실제로 캐시가 어떻게 쌓이고 있는지 직접 보기로 했습니다.

QueryClient에 있는 getQueryCache().getAll() 메서드로 현재 캐시 상태를 출력했습니다.

queryClient.getQueryCache().getAll();

화면에는 여러 쿼리가 사용되고 있었는데, 캐시에 남아 있는 데이터는 생각보다 훨씬 적었습니다. 특히 라우트를 이동하기 전과 후를 비교해보니 차이가 더 명확했습니다.

  • 이동 전: 여러 쿼리 캐시가 쌓여 있음
  • 이동 후: 방금 호출된 일부 쿼리만 남고 대부분 사라짐

처음에는 단순히 "라우트를 이동하면 원래 캐시가 이렇게 줄어드나?" 정도로 생각했습니다. 그런데 계속 보다 보니 뭔가 이상했습니다. TanStack Query의 캐시는 일반적으로 라우트 이동 이후에도 유지되는데, 지금은 페이지를 이동할 때마다 거의 새로 시작하는 것처럼 보였기 때문입니다.

문서를 다시 읽어보니 캐시는 QueryClient 인스턴스를 기준으로 관리되고 있었습니다. 즉 QueryClient가 새로 만들어지면 기존 캐시도 같이 사라지는 구조였습니다.

그 순간 자연스럽게 다음 질문으로 이어졌습니다.

"그러면 QueryClient는 어디서 생성되고 있지?"

그 질문을 들고 다시 App.tsx를 열었습니다.

3단계 — 1년 반 동안 못 본 한 줄

그리고 거의 바로 문제를 발견했습니다.

function App() {
  const queryClient = new QueryClient({ ... }) // App 리렌더 시마다 새 인스턴스 생성
  const location = useLocation() // 라우트 변경 시 App 리렌더
  // ...
}

QueryClient가 컴포넌트 body 안에서 생성되고 있었습니다.

즉 App이 다시 렌더링될 때마다 새로운 QueryClient 인스턴스가 만들어지고 있었고, 그 과정에서 기존 캐시도 함께 초기화되고 있었습니다.

결국 페이지를 이동할 때마다 캐시 전체가 매번 새로 시작되고 있었던 것입니다.

이상한 점은 분명 있었지만, TanStack Query가 fetch 자체는 다시 수행해주다 보니 화면은 대부분 “동작하는 것처럼” 보였습니다. 그래서 문제는 치명적인 오류보다는 로딩 깜빡임이나 뒤로가기 UX 저하 같은 형태로만 드러나고 있었습니다.

돌아보면 이 코드는 제가 입사한 뒤로도 1년 반 넘게 한 번도 의심하지 않고 지나쳤던 부분이었습니다.

그동안 화면 자체는 대부분 정상적으로 동작했고, 명확한 장애로 이어진 적도 없었기 때문에 이 부분을 의심해볼 생각 자체를 못 하고 있었습니다.

수정 자체는 단순했습니다.

// 수정 전
const queryClient = new QueryClient(...)

// 수정 후
const [queryClient] = useState(() => new QueryClient(...))

한 줄 수정의 효과

이 한 줄을 바꾼 것만으로 따라온 변화가 생각보다 컸습니다.

가장 먼저 눈에 띈 건 로딩 UX였습니다. 이전에는 페이지를 한 번 나갔다 다시 들어올 때마다 빈 화면과 스피너부터 보이는 경우가 많았는데, 수정 이후에는 캐시된 데이터가 먼저 보이고 필요한 데이터만 백그라운드에서 다시 갱신되기 시작했습니다.

여러 화면에서 보이던 UI 깜빡임도 전반적으로 줄었습니다. 처음 보고된 totalCount 문제 외에도, 데이터를 잠깐 비웠다가 다시 채우는 듯한 현상이 여러 페이지에서 함께 발생하고 있었다는 걸 그제야 알게 됐습니다.

리포트된 버그 하나를 추적했을 뿐인데, 결과적으로는 TanStack Query의 캐시 이점을 제대로 활용하지 못하고 있던 구조까지 함께 정리하게 됐습니다.


같은 실수를 반복하지 않기 위해 — ESLint 플러그인 추가

같은 종류의 실수가 다시 들어오지 않도록 재발 방지 장치를 걸어두기로 했습니다. QueryClient를 컴포넌트 body에서 생성하는 패턴은 코드 리뷰에서 사람 눈으로만 잡으려 하면 충분히 놓칠 수 있는 종류의 실수입니다.

찾아보니 TanStack Query는 공식 ESLint 플러그인을 제공하고 있었습니다.

pnpm add -D @tanstack/eslint-plugin-query@^4
{
  "extends": ["...", "plugin:@tanstack/query/recommended"]
}

설치하고 린트를 실행하면 정확히 그 줄을 짚어줍니다.

68:9  error  QueryClient is not stable. It should be either extracted from the component
             or wrapped in React.useState.  @tanstack/query/stable-query-client

게다가 stable-query-client 규칙은 autofix를 지원합니다. 저장 시 ESLint fix가 적용되도록 설정해두면 저장하는 순간 자동으로 useState로 감싸줍니다.

보너스로 따라온 규칙들

recommended에는 이번 사건 외에도 유용한 규칙이 같이 들어 있었습니다.

@tanstack/query/exhaustive-depsqueryKey에 들어가야 할 의존성을 빠뜨리면 잡아줍니다.

useQuery(["tutor"], () => fetchTutor(id));
//                                    ^^ id가 키에 없으면 경고

이걸 놓치면 "id가 바뀌었는데 이전 데이터가 그대로 나오는" 버그가 생깁니다. stable-query-client보다 훨씬 자주 사고가 나는 종류이고 눈으로 찾기도 어려워서, 이번 일을 안 겪었다면 깔아볼 생각도 못 했을 텐데 결과적으로 더 큰 사고를 미리 막은 셈이 됐습니다.

@tanstack/query/no-rest-destructuring — 성능 이슈 예방용입니다.

const { ...rest } = useQuery(...) // 리렌더 최적화가 깨집니다

useQuery의 반환 객체는 내부적으로 추적 최적화가 들어 있어서, rest 패턴으로 통째로 펼치면 그 최적화가 무력화됩니다. 동작은 하니까 코드 리뷰에서 잘 잡히지 않는 종류인데, 린트가 걸러주면 깔끔합니다.

이번 일을 계기로, 라이브러리를 도입할 때 공식 ESLint 플러그인이 있는지 확인하고 같이 설치하는 것을 기본 루틴으로 삼기로 했습니다.


그런데 진짜 원인은 따로 있었다

위 수정사항을 합쳐서 배포했지만, 처음 제보한 튜터의 환경에서 다시 확인해보니 여전히 0 → 22 → 0으로 깜빡이고 있었습니다. 다른 화면들의 깜빡임은 분명히 좋아졌는데, 처음 보고된 그 화면만큼은 똑같았습니다.

환경을 좁혀봤습니다.

  • 시크릿 탭 → 정상
  • 다른 크롬 프로필 → 정상
  • 제보자의 크롬 프로필 → 재현

크롬 프로필 단위로 차이가 난다는 것은 확장 프로그램이나 프로필 설정의 영향이라는 뜻이었습니다. 해당 프로필로 학생 페이지에 접속해보니 페이지가 한글로 잠깐 보였다가 영어로 바뀌었습니다. 구글 번역 확장 프로그램이 자동으로 켜져 있었던 것입니다.

튜터 페이지로 돌아가서 "Always translate Korean" 옵션을 해제하고 다시 테스트해보니, 깜빡임이 사라졌습니다.

직접 원인: React DOM과 구글 번역의 충돌

번역기가 숫자를 바꾼 것이 아니라, 번역 과정에서 텍스트 노드 자체를 교체하면서, React가 관리하던 노드 참조가 끊어지는 것이 원인이었습니다. React는 Virtual DOM 기준으로 이전 노드를 추적하고 있는데, 번역기가 실제 DOM 노드를 교체해버리면 React 입장에서는 자신이 관리하던 노드가 갑자기 사라진 상태가 됩니다.

  1. React가 Total 22 Classes 같은 텍스트 노드를 DOM에 렌더링합니다
  2. 구글 번역기가 DOM을 순회하며 텍스트 노드를 영어 노드로 교체합니다
  3. React가 추적하던 원본 노드는 사라집니다
  4. 다음 리렌더 시 React가 새 노드를 만드는데, 이때 초기값(0)이나 이전 상태가 잠깐 노출됩니다

튜터용 인터페이스는 영어로 제공되므로 튜터 페이지에서 한국어 → 영어 번역기가 트리거되지 않게끔 lang 설정을 손보면 되겠다고 생각했습니다.
확인해보니 index.html<html lang="ko">로 시작했고, 튜터 페이지에서는 React Helmet이 마운트된 후에야 lang="en"으로 교체되는 구조였습니다. 이 짧은 사이에 페이지가 한국어로 인식되어 번역기가 트리거된 것이었습니다.

튜터 페이지는 영어 네이티브가 주 사용자이고 수동 번역이 발생할 확률은 낮기에, index.html에 React보다 먼저 실행되는 인라인 스크립트를 추가했습니다.

<script>
  if (location.pathname.startsWith("/tutor")) {
    document.documentElement.lang = "en";
  }
</script>

회고

이번 일을 겪으면서, 라이브러리를 단순히 "사용해본다"와 실제 동작 방식을 이해하는 건 꽤 다르다는 걸 많이 느꼈습니다.

평소에는 그냥 지나갈 수 있는데, 디버깅처럼 "이게 정상인가?"를 빠르게 판단해야 하는 순간에 그 차이가 크게 드러났습니다.

부수적으로 두 가지 습관도 함께 챙기게 됐습니다. 하나는 물려받은 코드를 한 번은 의심의 시선으로 다시 보는 것. "문제가 보고된 적 없는 코드" 는 자연스럽게 검토 대상에서 빠지기 쉽습니다. 다른 하나는 TanStack Query Devtools 같은 도구를 평소 개발 흐름 안에 두는 것. 이번에도 Devtools는 1년 반 동안 프로젝트에 붙어 있었지만 "필요할 때 여는 도구" 정도로 취급하고 있었습니다.

정리

  • QueryClientuseState 또는 모듈 스코프에서 생성해 인스턴스가 유지되도록 한다
  • useQueryuseInfiniteQueryqueryKey를 분리한다
  • 라이브러리를 도입할 때는 기본 동작 방식을 한번쯤 직접 확인해본다
  • 공식 ESLint 플러그인과 Devtools가 있다면 초기에 함께 도입한다
  • 로컬에서 재현되지 않는 버그는 브라우저 확장 프로그램이나 환경 차이도 함께 의심해본다
  • React가 관리하는 DOM을 외부 스크립트가 변경하면 예상하지 못한 문제가 발생할 수 있다

마치며

"totalCount가 0으로 나옵니다" 라는 한 줄짜리 버그 리포트에서 시작해서, 결과적으로는 1년 넘게 지나치고 있던 구조 문제와 브라우저 확장 프로그램 이슈까지 함께 발견하게 된 작업이었습니다.

이번 일을 겪으면서 가장 크게 느낀 건, 화면에 데이터가 보인다고 해서 라이브러리가 의도한 방식대로 동작하고 있는 건 아닐 수 있다는 점이었습니다. 실제로 이번 문제도 화면 자체는 대부분 "동작하는 것처럼" 보였기 때문에 더 오래 숨어 있었습니다.

또 하나 인상적이었던 건, 디버깅에서는 결국 "이게 원래 정상 동작이 맞나?" 를 빠르게 판단하는 감각이 중요하다는 점이었습니다. 그 기준이 없으면 이상한 현상을 봐도 한동안은 그냥 지나치게 되는 경우가 많았습니다.

그동안은 TanStack Query를 데이터를 편하게 가져오는 도구 정도로 생각했던 부분도 있었는데, 이번 일을 겪으면서 서버 상태의 흐름 자체를 관리하는 라이브러리라는 걸 다시 체감하게 됐습니다.

앞으로 비슷한 문제를 만나더라도, 겉으로 드러난 증상만 빠르게 수정하고 끝내기보다는 왜 그런 현상이 나타났는지 한 단계 더 들어가서 확인해보려 합니다.