간단하게 사용해보는 React Query

Lee Sang Hyuk·2023년 2월 20일
0

React

목록 보기
2/4
post-thumbnail

점점 어려워지는 Data Fetching

웹 또는 앱의 기능을 구현하는데 서버를 통해서 데이터를 가져올 상황이 아주 많습니다. 특히, React를 이용하는 상황에서 데이터를 가져오기 위해 API를 호출하여 적절한 state에 집어넣게 되고 state가 변경된 것을 감지하여 원하는 데이터 및 컴포넌트를 도출할 수 있습니다. 대표적으로 아래와 같은 코드로 많이 구현한 경험이 있었습니다.

function App() {
  const [todoData, setTodoData] = useState([]);
  const fetchTodoData = useCallback(async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/todos");
    const json = await response.json();
    console.log(json);
    setTodoData(json);
  }, []);

  useEffect(() => {
    fetchTodoData();
  }, [fetchTodoData]);

  return (
    <div>
      {todoData.map(({ id, title, completed }) => (
        <ul>
          <li>{id}</li>
          <li>{title}</li>
          <li>{completed ? "YES" : "NO"}</li>
        </ul>
      ))}
    </div>
  );
}

여기까지는 문제가 없었습니다. 아주 간단한 일이라고 생각했지만, 사용자 중점으로 다양한 요구사항을 구현해야 하는 상황이 왔을 때 코드가 점점 복잡해지고 사소한 영역에서 신경 써야하는 일이 많아졌습니다. 아래와 같은 상황이 대표적이였습니다.

  • API를 호출하는 동안 로딩중 또는 에러인지 사용자한테 보여주기
  • 새로고침 필요없이 사용자가 상호작용하면 최신 데이터를 유지하는 것
  • 데이터가 같은데도 불필요한 렌더링이 발생하는 것
  • 전역 상태로 공유할 수 있으니 비동기 처리가 가능한 전역 상태 라이브러리 활용

Context API를 넘어 Redux, Recoil, Zustand와 같은 전역 상태 라이브러리를 활용하여 캐싱, 로딩중, 에러에 대한 구현을 시도했습니다. 그래도 여전히 코드의 복잡함은 남아있었고 전역 상태가 사용자의 데이터 및 서버 데이터를 혼용해서 저장한 결과 상태의 모호함이 느껴졌습니다. 혼자서는 이해하고 넘어갈 수 있었지만 다른 사람한테 코드를 쉽게 설명하기가 어려울 정도로 민망했으며 Data Fetching에 대해서 고민을 해보기로 했습니다. 이런 문제점을 한번에 해결할 수 있었던 것은 바로 React Query였습니다.

React Query를 경험해보자!

https://www.zigae.com/static/bc3e2663a884437e074dc018c8f4e59f/c1b63/rq-logo.png

정의

React Query는 데이터 Fetching, 캐싱, 동기화, 서버 쪽 데이터 업데이트 등을 쉽게 만들어 주는 React 라이브러리입니다. 기존에 Redux, Mobx, Recoil과 같은 다양하고 훌륭한 상태 관리 라이브러리들이 있긴 하지만, 클라이언트 쪽의 데이터들을 관리하기에 적합할 순 있어도 서버 쪽의 데이터들을 관리하기에는 적합하지 않은 점들이 있어서 등장하게 되었습니다.
“My구독의 React Query 전환기”, 카카오테크 블로그

Overview | TanStack Query Docs

Lifecycle

  • fetching: 데이터를 요청한 상태
  • fresh : 데이터가 만료되지 않는 상태
    • 컴포넌트 상태가 변경되더라도 데이터를 다시 요청하지 않습니다.
    • 단, 새로고침은 다시 요청
  • stale : 데이터가 만료된 상태(기본값 0)
    • 최신화가 필요한 상태로 판단되어 컴포넌트 상태가 변경되면 데이터를 다시 요청
  • inactive: 사용하지 않는 상태(기본값 5분)
  • delete : Garbage Collector에 의해 캐시에서 제거된 상태

별도의 옵션을 적용하지 않는 상태로 한 쿼리를 마운트하면 데이터를 네트워크 통해서 가져와 fresh 상태로 바뀝니다. 이후, staleTime에 의해 쿼리는 stale 상태로 변경되어 컴포넌트 상태 변경, refocus 상태로 인해 데이터를 다시 가져옵니다. 다른 쿼리를 이용할 경우, 기존 쿼리는 inactive 상태로 변경되어 5분이 지나면 delete가 됩니다.

useQuery

useQuery | TanStack Query Docs

👉 useQuery(queryKey, queryFn?, options?) ⇒ ({ data, status, isError, isLoading, … })
  • queryKey : 필수, unique값으로 선언 필요
    • string, array, hash 형태로 구분 가능
  • queryFn : 필수, default queryFn이 선언된 경우 필요없음
    • 데이터를 가져오는 함수
  • options : object 형태로 다양한 값이 존재
    • enabled, staleTime, cacheTime, onSuccess, onError, initialData 등 다양
    • 더 많은 옵션들은 공식 문서로 참고
// useTodoData.ts

interface ITodoData {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

const useTodoData = () => {
  const { data, isLoading, error } = useQuery<ITodoData[]>(
    "todo",
    async () => {
      const response = await fetch(
        "https://jsonplaceholder.typicode.com/todos"
      );
      const result = await response.json();
      return result;
    },
    { initialData: [] }
  );

  return { data, isLoading, error };
};

// App.tsx

function App() {
  const { data, isLoading, error } = useTodoData();

  if (isLoading) {
    return <div>loading</div>;
  }

  if (error) {
    return <div>error</div>;
  }

  return (
    <div>
      {data?.map(({ id, title, completed }) => (
        <ul key={`item_${id}`}>
          <li>{id}</li>
          <li>{title}</li>
          <li>{completed ? "YES" : "NO"}</li>
        </ul>
      ))}
    </div>
  );
}

useQuery를 쓸 때, 상황별로 필요한 값이나 옵션들은 적절하게 선언해 Custom Hook으로 감싸줬습니다. useQuery라는 hook 하나로 데이터 처리, 캐싱, 동기화가 바로 해결되니까 속이 시원했습니다. 이제, 데이터를 가져오는 것은 문제가 없지만 사용자의 상호작용에 의해 자연스럽게 데이터를 최신 상태로 보여줄 수 있는 방법은 useMutation과 같은 함수를 사용하면 됩니다. 서버로 데이터를 Insert, Update, Delete가 필요할 경우에 사용합니다.

useMutation

useMutation | TanStack Query Docs

👉 useMutation(mutationFn, options?) ⇒ ({ mutation, data, status, isError, isLoading, … })
  • mutationFn: 필수, POST/DELETE/PUT과 같은 API 호출할때 사용
  • options: object 형태로 다양한 값이 존재
    • mutation에 대한 상황별 로직 처리가 가능
    • 더 많은 옵션들은 공식 문서로 참고
// useTodoMutation.ts

const postTodoData = async () => {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    body: JSON.stringify({
      title: "foo",
      body: "bar",
      userId: 1,
    }),
    headers: {
      "Content-type": "application/json; charset=UTF-8",
    },
  });
  const result = await response.json();
  return result;
};

const useTodoMutation = () => {
  const { mutate, isLoading, error } = useMutation(postTodoData, {
    onSuccess: () => {
      console.log("onSuccess");
    },
    onError: () => {
      console.log("onError");
    },
    onSettled: () => {
      console.log("onSettled");
    },
  });

  return { mutate, isLoading, error };
};

// App.tsx

function App() {
	const { mutate } = useTodoMutation();
	...
	return (
		...
		<button onClick={mutate}></button>
		...
	)
}

useMutation을 정의해주면 우리가 어떤 이벤트를 발생시킬 때 mutate를 사용하면 됩니다. onSuccess, onError, onSettled과 같은 함수들은 굳이 선언할 필요없이 기본값으로 사용할 수 있으나 최신 데이터 상태를 위해서 별도의 로직을 구현할 필요가 있습니다. 그래서, useMutation을 사용할 때, React Query에서 제공해 다음과 같은 기능들을 참고할 필요있습니다.

  • invalidateQueries
  • setQueryData
  • Optimistic Updates

invaildateQueries

만약, 쿼리의 상태가 fresh이면 stale로 변경할 시간 이전에 사용자가 새로운 데이터를 입력해도 동일한 데이터를 볼 수 밖에 없습니다. 새로운 데이터는 이미 서버에 저장된 상태인데 이전 데이터를 보여주면 사용자는 당연히 혼란이 올 수 있기에 invaildateQueries 함수를 통해 query key의 유효성을 제거할 수 있습니다.

const queryClient = useQueryClient();

const useTodoMutation = () => {
  const { mutate, isLoading, error } = useMutation(postTodoData, {
    onSuccess: () => {
			queryClient.invaildateQueries('todo');
      console.log("onSuccess");
    },
    ...
  });

  return { mutate, isLoading, error };
};

유효성을 제거하면 캐싱되어 있는 데이터를 없애고 새로운 데이터를 가져올 수 있게 서버로 부터 요청합니다.

Optimistic Updates

이것도 Mutation를 사용할 때, 함께 고려하면 좋을 것 같아서 읽어봤습니다. Mutation의 성공, 실패 여부를 확인하기 전 성공할 것이라는 낙관적인 가정을 가지고 미리 화면의 UI를 바꿔줍니다. 그리고, 결과에 따라 확정 또는 Rollback 처리가 됩니다.

const queryClient = useQueryClient();

const useTodoMutation = () => {
  const { mutate, isLoading, error } = useMutation(postTodoData, {
		...
		onMutate: async (newTodo) => {
			// 해당된 쿼리의 연산에 영향 가지 않도록 정지
			await queryClient.cancelQueries("todo");
			// Snapshot(이전 쿼리 값을 가져온다)
			const previousTodo = queryClient.getQueryData("todo");
			// 캐시 데이터를 우선 수정하여 UI를 변경한다.
			queryClient.setQueryData("todo", (prevData) => ({ ...prevData, data: [...prevData.data, ...newTodo]}));
			// 에러 발생 대비를 위한 스냅샷 데이터 반환
			return { previousTodo };
		},
		// 에러에 대한 롤백 처리 구문
    onError: (error, payload, context) => {
			// context의 스냅샷 데이터로 캐시 데이터 원상 복구
			queryClient.setQueryData(
	      "todo",
	      context.previousTodo,
	    );
    },
		// 정보 변경 완료 시(onSuccess도 포함) 쿼리 갱신
		onSettled: () => {
			queryClient.invalidateQueries("todo");
		}
  });

  return { mutate, isLoading, error };
};

Refetching이 발생되는 시점?

React Query를 사용할 때, 아무래도 Refetching 시점을 알아야 효율적으로 코드를 작성하거나 에러를 찾기가 쉬울 것 같습니다.

  • 브라우저에 포커스가 들어왔을 경우 (refetchOnWindowFocus)
  • 새로 마운트가 되었을 경우 (refetchOnMount)
  • 네트워크가 끊어졌다가 다시 연결된 경우 (refetchOnReconnect)
  • stale 상태인 데이터를 Refetching

하지만, 쿼리의 옵션에 따라 위에 있는 상황임에도 불구하고 refetching을 방지할 수 있습니다. 결국, query key 및 옵션을 얼마나 적절하게 사용하느냐에 따라 사용자가 적절하게 최신 데이터를 볼 수 있고 효율적인 캐싱이 이루어지지 않을까 싶습니다.

일단, React Query를 알아보는 상황에서 좀 더 배워야 할 점들이 많기에 유용했던 점들을 다음에 이어서 작성하겠습니다.

profile
개발자가 될 수 있을까?

0개의 댓글