서버 상태는 왜 그냥 useState가 아닌가
서버에서 받아온 값을 useState에 넣는 일 자체는 문제 없습니다. 처음에는 목록을 받아서 보여주는 정도로 충분합니다.
다만 화면이 늘고 같은 데이터를 여러 곳에서 쓰기 시작하면, 값이 언제 최신인지, 어디까지 공유되는지, 수정 뒤 무엇을 다시 받아야 하는지가 같이 따라옵니다. 이때부터는 state 하나를 들고 있는 일보다 서버 데이터의 수명주기를 다루는 일이 됩니다.
상태가 다른 지점
useState로 관리하는 값은 대체로 브라우저가 원본입니다. input 값이나 모달 열림 여부처럼 setState를 호출한 쪽에서 바로 값을 바꿉니다.
서버 상태는 화면이 가진 값이 원본이 아닙니다. 화면에는 서버 데이터의 복사본이 있고, 다른 사용자의 변경이나 시간이 지난 뒤에는 그 복사본이 오래될 수 있습니다.
| client state | server state |
|---|---|
| 브라우저가 원본 | 서버가 원본 |
| 직접 값을 변경함 | 요청해서 다시 받아옴 |
| 주로 한 화면 안에서 사용 | 여러 화면에서 같은 데이터를 공유할 수 있음 |
| 최신성 판단이 거의 필요 없음 | cache, stale, refetch가 따라옴 |
그래서 서버 상태에는 값 외에 이런 흐름도 붙습니다.
캐시 없음
→ 첫 요청
→ fresh
→ stale
→ background refetch
→ 다시 fresh
- cache: 받아온 데이터를 다시 쓸 수 있게 저장한 값
- fresh: 아직 다시 확인하지 않아도 된다고 보는 상태
- stale: 잘못된 데이터가 아니라, 다시 확인해도 되는 상태
- refetch: 기존 데이터를 다시 요청하는 일
- invalidation: 특정 cache를 stale로 표시해 재검증 대상으로 만드는 일
처음에는 useState로도 충분하다
한 화면에서 첫 요청만 처리한다면 직접 관리해도 복잡하지 않습니다.
const [todos, setTodos] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
getTodos()
.then(setTodos)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
같은 목록을 다른 컴포넌트에서도 쓰기 시작하면 얘기가 달라집니다. cache를 어디에 둘지, 동시에 들어온 요청을 어떻게 합칠지, 다시 조회할 때 이전 목록을 유지할지까지 직접 정해야 합니다. 화면마다 비슷한 useEffect가 늘어나는 시점도 보통 여기입니다.
TanStack Query는 이 책임을 query 단위로 묶습니다.
function useTodos() {
return useQuery({
queryKey: ["todos"],
queryFn: getTodos,
staleTime: 60_000,
});
}
function TodoList() {
const { data = [], isPending, isFetching, isError } = useTodos();
if (isPending) return <p>처음 목록을 불러오는 중입니다.</p>;
if (isError) return <p>목록을 불러오지 못했습니다.</p>;
return (
<>
{isFetching && <p role="status">최신 내용을 확인하는 중입니다.</p>}
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
);
}
isPending은 아직 보여줄 데이터가 없는 첫 요청 상태에 가깝고, isFetching은 첫 요청과 background refetch를 함께 포함합니다. 이미 목록이 있는 상태에서 isFetching만 true라면 목록을 비우기보다 갱신 중이라는 표시만 남기는 쪽이 덜 끊겨 보입니다.
데이터를 수정한 뒤에는 관련 query를 stale로 돌려둘 수 있습니다.
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
invalidateQueries는 값을 바로 지우는 API가 아닙니다. 해당 query를 stale로 만들고, 현재 화면에서 사용 중이면 background refetch가 이어질 수 있습니다.
React Query에서 TanStack Query로
책 예제의 문제의식은 Redux 같은 전역 상태에 API 상태를 함께 넣을지, 서버 상태를 따로 다룰지에 있습니다. 서버가 원본인 데이터를 클라이언트 상태처럼만 관리하면 동기화 책임이 계속 남는다는 얘기입니다.
지금은 이름과 문법이 조금 바뀌었습니다.
// 전
useQuery(["todos"], getTodos);
// 후: TanStack Query v5
useQuery({
queryKey: ["todos"],
queryFn: getTodos,
});
React Query는 현재 TanStack Query라는 이름을 쓰고, v5에서는 object syntax를 사용합니다. status: "loading"도 status: "pending"으로 바뀌었습니다.
같이 봐야 하는 부분
stale은 데이터가 틀렸거나 바로 사라진다는 뜻이 아닙니다. 다시 확인해도 되는 상태에 가깝습니다. 시간이 지났다고 즉시 요청하는 것도 아니고, mount·focus·reconnect·invalidation 같은 조건에서 refetch가 일어날 수 있습니다.
cache가 있다고 요청이 멈추는 것도 아닙니다. cache를 먼저 보여주고 뒤에서 최신 데이터를 받아오는 흐름이 가능합니다.
모든 API 요청에 TanStack Query가 필요한 건 아닙니다. 한 번만 실행되고 공유·재조회·cache가 필요 없는 요청이면 직접 관리하는 편이 더 단순합니다.
실무에서 볼 점
- 필터나 페이지가 다르면
queryKey에도 조건을 넣습니다. 예:["todos", { status, page }] - 첫 loading과 background refetch를 같은 전체 화면 spinner로 보이면, 기존 데이터가 있어도 화면이 자주 비어 보일 수 있습니다.
- mutation 뒤 관련 query를 여러 곳에서 직접 고치기보다 invalidation으로 서버 값을 다시 확인하는 흐름을 둘 수 있습니다.
staleTime은 “몇 초 뒤 자동 요청”이 아니라 “그동안 fresh로 볼 시간”입니다.
예상 질문
서버 상태를 useState로 관리하면 안 되나요?
관리할 수 있습니다. 요청이 한 번이고 다른 화면과 공유하지 않는다면 useState와 useEffect만으로도 충분합니다.
다만 cache, 최신성, 중복 요청, background refetch, mutation 뒤 동기화가 반복되기 시작하면 직접 관리하는 state가 많아집니다. 그때 TanStack Query 같은 도구를 쓰면 서버 데이터의 수명주기를 query 단위로 모을 수 있습니다.