ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 리액트의 데이터 관리 React Query vs Asynchronous Recoil
    web 2023. 5. 8. 05:38

    들어가며

    리액트의 서버 데이터 관리란?

    리액트의 데이터 관리 방법에 대해 알아보기 이전에, '데이터 관리'란 무엇인지 짚고 넘어갈 필요가 있다.

    데이터 관리란 변화하는 데이터들을 관리하는 것인데, 상태의 초기 값을 저장하거나, 현재 상태의 값을 읽거나, 새로운 데이터로 상태를 업데이트 하는 등의 행위를 일컫는다. 데이터 관리는 곧 상태 관리라고 보아도 무방하다. 많은 상태들 중에서도 서버에서 받아오는 서버 데이터의 상태를 관리하는 것이 데이터 관리이기 때문이다. 리액트에서 관리해야하는 상태들은 크게 내부 상태와 서버 데이터가 있을 텐데, 오늘은 그 중에서도 서버 데이터를 관리하는 방법에 대해 집중적으로 공부해보자.

    기존의 서버 데이터 관리

    (1) state와 props

    리액트에서 상태를 관리할 때에는 state와 props를 사용한다. 서버 데이터를 패칭해 온 뒤, 이 데이터 상태를 state에 담고 자식 컴포넌트에 props로 내려주는 방식이다. 하지만 리액트의 상태관리에 대해 관심을 가져본 사람들이라면 알 수 있듯, state가 멀리멀리 props로 내려가게 되면 상태를 관리하기 어려워진다. 이 데이터가 어디로부터 왔는지 판단하기 어려워지기 때문이다. 이러한 문제점을 Props Drilling이라고 하며, 아래의 사진이 이를 잘 표현해주고 있다.

    이러한 문제점들을 해결해서 데이터를 조금 더 간편하고 쉽게 관리하기 위해 나온 것이 바로 Context API와 Redux이다.

     

    (2) Context API

    Context

    import React, { createContext, useState, useCallback } from 'react';
    
    const initialState = {};
    
    export const Context = createContext(initialState);
    
    export const Provider = ({ children }) => {
      const [data, setData] = useState(initialState);
    
      const fetchData = useCallback(async () => {
        try {
          const respond = await fetch(API);
          const result = await respond.json();
          setData({
            data: result,
          });
        } catch (error) {
          console.error('Error', error);
        }
      }, []);
    
      return (
        <Context.Provider
          value={{
            data,
            fetchData,
          }}
        >
          {children}
        </Context.Provider>
      );
    };

    Component

    import { Context } from 'Context';
    import React, { useContext, useEffect } from 'react';
    
    export const Component = () => {
      const { data, fetchData } = useContext(Context);
    
      useEffect(() => {
        fetchData();
      }, []);
    
      return <div>Data</div>;
    };

    위의 코드는 서버에서 데이터를 가져와 저장하는 부분을 Context내부에서 처리했기 때문에 `const { data, fetchData } = useContext(Context);` 와 같이 데이터를 가져오면 된다. 때문에 위에서 언급했던 props drilling 문제는 해결되었지만, Context API에는 데이터를 가져오는 과정을 표현한 다양한 프로퍼티들이 따로 존재하지 않는다.

    데이터를 가져오는 과정에서는 데이터를 가져오는 중을 표현하는 isLoading, 데이터를 가져오는 도중 에러가 났을 때 에러를 처리하는 error, 에러 여부를 알려주는 IsError 등의 프로퍼티가 있을 수 있다. 하지만 Context API에는 이러한 프로퍼티들이 따로 존재하지 않기 때문에, state값이 바뀌었을 때를 인지해서 로딩 중인지, 에러가 났는지 등을 일일이 처리해주어야한다.

     

    Context

    const fetchData = useCallback(async () => {  
    try {
      // Context에서 프로퍼티를 처리해주고 싶다면 다음과 같이 state를 일일이 변경해주어야한다. 
    
      setData((prev) => ({  
        ...prev,  
        error: undefined,  
        isError: false,  
        isLoading: true,  
      }));  
      const respond = await fetch(API);  
      const result = await respond.json();  
      setData({  
        data: result,  
      });  
    } catch (error) {  
      // error 처리  
      setData((prev) => ({  
        ...prev,  
        isError: true,  
        error,  
      }));  
    } finally {  
      setData((prev) => ({  
        ...prev,  
        isLoading: false,  
      }));  
    }  
    
    }, []);
    

    Component

    const { data, fetchData } = useContext(Context);
    
    useEffect(() => {  
    // Context의 data값 중 isLoading이 변경될 때마다 Loading 처리  
    }, [data.isLoading]);
    
    useEffect(() => {  
    // Context의 data값 중 isError가 변경될 때마다 Error 처리  
    }, [data.isError]);
    

    그리고 Component에서useEffect를 이용해 Context의 state값이 변경될 때마다 로딩과 에러를 처리한다. 이러한 작업들을 데이터를 사용해야하는 모든 컴포넌트들마다 해야한다고 생각하면 머리가 어질어질하다. 프로젝트가 커질수록 수많은 Context를 만들고, 컴포넌트에 유사한 useEffect코드들을 여러번 작성하면서 보일러플레이트 코드들이 정말 많이 생성될 것이다. Context가 많아지면 불필요하게 state값이 변경되면서 불필요한 렌더링이 발생할 수도 있고, 코드가 간결하지도 않다는 문제가 있다.

     

    * 보일러플레이트 코드란? 최소한의 변경으로 여러곳에서 재사용되며, 반복적으로 비슷한 형태를 띄는 코드

     

    (3) Redux

    그렇다면 가장 많이 이용되고 있는 상태관리 라이브러리인 Redux는 어떻게 데이터를 관리할까?

     

    Reducer

    const initialState = {
      data: undefined,
      isLoading: false,
      isError: false,
      error: undefined,
    };
    
    // Action Type
    const SET_DATA = 'SET_DATA';
    const SET_ERROR = 'SET_ERROR';
    const SET_LOADING = 'SET_LOADING';
    
    // Action creator
    // redux-thunk 를 활용한 비동기 액션 함수
    export const fetchData = () => async (dispatch) => {
      try {
        dispatch(setLoading());
        const respond = await fetch(API);
        const result = await respond.json();
        dispatch(setData(result));
      } catch (error) {
        // error 처리
        dispatch(setError(error));
      }
    };
    
    export const setData = (parameter) => ({
      type: SET_DATA,
      payload: parameter,
    });
    
    export const setLoading = () => ({
      type: SET_LOADING,
    });
    
    export const setError = (parameter) => ({
      type: SET_ERROR,
      payload: parameter,
    });
    
    // reducer
    export const reducer = (state = initialState, action) => {
      switch (action.type) {
        case SET_DATA: {
          return {
            ...state,
            data: action.payload,
            isLoading: false,
            isError: false,
            error: undefined,
          };
        }
        case SET_LOADING: {
          return {
            ...state,
            isLoading: true,
          };
        }
        case SET_ERROR: {
          return {
            ...state,
            isLoading: false,
            isError: true,
            error: action.payload,
          };
        }
        default: {
          return {
            ...state,
          };
        }
      }
    };

    Component

    import React, { useEffect } from 'react';
    import { useDispatch, useSelector } from 'react-redux';
    import { fetchData } from 'store';
    
    export const Component = () => {
      const dispatch = useDispatch();
      const { data, isLoading, isError } = useSelector((state) => state);
    
      useEffect(() => {
        // Loading 처리
      }, [isLoading]);
    
      useEffect(() => {
        // Error 처리
      }, [isError]);
    
      useEffect(() => {
        // 비동기 액션 함수 call
        dispatch(fetchData());
      }, []);
    
      return <div>Data</div>;
    };

    위의 Context API와 유사하게, reducer에서 값이 변경될 때마다, component는 useEffect로 변경을 캐치해서 처리해주고 있다.

    Context나 Redux 모두 props로 내려주는 데이터 관리 방식의 문제점은 해결했지만, 결국 정말 많은 보일러플레이트 코드를 작성해야한다는 문제점이 있다.

     

    데이터 관리의 아쉬움

    결론적으로, 기존의 데이터 관리 방법에서는 정말 많은 보일러플레이트 코드를 작성해야한다는 점, 로딩과 에러를 관리하기 쉽지 않다는 점이 가장 큰 아쉬움으로 남고 있다. 뿐만 아니라 로딩과 에러 이외의 관리해야할 상태가 또 생긴다면 보일러플레이트 코드는 더욱 더 많아질 것이다. 클라이언트 내부의 상태뿐만 아니라, 서버의 데이터 상태를 관리하게 되면 정말 여러컴포넌트에서 사용될 데이터를 관리해야한다. 그렇기 때문에 데이터를 어떻게 관리해야할지 생각하는 것은 중요한 사안이고, 기존 라이브러리를 이용해서 데이터 관리를 했을 때의 아쉬움을 아쉬움을 해결해줄 라이브러리로 React Query가 등장했다.

     

    React Query

    서버 데이터 가져오기

    React Query에서의 서버 데이터 관리는 앞선 라이브러리의 방식들보다도 훨씬 간결하다.

     

    Component

    import React from 'react';
    import { useQuery } from 'react-query';
    
    export const Component = () => {
      // Queries
      const { data, isLoading, isError } = useQuery('fetchKey', fetchData);
    
      const fetchData = useCallback(() => fetch(API), [])
    
      if (isLoading) {
        // Loading 처리
      }
    
      if (isError) {
        // Error 처리
      }
    
      return <div>Data</div>;
    };
    

    여기서 중요한 코드는 바로 이 부분이다.

    const { data, isLoading, isError } = useQuery('fetchKey', fetchData);

    fetchData를 통해 API를 호출하고, 고유가 key값으로 감싸준다. 여기서는 fetchKey라는 고유 키 이름으로 data를 관리한다. 그리고 useQuery로 감싸주면 자동으로 isLoading, isError와 같은 비동기 상태를 알려준다. 위에서 일일이 isLoading, isError를 담은 state값을 변경시키고 useEffect로 변경되었는지를 관리하던 것과 다르게 리액트 쿼리에서는 이렇게 바로 프로퍼티를 이용할 수 있다. 뿐만 아니라 위에서 사용했던 방법은 Context, Reducer를 많이 만들 수록 state값이 많아지고 그로 인해 불필요한 렌더링이 일어날 수 있다는 문제가 있었는데, 리액트 쿼리에서는 cache time을 이용해 이 문제점을 해결할 수도 있다. cache time이란, 데이터가 캐싱되고 부여한 cache time만큼 캐시가 유지되는 것을 의미한다. 즉, 컴포넌트가 렌더링될 때마다 API를 요청했던 기존 데이터 관리 방식과 다르게, 리액트 쿼리에서는 cache time을 적절히 활용하여 이미 캐싱된 활성화 데이터가 있다면 API를 요청하지 않을 수 있기 때문에 불필요한 API요청을 줄일 수 있게 된다.

    이에 대해서 더 자세히 알기 위해서는 리액트 쿼리가 데이터의 상태를 어떻게 표현하는지 알아야한다. 리액트 쿼리는 데이터 상태를 다음과 같이 5가지로 나타낸다.

     

    fresh - 만료되지 않은 쿼리. 컴포넌트가 마운트, 업데이트되어도 데이터를 다시 요청하지 않는다
    fetching - 요청 중
    stale - 만료된 쿼리. 컴포넌트가 마운트, 업데이트되면 데이터를 다시 요청한다.
    inactive - 사용하지 않는 쿼리. 일정 시간이 지나면 가비지 컬렉터가 캐시에서 제거한다

     

    cacheTime

    위에서 언급한 cacheTime을 다시 톺아보자면, cacheTime은 데이터가 inactive 상태일 때 캐싱된 상태로 남아있는 시간을 의미한다. 쿼리 인스턴스가 unmount 되면 데이터는 inactive 상태로 변경되며, 캐시는 cacheTime만큼 유지되고 만일 이 cacheTime이 지나면 가비지 콜렉터로 수집이 된다. 만일 cacheTime이 지나기 전에 쿼리 인스턴스가 다시 마운트 되면, 데이터를 fetch하는 동안 캐시 데이터를 보여준다. 그리고, cacheTime은 staleTime과 관계없이, 무조건 inactive 된 시점을 기준으로 캐시 데이터 삭제를 결정한다.

     

    staleTime

    staleTime은 데이터가 fresh상태에서 stale 상태로 변경되는데 걸리는 시간을 의미한다. 데이터가 fresh 상태일때는 쿼리 인스턴스가 새롭게 mount 되어도 네트워크 fetch가 일어나지 않는다. 또한, 데이터가 한번 fetch 되고 나서 staleTime이 지나지 않았다면 unmount 후 mount 되어도 fetch가 일어나지 않는다. 즉, 만일 staleTime을 30분으로 설정해두었다면 30분 동안 컴포넌트가 마운트, 업데이트되어도 서버에 재요청을 보내지 않게 되는 것이다.

    이외에도 리액트 쿼리에서는 refetchOnWindowFocus: false, retry: 0와 같은 속성을 줄 수도 있다.

    refetchOnWindowFocus: false는 window focus될 때마다 불필요한 요청을 하지 않게 한다는 뜻이고, retry: 0은 API요청이 필요하였을 때 내부적으로 다시 요청을 0번 보낸다는 뜻이다.

     

    서버 데이터 업데이트하기

    export const CartOptions = () => {
      const { data } = useGetCarts();
    
      const queryClient = useQueryClient();
    
      const updateOptionCountMutation = useMutation(
        'cart',
        variables => CartApi.updateCartItem(variables),
        {
          onMutate: variables => {
            const context = queryClient.getQueryData('cart');
            // 사용자 인터렉션을 위해 먼저 API응답이 오기전에 UI를 바꿔주기 위함
            queryClient.setQueryData('cart', () => '새로운 data');
            return context;
          },
          onError: (error, variables, context) => {
            // onMutate 함수에서 return 한 context 값
            // 따라서 에러 발생시 기존에 data로 롤백시켜줌
            queryClient.setQueryData('cart', context);
            alert('수량 변경에 문제가 생겼습니다.');
          },
          onSuccess: (data, variables, context) => {
            // 비동기 처리가 성공하면 'cart'키를 가진 쿼리를 invalid시켜준다.
            // invalid되면 React Query 는 다시 요청을 하게 되고 자연스럽게 서버와 sync 된다.
            queryClient.invalidateQueries('cart');
          },
        },
      );
    
      return (
        <>
          {data.map(cart => (
            <CartOption
              key={cart.id}
              cart={cart}
              onChangeOptionAmount={() => updateOptionCountMutation.mutate(cart)}
            />
          ))}
        </>
      );
    };

    리액트쿼리를 이용해서 서버 데이터를 업데이트하는 경우에는 useQuery가 아니라 useMutation을 이용한다. onMute는 일시적으로 데이터를 제공할 수 없는 경우를 의미한다. 또한 API 에 대한 응답이 오고 상황에 따라 onError, onSucess 가 불리우게 된다. onError가 불리는 경우에는 error parameter에 에러에 대한 값이 들어오고 onSuccess가 불리는 경우에는 data parameter에 API 에 대한 응답이 들어오게 된다. onError, onSucess 각각의 variables 는 기존에 mutate 메소드에서 넣어주었던 parameter, context 는 onMutate 함수에서 리턴한 context 가 들어오게 됩니다. (여기서는 'cart' 키로 기존에 가져왔던 Query 데이터가 들어온다. )

    이 밖에도 리액트 쿼리를 이용하면, isLoading, error 처리뿐만 아니라 pagination을 처리할 수 있게 되고, pagination을 사용하면 hasNextPage, isFetchingNextPage, fetchNextPage등의 자동생성된 프로퍼티들을 사용할 수 있다.

     

    이렇게 리액트 쿼리에 내장된 비동기 과정을 이용하는 방법 외에도 리액트의 Suspense를 이용하는 방법도 존재한다. 

     

    Suspense

    React 18버전부터 suspense를 이용해 API를 콜할 때 로딩 상태를 표현할 수 있게 되었다.  Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을때 로딩 화면을 보여주고 로딩이 완료되면 해당 컴포넌트를 보여주는 React에 내장되어 있는 기능이다. Suspense 출시 문서에 따르면, 아직 표시할 상태가 되지 않은 경우, 컴포넌트의 일부 데이터에 대한 로드 상태를 선언적으로 지정할수 있다고 표기되어있다. Suspense는 Promise를 catch한다. Promise는 resolve되거나 reject될 때까지 컴포넌트의 트리 생성을 연기하고, 이렇게 연기되는 동안 해당 Promise를 포함한 컴포넌트는 DOM에 존재하지 않아서 브라우저 화면에 보이지 않는 현상이 발생한다. 그래서, Suspense가 로딩을 보여주게 하기 위해서 Promise를 throw하면 된다. 어려운 말만 늘어놓았지만, API를 콜했을 때 suspense를 true로 두면 suspense가 Promise를 캐치하고, 그 때의 Promise가 pending상태라면 fallback을, resolve나 reject면 그 안의 children을 return한다. 

    현재로서는 로딩 여부를 표현하는데에 suspense를 사용하는 것이 다이기때문에 suspense를 데이터 관리 라이브러리로 보기는 어렵지만, 추후 데이터 패칭 기능까지 가능할 수 있도록 확장될 전망이라고 한다. 

    suspense를 이용하는 방법은 간단하다. 앞서 말했듯, suspense를 true로 선언해주면 된다. 

    function App() {
      return (
        <Suspense fallback={<div>...loading</div>}>
          <TodoList />
        </Suspense>
      );
    }
    
    function TodoList() {
      const { data: todoList } = useQuery("todos", () => client.get("/todos"), {
        suspense: true,
      });
    
      return (
        <div>
          <section>
            <h2>Todo List</h2>
            {todoList?.data.map(({ id, title }) => (
              <div key={id}>{title}</div>
            ))}
          </section>
        </div>
      );
    }

    위와 같이 데이터를 가져올 때에 suspense를 true로 설정해주고, 로딩 시에 보여줄 컴포넌트를 fallback으로 표현해주면 된다. 여기서는 API를 콜할 때 suspense를 true로 주었기 때문에 Promise가 pending이면 fallback안의 내용을, resolve나 reject면 children인 <TodoList/>의 return 내용이 나오게 될 것이다.  

     

    Suspense를 이용하는 것이 굉장히 편리하기 때문에 한 컴포넌트 내에서 두 개의 API에 사용하는 등 남용할 수도 있다. 이렇게 되면 네트워크 병목 현상이 생길 수 있기 때문에, suspense를 남용하지 않는 것이 좋다. 

     

    ErrorBoundary

    리액트 쿼리에 내장된 로딩 방법 말고 suspense를 이용했다면, 리액트 쿼리의 isError말고도 에러 처리를 할 수 있을까? ErrorBoundary를 이용하면 가능하다. 

    // Children Component
    
    function TodoList() {
      // ✅ will propagate all fetching errors to the nearest Error Boundary
      // ✅ 모든 fetching 오류를 가장 가까운 에러바운더리로 전파합니다.
      const todos = useQuery(['todos'], fetchTodos, { useErrorBoundary: true })
    
      if (todos.data) {
        return (
          <div>
            {todos.data.map((todo) => (
              <Todo key={todo.id} {...todo} />
            ))}
          </div>
        )
      }
    
      return 'Loading...'
    }

    API를 콜하는 부분에서 useErrorBoundary:true 값을 주고, 부모 컴포넌트에서 FallbackComponent로 에러가 났을 때 보여줄 컴포넌트를 처리해준다. 

    // Parent Component
    
    // ...
      <ErrorBoundary fallback={<div>error</div>}>
        <RQError />
      </ErrorBoundary>

     

     

    Asynchronous Recoil

    Recoil을 이용해서도 데이터를 관리할 수 있다. 비동기 리코일(Asynchronous Recoil)은 동기 리코일과 다르게 selector에서 Promise를 리턴하거나 async함수를 사용한다.

    const currentUserNameQuery = selector({
      key: 'CurrentUserName',
      get: async ({get}) => {
        const response = await myDBQuery({
          userID: get(currentUserIDState),
        });
        return response.name;
      },
    });
    
    function CurrentUserInfo() {
      const userName = useRecoilValue(currentUserNameQuery);
      return <div>{userName}</div>;
    }

    위의 selector에서 async함수를 사용하여 리코일을 비동기적으로 사용한 것을 알 수 있다.

    만일 이렇게 비동기 리코일을 사용하면서 비동기 과정인 로딩이나 에러는 어떻게 처리할 수 있을까?

    리코일에서 데이터를 관리하기 위해서는 앞서 사용했던 suspense와 error boundary를 사용해야한다!

     

    보류 중인 항목들 처리하기(isLoading)

    만일 보류 중인 데이터(아직 데이터를 불러오고 있는 중인 경우)를 다루기 위해서는 React Suspense와 함께 동작해야한다.

    function MyApp() {
      return (
        <RecoilRoot>
          <React.Suspense fallback={<div>Loading...</div>}>
            <CurrentUserInfo />
          </React.Suspense>
        </RecoilRoot>
      );
    }

    데이터가 아직 보류 중인 하위 항목들을 잡아내기 위해 컴포넌트를 Suspense의 경계로 감싸고, 대체 UI인 Loading...을 렌더하는 코드이다. 즉, 비동기 리코일에서 로딩을 처리하려면 React.Suspense로 컴포넌트를 감싸준 뒤, 대체 UI를 fallback으로 표현해준다.

     

    에러 처리하기(isError)

    만일 요청에 에러가 있다면 ErrorBoundary를 이용한다.

    const currentUserNameQuery = selector({
      key: 'CurrentUserName',
      get: async ({get}) => {
        const response = await myDBQuery({
          userID: get(currentUserIDState),
        });
        if (response.error) {
          throw response.error;
        }
        return response.name;
      },
    });
    
    function CurrentUserInfo() {
      const userName = useRecoilValue(currentUserNameQuery);
      return <div>{userName}</div>;
    }
    
    function MyApp() {
      return (
        <RecoilRoot>
          <ErrorBoundary>
            <React.Suspense fallback={<div>Loading...</div>}>
              <CurrentUserInfo />
            </React.Suspense>
          </ErrorBoundary>
        </RecoilRoot>
      );
    }

    여기서 에러가 난다면 throw된 에러가 캐치될 것이다. 

     

    참고자료

    React Query 를 통하여 서버 데이터 관리하기

    비동기 데이터 쿼리

    Recoil+React Query

    https://www.daleseo.com/react-suspense/

     

    React Suspense 소개 (feat. React v18)

    Engineering Blog by Dale Seo

    www.daleseo.com

    https://ko.legacy.reactjs.org/docs/react-api.html#reactsuspense

     

    React 최상위 API – React

    A JavaScript library for building user interfaces

    ko.legacy.reactjs.org

    https://ko.legacy.reactjs.org/docs/error-boundaries.html#introducing-error-boundaries

    댓글

Designed by Tistory.