logo

사내 프로젝트에 React Query 도입, 그로 인한 변화

·
32 min read

이번 포스팅에서는 사내 프로젝트를 진행하면서 React-Query를 도입한 경험과 이로인해 어떠한 변화가 있었는지 말해보려합니다.

설명에 앞서 먼저 React-Query란 무엇인지에 대해 간략하게 알아보겠습니다.

React Query 소개

React Query는 React 어플리케이션에서 서버 상태를 효율적으로 가져오고, 캐싱, 동기화, 업데이트하는데 도움을 주는 라이브러리입니다.

쉽게 말해 React에서 서버 상태를 효율적으로 관리하는데 도움을 주는 라이브러리입니다.

클라이언트 상태와 서버 상태

React Query를 왜 써야하냐? 라고 묻는다면 다른 여러가지 장점도 있겠지만 먼저 "클라이언트 상태"와 "서버 상태"를 분리할 수 있다는 것을 강조할 수 있습니다.

클라이언트 상태는 흔히 사용자의 인터페이스 상태를 의미합니다. 예를 들면, Input에 입력한 사용자의 입력 값, 선택된 체크박스들, 모달의 오픈 상태등이 해당됩니다. 이러한 상태들은 주로 클라이언트에서만 사용되므로 서버와의 동기화가 필요하지 않습니다.

서버 상태는 API 호출을 통해 외부로 부터 받아오는 데이터를 의미합니다. 예를 들어 사용자의 프로필 정보가 해당됩니다.

두 상태는 서로 다른 관심사를 가지고 있지만, 서버에서 받아온 데이터를 결국 클라이언트 상태로 관리하다보니 코드를 작성하다보면 마치 하나의 상태 덩어리가 되는듯한 느낌을 받기 쉬웠습니다.

도입하기로 결심한 이유

제가 사내 프로젝트에 React Query를 도입하기로 결정한 이유에는 클라이언트 상태와 서버 상태를 분리하는 장점과 더불어 다양한 장점이 있었기 때문입니다.

  1. 클라이언트 상태와 서버 상태를 분리할 수 있다.
  2. 보일러 플레이트 코드 감소.
  3. 데이터를 자동으로 캐싱해 중복 요청을 막으므로 서버 비용을 아낄 수 있다.
  4. 서버 데이터의 로딩 및 에러 상태를 쉽게 관리할 수 있다.
  5. 기존에 필요에 따라 전역 상태로 따로 관리하던 서버 상태를 쉽게 관리할 수 있다.

위의 장점만으로도 그동안 개발하면서 겪었던 UX 및 DX 이슈를 많이 개선할 수 있을거라고 생각됐고 기존 프로젝트를 갈아 엎으면서 React Query도 함께 도입했습니다.

기존의 문제점

보일러 플레이트 코드의 증가

React에서 일반적으로 서버에서 데이터를 불러올때는 다음과 같이 코드를 작성합니다.

 
function User() {
    const [data, setData] = useState([]);
    const [loading, setLoading] = useState(true);
 
    useEffect(() => {
        const fetchData = async () => {
            setLoading(true);
            try {
                const response = await axios.get('https://example.com/api/users');
                setData(response.data);
            } catch (error) {
                // 에러 처리
            } finally {
                setLoading(false);
            }
        };
        fetchData();
    }, []);
 
    return (
        // 후략
    );
}
 
export default Users;

위 코드는 다음의 행위들을 하게 됩니다.

  1. 서버 상태를 담을 data를 선언한다.
  2. API 호출의 로딩 상태를 담을 loading을 선언한다.
  3. API 호출에 대한 명령적 로직을 작성한다.
  4. 컴포넌트 마운트시 최초로 호출되야하므로 useEffect를 사용한다.

얼핏보면 잘 정리된 로직 같지만 문제는 이와 같은 코드를 API를 호출하는 모든 컴포넌트에서 동일하게 작성해야 한다는 것입니다. 결국 위에 작성한 코드는 API를 호출해서 데이터를 불러와 상태에 담아두려는 것에 불과하지만 이를 위한 보일러 플레이트 코드는 컴포넌트가 늘어날 수록 비례해서 늘어나게 됩니다.

데이터 중복 호출

한편 유저의 정보를 수정하기 위해 수정 페이지로 라우팅을 해야했고 위에서 호출한 유저의 정보를 기반으로 미리 Input 값들을 채워야 했습니다. 이 때 저는 다음 두 가지에서 깊은 고민에 빠지게 됐습니다.

  1. 수정 페이지로 라우팅 될 때 기존 유저의 id를 useParams로 받아 데이터를 다시 호출한다.
  2. React-Router의 useNavigate 함수에 state 속성으로 유저의 정보를 전달한다.

1번은 이전에 호출했던 데이터를 재호출 한다는점이 걸렸고 2번은 유저가 직접 브라우저 주소창에 URL을 입력하여 수정 페이지로 라우팅 했을때 처리, 유저의 정보가 오래됐을 때 서버와 클라이언트간의 데이터 불일치 가능성이 걸렸습니다.

이를 두고 계속해서 고민한 결과 2번은 호출한 유저 정보가 오래됐을 경우 수정 페이지로 라우팅시 클라이언트와 서버간의 데이터 불일치 가능성이 좀 더 문제라고 판단하여 1번을 선택했습니다.

물론 재호출되는 데이터의 크기 자체는 작았지만 제 마음은 여전히 찜찜했죠. 🥲

이렇게 서버 상태를 관리하기 위해 하던 크고 작은 고민들은 계속 쌓여만 갔고 기술 부채 또한 쌓여 갔습니다.

이러한 고민들을 React Query를 통해 어떻게 개선할 수 있었는지 실제로 적용한 사례를 통해 알아보겠습니다.

staleTime, gcTime

적용 사례를 설명하기전에, 아직 React Query를 사용하지 않으신 분들을 위해 먼저 데이터의 상태값들과 staleTime, gcTime 같은 기본 개념부터 소개하겠습니다.

React Query에서 데이터는 fresh, stale, inactive, fetching, paused 상태 값을 가집니다. 여기서는 가장 중요한 세 개의 상태에 대해서만 알아보겠습니다.

fresh 상태는 데이터가 신선한 상태를 의미합니다. 데이터가 신선하니 캐싱된 데이터를 사용해도 충분하다는 의미입니다.

stale 상태는 데이터가 신선하지 않은 상태를 의미합니다. 데이터의 유통기한이 만료되었으므로, 새로운 데이터를 요청하여 다시 신선한 상태로 업데이트 해야합니다.

inactive 상태는 데이터가 사용되지 않는 상태를 의미합니다. 예를 들어 유저 정보 페이지에서 데이터를 불러오면 데이터는 즉시 fresh 한 상태를 가지지만 유저 정보 페이지에서 이탈하면 해당 데이터는 더 이상 화면에서 쓰이지 않으므로 unmount되고 inactive 상태가 됩니다.

쿼리의 상태들에 대해 알아보았으니 이제는 staleTime과 gcTime에 대해 좀 더 알아보겠습니다.

staleTime은 데이터가 fresh에서 stale 상태로 변경되는데 걸리는 시간을 의미합니다. 예를 들어 staleTime을 3000으로 설정했다면 데이터는 3초 (3000ms) 동안 fresh 상태이다가 해당 시간이 지나면 stale 상태로 변합니다.

gcTime은 데이터가 사용되지 않거나, inactive 상태일 때 메모리에 캐싱된 상태로 남아있는 시간을 의미합니다. 예를 들어 gcTime을 5000으로 설정했다면 메모리에 데이터를 5초 (5000ms) 동안 캐시해뒀다가 시간이 지나면 가비지 컬렉터로 수집됩니다. 즉, gcTime의 gc는 garbage collector의 약자를 의미합니다.

React Query v4에서 gcTime은 원래 cacheTime 라는 이름을 가졌습니다. cacheTime은 쿼리를 사용했던 컴포넌트가 언마운트 되면서 쿼리 인스턴스가 비활성화됐을 때 부터 유효한 시간을 의미합니다. 이는 곧 캐시된 데이터가 가비지 컬렉터로 수집되기까지 남아있는 시간을 의미하지만 많은 사람들이 cacheTime을 단순히 데이터가 캐싱되는 시간으로 오해했습니다. 이에 React Query v5 부터는 gcTime이라는 더 명확한 명칭을 쓰기로 결정했습니다.

Rename "cacheTime" to "gcTime"

위에 설명이 조금 복잡할 수도 있으니 간단한 예시로 라이프 사이클을 살펴보겠습니다.

회사 정보 페이지에 접속한다고 가정하고 staleTime과 gcTime을 각각 1분, 5분으로 설정해보겠습니다.

  • 회사 정보 페이지에 처음 접속: 쿼리가 mount 되어 staleTime인 1분 동안 데이터는 fresh 상태를 가지게 됩니다.
  • 1분 이내 페이지 이탈 후 재접속: 페이지를 이탈하여 컴포넌트가 unmount 되면 inactive 상태가 되지만 staleTime이 만료되지 않았으므로 데이터는 여전히 fresh 상태입니다. 따라서 데이터를 재호출하지 않고 캐시된 데이터를 사용합니다.
  • 1분 경과 후: 데이터는 이제 stale 상태로 변하게 됩니다. 데이터가 stale 상태가 된 이후에도 페이지를 이탈하지 않았으므로 상태는 여전히 stale을 유지하며 gcTime은 작동하지 않습니다.
  • 회사 정보 페이지에서 이탈: 쿼리는 언마운트되어 inactive 상태를 가지고 이전에 설정한 gcTime이 5분동안 작동하게 됩니다.
  • 2분 후에 회사 정보 페이지에 재접속: 데이터가 stale 상태이므로 쿼리를 재호출해 fresh한 상태를 다시 가집니다. gcTime은 3분이 남았으므로 캐시된 데이터를 먼저 보여주고 쿼리 재호출이 끝나면 fresh한 데이터로 교체합니다. 이 경우 유저는 로딩화면을 보지 않게 됩니다.
  • 회사 정보 페이지에서 이탈 후 5분 경과: gcTime이 만료됐으므로 캐싱된 데이터는 가비지 컬렉터에 의해 제거되고 이후에 유저 정보 페이지에 다시 접속한다면 캐시된 데이터가 없으므로 새로운 쿼리를 다시 호출합니다. 이 경우 유저는 로딩화면을 보게 됩니다.

쿼리의 각 상태와 staleTime, gcTime에 대해 알아보았으니 본격적으로 적용을 해보겠습니다. 저희 서비스는 staleTime은 1분, gcTime은 5분 정도로 설정했습니다.

import { QueryClient } from '@tanstack/react-query';
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,
      gcTime: 1000 * 60 * 5,
    },
  },
});

참고로 전역으로 staleTime과 gcTime을 설정한 이후 특정 쿼리에 대해서도 staleTime과 gcTime도 조정할 수 있으니 상황에 맞게 적용하면 되겠습니다.

개선 사항

useQuery, useMutation

React Query는 API 요청을 Query 그리고 Mutation 이라는 두 가지 유형으로 나눕니다.

useQuery Hook을 이용한 Query 요청은 HTTP METHOD의 GET 요청과 같이 서버에 저장되어 있는 상태를 불러올 때 사용합니다.

아래의 코드는 useQuery를 통해 다음과 같이 바꿀 수 있었습니다.

const [data, setData] = useState();
const [isLoading, setLoading] = useState(false);
const getCompanyList = async () => {
  setLoading(true);
  try {
    const response = await Api.get('/api/v2/users/companies');
    if (response.status === 200 && response.data.code === 0) {
      setData(response.data.result);
    }
  } catch (err) {
    // 에러 처리..
  } finally {
    setLoading(false);
  }
};
 
useEffect(() => {
  getCompanyList();
}, []);
const companyList = useQuery({
  queryKey: ['company', 'list'],
  queryFn: () => getCompanyList(),
});

useMutation Hook을 이용한 Mutation 요청은 HTTP METHOD의 POST, PUT, DELETE의 요청을 할 때 사용합니다.

아래의 코드는 useMutation을 통해 다음과 같이 바꿀 수 있었습니다.

const deleteCompany = async () => {
  try {
    const res = await axios.delete(`api/delete/${id}`).then((res) => console.log(res.data));
  } catch (err) {
    console.error('error', err.message);
  } finally {
    // 무조건 실행
  }
};
const { mutate } = useMutation({
  mutationFn: deleteCompany,
  onSuccess(data) {
    // 성공 처리
  },
  onError(error: Error) {
    // 에러 처리
  },
});

커스텀 훅으로 분리

작성하고 보니 코드가 깔끔해지긴 했지만 뭔가 이것마저도 보일러플레이트 코드라고 느껴졌습니다.

그래서 좀 더 분리해보고자 커스텀 훅으로 따로 만들어줬습니다.

const getCompanyList = async (): Promise<ICompany[]> => {
  const response = await Api.get('/api/v2/users/companies');
  return response.data.result;
};
 
const deleteCompany = async ({ id }: { id: number }) => {
  const response = await Api.delete(`/api/delete${id}`);
  return response;
};
 
export const useCompanyList = () =>
  useQuery({
    queryKey: ['company', 'list'],
    queryFn: () => getCompanyList(),
  });
 
export const useDeleteCompany = () => {
  return useMutation({
    mutationFn: deleteCompany,
    onSuccess(data) {
      // 성공 처리
    },
    onError(error: Error) {
      // 에러 처리
    },
  });
};
// company-list.tsx
import { useCompanyList } from 'quires/useCompanyList';
import { useDeleteCompany } from 'quires/useDeleteCompany';
 
const { data } = useCompaniesQuery();
const { mutate } = useDeleteCompany();

커스텀 훅을 사용하여 useQuery와 useMutation을 분리하니 서버 상태에 대한 정의를 클라이언트 측 관심사와 더욱 명확히 분리할 수 있었습니다.

selector 속성으로 데이터 재가공

몇몇 API들은 데이터를 가져온 후 필터링을 걸쳐 유저에게 보여줬어야 했는데요. 전에는 이렇게 데이터를 받는 함수안에 필터링 로직까지 포함했습니다. 단일 책임 원칙에도 어긋나고 코드도 굉장히 지저분해졌죠. 필터링 하는 함수를 따로 만들어 단일 책임 원칙을 고수해도 서버 상태를 가공하는 로직은 여전히 존재했습니다.

React Query에서는 select 속성을 통해 데이터를 가져오기 전 미리 원하는 형태로 가공할 수 있었습니다.

const getCompanyList = async (): Promise<ICompany[]> => {
  const response = await Api.get('/api/v2/users/companies');
  return response.data.result;
};
 
export const useCompanyList = () =>
  useQuery({
    queryKey: ['company', 'list'],
    queryFn: () => getCompanyList(),
    select: (companies) => companies.filter((company) => company.type === 'active'),
  });

enabled 속성으로 호출 시점 결정

어떤 컴포넌트는 현재 선택된 탭에 따라서 각기 다른 API를 호출했어야 했습니다. 이를 위해 전에는 이렇게 장황하게 코드를 작성했습니다.

const [info, setInfo] = useState();
const [sites, setSites] = useState();
const [staffs, setStaffs] = useState();
const [tabs, setTabs] = useState<'info' | 'sites' | 'staffs'>('info');
 
const fetchInfo = async () => {
  try {
    const response = await Api.get(`/api/companies/${id}`);
    if (response.status === 200 && response.data.code === 0) {
      setInfo(response.data.result);
    }
  } catch (error) {
    // 에러 처리
  }
};
 
const fetchSitesData = async () => {
  try {
    const response = await Api.get(`/api/companies/${id}/sites`);
    if (response.status === 200 && response.data.code === 0) {
      setSites(response.data.result);
    }
  } catch (error) {
    // 에러 처리
  }
};
 
const fetchStaffsData = async () => {
  try {
    const response = await Api.get(`/api/companies/${id}/staffs`);
    if (response.status === 200 && response.data.code === 0) {
      setStaffs(response.data.result.items);
    }
  } catch (error) {
    // 에러 처리
  }
};
 
useEffect(() => {
  if (tabs === 'info') {
    fetchInfo();
  } else if (tabs === 'sites') {
    fetchSitesData();
  } else if (tabs === 'staffs') {
    fetchStaffsData();
  }
}, [tabs]);

탭에 따른 3개의 API 함수를 정의하고 탭 상태가 바뀔때마다 API를 호출할 수 있게 useEffect에 tabs 상태를 의존성 배열을 넣어줬습니다. 위 코드는 보일러 플레이트 코드가 많다는 점과 함께 각 데이터들이 캐시되지 않기에 서로 다른 탭을 계속 누르면 새로운 데이터를 계속 호출한다는 문제점도 가지고 있습니다.

이를 React Query를 이용해 다음과 같이 변경했습니다.

const [tabs, setTabs] = useState<'info' | 'sites' | 'staffs'>('info');
const { data: info } = useCompany({ id: +id, enabled: tabs === 'info' });
const { data: staffs } = useCompanyStaffs({ id: +id, enabled: tabs === 'staffs' });
const { data: sites } = useCompanySites({ id: +id, enabled: tabs === 'sites' });

각 쿼리들을 커스텀 훅으로 분리하고 React Query에서 제공하는 enabled 속성을 이용해 특정 조건에서만 데이터가 호출하게끔 바꿨습니다. 이로 인해 코드의 가독성이 매우 향상됐고 코드 양은 굉장히 감소했습니다. 그리고 데이터 캐시 이점까지 가질 수 있게 됐습니다.

쿼리 무효화를 이용한 데이터 업데이트

회사 리스트 페이지와 회사 정보 수정 두 페이지가 있습니다. 회사의 정보를 수정한다면 수정된 결과를 리스트에 반영해 데이터를 동기화 해줘야합니다. 이전에는 회사 리스트 페이지에 방문할 때마다 API를 재호출 했기 때문에 문제가 되지 않았지만 React Query를 도입하고 난 뒤에는 고민이 생기게 됐습니다.

이전에 전역으로 설정한 staleTime과 gcTime에 의해 회사 리스트 페이지의 staleTime이 만료되지 않아 아직 fresh한 상태를 가진다면 어떨까요? 쿼리는 아직 데이터가 신선하다고 판단하고 새로운 데이터를 가져오지 못합니다. 즉, 캐시 때문에 실제 데이터와 동기화되지 못하는 일이 발생합니다.

이를 위해 React Query는 쿼리 무효화를 제공합니다. 쿼리 무효화를 하게 되면 캐시된 데이터를 무효화 시켜 자동으로 해당 쿼리를 다시 실행하여 새로운 데이터를 가져오게 합니다.

export const useEditCompany = () => {
  return useMutation({
    mutationFn: editCompany,
    onSuccess(data) {
      // 회사 정보 수정 완료 후 회사 리스트 쿼리를 무효화
      queryClient.invalidateQueries({ queryKey: ['company', 'list'] });
    },
    onError(error: Error) {
      // 에러 처리
    },
  });
};

이전에는 회사 리스트 페이지에 방문할 때마다 데이터를 매번 재호출했습니다. 하지만 이제 캐싱된 데이터를 재사용함으로써, 필요할 때만 인위적으로 데이터를 갱신하여 서버의 상태를 효율적으로 관리할 수 있게 되었습니다.

마무리

결론적으로 "사내 프로젝트에 React Query의 도입은 탁월한 선택이었다" 라는 생각이 듭니다.

서버 상태를 관리하기 위해 Redux와 같은 별도의 전역 상태 관리 라이브러리의 도입을 고민하지 않아도 됐고, 클라이언트와 서버 상태 사이의 구분이 명확해졌습니다. 또한, API 호출과 데이터 캐싱을 필요에 따라 효율적으로 수행할 수 있게 되었습니다.

이 글을 읽고 계시는 분이 서버 상태 관리에 어려움을 겪고 계신다거나 서버 상태를 보다 효율적으로 관리하고 싶으시다면 도입을 고려해보시는 것을 추천드립니다!