react-query v3를 TanStack Query v5로 올리려고 보니 useErrorBoundary가 사라져 있었다
react-query v3에서 TanStack Query v5로 가면서 useErrorBoundary가 throwOnError로 바뀌었다. 인자 객체 통일, isPending, onSuccess 제거까지 무엇이 바뀌었고 왜 그렇게 바꿨는지 정리했다.
#TanStack Query #react-query #프론트엔드 #리팩토링 #React
예전에 react-query(v3)랑 useErrorBoundary로 API 에러를 한 곳에서 처리하는 글을 썼다. 그 구조를 잘 쓰고 있었는데, 언젠가는 올려야지 하고 TanStack Query v5 마이그레이션 문서를 열어봤더니 그 useErrorBoundary가 throwOnError 로 이름이 바뀌어 있었다. 내 옛날 코드를 그대로 올리면 안 도는 거다. 이 글에서는 v3에서 v5로 오면서 뭐가 바뀌었는지, 그리고 왜 그렇게 바꿨는지 를 정리해본다. 변경 이유를 보면 이 라이브러리가 뭘 후회했는지가 보인다. 일단 이름부터 바뀌었다 react-query는 v4부터 이름이 @tanstack/react-query 로 바뀌었다. React 말고 Vue, Svelte에서도 같은 코어를 쓰게 되면서, 특정 프레임워크 이름을 떼고 TanStack Query라는 우산 아래로 들어간 거다. import 경로부터 갈아야 한다. // v3 import { useQuery, QueryClient } from 'react-query'; // v4 부터 import { useQuery, QueryClient } from '@tanstack/react-query'; useErrorBoundary가 throwOnError로 내 49번 글의 핵심이던 옵션이다. 쿼리가 실패하면 에러를 ErrorBoundary로 던지는 그 옵션인데, 이름이 throwOnError 로 바뀌었다. 이유는 단순하다. 이름이 기능을 더 정확하게 설명 하기 때문이다. "에러 바운더리를 쓴다"가 아니라 "에러를 throw한다"가 실제 동작이다. 던진 에러를 누가 받는지는 이 옵션이 알 바가 아니다. // v3 useQuery('posts', fetchPosts, { useErrorBoundary: true }); // v5: 이름도 바뀌고, 인자 구조도 바뀐다 useQuery({ queryKey: ['posts'], queryFn: fetchPosts, throwOnError: true }); // v5 에서는 함수로도 줄 수 있다. 서버 에러만 바운더리로 던지고 404는 화면에서 처리하는 식 useQuery({ queryKey: ['posts'], queryFn: fetchPosts, throwOnError: (error) = error.response?.status = 500, }); 마지막 예시가 v3에는 없던 것이다. 내 ApiErrorBoundary는 모든 실패를 바운더리로 넘기고 fallback 안에서 statusCode를 보고 갈라치기하는데, v5라면 던질지 말지를 쿼리 쪽에서 먼저 고를 수 있다. 인자가 객체 하나로 통일됐다 위 코드에서 봤듯, 호출 방식 자체가 바뀌었다. v3는 useQuery(키, 함수, 옵션)처럼 인자를 넣는 방법이 여러 가지 였다. 키만 넣어도 되고, 함수까지 넣어도 되고, 객체 하나로 넣어도 됐다. 이런 오버로드가 너무 많았다. v5는 이걸 다 없애고 객체 하나 로 통일했다. queryKey, queryFn을 객체에 담아 넘긴다. 왜 이렇게 바꿨냐면, 오버로드가 많을수록 라이브러리 내부에서 "지금 들어온 인자가 어떤 형태냐"를 런타임에 매번 확인해야 했고, 타입 정의도 그만큼 복잡해졌기 때문이다. 입구를 하나로 줄여서 유지보수랑 타입을 단순하게 만든 거다. 쓰는 쪽에서도 키가 문자열인지 배열인지 헷갈릴 일이 없어진다. 상태 이름도 바뀌었다, loading이 pending으로 이것도 헷갈리던 걸 바로잡은 변경이다. 기존 status의 'loading' 이 'pending' 으로, isLoading이 isPending으로 바뀌었다. 대신 새로운 isLoading이 생겼는데, 이건 isPending && isFetching 으로 정의된다. 즉 "데이터가 아직 없고(첫 로딩) + 지금 가져오는 중"일 때만 참이다. "처음 불러오는 중"과 "백그라운드에서 갱신 중"을 명확히 구분하려고 이름을 정리한 거다. v3에서는 이 둘을 isLoading과 isFetching으로 구분했는데, 이름만 봐서는 뭐가 뭔지 알기 어려웠다. const { data, isPending, isFetching, isLoading } = useQuery({ queryKey, queryFn }); // isPending 데이터가 아직 없다 (캐시에도 없다) // isFetching 지금 요청이 나가 있다 (첫 요청이든 백그라운드 갱신이든) // isLoading 둘 다 참일 때. 즉 처음 불러오는 중일 때만 useQuery에서 onSuccess와 onError 콜백이 빠졌다 이게 처음엔 제일 당황스러웠다. 쿼리 성공이나 실패 시 부르던 onSuccess, onError 콜백이 useQuery에서 통째로 제거 됐다. 이유가 설득력 있다. 이 콜백들이 리렌더링마다 일관되게 불리지 않아서 사람들이 자꾸 헷갈리고 버그를 냈기 때문이다. 캐시된 데이터를 쓸 땐 안 불리기도 해서, "왜 안 불리지?"가 반복됐다. 같은 쿼리를 컴포넌트 두 곳에서 쓰면 콜백이 두 번 도는 문제도 있었다. 그래서 부수효과는 useQuery 밖에서 다루도록 정리됐다. 데이터가 오면 뭘 해야 한다면 useEffect로, 요청 단위의 성공 처리라면 mutation 쪽으로 옮기라는 얘기다. 헷갈리는 기능을 보강하는 대신 아예 빼버린 결정이다. 그 외 바뀐 것들 keepPreviousData 옵션은 placeholderData 로 흡수됐고, Suspense를 쓸 거면 전용 훅인 useSuspenseQuery 를 따로 쓰게 됐다. 그리고 v5는 내부적으로 useSyncExternalStore를 써서 React 18 이상이 필수 다. 내 프로젝트들은 전부 18이라 여기서 걸리진 않는다. 다행히 공식 codemod가 있어서, 이름 바뀐 것들은 어느 정도 자동으로 갈아준다고 한다. 완벽하진 않아도 손이 많이 줄 것 같다. # v4 - v5 codemod. 호출 형태를 객체 하나로 바꿔준다 (경로는 공식 문서 기준, 버전에 따라 다를 수 있다) npx jscodeshift ./src \ --extensions=ts,tsx \ --parser=tsx \ --transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.cjs 그런데 정작 올리지는 않았다 솔직하게 적어두면, 이 글을 다시 손보는 2026년 9월 기준으로 내 프로젝트 중에 v5를 쓰는 건 하나도 없다. react-query를 쓰는 프로젝트를 다 열어봤더니 전부 이렇다. blog-server/blog "react-query": "^3.39.3" couple-server/couple-restaurant-share "react-query": "^3.39.3" game-server/jaeyonging-game "react-query": "^3.39.3" taro-server/taroSite "react-query": "^3.39.3" 그래서 49번 글에서 만든 ApiErrorBoundary도 여전히 옛날 옵션 이름을 쓰고 있다. 위에 적은 변경점들은 마이그레이션 문서를 따라가면서 내 코드에 대입해본 것이지, 실제로 올리면서 겪은 게 아니다. 올리면 되는데 안 하는 이유는 단순하다. 블로그는 잘 돌고 있고, 안 돌 때만 손대게 된다 . 개인 프로젝트를 여러 개 굴리다 보니 "잘 도는 것"이 곧 "가장 낡은 것"이 되어가고 있다. 그래도 옵션 이름이 바뀐 건 알고 있어야 나중에 올릴 때 덜 헤맨다. 지금 v3나 v4를 쓰고 있다면 마이그레이션 문서의 throwOnError와 onSuccess 항목만이라도 미리 읽어두길 추천한다.