JavaScript 비동기 흐름 다시 보기 (07) - React Query는 Promise를 어떻게 UI 상태로 바꾸는가
query function의 fulfilled와 rejected 결과가 React Query의 isPending, isError, data, error, isFetching으로 이어지는 흐름을 정리합니다.
React Query는 Promise를 없애지 않습니다. query function이 반환한 Promise의 결과를 UI가 읽기 좋은 상태로 번역합니다.
들어가며
앞선 글에서는 fetch가 HTTP 404나 500을 자동으로 rejected Promise로 만들지 않는다는 점을 정리했습니다. 그래서 response.ok를 확인하고, 앱에서 실패로 다룰 응답은 직접 throw해야 했습니다.
이제 이 흐름을 React Query 관점에서 보겠습니다.
React Query는 현재 공식 문서에서는 TanStack Query라는 이름으로 다뤄집니다. 다만 React 생태계에서는 여전히 React Query라는 이름도 널리 쓰이므로, 이 글에서는 독자에게 익숙한 React Query라는 표현을 중심으로 쓰겠습니다.
우리가 직접 비동기 상태를 관리하면 보통 이런 코드를 작성합니다.
type PostState =
| { status: "pending" }
| { status: "success"; data: Post }
| { status: "error"; error: Error };
async function loadPost(id: string) {
setState({ status: "pending" });
try {
const post = await getPost(id);
setState({ status: "success", data: post });
} catch (error) {
setState({
status: "error",
error: error instanceof Error ? error : new Error("알 수 없는 오류"),
});
}
}React Query를 쓰면 이 상태 전환을 직접 모두 쓰지 않아도 됩니다.
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});하지만 상태 전환이 사라진 것은 아닙니다. React Query가 대신 추적하고, 컴포넌트가 읽을 수 있는 형태로 돌려줄 뿐입니다.
이번 글의 질문은 하나입니다.
React Query는 Promise를 어떻게 UI 상태로 바꿀까요. 기준은 query function이 resolve했는지, throw했는지, 그리고 캐시에 이전 데이터가 있는지 입니다.
query function은 Promise 경계입니다
React Query에서 가장 중요한 경계는 queryFn입니다. queryFn은 데이터를 가져오는 함수이고, Promise를 반환합니다.
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});여기서 getPost(id)가 fulfilled Promise가 되면 React Query는 성공 상태를 만듭니다. 반환된 값은 data에 들어갑니다.
반대로 getPost(id)가 rejected Promise가 되거나 함수 안에서 에러가 던져지면 React Query는 에러 상태를 만듭니다. 그 에러는 error에 들어갑니다.
즉 React Query의 상태 모델은 새로운 마법이 아닙니다. Promise의 결과를 기준으로 상태를 붙이는 구조입니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) {
throw new Error("게시글을 불러오지 못했습니다.");
}
return response.json() as Promise<Post>;
}이 함수는 성공하면 Post를 반환합니다. 실패로 볼 HTTP 응답은 직접 throw합니다. 그래서 React Query가 성공과 실패를 구분할 수 있습니다.
throw하지 않으면 React Query는 실패로 알 수 없습니다
일부러 잘못 쓴 예를 보겠습니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
return response.json() as Promise<Post>;
}이 함수는 response.ok를 확인하지 않습니다. 서버가 404 응답을 보내도 JSON 파싱이 성공하면 Promise는 fulfilled가 될 수 있습니다.
그러면 React Query 입장에서는 요청이 성공한 것입니다.
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (postQuery.isError) {
return <ErrorMessage error={postQuery.error} />;
}
return <PostView post={postQuery.data} />;이 코드는 isError가 될 것처럼 보이지만, queryFn이 throw하지 않았다면 React Query는 에러 상태로 바꿀 근거가 없습니다.
그래서 6편의 결론이 여기서 다시 중요해집니다. fetch를 쓸 때는 HTTP 응답을 앱의 성공 또는 실패로 번역해야 합니다. 실패로 볼 응답은 queryFn 안에서 rejected 흐름으로 바뀌어야 React Query의 isError로 이어집니다.
isPending, isError, isSuccess는 query의 큰 상태입니다
React Query의 useQuery 결과에는 여러 값이 들어 있습니다. 그중 가장 큰 줄기는 isPending, isError, isSuccess입니다.
function PostPage({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (postQuery.isPending) {
return <PageSkeleton />;
}
if (postQuery.isError) {
return <ErrorMessage error={postQuery.error} />;
}
return <PostView post={postQuery.data} />;
}이 코드는 직접 만든 PostState와 거의 같은 순서로 읽을 수 있습니다.
isPending은 아직 보여 줄 데이터가 없는 상태입니다. 첫 요청이 끝나지 않았고, 성공 데이터도 에러도 확정되지 않았습니다.
isError는 queryFn이 실패한 상태입니다. 이때 error를 읽어 실패 UI를 만들 수 있습니다.
isSuccess는 성공 데이터가 준비된 상태입니다. 위 코드에서는 pending과 error를 먼저 처리했기 때문에 마지막 분기에서 data를 사용합니다.
여기서 중요한 점은 컴포넌트가 Promise를 직접 await하지 않는다는 것입니다. 컴포넌트는 useQuery가 돌려준 상태를 읽습니다. React Query가 query function의 Promise를 실행하고, 그 결과를 상태로 보관합니다.
query key는 어떤 Promise 결과를 어떤 상태로 볼지 정합니다
React Query가 단순히 "Promise를 상태로 바꾼다"에서 끝나지 않는 이유는 queryKey 때문입니다.
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});queryKey는 이 요청 결과를 어떤 캐시 항목으로 볼지 정합니다. 같은 ["post", id]를 쓰는 컴포넌트는 같은 서버 상태를 바라봅니다.
직접 상태를 관리할 때는 컴포넌트마다 useState가 따로 생기기 쉽습니다. 하지만 React Query는 queryKey를 기준으로 상태를 공유합니다.
function PostTitle({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (!postQuery.data) return null;
return <h1>{postQuery.data.title}</h1>;
}
function PostSummary({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (!postQuery.data) return null;
return <p>{postQuery.data.summary}</p>;
}두 컴포넌트는 같은 queryKey를 사용합니다. 그래서 "게시글 id에 해당하는 서버 상태"를 같은 캐시 항목으로 읽습니다.
이때 React Query가 관리하는 것은 단순 데이터 값만이 아닙니다. 해당 key의 요청이 진행 중인지, 마지막 요청이 성공했는지, 실패했는지, 다시 가져오는 중인지까지 함께 관리합니다.
즉 queryKey는 Promise 결과를 어디에 저장할지 정하는 주소에 가깝습니다.
isFetching은 pending과 다릅니다
React Query를 처음 쓰면 isPending과 isFetching이 비슷해 보일 수 있습니다. 둘 다 뭔가 가져오는 중이라는 느낌을 줍니다.
하지만 둘은 다른 질문에 답합니다.
isPending은 아직 보여 줄 데이터가 없는지 묻습니다. 첫 데이터가 없고, query가 성공도 실패도 끝내지 못한 상태입니다.
isFetching은 지금 query function이 실행 중인지 묻습니다. 이미 성공 데이터가 있어도, 뒤에서 다시 가져오는 중이면 true가 될 수 있습니다.
function PostPage({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (postQuery.isPending) {
return <PageSkeleton />;
}
if (postQuery.isError) {
return <ErrorMessage error={postQuery.error} />;
}
return (
<>
{postQuery.isFetching ? <SmallRefreshIndicator /> : null}
<PostView post={postQuery.data} />
</>
);
}이 코드는 첫 진입에서는 skeleton을 보여 줍니다. 하지만 이미 데이터가 있는 상태에서 background refetch가 일어나면 화면 전체를 비우지 않습니다. 기존 data를 유지한 채 작은 갱신 표시만 보여 줍니다.
이 차이가 React Query의 실무 감각에서 중요합니다. 모든 요청 중 상태를 큰 로딩 화면으로 처리하면 화면이 자주 흔들립니다. 반대로 isFetching을 별도로 보면 "처음 기다리는 상태"와 "이미 보여 주면서 다시 맞추는 상태"를 나눌 수 있습니다.
에러도 첫 실패와 재요청 실패가 다르게 보일 수 있습니다
직접 try/catch로 상태를 만들면 실패를 하나의 rejected 상태로만 처리하기 쉽습니다. 하지만 React Query에서는 이전 데이터가 있는 상태에서 재요청이 실패할 수 있습니다.
예를 들어 사용자가 이미 게시글을 보고 있습니다. 잠시 뒤 React Query가 같은 key를 background에서 다시 가져오다가 실패했다고 하겠습니다.
이때 화면을 바로 에러 페이지로 바꿔야 할까요. 아니면 기존 데이터를 유지하고 작은 안내만 보여 주면 될까요.
function PostPage({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (postQuery.isPending) {
return <PageSkeleton />;
}
if (postQuery.isError && !postQuery.data) {
return <ErrorMessage error={postQuery.error} />;
}
return (
<>
{postQuery.isRefetchError ? (
<InlineNotice message="최신 내용을 다시 확인하지 못했습니다." />
) : null}
<PostView post={postQuery.data} />
</>
);
}이 예시는 처음 실패와 재요청 실패를 다르게 다룹니다. 처음부터 데이터가 없으면 에러 화면이 필요합니다. 하지만 이미 보여 줄 데이터가 있다면, 전체 화면을 비우지 않고 안내만 붙일 수 있습니다.
Promise만 직접 다룰 때는 이런 상태를 우리가 직접 설계해야 합니다. React Query는 이 구분을 할 수 있는 상태 정보를 제공합니다.
React Query가 대신해도 query function의 책임은 남습니다
React Query를 쓰면 컴포넌트에서 try/catch가 줄어듭니다. 하지만 실패를 성공으로 볼지 에러로 볼지 정하는 책임까지 사라지는 것은 아닙니다.
async function getPost(id: string) {
const response = await fetch(`/api/posts/${id}`);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error("게시글을 불러오지 못했습니다.");
}
return response.json() as Promise<Post>;
}이 함수는 404를 null이라는 성공 값으로 반환합니다. 그러면 React Query는 이 결과를 에러가 아니라 성공으로 봅니다.
function PostPage({ id }: { id: string }) {
const postQuery = useQuery({
queryKey: ["post", id],
queryFn: () => getPost(id),
});
if (postQuery.isPending) {
return <PageSkeleton />;
}
if (postQuery.isError) {
return <ErrorMessage error={postQuery.error} />;
}
if (postQuery.data === null) {
return <EmptyPost />;
}
return <PostView post={postQuery.data} />;
}이 구조는 나쁜 것이 아닙니다. 없는 게시글을 에러가 아니라 빈 상태로 보여 주겠다는 판단일 수 있습니다.
다만 이 판단은 명시적이어야 합니다. queryFn이 값을 반환하면 React Query는 성공으로 봅니다. queryFn이 에러를 던지면 React Query는 실패로 봅니다. UI 상태는 이 경계를 따라갑니다.
마치며
React Query는 Promise를 감춰 주지만, Promise의 결과를 없애지는 않습니다. queryFn이 fulfilled되면 data가 생기고 성공 상태가 됩니다. queryFn이 rejected되거나 에러를 던지면 error가 생기고 에러 상태가 됩니다.
여기에 queryKey와 query cache가 더해지면서 상태는 컴포넌트 하나의 지역 상태가 아니라 같은 서버 상태를 공유하는 모델이 됩니다. 그리고 isFetching 같은 상태 덕분에 처음 기다리는 로딩과 background refetch를 나눠 보여 줄 수 있습니다.
결국 시리즈 전체에서 잡아 온 기준은 그대로 이어집니다.
React Query는 비동기 흐름을 마법처럼 없애는 도구가 아닙니다. Promise의 pending, fulfilled, rejected 흐름을 query key와 cache 위에서 UI가 읽을 수 있는 상태로 번역하는 도구 입니다.
이 시리즈에서 Promise, async/await, 이벤트 루프, try/catch, Promise 조합 메서드, fetch 에러 변환을 차례로 본 이유도 여기에 있습니다. 라이브러리가 상태를 예쁘게 나눠 주더라도, 그 밑에서는 여전히 Promise가 성공했는지 실패했는지가 UI의 출발점입니다.