본문 바로가기

카테고리 없음

[번역] Why You Need React Query

https://tkdodo.eu/blog/why-you-want-react-query

 

Why You Want React Query

Let's take a look at why you'd want a library like React Query, even if you don't need all the extra features it provides...

tkdodo.eu

 

저는 React 애플리케이션에서 비동기 상태와 상호 작용하는 방식을 간소화하는 React Query를 좋아합니다. 그리고 많은 동료 개발자들도 같은 생각을 하고 있다는 것을 알고 있습니다.

 

하지만 가끔 서버에서 데이터를 가져오는 것과 같은 간단한 작업에는 필요하지 않다고 주장하는 게시물을 보게됩니다. 

 

React Query가 제공하는 모든 추가 기능이 필요하지 않으므로 useEffect에서 불러오기를 쉽게 실행할 수 있는데 서드 파티 라이브러리를 추가하고 싶지 않습니다. 

 

 

어느 정도는 타당한 지적이라고 생각합니다. React Query는 캐싱, 재시도, 폴링, 데이터 동기화, prefetching등 이 글의 범위를 훨씬 넘어서는 약 백만 가지의  다양한 기능을 제공합니다. 이 기능들이 필요하지 않더라도 괜찮지만, 그렇다고 해서 React Query를 사용하는데 방해가 되어서는 안된다고 생각합니다. 

 

대신 최근 트위터에 올라온 표준 fetch-in-useEffect 예시를 살펴보고, 왜 이러한 상황에서도 React 쿼리를 사용하는 것이 좋은지 알아보겠습니다:

 

function Bookmarks({ category }) {
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => setData(d))
      .catch(e => setError(e))
  }, [category])

  // Return JSX based on data and error state
}

 

이 10줄의 코드에 숨어있는 5개의 버그를 발견했습니다. 

 

1. Race Condition 🏎

공식 리액트 문서에서 데이터 불러오기에 프레임워크나 리액트쿼리와 같은 라이브러리를 사용하도록 권장하는데에는 이유가 있습니다. 실제 불러오기 요청을 하는 것은 매우 사소한 작업일 수 있지만, 애플리케이션에서 해당 상태를 예측 가능하게 만드는 것은 확실히 그렇지 않습니다. 

 

이 Effect는 카테고리가 변경될 때마다 다시 가져오는 방식으로 설정되어 있으며, 이는 확실히 맞습니다. 그러나 네트워크 응답은 사용자가 보낸 것과 다른 순서로 도착할 수 있습니다. 따라서 카테고리를 책에서 영화로 변경했는데 영화에 대한 응답이 책에 대한 응답보다 먼저 도착하면 컴포넌트에 잘못된 데이터가 남게됩니다. 

 

결국에는 일관되지 않은 상태가 남게 된다. 로컬 상태에서는 영화가 선택되어 있다고 표시되지만 실제로 렌더링하는 데이터는 책입니다. 

 

React 문서에 따르면 정리 함수와 무시 boolean을 사용하여 이 문제를 해결할 수 있다고 하니 그렇게 해보겠습니다:

 

function Bookmarks({ category }) {
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // Return JSX based on data and error state
}

 

이제 카테고리가 변경되면 cleanup function이 실행되어 로컬 플래그가 true로 설정됩니다. 그 후에 fetch 응답이 돌아오면 더 이상 setState를 호출하지 않습니다. 

 

2. Loading state 🕐

첫번째 요청도 아니고 추가 요청도 아닌 요청이 진행되는 동안 보류중인 UI를 표시하는 방법을 추가해야합니다. 

 

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState([])
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // Return JSX based on data and error state
}

 

3. Empty state 🗑️

빈 배열로 데이터를 초기화하는 것은 undefined 체크를 피하기 위해 좋은 아이디어 같습니다. 하지만 아직 항목이 없는 카테고리의 데이터를 가져왔는데 실제로 빈 배열이 반환된다면 어떻게 해야할까요? 아직 데이터가 없는 것과 데이터가 전혀없는 것을 구분할 방법이 없습니다. 로딩 상태가 도움이 되긴 하지만 여전히 undefined 상태로 초기화하는 것이 좋습니다. 

 

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // Return JSX based on data and error state
}

 

4. Data & Error are not reset when category changes 🔄

데이터와 오류는 모두 별도의 상태 변수이며 카테고리가 변경되어도 재설정되지 않습니다. 즉 카테고리가 실패하고 성공적으로 가져온 다른 카테고리로 전환되면 상태가 그대로 유지됩니다. 

 

data: dataFromCurrentCategory
error: errorFromPreviousCategory

 

결과는 이 상태를 기반으로 JSX를 실제로 렌더링하는 방법에 따라 달라집니다. 먼저 오류 상태를 렌더링한다면 유효한 데이터가 있음에도 불구하고 이전 메시지로 오류 UI를 렌더링합니다. 

 

return (
  <div>
    { error ? (
      <div>Error: {error.message}</div>
    ) : (
      <ul>
        {data.map(item => (
          <li key={item.id}>{item.name}</div>
        ))}
      </ul>
    )}
  </div>
)

 

데이터를 먼저 확인하면 두 번째 요청이 실패할 경우 동일한 문제가 발생합니다. 항상 오류와 데이터를 모두 렌더링하면 잠재적으로 오래된 정보도 렌더링하게 됩니다.

이 문제를 해결하려면 카테고리가 변경될 때 로컬 상태를 재설정해야 합니다:

 

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => res.json())
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // Return JSX based on data and error state
}

 

 

5. Will fire twice in StrictMode 🔥🔥

이것은 버기라기보다는 성가신 문제이지만, 새로운 React 개발자들을 당황하게 만드는 문제입니다. 앱이 <React.StrictMode>로 래핑된 경우, 리액트는 개발 모드에서 의도적으로 effect를 두번 호출하여 누락된 cleanup 함수와 같은 버그를 찾을 수 있도록 도와줍니다. 이를 방지하려면 또 다른 "ref workaround"을 추가해야 하는데, 그럴 가치가 없다고 생각합니다. 

 

Bonus: Error handling 🚨

원래 버그 목록에 포함시키지 않은 이유는 리액트 쿼리도 동일한 문제가 발생하기 때문입니다. fetch는 HTTP errors에서 reject되지 않으므로 res.ok를 확인하고 직접 오류를 던져야합니다.

function Bookmarks({ category }) {
  const [isLoading, setIsLoading] = useState(true)
  const [data, setData] = useState()
  const [error, setError] = useState()

  useEffect(() => {
    let ignore = false
    setIsLoading(true)
    fetch(`${endpoint}/${category}`)
      .then(res => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      })
      .then(d => {
        if (!ignore) {
          setData(d)
          setError(undefined)
        }
      })
      .catch(e => {
        if (!ignore) {
          setError(e)
          setData(undefined)
        }
      })
      .finally(() => {
        if (!ignore) {
          setIsLoading(false)
        }
      })
      return () => {
        ignore = true
      }
  }, [category])

  // Return JSX based on data and error state
}

 

데이터를 가져오기만하면 되는데 얼마나어렵겠어? 라고 생각했던 useEffect훅은 에지 케이스와 상태 관리를 고려해야하는 순간 거대한 코드가 되어버렸습니다. 여기서 얻을 수 있는 교훈은 무엇일까요 ? 

 

Data fetcing is simple. Async State Management is not.

 

React Query는 데이터 불러오기 라이브러리가 아니라 비동기 상태 관리자이므로 이 점이 바로 React Query가 필요한 부분입니다. 따라서 엔드포인트에서 데이터를 가져오는 것과 같은 간단한 작업에는 필요하지 않다고 말한 것이 사실 맞습니다. React Query를 사용하더라도 이전과 동일한 fetch 코드를 작성해야한다. 

 

하지만 앱에서 상태를 가능한 쉽게 예측 가능하게하려면 여전히 필요합니다. 솔직히 말씀드리면, 저도 리액트 쿼리를 사용하기 전에 ignore boolean을 작성하지 않았고, 아마 여러분도 마찬가지일 것입니다. 

 

리액트 쿼리를 사용하면 아래와 같이 작성할 수 있다. 

 

Copiedreact-query copied to clipboard
function Bookmarks({ category }) {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: () =>
      fetch(`${endpoint}/${category}`).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  // Return JSX based on data and error state
}

 

 

🐛 Bugs

  • 🏎️   상태는 입력한 카테고리에 따라 저장되므로 race condition이 존재하지 않는다. 
  • 🕐   You get loading, data and error states for free, including discriminated unions on type level.
  • 🗑️   Empty states are clearly separated and can further be enhanced with features like placeholderData.
  • 🔄   You will not get data or error from a previous category unless you opt into it.
  • 🔥   Multiple fetches are efficiently deduplicated, including those fired by StrictMode.

아직 리액트 쿼리를 사용하고 싶지 않다고 생각하신다면 다음 프로젝트에서 사용해보시길 권해드리고 싶습니다. 엣지 케이스에 회복력있는 코드를 작성할 수 있을 뿐만 아니라 유지 관리와 확장도 더 쉬워질 것입니다. 그리고 이 기능이 제공하는 모든 기능을 한번 맛보고나면 아마 다시는 뒤돌아보지 않을 것입니다. 

 

Bonus: Cancellation

트위터에서 많은 사람들이 원래 스니펫에서 요청 취소 기능이 누락되었다고 언급했습니다. 저는 이것이 반드시 버그가 아니라 누락된 기능일 뿐이라고 생각합니다. 물론 React Query는 이 부분도 매우 간단하게 변경하여 해결했습니다:

 

function Bookmarks({ category }) {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: ({ signal }) =>
      fetch(`${endpoint}/${category}`, { signal }).then((res) => {
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
        return res.json()
      }),
  })

  // Return JSX based on data and error state
}

 

queryFn으로 부터 받은 signal을 가져와서 전달하기만 하면 카테고리가 변경되면 요청이 자동으로 중단됩니다.