티스토리 뷰

최근 들어 서버 데이터를 프론트 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을 활용을 고려할 수 있습니다.

 

위 경우까지 처리를 한다면 아직까지 제 경험상 더 이상 고려해야할 서버 데이터의 파생상태 처리는 없을것으로 보입니다.

 

여러분들은 어떤 생각을 가지고 계신가요?

혹시 비슷한 문제를 고민하고 있던 개발자분이 계시면 공유부탁드립니다. 큰 도움이 될 것 같습니다!

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함