
리액트 프로젝트에서 데이터 페칭에 필수적인 지위를 차지하는 Tanstack Query의 장점은 무엇일까에 대한 쓸모있는 아티클을 찾아서 내용을 정리한다.
https://ui.dev/why-react-query
어떤 기술이 유명해진다면 그 이유는 다양하겠지만 이 아티클에서는 그 필수 요소 중 하나를 아래와 같이 정의하고 있다.
오후 다섯시 룰이란 간단히 말해서 우리같은 개발자들이 문제를 해결하는데 있어서 그 고민을 그만두게 되는 시점을 말한다.
안타깝지만 일반적인 개발자들에게 있어서 해당 문제에 대한 답이 아름답거나, 유연하거나, 다른 문제를 일으킬수 있는 가능성이 있는가에 대한 것은 중요하지 않다. 우리들에게는 오후 다섯시에 지라 티켓을 닫고 집에 갈 수 있는가? 가 더 중요한 문제이다.
그렇기 때문에 is-string과 같은 라이브러리가 주간 2,800만 다운로드를 기록하는 것이다.
다르게 생각하면, 아름답고 오후 다섯시 룰을 충족하는 어떤 해결책이 있을 수 있다는 것이다.
이 아티클은 유타의 작은 도시에 사는 한 명의 개발자가 남는 시간에 한 라이브러리를 개발했고, 해당 라이브러리는 1주에 3.3백만 다운로드를 기록하는데, 이 리액트 쿼리 라이브러리가 어떻게 오후 다섯시 룰을 지켰는가에 대한 이야기이다.
이 이야기 전에 먼저 리액트의 원리를 알아야 할 필요가 있다.
리액트란 UI를 만들기 위한 라이브러리이다. 이를 위해서 리액트는 펑션이 뷰 그 자체를 리턴할 수 있도록 하고 이를 스테이트로 조절할 수 있게 했다.
개발자는 이에 대해 스테이트에만 집중하고 리액트가 나머지를 처리해 준다.
이 기법에서 컴포넌트가 캡슐화를 담당하게 된다. 컴포넌트들의 집합이 UI의 조각을 이루고, 해당 로직과 스테이트를 관리하게 된다.
리액트적으로 생각하면 UI를 조합하는 것은 컴포넌트를 조합하는 것이고, 이것이 리액트의 가장 큰 장점이다.
하지만 실제로 개발을 하다보면 항상 UI 단에서는 처리할 수 없는 문제를 만나게 된다. 대표적으로 UI와 관련없는 로직을 재사용하고, 조합하는 것에 대한 고민이 그렇다.
이를 위해서 리액트 훅이 만들어졌는데 훅 역시 컴포넌트와 마찬가지로 조합과 재사용에 중점을 두고 설계되었다.
useState
렌더링 이후에도 남는 변수를 선언하고 변수가 변하면 리렌더를 일으킨다.
useEffect
컴포넌트의 상태를 외부 상태와 싱크한다.(props가 변화하는 것이 리렌더를 불러일으키지는 않으니까)
useRef
state처럼 변수가 렌더 이후에도 보존되지만, 변해도 리렌더를 일으키지는 않는다.
useContext
선언한 컨텍스트의 프로바이더에 보존되는 값을 얻는다.
useReducer
state와 동일하지만 reducer 패턴을 사용한다.
useMemo
cache the result of a calculation between renders
컴퓨테이션의 결과를 렌더 이후에도 남도록 캐싱한다.
useCallback
렌더 이후에도 펑션 그 자체를 캐싱한다.
useLayoutEffect
useEffect와 동일하지만 브라우저가 페인트하기 이전에 동작한다.
useAnything
그 외에도 커스텀해서 만들 수 있다.
이 훅의 등장으로 인해 리액트는 새 시대를 맞이하게 되었지만, 데이터를 어디서 페칭하는지?에 대한 의문은 그대로였다.
The closest we can get out of the box with React is fetching data inside of useEffect, and then preserving the response with useState.
위 훅으로 생각하면, 데이터는 useEffect에서 페칭해서 useState로 보존하는 것이 가장 일반적인 방법이라고 생각할 수 있다.
간단한 튜토리얼 코드를 보자.
export default function UseWhyReactQuery() {
const [id, setId] = useState<number>(1);
const [pokemon, setPokemon] = useState<PokeDataType>(
Object.create(null) as PokeDataType,
);
useEffect(() => {
const handleFetchPokemon = async () => {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
const json = await res.json();
setPokemon(json);
};
handleFetchPokemon();
}, [id]);
return (
<main className="main">
<section className="card-container absolute-center">
<PokemonCard data={pokemon} />
<ButtonGroup handleSetId={setId} />
</section>
</main>
);
}
실제로 잘 동작하는 코드지만, 실제 현업에서 사용하기에는 무리가 있다.

로딩 UI가 없다는 게 첫번째 문제인데,
이 문제는 가장 심각한 UX적인 문제인 cumulative layout shift를 일으키게 된다.
가장 쉽게 생각할 수 있는 해결방안은 프로미스가 리턴될 때 까지 빈 카드를 보여주는 방법일 것이다.
export default function UseWhyReactQuery() {
const [id, setId] = useState<number>(1);
const [pokemon, setPokemon] = useState<PokeDataType>(
Object.create(null) as PokeDataType,
);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const handleFetchPokemon = async () => {
setIsLoading(true);
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
const json = await res.json();
setPokemon(json);
setIsLoading(false);
};
handleFetchPokemon();
}, [id]);
return (
<main className="main">
<section className="card-container absolute-center">
<PokemonCard isLoading={isLoading} data={pokemon} />
<ButtonGroup handleSetId={setId} />
</section>
</main>
);
}
빈 로딩화면이 추가되어 CLS는 해결되었지만, 에러 핸들링이 없기 때문에 페칭이 실패했을 때 무한 로딩 스크린이 보일 것이다.
export default function UseWhyReactQuery() {
const [id, setId] = useState<number>(1);
const [pokemon, setPokemon] = useState<PokeDataType>(
Object.create(null) as PokeDataType,
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(null);
useEffect(() => {
const handleFetchPokemon = async () => {
setIsLoading(true);
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
if (true) {
throw new Error(`Error fetching pokemon #${id}`);
}
const json = await res.json();
setPokemon(json);
setIsLoading(false);
} catch (e: any) {
setError(e.message);
setIsLoading(false);
}
};
handleFetchPokemon();
}, [id]);
return (
<main className="main">
<section className="card-container absolute-center">
<PokemonCard error={error} isLoading={isLoading} data={pokemon} />
<ButtonGroup handleSetId={setId} />
</section>
</main>
);
}
이렇게 네트워크 요청의 가장 기초적인 세가지 핸들링인 로딩, 성공, 에러가 완성되었다.
현재 useEffect로 api의 데이터와 스테이트를 연동했고, 스테이트인 id가 변경되면 effect의 페칭이 작동하는 방식이다.
하지만 역시 여기에도 고려하지 못한 부분이 있다. 어떻게 본다면 가장 심각한 버그일 수도 있는데, 이것이 무엇일까?
페치는 비동기적으로 돌아가기 때문에 해당 요청이 언제 끝날 지 알 수가 없다. 따라서 인터넷이 불안정하거나 api 자체가 문제인 경우, 위와 같이 유저가 프로미스가 끝나는 것을 알 수 없어 계속해서 인터렉션을 시도할 수 있다! 위 ui에서 인터랙션은 id를 바꾸는 것 하나이기 때문에, 결국 요청이 여러번 일어나고 셋스테이트 역시 여러번 일어날 것이다! 드문 경우겠지만, id3인 요청이 2보다 먼저 resolve된다면 어떻게 될까? ui는 id가 3인 포켓몬을 보여준 다음2를 보여주게 되지 않을까?
이렇게 요청이 한번에 여러번 일어나는 현상을 Race condition이라고 부른다.
이 useEffect의 결점을 해결하려면 어떻게 하는 것이 좋을까?
여러번 요청이 들어가게 될 경우, 마지막 요청만 뺴고 나머지를 무시하면 될 것이다. 그렇다면 어떤 이펙트가 맨 마지막(최근)에 일어났는지 알아야 한다.
리액트의 이펙트 클린업 펑션으로 해결할 수 있다.
export default function UseWhyReactQuery() {
const [id, setId] = useState<number>(1);
const [pokemon, setPokemon] = useState<PokeDataType>(
Object.create(null) as PokeDataType,
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<any>(null);
useEffect(() => {
const handleFetchPokemon = async () => {
setIsLoading(true);
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
if (res.ok === false) {
throw new Error(`Error fetching pokemon #${id}`);
}
const json = await res.json();
setPokemon(json);
setIsLoading(false);
} catch (e: any) {
setError(e.message);
setIsLoading(false);
}
};
handleFetchPokemon();
return () => {
console.log(`The cleanup function for ${id} was called`);
};
}, [id]);
return (
<main className="main">
<section className="card-container absolute-center">
<PokemonCard error={error} isLoading={isLoading} data={pokemon} />
<ButtonGroup handleSetId={setId} />
</section>
</main>
);
}
첨가)
해당 클린업 형태를 보자.
해당 클린업 펑션이 실행되는 조건은

1.디펜던시 그래프에 담긴 밸류가 변경되었을 때, 클린업 펑션은 예전 밸류로 한번 실행한다.
2.컴포넌트 자체가 umount될때, 클린업을 실행한다.
따라서 위 코드에 담긴 콘솔은 예전 id 밸류를 바라보고 반환하게 된다.
결론적으로, 위와 같이 스테이트 id가 계속해서 바뀌는 상황이 발생한다면, 이펙트 자체는 계속해서 queue에 등록될 것이지만 최신의 effect가 들어오게 되면서 예전의 밸류를 바라보는 클린업 펑션이 실행된다.
useEffect(() => {
let ignore = false;
const handleFetchPokemon = async () => {
setIsLoading(true);
try {
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
if (ignore) {
return;
}
if (res.ok === false) {
throw new Error(`Error fetching pokemon #${id}`);
}
const json = await res.json();
setPokemon(json);
setIsLoading(false);
} catch (e: any) {
setError(e.message);
setIsLoading(false);
}
};
handleFetchPokemon();
return () => {
ignore = true;
console.log(`The cleanup function for ${id} was called`);
};
}, [id]);
따라서 해결책은 다음과 같다.
클린업 펑션이 예전 id를 바라보고 있으니까, 요청이 중첩되는 상황에서 예전 이펙트의 ignore 변수를 true로 돌려주고, 요청이 resolve되었을때 만약 ignore가 true라면 요청이 완료되었지만 setState와 에러 핸들링 자체를 돌리지 않는다!
If you were to make a PR with this code at work, more than likely someone would ask you to abstract all the logic for handling the fetch request into a custom hook. If you did that, you'd have two options. Either create a usePokemon hook, or create a more generic useQuery hook that could be used for any kind of network request.
하지만 위 코드를 pr한다면 또 다른 이야기일 수 있다. 동료들이 보기에는 해당 부분을 custom hook으로 구현한다면 더 좋지 않을까?
코드는 다음과 같을 것이다.
// useQuery 커스텀 훅
export default function useQuery(url: string) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
const handleFetch = async () => {
setData(null);
setIsLoading(true);
setError(null);
try {
const res = await fetch(url);
if (ignore) {
return;
}
if (res.ok === false) {
throw new Error(`A network error occurred.`);
}
const json = await res.json();
setData(json);
setIsLoading(false);
} catch (e: any) {
setError(e.message);
setIsLoading(false);
}
};
handleFetch();
return () => {
ignore = true;
};
}, [url]);
return { data, isLoading, error };
}
// app
export default function UseWhyReactQuery() {
const [id, setId] = useState<number>(1);
const {
data: pokemon,
isLoading,
error,
} = useQuery(`https://pokeapi.co/api/v2/pokemon/${id}`);
return (
<main className="main">
<section className="card-container absolute-center">
<PokemonCard error={error} isLoading={isLoading} data={pokemon} />
<ButtonGroup handleSetId={setId} />
</section>
</main>
);
}
처음 이렇게 추상화를 끝냈을때, 아주 자랑스러웠던 것이 기억난다.
하지만 이 커스텀 훅 역시 리액트 이펙트의 본질적인 문제를 해결해주지는 못한다. 해당 커스텀 훅을 다른 곳에서 같이 바라볼 수 없기 때문에, 똑같은 데이터를 다른 컴포넌트에서 사용하고자 할 경우, 페칭을 한번 더 해야 하는 Data duplication이 일어나는 것이다.
유저는 같은 데이터를 사용하는데도 로딩 및 페칭이 일어나는 것에 대해 이상하다고 느낄 것이다. 또한 같은 요청을 다른 곳에서 여러번 일으키는 것 역시 문제가 된다. 더해서 만약 컴포넌트 a에서 이 요청이 성공하지만 b에서는 실패하면 어떨까?
생각보다 현업에서 이런 문제를 많이 겪게 될 것이다. 그리고 아마 많은 사람들이 이런 주제에 대해 생각하지는 않을 것이다.
만약 자신이 숙련도 있는 리액트 개발자라면, 이 문제에 대해서 어떻게 생각할까? 스테이트를 컴포넌트 구조의 상위로 올려서 props로 내려주면 괜찮을까?
아니면 차라리 컨텍스트를 사용해서 글로벌하게 만들어버리면 괜찮지 않을까?
import * as React from "react"
const queryContext = React.createContext([
{},
() => {}
])
export function QueryProvider({ children }) {
const tuple = React.useState({})
return (
<queryContext.Provider value={tuple}>
{children}
</queryContext.Provider>
)
}
export default function useQuery(url) {
const [state, setState] = React.useContext(queryContext)
React.useEffect(() => {
const update = (newState) => setState((prevState) => ({
...prevState, [url]: { ...prevState[url], ...newState }
}))
let ignore = false
const handleFetch = async () => {
update({ data: null, isLoading: true, error: null })
try {
const res = await fetch(url)
if (ignore) {
return
}
if (res.ok === false) {
throw new Error(`A network error occurred.`)
}
const data = await res.json()
update({ data, isLoading: false, error: null })
} catch (e) {
update({ error: e.message, isLoading: false, data: null })
}
}
handleFetch()
}, [url])
return state[url] || { data: null, isLoading: true, error: null }
}
역시 가능하다.
첨가) 지금 최적화 관련해서 이것저것 스터디 중이지만 이런식으로 컨텍스트를 맨 상위에서 사용하는 경우, 원하지 않는 리렌더를 불러일으키기 때문에 퍼포먼스에 매우 좋지 않다! 추후 추가로 포스트를 작성할 예정이다. 이 아티클에서도 같은 점을 지적하고 있다.
하지만 이렇게 사용할 경우 리액트 컨텍스트는 다이나믹 데이터를 앱 전체에 뿌려주는 용도로는 좋지 않은데, context에서 구독하지 않는 밸류가 변경되더라도 리렌더 조건을 총족시키기 떄문이다.
그 말은 위 훅을 사용하는(구독하는) 컴포넌트가 있다면,
1.실제 해당 데이터를 사용하지 않더라도 update가 일어나는 즉시 리렌더 되어버리게 된다는 것이다.
2.만약 위에서 가정한 것처럼 같은 url을 구독하는 컴포넌트가 하나 이상이라면 더욱 심각해진다.
3.그리고 해당 데이터를 업데이트하기 위한 cache invalidation 역시 고민해볼 대상이다.
위 문제의 원인은 다음과 같다.
따라서 이 문제를 해결하기 위해선 4. 어떤것이 동기적이고 어떤것이 비동기적인 데이터인지?를 먼저 구분해야 한다.
사실 클라이언트 스테이트만 사용한다면 데이터를 예측하지 못할 일은 없다.
하지만 비동기 스테이트는, 클라이언트 스테이트가 아니다. 대부분 서버에서 가져올텐데 이것에 대해서는 다른 정의를 사용해야 한다.
클라이언트 스테이트와 다르게 해당 기록은 계속해서 유지되지만 데이터베이스에 있으므로 가져오는데 시간이 걸리기 때문이다. 이 부분이 위에서 설명한 문제를 만들게 된다.
클라이언트 스테이트는 여러 방식, 외부 라이브러리들로 조정할 수 있지만, 서버 사이드 스테이트는 위에서 확인했듯이 다른 방식으로 접근해야 한다.
여기서 리액트 쿼리가 등장한다. 하지만 한가지 잘못 알고있는것이 있다면, 리액트 쿼리는 데이터 페칭 라이브러리가 아니다.
하지만 오히려 더 좋은 일이다! 데이터 페칭이 어려운 것이 아니라, 가져온 데이터를 어떻게 핸들링하는 것이 더 중요하기 때문이다.
따라서 리액트 쿼리는 비동기적으로 가져온 비동기 서버 스테이트를 어떻게 핸들링하는 것에 관한 것이다.
실제로 리액트 쿼리는 데이터 페칭에 관여하지 않기 때문에, 이 부분만 내가 가져와 준다면 서버 스테이트는 리액트 쿼리쪽에서 관리할 수 있다.
import * as React from "react"
import PokemonCard from "./PokemonCard"
import ButtonGroup from "./ButtonGroup"
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App () {
const [id, setId] = React.useState(1)
const { data: pokemon, isLoading, error } = useQuery({
queryKey: ['pokemon', id],
queryFn: () => fetch(`https://pokeapi.co/api/v2/pokemon/${id}`)
.then(res => res.json())
})
return (
<>
<PokemonCard
isLoading={isLoading}
data={pokemon}
error={error}
/>
<ButtonGroup handleSetId={setId} />
</>
)
}
export default function Root() {
return (
<QueryClientProvider client={queryClient}>
<App/>
</QueryClientProvider>
)
}
// 해당 부분의 코드는 굳이 따로 작성하지 않았음.
// 추후 react query와 context 를 합친 사용예를 따로 포스팅할 예정.
From there, it can handle all of the dirty work that you're either unaware of, or you shouldn't be thinking about.
리액트 쿼리는 위에서 우리가 고민했던 것들(dirty work)에 대한 것이나 우리가 몰랐던 것들에 대한 해결책을 재공한다.
거기다가, 더이상 useEffect가 어떻게 동작하는지? 에 대한 연구를 하지 않아도 된다. 오후 5시간 룰을 지킬수 있게 되는것이다!