🔥 React Query 데이터 변환

934자
12분

React Query에 대해 이야기하는 시리즈의 두 번째 글입니다. 이 라이브러리와 그 주변 커뮤니티에 더 깊이 관여하면서 사람들이 자주 물어보는 패턴들을 더 많이 발견했습니다. 처음에는 이 모든 내용을 하나의 큰 글로 작성하려 했지만, 더 다루기 쉬운 조각들로 나누기로 결정했습니다. 첫 번째 주제는 매우 흔하고 중요한 작업인 데이터 변환입니다.

데이터 변환

사실을 직시해 봅시다. 우리 대부분은 GraphQL을 사용하지 않습니다. GraphQL을 사용한다면 원하는 형식으로 데이터를 요청할 수 있는 특권을 누릴 수 있어 행운입니다.

하지만 REST를 사용한다면 백엔드가 반환하는 형식에 제약을 받습니다. 그렇다면 React Query를 사용할 때 데이터를 어디서 어떻게 가장 잘 변환할 수 있을까요? 소프트웨어 개발에서 유일하게 가치 있는 대답이 여기에도 적용됩니다.

"상황에 따라 다릅니다."

— 모든 개발자, 항상

데이터를 변환할 수 있는 3+1가지 접근 방식을 각각의 장단점과 함께 살펴보겠습니다:

0. 백엔드에서

가능하다면 이 방법이 가장 좋습니다. 백엔드가 우리가 원하는 구조 그대로 데이터를 반환한다면, 우리가 할 일은 없습니다. 공개 REST API를 사용하는 경우처럼 많은 상황에서 이는 비현실적으로 들릴 수 있지만, 기업용 애플리케이션에서는 충분히 가능한 방법입니다. 백엔드를 제어할 수 있고 특정 사용 사례에 맞는 데이터를 반환하는 엔드포인트가 있다면, 예상하는 대로 데이터를 제공하는 것이 좋습니다.

🟢 프론트엔드에서 추가 작업이 필요 없음
🔴 항상 가능한 것은 아님

1. queryFn에서

queryFnuseQuery에 전달하는 함수입니다. 이 함수는 Promise를 반환해야 하며, 그 결과 데이터가 쿼리 캐시에 저장됩니다. 하지만 이는 백엔드가 전달하는 구조 그대로 데이터를 반환해야 한다는 의미는 아닙니다. 반환하기 전에 데이터를 변환할 수 있습니다:

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  const data: Todos = response.data
 
  return data.map((todo) => todo.name.toUpperCase())
}
 
export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
 
javascript

프론트엔드에서는 이 데이터를 "마치 백엔드에서 이렇게 왔다"는 것처럼 사용할 수 있습니다. 코드 어디에서도 대문자가 아닌 할 일 이름을 다루지 않게 됩니다. 또한 원래 구조에 접근할 수 없게 됩니다. React Query 개발 도구를 보면 변환된 구조를 볼 수 있지만, 네트워크 트레이스에서는 원래 구조를 볼 수 있습니다. 이는 혼란스러울 수 있으니 주의해야 합니다.

또한, React Query가 이 부분에서 최적화할 수 있는 것은 없습니다. 데이터를 가져올 때마다 변환 작업이 실행됩니다. 변환 작업이 복잡하다면 다른 대안을 고려해 보세요. 일부 회사에서는 데이터 가져오기를 추상화하는 공유 API 계층이 있어 이 계층에서 변환 작업을 할 수 없을 수도 있습니다.

🟢 위치상 백엔드와 가까워 관리하기 편함
🟡 변환된 구조가 캐시에 저장되어 원래 구조에 접근할 수 없음
🔴 데이터를 가져올 때마다 실행됨
🔴 자유롭게 수정할 수 없는 공유 API 계층이 있다면 적용하기 어려움

2. 렌더링 함수에서

Part 1에서 조언한 대로, 사용자 정의 훅을 만들면 쉽게 변환 작업을 수행할 수 있습니다:

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  return response.data
}
 
export const useTodosQuery = () => {
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
 
  return {
    ...queryInfo,
    data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  }
}
 
javascript

이 방식은 데이터를 가져올 때마다 실행될 뿐만 아니라 실제로 모든 렌더링마다 실행됩니다 (데이터 가져오기와 관련 없는 렌더링도 포함). 대부분의 경우 이는 문제가 되지 않지만, 문제가 된다면 useMemo를 사용해 최적화할 수 있습니다. 의존성을 가능한 한 좁게 정의하는 것이 중요합니다. queryInfo 내부의 data는 실제로 변경되지 않는 한 참조적으로 안정적이지만, queryInfo 자체는 그렇지 않습니다. queryInfo를 의존성으로 추가하면 변환 작업이 다시 모든 렌더링마다 실행됩니다:

export const useTodosQuery = () => {
  const queryInfo = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })
 
  return {
    ...queryInfo,
    // 🚨 이렇게 하지 마세요 - useMemo가 아무 역할도 하지 않습니다!
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo]
    ),
 
    // ✅ queryInfo.data로 올바르게 메모이제이션 합니다
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo.data]
    ),
  }
}
 
javascript

특히 사용자 정의 훅에 데이터 변환과 결합할 추가 로직이 있다면 이 방법이 좋은 선택이 될 수 있습니다. 데이터가 잠재적으로 undefined일 수 있으므로 데이터를 다룰 때 옵셔널 체이닝을 사용해야 합니다.

업데이트: React Query v4부터는 tracked queries가 기본적으로 켜져 있기 때문에 ...queryInfo를 펼치는 것은 더 이상 권장되지 않습니다. 모든 속성에 대해 getter를 호출하기 때문입니다.

🟢 useMemo를 통해 최적화 가능
🟡 개발 도구에서 정확한 구조를 검사할 수 없음
🔴 구문이 약간 복잡함
🔴 데이터가 잠재적으로 undefined일 수 있음
🔴 tracked queries와 함께 사용하는 것은 권장되지 않음

3. select 옵션 사용하기

v3에서는 내장 셀렉터가 도입되었는데, 이를 데이터 변환에도 사용할 수 있습니다:

export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select: (data) => data.map((todo) => todo.name.toUpperCase()),
  })
 
javascript

셀렉터는 data가 존재할 때만 호출되므로 여기서는 undefined에 대해 걱정할 필요가 없습니다. 위와 같은 셀렉터는 함수의 동일성이 변경되기 때문에 (인라인 함수이므로) 모든 렌더링마다 실행됩니다. 변환 작업이 복잡하다면 useCallback을 사용하거나 안정적인 함수 참조로 추출하여 메모이제이션할 수 있습니다:

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())
 
export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ 안정적인 함수 참조 사용
    select: transformTodoNames,
  })
 
export const useTodosQuery = () =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    // ✅ useCallback으로 메모이제이션
    select: React.useCallback(
      (data: Todos) => data.map((todo) => todo.name.toUpperCase()),
      []
    ),
  })
 
javascript

또한 select 옵션을 사용하면 데이터의 일부분만 구독할 수 있습니다. 이것이 이 접근 방식을 진정으로 독특하게 만드는 점입니다. 다음 예제를 살펴보겠습니다:

export const useTodosQuery = (select) =>
  useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    select,
  })
 
export const useTodosCount = () =>
  useTodosQuery((data) => data.length)
export const useTodo = (id) =>
  useTodosQuery((data) => data.find((todo) => todo.id === id))
 
javascript

여기서는 useTodosQuery에 사용자 정의 셀렉터를 전달하여 useSelector와 유사한 API를 만들었습니다. 사용자 정의 훅은 이전과 같이 작동하며, select를 전달하지 않으면 undefined가 되어 전체 상태가 반환됩니다.

하지만 셀렉터를 전달하면 셀렉터 함수의 결과에만 구독하게 됩니다. 이는 매우 강력한 기능입니다. 할 일의 이름을 업데이트하더라도 useTodosCount를 통해 개수만 구독하는 컴포넌트는 다시 렌더링되지 않습니다. 개수가 변경되지 않았으므로 React Query는 이 관찰자에게 업데이트를 알리지 않기로 선택할 수 있습니다 🥳 (이는 약간 단순화된 설명이며 기술적으로 완전히 정확하지는 않습니다 - 렌더링 최적화에 대해서는 Part 3에서 더 자세히 다루겠습니다).

🟢 최적의 최적화
🟢 부분 구독 가능
🟡 각 관찰자마다 구조가 다를 수 있음
🟡 구조적 공유가 두 번 수행됨 (Part 3에서 이에 대해 더 자세히 다루겠습니다)