프론트엔드에서 서버 데이터 파생 상태 관리하기: TanStack Query와 상태 관리 전략
최근 들어 서버 데이터를 프론트 state로 변형해 관리하여 처리하는 경우가 종종 있었습니다.
모든 서버에 API 응답값이 페이지 내에서 구현해야 하는 비즈니스 로직에 알맞으면 좋겠지만 그러지 못한 경우가 대다수일 것 입니다.
당시에는 서버 응답값을 기반으로 파생 상태를 만들어 로직을 전개했지만 파생상태를 정의하는 과정이 매끄럽지 못한것을 인지하고 있었습니다. 지금 돌이켜서 어떻게 하면 더 좋은 방법으로 서버 데이터로부터 프론트 파생 상태를 정의할 수 있는지 고민해보았습니다.
여러 방법들이 눈에 띄었지만 다음 두가지 방법이 적용과 이해가 쉬웠습니다.
1. TanStack Query의 select 옵션 사용
import { useQuery } from '@tanstack/react-query';
const useProcessedData = () => {
return useQuery(['dataKey'], fetchData, {
select: (data) => {
// 여기서 데이터를 가공합니다
return processData(data);
},
});
};
// 사용 예
const { data: processedData } = useProcessedData();
위 예제처럼 적용한다면 다음과 같은 장점을 얻을 수 있습니다.
장점:
- React Query 내부에서 최적화되어 있어 성능상 이점이 있습니다.
- 코드가 간결하고 React Query의 패러다임에 잘 맞습니다.
- 캐시된 데이터가 변경될 때만 실행되므로 불필요한 재계산을 방지합니다.
하지만, 모든 방법과 기술이 장점만 있지는 않습니다. 단점으로는 다음과 같습니다.
단점:
- 복잡한 연산의 경우 디버깅이 어려울 수 있습니다.
- select 함수 내부에서 외부 상태에 접근하기 어렵습니다.
추가 예시
단점 중 'select 함수 내부에서 외부 상태에 접근하기 어렵습니다.' 를 보완하기 위해 다음과 같이 useQuery 훅에서 select와 같은 옵션을 받게 설정할 수 있습니다.
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
// fetchData의 반환 타입을 RawDataType이라고 가정합니다.
type RawDataType = ReturnType<typeof fetchData>;
export const useProcessedData = <TData = RawDataType>(
options?: Omit<UseQueryOptions<RawDataType, Error, TData>, 'queryKey' | 'queryFn'>
) => {
return useQuery<RawDataType, Error, TData>(['dataKey'], fetchData, {
select: (data) => {
// 여기서 데이터를 가공합니다
return (options?.select ? options.select(data) : processData(data)) as TData;
},
...options,
});
};
// 사용 예시
// 1. 기본 사용 (processData의 반환 타입을 사용)
const { data: processedData } = useProcessedData();
// 2. 커스텀 select 함수를 사용하여 다른 타입으로 변환
const { data: customProcessedData } = useProcessedData<CustomDataType>({
select: (data) => customProcessData(data),
});
위 예시처럼 select 옵션을 사용하는쪽에서 정의하고 타입을 부여하면 select 함수 내에서 외부 상태에 접근할 수 있으나 함수의 순수성을 해칠 수 있고 오류를 파악할때 복잡성이 증가할 수 있습니다.
2. 메모이제이션 활용 (useMemo)
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
const useProcessedData = () => {
const { data: rawData, ...rest } = useQuery(['dataKey'], fetchData);
const processedData = useMemo(() => {
if (!rawData) return null;
return processData(rawData);
}, [rawData]);
return { ...rest, data: processedData };
};
위 예시 코드의 장점은 다음과 같습니다.
장점:
- 컴포넌트의 다른 상태나 props에 의존하는 복잡한 계산에 유용합니다.
- 디버깅이 비교적 쉽습니다.
- 외부 상태나 함수를 활용한 계산에 유연합니다.
반대로 단점으로는 다음과 같습니다.
단점:
- React의 렌더링 사이클에 종속되어 있어, 불필요한 재렌더링이 발생할 수 있습니다.
- 의존성 배열 관리에 주의를 기울여야 합니다.
select 옵션 vs 메모이제이션
그렇다면 실제 실무에 적용할때는 어떤 방법을 선택해야 할까요?
각 방법의 장단점을 고려하여 다음과 같은 기준을 세울 수 있다고 생각합니다.
선택 기준:
1. 단순한 데이터 변환의 경우:
select 옵션을 사용하는 것이 더 적합합니다. 코드가 간결하고 React Query의 최적화를 직접 활용할 수 있습니다.
const useProcessedData = () => {
return useQuery(['dataKey'], fetchData, {
select: (data) => processData(data),
});
};
2. 복잡한 계산이나 외부 상태에 의존하는 경우:
useMemo를 사용하는 것이 더 적합할 수 있습니다. 특히 다른 상태나 props에 의존하는 계산의 경우에 유용합니다.
const useProcessedData = (someProps) => {
const { data } = useQuery(['dataKey'], fetchData);
const processedData = useMemo(() => {
if (!data) return null;
return complexProcessing(data, someProps);
}, [data, someProps]);
return { data: processedData };
};
3. 성능이 중요한 경우:
대부분의 경우 select 옵션이 성능상 이점이 있습니다. React Query가 내부적으로 최적화를 제공하기 때문입니다.
4. 재사용성과 테스트 용이성:
select 옵션 내의 함수를 별도로 분리하여 재사용하고 테스트하기 쉽게 만들 수 있습니다.
위 경우로 모든 경우가 대처되면 상관이 없지만 다음과 같은 경우에는 어떻게 해야할까?
- 서버로부터 데이터를 가져옴
- 프론트에서 데이터를 재가공함
- 재가공한 데이터를 사용자 동작에 따라 업데이트하여 UI로 보여주어야함
예시의 경우에는 결국 TanStack Query의 데이터 외에 useState라는 훅을 사용해 state와 setState를 사용해야 합니다.
TanStack Query와 useState 함께 사용하기
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
const useProcessedData = () => {
const queryClient = useQueryClient();
const [localData, setLocalData] = useState(null);
// 서버에서 데이터 fetching
const { data: serverData, isLoading, error } = useQuery(['dataKey'], fetchData);
// 서버 데이터를 가공하여 로컬 상태로 설정
useEffect(() => {
if (serverData) {
const processedData = processServerData(serverData);
setLocalData(processedData);
}
}, [serverData]);
// 사용자 액션에 따른 로컬 데이터 업데이트 함수
const updateLocalData = useCallback((updateFn) => {
setLocalData(prev => {
const updated = updateFn(prev);
// React Query 캐시 업데이트 (선택적)
queryClient.setQueryData(['dataKey'], oldData => ({
...oldData,
// 업데이트된 데이터를 서버 데이터 형식에 맞게 변환
...reverseProcessServerData(updated)
}));
return updated;
});
}, [queryClient]);
return {
data: localData,
isLoading,
error,
updateLocalData
};
};
// 컴포넌트에서 사용
const MyComponent = () => {
const { data, isLoading, error, updateLocalData } = useProcessedData();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
const handleUserAction = () => {
updateLocalData(prevData => ({
...prevData,
// 사용자 액션에 따른 데이터 업데이트 로직
}));
};
return (
<div>
{/* 데이터 표시 및 사용자 인터랙션 UI */}
</div>
);
};
위처럼 사용하게 되면 다음과 같은 이점을 얻을 수 있습니다.
- 서버 데이터와 로컬 상태를 분리하여 관리할 수 있습니다.
- React Query의 장점(캐싱, 자동 리프레시 등)을 그대로 활용할 수 있습니다.
- 사용자 액션에 따른 즉각적인 UI 업데이트가 가능합니다.
- 필요한 경우 로컬 상태 변경을 React Query 캐시에 반영할 수 있습니다.
대신 다음 사항에 대한 주의가 필요합니다.
- 서버 데이터와 로컬 상태의 동기화를 주의깊게 관리해야 합니다.
- 성능 최적화를 위해 불필요한 리렌더링을 방지하는 것이 중요합니다. 필요한 경우 useMemo나 useCallback을 활용을 고려할 수 있습니다.
위 경우까지 처리를 한다면 아직까지 제 경험상 더 이상 고려해야할 서버 데이터의 파생상태 처리는 없을것으로 보입니다.
여러분들은 어떤 생각을 가지고 계신가요?
혹시 비슷한 문제를 고민하고 있던 개발자분이 계시면 공유부탁드립니다. 큰 도움이 될 것 같습니다!