취미로 개발을 배울 때, 상태라는 개념과 활용도 알까 말까 하며 Redux(툴킷 X)도 겨우 이해하고 있는데 서버 상태라는 개념에 좌절했던 기억이 난다. 완전히 개발자로의 전향을 결심한 뒤에 시작한 모든 프로젝트에는 React Query를 사용했다. 처음에는 데이터 페칭 상태 관리가 너무 편해서 사용했고, 최근 스크럼블 프로젝트에서는 백엔드와 협업하면서 네트워크 호출을 줄이기 위한 고민을 많이 하게 되면서 알차게 활용할 수 있었다.
* TanStack Query로 명칭이 변경되었지만, 해당 글에서는 React Query로 사용했다.
리액트 쿼리를 프로젝트에 도입하는 주요 목적은 데이터 페칭에 대한 상태 관리와 캐싱으로 생각해볼 수 있다. 서버와의 통신은 언제나 성공적인 것은 아니다. 요청이 느리거나 실패했을 때 이를 적절히 처리하지 않으면 사용자 경험이 크게 저하된다. 리액트 쿼리는 이 과정을 손쉽게 관리하며, 로딩 상태, 에러 처리, 재시도 등의 기능을 간단하게 구현할 수 있도록 돕는다.
그렇다고 해서 무조건 써야 하는 것은 아니다. 네트워크 통신이 많지 않거나 규모가 작은 서비스라면 라이브러리 사용 없이 충분히 상태 제어가 가능하다. 오히려 쿼리키나 캐싱 관리 등을 신경 써야 해서 배 보다 배꼽이 더 큰 상황이 발생할 수도 있다.
지금까지의 프로젝트에서는 항상 리액트 쿼리를 사용했지만, 채팅 서비스였던 디버스에서는 제대로 활용하지 못했다고 느낀다. 물론, 채팅이라는 기능 하나뿐인 서비스는 많지 않으므로 복합적으로 고려하면 필요할 수 있다. 하지만 파이어베이스를 이용했고, 익명 오픈 채팅이라는 한정된 기능으로 마무리를 했기 때문에 여기까지는 리액트 쿼리가 큰 활약을 하지 못했다. 학습 목적으로 서버와 데이터 동기화에 대해 깊이 이해할 수 있었지만, 프로젝트 자체만으로는 활용도가 낮았음은 부정할 수 없다.
리액트 쿼리를 도입할지 고민하게 될 상황은 많다. 단순히 로딩 상태를 관리하는 용도로라면 꼭 사용할 필요는 없을지도 모른다. 그러나, 이 로직이 여러 컴포넌트에서 자주 사용되거나 중복될 가능성이 높다면, 도입하여 중복을 줄이고 유지보수성을 높이는 데 충분한 가치가 있을 것이다.
또한, 간단한 프로젝트라면 Suspense와 ErrorBoundary만으로도 기본적인 로딩 및 에러 관리를 처리할 수 있다. 하지만 프로젝트 규모가 커지고 API 호출이 많아질수록 React Query가 제공하는 캐싱, 자동 리페치, 에러 관리 같은 기능이 큰 도움이 될 것 같다.
특히, 새로운 사이드 프로젝트를 시작할 때는 처음부터 도입하기보다는 프로젝트의 복잡성과 데이터 흐름을 살펴본 후 필요한 시점에 도입을 고려하는 것을 추천해 본다. 작은 프로젝트에서는 직접 상태 관리 로직을 구현해 보고, 프로젝트가 커지면서 중복이 생기거나 유지보수가 어려워질 때 React Query를 적용하면 더 효율적으로 사용할 수 있을 것이라 기대한다.
리액트 쿼리의 캐싱 기능을 잘 활용하기 위해서는 쿼리 초기화에 대해 이해하고 있는 것이 좋다. 데이터 캐싱이 쿼리키를 기반으로 동작하고, 이 키를 기반으로 초기화나 리페치도 일어나기 때문이다. 그래서 오늘의 주제인 낙관적 업데이트에 연관이 가장 큰 메서드들을 정리해 본다.
리액트 쿼리 캐시에 저장된 쿼리키의 데이터를 무효화하여 즉시 네트워크 요청하여 데이터를 새로고침하고, 최신 데이터를 가져온다.
쿼리키의 데이터를 무효화하고, 즉시 네트워크 요청을 하는 등 refetch와 동일하지만, 특정 쿼리 키를 명시적으로 지정하여 여러 쿼리를 동시에 다시 가져올 수 있다.
📌 refetch vs refetchQueries
- refetch는 useQuery가 제공하는 메서드이다. useQuery가 호출된 컴포넌트에서 사용되며, 이 때문에 영향 범위가 좁고, 단일 쿼리키에 대해서만 적용 가능하다.
- refetchQueries는 어디서든 쿼리티 지정을 통해 전역적으로 적용할 수 있다. 복수형으로 알 수 있듯이 여러 쿼리키에 동시 적용 가능하다. exact를 false로 설정하면 쿼리키에 ‘data’가 포함된 모든 캐싱 데이터를 재호출 한다.
`queryClient.refetchQueries({ queryKey: ['data'], exact: false });`
특정 쿼리를 무효화하는 것은 동일하지만, 다음에 해당 쿼리가 참조될 때 적용된다. 즉, 데이터가 필요할 때까지 기다린 후에 네트워크 요청이 발생한다. refetchQueries와 동일하게 여러 쿼리키에 동시 적용 가능하다.
캐시에서 직접적으로 데이터를 수정한다. 수동으로(개발자가 원하는 데이터로) 변경하기 때문에 데이터의 불변성이 유지되지 않거나 제대로 초기화되지 않으면 리액트 쿼리가 상태 변경을 감지하지 못할 수 있다. `setQueryData`가 호출되면 캐시 데이터를 덮어씌우고 리렌더링을 트리거한다. 새로운 값이 기존 참조와 다르다면 UI를 즉시 업데이트한다.
초기값 설정
queryClient.setQueryData(['todos'], [{ id: 1, content: '뭐할래' }]);
캐싱 데이터 직접 수정
queryClient.setQueryData(['todos'], [{ id: 1, content: '밥 먹자' }]);
서버와 독립적으로 데이터를 관리하기때문에 서버와 클라이언트 간의 데이터 상태가 동기화되지 않을 수 있다.
데이터가 필요한 시점 이전에 서버에서 데이터를 페칭 하여 캐시에 저장한다.
UI 디자인 상 특정 행동(예: 링크 클릭, 버튼 클릭)이나 이벤트(onFocus, onClick)와 같은 트리거 로직으로 사용자의 다음 행동을 감지한다. 즉, 사용자의 동작을 미리 예측하여 더 나은 사용자 경험을 제공한다. 이 때문에 모든 로직에 적용하면 과도한 네트워크 호출이 일어날 수 있어 적절한 시점에 잘 활용해야 한다.
진행 중인 네트워크 요청을 취소한다. 단, 해당 쿼리 키와 관련된 캐시 데이터는 유지한다. 이 메서드는 낙관적 업데이트에 유용하다. 현재 진행 중인 네트워크 요청이 있다면 이를 중단하여 충돌 가능성을 줄일 수 있기 때문이다. 이 과정에서 서버와 클라이언트 간의 상태 불일치를 최소화할 수 있다.
특정 쿼리 키에 대한 캐시 데이터와 상태를 완전히 삭제한다.
스크럼블 프로젝트는 주 기능이 투두리스트 + 무한스크롤 조합이었기 때문에 낙관적 업데이트를 경험해 보기 좋았다. 엄청난 네트워크 요청에 기겁하며 API 수정도 해가며 마주했던 과정을 기록해 본다.
낙관적 업데이트하면 좋아요 기능을 가장 먼저 떠올리게 된다. 좋아요 버튼을 눌렀을 때, 네트워크 응답을 받은 뒤에 처리하게 되면 상황에 따라 눌렀다는 표시를 바로 보여줄 수 없어 사용자 경험(UX)이 저하된다. 여기까지는 이해가 잘 되었는데 너무 단편적인 예시로만 알게 되어서인지 직접 프로젝트에 도입할 때는 막상 필요함에도 적용하지 못해서 문제를 인지하고 나서야 적용할 수 있었다.
등록된 투두는 `완료 여부`/`내용`, 이렇게 2가지 값을 수정할 수 있다. 날짜별로 구분되고, 리스트는 무한스크롤을 적용하고 있어 페이지 단위로 관리된다. 만일 size를 10개로 하면서 수정하려는 투두가 31번째에 있다면 조회를 위해서는 3번의 네트워크 호출이 이루어진다. 여기까진 괜찮다. 어차피 조회를 위해서는 그래야 하기 때문이다.
하지만 31번째 투두를 수정하게 되면 전체 리스트에 변동이 생기게 되고, 사용자에게 변경된 값을 보여주려면 재조회를 해야 한다. 그럼 바뀌지 않은 1,2번째 페이지도 다시 조회하게 되어 불필요한 네트워크 요청이 발생한다.
낙관적 업데이트는 이 문제를 해결한다. 포인트는 “클라이언트는 변경하고자 하는 값을 알고 있다는 점”이다. 서버에 전달하려는 값을 이용해서 캐싱하고 있는 데이터를 수정해 네트워크 통신 없이 사용자에게 변경된 화면을 보여주는 것이다.
onMutate: async () => {
try {
await queryClient.cancelQueries({ queryKey: ['todos'] });
} catch (error) {
console.error('Optimistic Update Error:', error);
}
};
가장 먼저 업데이트를 처리하는데 충돌을 방지하기 위해 쿼리 취소가 필요하다. 이때 유의할 점은 `async ~ await`부분이다.
`refetchQueries`, `invalidateQueries`, `cancelQueries`, `prefetchQuery`는 비동기적으로 실행된다. 그러나 컴포넌트 내에서 쿼리를 초기화할 때 따로 비동기 처리를 하지 않아도 잘 동작하는 이유는 리액트 쿼리가 비동기 상태를 내부적으로 관리하며, UI와 비동기 작업 간 의존성이 낮아 에러가 발생하지 않은 것이다.
예를 들어, 컴포넌트 내에서 useEffect를 통해 리렌더링 될 때 쿼리를 초기화한다면 refetchQueries일 때는 데이터를 즉시 다시 가져오기 때문에 네트워크 요청이 완료된 후 바로 리렌더링이 발생하고, invalidateQueries일 때는 다음 리렌더링 시 적용된다. 이처럼 UI와 비동기 작업 간 의존성이 없는 경우, Promise 핸들링 없이도 자연스럽게 동작할 수 있다.
하지만 작업 순서가 중요한 경우에는 Promise 핸들링이 필요하다. 예를 들어, cancelQueries처럼 낙관적 업데이트를 위해 실행 중인 쿼리를 취소한 뒤 캐시를 업데이트해야 한다면, await을 사용해야 한다.
const oldData = queryClient.getQueryData(['todos']) ?? [];
네트워크 통신에 실패했을 경우 롤백을 위해 캐싱된 데이터를 조회하여 백업한다.
queryClient.setQueryData(['todos'], (prevData) => ({
...prevData,
pages: prevData.pages.map((page) => ({
...page,
data: page.data.map((prevTodo) => (prevTodo.toDoId === toDoId ? todo : prevTodo)),
})),
}));
`useInfiniteQuery`를 사용해 무한스크롤을 구현했기 때문에 수동 업데이트 시 불변성을 지키는 로직이 복잡하게 느껴지지만, setQueryData의 사용 목적에 대해서만 생각해 보기로 한다.
queryClient.setQueryData(queryKey, updater)
setQueryData는 캐시 데이터를 업데이트하기 위한 메서드로, 두 번째 인자로 업데이트 함수(updater)를 받는다. 이 함수는 매개변수로 이전 캐싱 값(prevData)을 제공하며, 새로운 데이터를 반환해야 한다.
이때 중요한 점은 불변성을 유지하는 것이다. 리액트 쿼리는 참조 비교로 데이터 변경을 감지하므로, 반환된 객체가 이전 객체와 동일한 참조를 갖는다면 값이 변경되지 않았다고 판단할 수 있다. 따라서 새로운 객체를 생성하여 반환해야 UI와 캐시가 정상적으로 업데이트된다.
data: page.data.map((prevTodo) => (prevTodo.toDoId === toDoId ? todo : prevTodo))
그리고 수정이나 삭제 등 업데이트하고자 하는 로직을 작성하면 된다.
onMutate: async ( 1.mutateFn 매개변수 ) => {
try {
...
return { 2.oldData }
} catch (error) {
console.error('Optimistic Update Error:', error);
}
}
낙관적 업데이트는 클라이언트가 네트워크 요청을 기다리지 않고, 변경된 데이터를 캐싱하여 UI를 즉시 리렌더링 한다. 리액트 쿼리는 캐시 데이터의 참조 변경을 감지하고, 해당 데이터를 참조하는 컴포넌트를 리렌더링 한다.
데이터 생성도 낙관적 업데이트를 적용할 수 있다. 하지만 프로젝트 구조상 투두가 생성될 때, 서버에서 id를 부여하고 있어서 onMutate에서 투두 id를 알지 못했다. id 없이 생성하면 key Props 에러가 발생하여 다른 방법을 고려했다.
원했던 것은 투두 생성도 낙관적 업데이트를 적용하는 것이지만, 서버에서 생성된 데이터를 활용하는 것이었다. 이를 위해 API 응답값을 수정했다.
AS-IS
data: null
TO-BE
data: { toDoId: number; contents: string; toDoAt: string; toDoStatus: TodoStatus; };
이전에는 클라이언트가 가진 값으로 서버 통신의 성공/실패 여부를 알기 전에 업데이트를 진행했다면 이번에는 onSuccess를 통해 성공 시 응답값으로 캐싱 데이터를 업데이트한다. 즉, 서버 데이터를 이용해 낙관적 업데이트를 구현한다.
onSuccess: async ({ data }) => {
queryClient.setQueryData(
todoKeys.todos(squadId, selectedDay),
(prevData) => ({
...prevData,
pages: prevData.pages.map((page, index) => (index === 0 ? { ...page, data: [data, ...page.data] } : page)),
}),
);
},
onSuccess는 서버 통신이 성공적으로 이루어진 후 실행된다. 이 메서드가 받는 매개 변수 data는 서버 통신 성공 후 응답값이다. 이 응답값을 백엔드와 협의 후 TodoItem 타입과 맞추어 추가할 수 있도록 했다. API 재호출 없이 캐싱된 투두리스트 데이터에서 원하는 페이지의 앞 혹은 뒤에 새로운 투두를 추가할 수 있다.
프로젝트에서는 투두가 등록된 직후에는 리스트 맨 위에 표시하고 싶었기 때문에 0번째 페이지의 데이터 중에서도 가장 첫 번째에 올 수 있도록 했다. 이처럼 서버의 성공 시 응답값으로 UI를 먼저 업데이트하고, 네트워크 재호출 시 백엔드에서 반환된 데이터로 다시 적용된다. 사용자는 등록한 투두 항목을 즉시 확인할 수 있으며, 이후 서버 데이터와 동기화된다.
여러 예제들을 보면 onSuccess 혹은 onSetteld에서 `invalidateQueries`를 이용해 쿼리 무효화를 해주는 것을 볼 수 있다. 스크럼블 프로젝트에서는 모든 로직에 이를 적용하지 않아도 의도한 대로 잘 동작해서 어떤 상황에 써야 하는 것인지 고민해 보았다.
주요 이유로 낙관적 업데이트로 변경된 클라이언트 데이터와 최종적으로 서버에 반영된 데이터의 동기화를 위함이다.
스크럼블에서의 주요 낙관적 업데이트는 로그인된 사용자의 개별 투두이다. 동시에 접근할 일이 적고, 다른 곳에서 동기화 되어야 할 경우가 없었기 때문에 문제없이 동작했다. 결론적으로, 스크럼블 프로젝트에서는 데이터 동기화를 위한 invalidateQueries 호출 없이도 안정적으로 동작할 수 있었다.
낙관적 업데이트를 구현하면서 onError에서 setQueryData를 사용했는데 에러 발생 후 리렌더링이 되는 과정에서 undefined로 뜬다.
onMutate에서 oldData를 제대로 반환하도록 설정하지 않았다.
낙관적 업데이트를 위해서는 getQueryData로 조회한 이전 데이터를 반환하도록 한다.
스크럼블은 각 날짜별로 무한 스크롤 조회가 적용되어 있는데 특정 날짜를 한번 조회한 뒤에 다른 날짜를 조회하고, 다시 이전 날짜로 돌아오면 모든 페이지를 재조회 한다.
리액트 쿼리 캐싱 데이터가 마지막에 조회한 pageParam을 기억하여 1 page부터 모든 페이지에 대해 API 호출을 실행한다.
리액트 쿼리가 마지막 pageParam을 캐싱하고 있는 문제를 해결하기 위해 무한스크롤 로직에서 gcTime을 0으로 설정하여 참조되지 않는 데이터를 즉시 삭제하도록 한다. 이는 데이터가 다시 사용될 가능성이 낮거나, 실시간 업데이트로 인해 자주 변경되는 경우에 적합하다.
하지만 gcTime을 0으로 설정해야 하는 상황은 보통 일회성 조회 데이터(예: 검색 결과)나 자주 업데이트되어 캐싱이 필요 없는 데이터에 국한된다. 투두리스트는 사용자가 자주 확인할 가능성이 있고, 캐싱된 데이터를 반복적으로 사용할 수 있으므로 얼마나 자주 업데이트되는지에 따라 적절한 값을 설정하는 것이 중요하다.
만약 투두리스트가 실시간으로 자주 업데이트되거나, 중복 데이터 요청을 방지할 필요가 없다면 해당 설정이 적합할 수 있지만 현재는 gcTime 설정이 오히려 불필요한 호출을 발생시킬 수 있다고 생각했다.
다른 날짜가 선택되어 컴포넌트가 언마운트 될 때, 캐싱된 쿼리 지우기
useEffect(() => {
queryClient.removeQueries({
queryKey: todoKeys.todos(squadId, selectedDay),
});
}, [selectedDay]);
처음에는 SquadDetailPage 내 useEffect에 로직을 넣었다. SquadDetailPage가 리렌더링 될 때, selectedDay에 변경이 있으면 캐시를 제거하도록 했는데 적용되지 않았다. 그 이유는 날짜 선택에 따라 해당 로직을 실행하면 현재 렌더링 된 날짜의 키가 제거되는 로직이 되어 데이터가 사라진다.
useEffect(
() => () =>
queryClient.removeQueries({
queryKey: todoKeys.todos(squadId, selectedDay),
}),
[selectedDay],
);
날짜가 변경이 되어 현재 날짜를 벗어날 때(언마운트 될 때) 캐싱된 키를 제거해 주어 변경되기 이전의 날짜의 정보를 지우도록 한다. 단, 이 해결방법은 이미 조회한 날짜의 데이터를 캐싱하지 못해 이미 조회했던 날짜를 선택해도 재조회를 해야 한다. 이 부분에 대해 고민을 많이 했다. 가장 먼저 일자별 투두리스트가 무한스크롤 3 page 이상을 넘어가기가 쉽지 않기 때문에 재조회 시에도 보통은 1 page 조회로 끝나지 않을까?라는 생각을 했다. 만일 그렇다면 몇 page를 재조회 하더라도 캐싱하는 방법이 더 좋을 수 있다.
우선 무한스크롤의 page가 많아질 정도로 일자별 투두가 많지 않을 것이라 예상해 값을 조금 높게 잡았다. 그리고 staleTime도 3분 정도로 조정했다. 이 값도 얼마큼 해야할 지 가늠이 안되지만, 생성/수정/삭제를 낙관적 업데이트를 이용해 캐시 된 데이터를 활용하고 있기 때문에 사용자 경험에는 영향이 없을 것이라 판단했다. staleTime 동안에는 언제나 서버와 동기화될 것이라 확신할 수는 없지만, 현재 프로젝트 규모나 특성에서 캐싱의 장점을 포기하고, 매번 재조회를 하는 것은 비효율적이라 판단하여 결정했다.
`useInfiniteQuery`를 쓰다 보니 업데이트를 할 때마다 pages의 객체 구조를 불변성을 유지하면서 관리하기가 쉽지 않았다. 어떻게 해도 특정 아이템을 찾기 위해서는 이중 map 구조가 되어 시간복잡도 O(m * n)으로 배열 요소가 많아질수록 더욱 성능이 떨어진다고 생각했다.
return {
...prevData,
pages: prevData.pages.map((page) => ({
...page,
data: page.data.map((prevTodo) => (prevTodo.toDoId === toDoId ? todo : prevTodo)),
})),
};
(prevData) => {
const updatedPages = [...prevData.pages];
updatedPages.some((page, pageIndex) => {
const targetIndex = page.data.findIndex((v) => v.notificationId === data.notificationId);
if (targetIndex === -1) return false;
const updatedData = [...page.data];
updatedData[targetIndex] = {
...updatedData[targetIndex],
read: true,
};
updatedPages[pageIndex] = { ...page, data: updatedData };
return true;
});
return { ...prevData, pages: updatedPages };
}
(prevData) => {
const updatedPages = [...prevData.pages];
for (let pageIndex = 0; pageIndex < updatedPages.length; pageIndex++) {
const page = updatedPages[pageIndex];
const targetIndex = page.data.findIndex(
(v) => v.notificationId === data.notificationId
);
if (targetIndex !== -1) {
const updatedData = [...page.data];
updatedData[targetIndex] = {
...updatedData[targetIndex],
read: true,
};
updatedPages[pageIndex] = { ...page, data: updatedData };
break; // 찾으면 루프 종료
}
}
return { ...prevData, pages: updatedPages };
}
2가지로 시도해 본 로직은 최선의 경우 O(1), 최악의 경우 O(m * n)이 된다. 이는 조건이 빠르게 발견될 경우 탐색을 중단하여 성능을 최적화할 수 있다. 성능을 얻는 대신 코드가 다소 복잡해지면서 가독성을 잃은 느낌도 있지만, 데이터 크기가 커질수록 조금의 성능 개선 효과를 기대할 수 있지 않을까… 생각해 본다.
무한스크롤을 처음 구현하고 나서, 네트워크 요청이 화려하게 호출되는 목록을 보며 기겁했던 기억이 난다. 단순 호출도 부담스러운데 페이지마다 재호출까지 이루어지는 상황은 정말 무서울 정도였다. 이전에는 파이어베이스 같은 서버를 주로 사용해 이런 문제를 깊이 고민해보지 않았기에, 직접 문제를 마주하고 해결하는 과정은 쉽지 않았지만 동시에 재밌기도 했다.
이번 프로젝트를 통해 리액트 쿼리를 어느 정도 안다고 생각했던 자신감이 크게 꺾였다. 사실은 겨우 손톱만큼 알고 있었던 게 아니었나 싶다. 낙관적 업데이트를 시도하며 캐싱의 강력함을 체감했고, 다양한 옵션과 쿼리 키를 적절히 활용하면 서버 상태 관리를 더욱 강력하게 할 수 있다는 가능성도 느낄 수 있었다.
한편으로는, 이것 말고도 또 내가 모르는 무언가가 분명 있을 텐데, 그게 무엇일지 몰라 막연히 두려운 마음이 들기도 한다. 결국 이 또한 새로운 기능을 구현하고, 문제에 부딪히며 하나씩 익혀나가야만 해결할 수 있을 텐데 하나하나 배워가며 조금이나마 더 최적화된 성능의 서비스를 만들고 싶다.
에러가 있었는데요. 없었습니다. (1) | 2024.12.22 |
---|---|
좋은 UX를 위한 보이지 않는 에러 다루기 (0) | 2024.12.16 |
거절이 취미인 CORS, 이유가 있겠지 (0) | 2024.12.02 |
나만 빼고 다 하는 오픈소스 기여 맛보기 (2) | 2024.09.08 |
얕은 지식으로 시작하는 React 프로젝트 세팅 (0) | 2024.08.08 |