🔥 React Query 데이터 변환
React Query에 대해 이야기하는 시리즈의 두 번째 글입니다. 이 라이브러리와 그 주변 커뮤니티에 더 깊이 관여하면서 사람들이 자주 물어보는 패턴들을 더 많이 발견했습니다. 처음에는 이 모든 내용을 하나의 큰 글로 작성하려 했지만, 더 다루기 쉬운 조각들로 나누기로 결정했습니다. 첫 번째 주제는 매우 흔하고 중요한 작업인 데이터 변환입니다.
데이터 변환
사실을 직시해 봅시다. 우리 대부분은 GraphQL을 사용하지 않습니다. GraphQL을 사용한다면 원하는 형식으로 데이터를 요청할 수 있는 특권을 누릴 수 있어 행운입니다.
하지만 REST를 사용한다면 백엔드가 반환하는 형식에 제약을 받습니다. 그렇다면 React Query를 사용할 때 데이터를 어디서 어떻게 가장 잘 변환할 수 있을까요? 소프트웨어 개발에서 유일하게 가치 있는 대답이 여기에도 적용됩니다.
"상황에 따라 다릅니다."
— 모든 개발자, 항상
데이터를 변환할 수 있는 3+1가지 접근 방식을 각각의 장단점과 함께 살펴보겠습니다:
0. 백엔드에서
가능하다면 이 방법이 가장 좋습니다. 백엔드가 우리가 원하는 구조 그대로 데이터를 반환한다면, 우리가 할 일은 없습니다. 공개 REST API를 사용하는 경우처럼 많은 상황에서 이는 비현실적으로 들릴 수 있지만, 기업용 애플리케이션에서는 충분히 가능한 방법입니다. 백엔드를 제어할 수 있고 특정 사용 사례에 맞는 데이터를 반환하는 엔드포인트가 있다면, 예상하는 대로 데이터를 제공하는 것이 좋습니다.
🟢 프론트엔드에서 추가 작업이 필요 없음
🔴 항상 가능한 것은 아님
1. queryFn에서
queryFn
은 useQuery
에 전달하는 함수입니다. 이 함수는 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에서 이에 대해 더 자세히 다루겠습니다)