🔥 React Query 자주 묻는 질문
강의 목차
지난 18개월 동안 React Query에 관한 수많은 질문에 답변해왔습니다. 커뮤니티에 참여하고 질문에 답변하는 일이 제가 오픈 소스 활동을 시작하게 된 계기였고, 이 React Query 관련 글 시리즈를 쓰게 된 큰 요인이기도 합니다.
여전히 잘 정리된 질문, 특히 흔하지 않은 종류의 질문에 답변하는 일에 열정을 느낍니다. 좋은 질문이 무엇인지 모르거나 알고 싶다면 제 글 "어떻게 하면 될까요?"를 참고해보세요.
하지만 몇 가지 반복되는 질문들도 있었습니다. 이런 질문들은 제게는 대답하기 쉽지만, 글로 정리하려면 여전히 약간의 노력이 필요합니다. 이 글의 주된 목적이 바로 여기에 있습니다. 이런 질문들을 다시 보게 될 때 사람들에게 안내할 수 있는 또 하나의 자료를 만들고자 합니다.
더 이상 지체하지 않고, 가장 많이 나온 질문들과 그에 대한 제 의견을 소개하겠습니다:
어떻게 다시 가져오기(refetch)에 매개변수를 전달할 수 있나요?
간단한 답변은 여전히 '할 수 없습니다'입니다. 하지만 여기에는 아주 좋은 이유가 있습니다. 대부분의 경우, 여러분이 그렇게 하고 싶다고 생각할 때 실제로는 그럴 필요가 없습니다.
주로 매개변수와 함께 다시 가져오기를 하려는 코드는 다음과 같은 모습을 띱니다:
const { data, refetch } = useQuery({ queryKey: ['item'], queryFn: () => fetchItem({ id: 1 }), }) <button onClick={() => { // 🚨 이렇게 작동하지 않습니다 refetch({ id: 2 }) }})>아이템 2 보기</button>
javascript
매개변수나 변수는 쿼리의 종속성입니다. 위 코드에서는 쿼리 키를 ['item']
으로 정의했습니다. 따라서 우리가 가져오는 모든 것은 해당 키 아래에 저장됩니다. 다른 id로 다시 가져오기를 하더라도 키는 같기 때문에 캐시의 같은 위치에 쓰여질 것입니다. 그래서 id 2가 id 1의 데이터를 덮어쓰게 됩니다. id 1로 다시 전환하면 그 데이터는 사라지게 됩니다.
서로 다른 응답을 다른 쿼리 키 아래에 캐싱하는 것은 React Query의 가장 큰 장점 중 하나입니다. 가상의 "매개변수와 함께 다시 가져오기" API는 이 기능을 없애버릴 것입니다. 이것이 refetch
가 같은 변수로 요청을 다시 실행하는 데에만 사용되는 이유입니다. 즉, 여러분이 정말로 원하는 것은 refetch
가 아니라 다른 id에 대한 _새로운 가져오기_입니다!
React Query를 효과적으로 사용하려면 선언적 접근 방식을 받아들여야 합니다: 쿼리 키는 쿼리 함수가 데이터를 가져오는 데 필요한 모든 종속성을 정의합니다. 이 방식을 따르면 다시 가져오기를 위해 해야 할 일은 종속성을 업데이트하는 것뿐입니다. 더 현실적인 예제는 다음과 같습니다:
const [id, setId] = useState(1) const { data } = useQuery({ queryKey: ['item', id], queryFn: () => fetchItem({ id }), }) <button onClick={() => { // ✅ 명시적으로 다시 가져오기하지 않고 id를 설정합니다 setId(2) }})>아이템 2 보기</button>
javascript
setId
는 컴포넌트를 다시 렌더링하고, React Query는 새로운 키를 감지하여 해당 키에 대한 가져오기를 시작합니다. 또한 id 1과는 별도로 캐시에 저장합니다.
선언적 접근 방식은 또한 id를 어디서 어떻게 업데이트하든 상관없이 쿼리 데이터가 항상 그것과 "동기화"되도록 합니다. 따라서 여러분의 사고방식은 "그 버튼을 클릭하면 다시 가져오기를 하고 싶다"에서 "항상 현재 id에 대한 데이터를 보고 싶다"로 바뀌게 됩니다.
또한 그 id를 useState
에 저장할 필요는 없습니다 - 클라이언트 측 상태를 저장하는 다른 방법(zustand, redux 등)으로도 할 수 있습니다. 위의 예제에서는 URL도 id를 저장하기에 좋은 장소가 될 수 있습니다:
const { id } = useParams() const { data } = useQuery({ queryKey: ['item', id], queryFn: () => fetchItem({ id }), }) // ✅ URL을 변경하여 useParams가 이를 감지하게 합니다 <Link to="/2">아이템 2 보기</Link>
javascript
이 접근 방식의 가장 좋은 점은 상태를 관리할 필요가 없고, 공유 가능한 URL을 얻을 수 있으며, 사용자가 아이템 간을 이동할 때 브라우저 뒤로 가기 버튼도 잘 작동한다는 것입니다.
로딩 상태
쿼리 키를 전환하면 쿼리가 다시 하드 로딩 상태로 들어가는 것을 알 수 있습니다. 이는 예상된 동작입니다. 키를 변경하고 그 키에 대한 데이터가 아직 없기 때문입니다.
이 전환을 부드럽게 만들 수 있는 방법이 여러 가지 있습니다. 예를 들어 그 키에 대한 placeholderData를 설정하거나 새 키에 대한 데이터를 미리 prefetching하는 방법이 있습니다. 이 문제를 해결하는 좋은 접근 방식은 쿼리에게 이전 데이터를 유지하도록 지시하는 것입니다:
import { keepPreviousData } from '@tanstack/react-query' const { data, isPlaceholderData } = useQuery({ queryKey: ['item', id], queryFn: () => fetchItem({ id }), // ⬇️ 이렇게 합니다️ placeholderData: keepPreviousData, })
javascript
이 설정을 사용하면 React Query는 id 2에 대한 데이터를 가져오는 동안에도 여전히 id 1의 데이터를 보여줍니다. 또한 쿼리 결과의 isPlaceholderData
플래그가 true로 설정되므로 UI에서 그에 따라 처리할 수 있습니다. 아마도 데이터와 함께 배경 로딩 스피너를 보여주거나, 데이터가 오래되었음을 나타내기 위해 불투명도를 추가하고 싶을 수 있습니다. 이는 전적으로 여러분의 선택입니다 - React Query는 단지 그렇게 할 수 있는 수단을 제공할 뿐입니다. 🙌
업데이트
v5 이전에는 useQuery에 별도의 keepPreviousData: true 플래그를 전달해야 했지만, 이제는 placeholderData와 결합되었습니다. 자세한 내용을 알고 싶다면 RFC를 읽어보세요.
왜 업데이트가 보이지 않나요?
쿼리 캐시와 직접 상호작용할 때, 뮤테이션 응답에서 업데이트를 수행하거나 뮤테이션에서 무효화를 하려고 할 때, 가끔 업데이트가 화면에 반영되지 않거나 단순히 "작동하지 않는다"는 보고를 받습니다. 이런 경우, 대개 두 가지 문제 중 하나로 귀결됩니다:
1: 쿼리 키가 일치하지 않음
쿼리 키는 결정론적으로 해시됩니다. 따라서 참조 안정성이나 객체 키 순서를 고려할 필요가 없습니다. 그러나 queryClient.setQueryData
를 호출할 때는 여전히 키가 기존 키와 완전히 일치해야 합니다. 예를 들어, 다음 두 키는 일치하지 않습니다:
['item', '1'] ['item', 1]
javascript
키 배열의 두 번째 값이 첫 번째 예제에서는 _문자열_이고 두 번째 예제에서는 _숫자_입니다. 이는 보통 숫자로 작업하지만 useParams
로 URL에서 읽을 때 문자열을 얻는 경우에 발생할 수 있습니다.
React Query 개발자 도구는 이 경우 가장 좋은 친구입니다. 어떤 키가 존재하고 어떤 키가 현재 가져오고 있는지 명확히 볼 수 있기 때문입니다. 하지만 이런 까다로운 세부 사항에 주의를 기울이세요!
이 문제를 해결하는 데 도움이 되도록 TypeScript와 쿼리 키 팩토리를 사용하는 것을 추천합니다.
2: QueryClient가 안정적이지 않음
대부분의 예제에서는 App
컴포넌트 _외부_에 queryClient를 생성합니다. 이렇게 하면 참조적으로 안정적이 됩니다:
// ✅ App 외부에서 생성됨 const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <Example /> </QueryClientProvider> ) }
javascript
QueryClient
는 QueryCache
를 보유합니다. 따라서 새 클라이언트를 생성하면 새 캐시도 얻게 되며, 이는 비어 있을 것입니다. 클라이언트 생성을 App
컴포넌트 내부로 옮기고, 컴포넌트가 다른 이유로 (예: 라우트 변경) 다시 렌더링되면 캐시가 버려집니다:
export default function App() { // 🚨 이는 좋지 않습니다 const queryClient = new QueryClient() return ( <QueryClientProvider client={queryClient}> <Example /> </QueryClientProvider> ) }
javascript
App
내부에서 클라이언트를 생성해야 한다면, 인스턴스 ref나 React 상태를 사용하여 참조적으로 안정적이도록 만드세요:
export default function App() { // ✅ 이는 안정적입니다 const [queryClient] = React.useState(() => new QueryClient()) return ( <QueryClientProvider client={queryClient}> <Example /> </QueryClientProvider> ) }
javascript
이 주제에 대해 별도의 블로그 포스트가 있습니다: 일회성 초기화를 위한 useState.
왜 useQueryClient 를 사용해야 하나요... 클라이언트를 그냥 가져오면 되는데?
QueryClientProvider
는 생성된 queryClient
를 React Context에 넣어 앱 전체에 배포합니다. 이를 가장 잘 읽는 방법은 useQueryClient
를 사용하는 것입니다. 이는 추가적인 구독을 만들지 않으며 추가적인 리렌더링을 유발하지 않습니다(클라이언트가 안정적일 경우 - 위 참조). 단지 클라이언트를 prop으로 전달하는 것을 피할 수 있게 해줍니다.
alternatively, 클라이언트를 내보내고 필요한 곳에서 가져올 수 있습니다:
// ⬇️ 가져올 수 있도록 내보냅니다 export const queryClient = new QueryClient() export default function App() { return ( <QueryClientProvider client={queryClient}> <Example /> </QueryClientProvider> ) }
javascript
훅을 사용하는 것이 선호되는 이유는 다음과 같습니다:
1: useQuery도 훅을 사용합니다
useQuery
를 호출할 때, 내부적으로 useQueryClient
를 호출합니다. 이는 React Context에서 가장 가까운 클라이언트를 찾습니다. 큰 문제는 아니지만, 가져온 클라이언트가 Context의 클라이언트와 다른 상황이 발생한다면 추적하기 어려운 버그가 생길 수 있습니다. 이는 피할 수 있습니다.
2: 앱을 클라이언트로부터 분리합니다
App
에서 정의한 클라이언트는 프로덕션 클라이언트입니다. 프로덕션에서 잘 작동하는 기본 설정이 있을 수 있습니다. 하지만 테스트에서는 다른 기본값을 사용하는 것이 좋을 수 있습니다. 예를 들어, 테스트 중에는 재시도를 끄는 것이 좋습니다. 오류가 있는 쿼리를 테스트하는 것이 시간 초과로 이어질 수 있기 때문입니다.
의존성 주입 메커니즘으로 사용될 때 React Context의 큰 장점은 앱을 의존성으로부터 분리한다는 것입니다. useQueryClient
는 단지 트리 위에 어떤 클라이언트가 있기를 기대합니다 - 특정 클라이언트를 기대하지 않습니다. 프로덕션 클라이언트를 직접 가져오면 이 장점을 잃게 됩니다.
3: 때로는 내보낼 수 없습니다
때로는 App
컴포넌트 내부에서 queryClient
를 생성해야 할 필요가 있습니다(위에서 보여준 것처럼). 예를 들어 서버 사이드 렌더링을 사용할 때, 여러 사용자가 동일한 클라이언트를 공유하는 것을 피하고 싶을 때입니다.
마이크로프론트엔드를 사용할 때도 마찬가지입니다 - 앱은 격리되어야 합니다. App
외부에서 클라이언트를 생성하고 같은 페이지에서 동일한 App
을 두 번 사용하면, 클라이언트를 공유하게 됩니다.
마지막으로, queryClient
의 기본값에서 다른 훅을 사용하고 싶다면 App
내부에서 생성해야 합니다. 모든 실패한 뮤테이션에 대해 토스트를 보여주는 전역 오류 핸들러를 고려해보세요:
export default function App() { // ✅ App 외부에서는 useToast를 사용할 수 없습니다 const toast = useToast() const [queryClient] = React.useState( () => new QueryClient({ mutationCache: new MutationCache({ // ⬇️ 하지만 여기서는 필요합니다 onError: (error) => toast.show({ type: 'error', error }), }), }) ) return ( <QueryClientProvider client={queryClient}> <Example /> </QueryClientProvider> ) }
javascript
따라서 이런 방식으로 queryClient
를 생성한다면, 그냥 내보내서 앱에서 가져올 방법이 없습니다.
클라이언트를 내보내고 싶은 가장 좋은 이유는 쿼리 무효화를 수행해야 하는 레거시 클래스 컴포넌트에서 작업하는 경우일 것입니다 - 그리고 그곳에서는 훅을 사용할 수 없습니다. 그런 경우라면, 그리고 함수형 컴포넌트로 쉽게 리팩토링할 수 없다면, 렌더 prop 버전을 만드는 것을 고려해보세요:
const UseQueryClient = ({ children }) => children(useQueryClient())
javascript
사용법:
<UseQueryClient> {(queryClient) => ( <button onClick={() => queryClient.invalidateQueries({ queryKey: ['items'] })} > 아이템 무효화 </button> )} </UseQueryClient>
javascript
그리고 참고로, useQuery나 다른 훅에 대해서도 같은 작업을 할 수 있습니다:
const UseQuery = ({ children, ...props }) => children(useQuery(props))
javascript
사용법:
<UseQuery queryKey={["items"]} queryFn={fetchItems}> {({ data, isPending, isError }) => ( // 🙌 여기서 jsx를 반환합니다 )} </UseQuery>
javascript
왜 오류가 발생하지 않나요?
네트워크 요청이 실패하면, 이상적으로는 쿼리가 error
상태로 전환되기를 원할 것입니다. 그렇게 되지 않고 여전히 성공한 쿼리가 보인다면, queryFn
이 실패한 Promise를 반환하지 않았다는 의미입니다.
기억하세요: React Query는 상태 코드나 네트워크 요청에 대해 전혀 알지 못하고 신경 쓰지도 않습니다. queryFn
이 제공해야 하는 해결되거나 거부된 Promise가 필요할 뿐입니다.
React Query가 거부된 Promise를 보면, 잠재적으로 재시도를 시작하고, 오프라인 상태일 때 쿼리를 일시 중지하고, 결국 쿼리를 오류 상태로 전환할 수 있습니다. 따라서 이를 올바르게 처리하는 것이 매우 중요합니다.
fetch API
다행히도 axios나 ky와 같은 많은 데이터 가져오기 라이브러리는 4xx나 5xx와 같은 오류 상태 코드를 실패한 Promise로 변환합니다. 따라서 네트워크 요청이 실패하면 쿼리도 실패합니다. 주목할 만한 예외는 내장된 fetch API입니다. 이는 네트워크 오류로 인해 요청이 실패한 경우에만 실패한 Promise를 제공합니다.
이는 물론 여기에 문서화되어 있지만, 이를 놓친 경우에는 여전히 걸림돌이 될 수 있습니다.
잘못된 fetch API 예제:
useQuery({ queryKey: ['todos', todoId], queryFn: async () => { const response = await fetch('/todos/' + todoId) // 🚨 4xx나 5xx는 오류로 처리되지 않습니다 return response.json() }, })
javascript
이를 극복하려면 응답이 _ok_인지 확인하고 그렇지 않다면 거부된 Promise로 변환해야 합니다:
올바른 fetch API 예제:
useQuery({ queryKey: ['todos', todoId], queryFn: async () => { const response = await fetch('/todos/' + todoId) // ✅ 4xx와 5xx를 실패한 Promise로 변환합니다 if (!response.ok) { throw new Error('네트워크 응답이 정상이 아닙니다') } return response.json() }, })
javascript
로깅
자주 보는 두 번째 이유는 로깅 목적으로 queryFn
내에서 오류를 잡는 경우입니다. 오류를 다시 던지지 않으면 암시적으로 성공한 Promise를 반환하게 됩니다:
잘못된 로깅 예제:
useQuery({ queryKey: ['todos', todoId], queryFn: async () => { try { const { data } = await axios.get('/todos/' + todoId) return data } catch (error) { console.error(error) // 🚨 여기서 빈 Promise<void>가 반환됩니다 } }, })
javascript
이렇게 하고 싶다면 오류를 다시 던지는 것을 잊지 마세요:
올바른 로깅 예제:
useQuery({ queryKey: ['todos', todoId], queryFn: async () => { try { const { data } = await axios.get('/todos/' + todoId) return data } catch (error) { console.error(error) // ✅ 여기서 실패한 Promise가 반환됩니다 throw error } }, })
javascript
오류를 처리하는 대안적이고 덜 장황한 방법은 QueryCache의 onError
콜백을 사용하는 것입니다. 오류 처리에 대한 다양한 방법에 대해 더 자세히 알고 싶다면 #11: React Query 오류 처리를 읽어보세요.
왜 queryFn
이 호출되지 않나요?
때때로 queryFn
이 호출되어야 하는데 그렇지 않다는 버그 리포트를 받습니다. 이런 일이 발생할 때 가장 가능성 있는 이유는 initialData
와 staleTime
을 함께 사용했기 때문입니다:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, initialData: [], staleTime: 5 * 1000, })
javascript
중요한 점은 initialData
가 새로운 캐시 항목이 생성될 때마다 고려되고, 그 데이터가 캐시에 들어간다는 것입니다. 데이터가 캐시에 있으면 React Query는 그것이 어디서 왔는지 신경 쓰지 않습니다(실제로 알지도 못합니다). queryFn
에서 왔을 수도 있고, 수동으로 queryClient.setQueryData
를 호출했기 때문일 수도 있고, initialData
때문일 수도 있습니다.
staleTime
설정과 결합하면, 이 initialData
는 이제 다음 5초 동안 fresh
로 간주됩니다. 따라서 이 useQuery
인스턴스의 "마운트"는 백그라운드 재가져오기를 트리거하지 않습니다. 왜 그래야 할까요 - 캐시에 신선한 데이터(빈 배열)가 있습니다. 이는 staleTime
이 전역적으로 적용되고 useQuery
자체에 적용되지 않을 때 특히 알아채기 어렵습니다.
여기서 핵심은 initialData
는 동기적으로 사용 가능한 "실제" 데이터가 있을 때만 사용해야 한다는 것입니다 - 사용자를 위해 기꺼이 캐시할 수 있는 데이터입니다. 빈 배열은 아마도 실제 데이터가 가져와질 때까지 보여주고 싶은 "대체"에 가깝습니다. 이런 사용 사례에는 placeholderData
가 더 적합합니다:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, placeholderData: [], staleTime: 5 * 1000, })
javascript
placeholderData
는 절대 캐시되지 않기 때문에 항상 백그라운드 재가져오기를 받게 됩니다. placeholderData
와 initialData
의 차이점에 대해 여기에서 더 자세히 읽어볼 수 있습니다.
또 다른 해결책(실제로는 우회책)은 initialData
가 처음부터 stale
하다고 명시하는 것입니다. 기본적으로 React Query는 initialData
를 캐시에 넣을 때 Date.now()
를 사용합니다. 하지만 initialDataUpdatedAt
으로 이를 사용자 정의할 수 있습니다. 0
(또는 과거의 어떤 시간)으로 설정하면 백그라운드 업데이트를 트리거하는 데 잘 작동한다는 것을 알게 되었습니다:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, initialData: [], initialDataUpdatedAt: 0, staleTime: 5 * 1000, })
javascript
이 동작을 발견하기 어려운 또 다른 상황은 동적 쿼리 키를 사용할 때입니다. 예를 들어 페이지네이션된 쿼리의 경우:
const [page, setPage] = React.useState(0) const { data } = useQuery({ queryKey: ['todos', page], queryFn: () => fetchTodos(page), initialData: initialDataForPageZero, staleTime: 5 * 1000, })
javascript
여러분은 page:0
인 쿼리에만 initialData
가 캐시에 들어가고, page
가 0
에서 1
로 바뀔 때 queryFn
이 호출된다고 표현하고 싶었을 것입니다.
하지만 그렇지 않습니다. 다른 쿼리 키를 가진 쿼리는 캐시에 완전히 새로운 쿼리입니다. 이는 여러분의 컴포넌트나 이전에 다른 쿼리 키를 사용했다는 것에 대해 전혀 알지 못합니다. 즉, initialData
가 (위와 같이 지정된 경우) 그 쿼리에도 적용될 것입니다.
우리가 해야 할 일은 어떤 쿼리가 initialData
를 받아야 하는지 매우 구체적으로 지정하는 것입니다:
const [page, setPage] = React.useState(0) const { data } = useQuery({ queryKey: ['todos', page], queryFn: () => fetchTodos(page), initialData: page === 0 ? initialDataForPageZero : undefined, staleTime: 5 * 1000, })
javascript
이렇게 하면 page
가 0
일 때만 initialData
가 적용되고, 다른 페이지로 전환할 때는 queryFn
이 호출됩니다.
이러한 접근 방식을 통해 React Query를 더 효과적으로 사용할 수 있으며, 데이터 가져오기와 관련된 일반적인 문제들을 피할 수 있습니다. 쿼리 키, 클라이언트 안정성, 오류 처리, 그리고 초기 데이터 관리에 주의를 기울이면 React Query의 강력한 기능을 최대한 활용할 수 있습니다.