🔥 React Query가 필요한 이유

1417자
16분

React Query는 React 애플리케이션에서 비동기 상태를 다루는 방식을 간소화합니다. 많은 개발자들이 이 라이브러리를 사랑하는 이유가 바로 여기에 있죠. 하지만 가끔 서버에서 데이터를 가져오는 것처럼 "간단한" 작업에는 React Query가 필요 없다고 주장하는 사람들도 있습니다.

React Query가 제공하는 추가 기능들이 필요 없으니, 그냥 useEffect에서 fetch를 호출하면 되지 않을까요?

이런 의견도 일리가 있습니다. React Query는 캐싱, 재시도, 폴링, 데이터 동기화, 미리 가져오기 등 수많은 기능을 제공합니다. 이 글에서 다루기에는 너무 많은 기능이죠. 이런 기능들이 필요 없다면 React Query를 사용하지 않아도 됩니다. 하지만 그렇다고 해서 React Query 사용을 완전히 배제할 필요는 없습니다.

프레임워크
데이터 가져오기와 뮤테이션을 위한 내장 솔루션을 제공하는 프레임워크를 사용한다면, React Query가 필요 없을 수도 있습니다.

그럼 이제 트위터에서 최근에 화제가 된 표준 fetch-in-useEffect 예제를 살펴보고, 이런 상황에서도 React Query를 사용하는 것이 좋은 이유를 알아보겠습니다:

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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

간단한 사용 사례에서 추가 기능이 필요 없다면 이 코드로도 충분하다고 생각할 수 있습니다. 하지만 이 10줄의 코드에 숨어있는 🐛 5가지 버그 🪲를 즉시 발견했습니다.

lecture image

잠시 시간을 가지고 모든 버그를 찾아볼 수 있는지 확인해보세요. 기다리고 있겠습니다...


힌트: 의존성 배열은 문제가 없습니다. 그건 괜찮아요.


1. 경쟁 상태

공식 React 문서에서 데이터 가져오기를 위해 프레임워크나 React Query 같은 라이브러리를 사용하라고 권장하는 이유가 있습니다. 실제로 fetch 요청을 보내는 것은 꽤 간단할 수 있지만, 그 상태를 애플리케이션에서 예측 가능하게 사용할 수 있게 만드는 것은 결코 쉽지 않습니다.

이 effect는 category가 변경될 때마다 다시 가져오도록 설정되어 있습니다. 이는 분명 올바른 방식입니다. 하지만 네트워크 응답은 보낸 순서와 다르게 도착할 수 있습니다. 따라서 카테고리를 books에서 movies로 변경했는데 movies에 대한 응답이 books에 대한 응답보다 먼저 도착하면, 컴포넌트에 잘못된 데이터가 표시될 수 있습니다.

lecture image

결국 일관성 없는 상태가 됩니다: 로컬 상태는 movies가 선택되었다고 하지만, 실제로 렌더링되는 데이터는 books입니다.

React 문서에서는 정리 함수와 ignore 불리언 값으로 이 문제를 해결할 수 있다고 합니다. 그럼 그렇게 해보겠습니다:

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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

이제 category가 변경되면 effect 정리 함수가 실행되어 로컬 ignore 플래그를 true로 설정합니다. 그 후에 fetch 응답이 도착하면 더 이상 setState를 호출하지 않습니다. 간단하죠.

2. 로딩 상태

로딩 상태가 전혀 없습니다. 요청이 진행 중일 때 대기 중인 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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

3. 빈 상태

data를 빈 배열로 초기화하면 항상 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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

4. 카테고리가 변경되어도 데이터와 오류가 초기화되지 않음

dataerror는 별개의 상태 변수이며, category가 변경되어도 초기화되지 않습니다. 즉, 한 카테고리에서 실패하고 다른 카테고리에서 성공적으로 가져온 경우 상태는 다음과 같이 됩니다:

data: dataFromCurrentCategory
error: errorFromPreviousCategory
 
javascript

그러면 실제로 이 상태를 기반으로 JSX를 어떻게 렌더링하느냐에 따라 결과가 달라집니다. error를 먼저 확인하면 유효한 데이터가 있음에도 불구하고 이전 오류 메시지와 함께 오류 UI를 렌더링하게 됩니다:

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

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

이를 해결하려면 카테고리가 변경될 때 로컬 상태를 초기화해야 합니다:

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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

5. StrictMode에서 두 번 실행됨

이는 버그라기보다는 성가신 문제에 가깝습니다. 하지만 새로운 React 개발자들을 당황하게 만드는 요소입니다. 앱이 <React.StrictMode>로 감싸져 있다면, React는 개발 모드에서 의도적으로 effect를 두 번 호출하여 정리 함수 누락과 같은 버그를 찾는 데 도움을 줍니다.

이를 피하려면 또 다른 "ref 임시방편"을 추가해야 하는데, 그렇게 할 가치가 있다고 생각하지 않습니다.

보너스: 오류 처리

원래 버그 목록에 포함하지 않았던 이유는 React Query를 사용해도 같은 문제가 발생하기 때문입니다: fetch는 HTTP 오류에서 거부하지 않으므로 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('가져오기 실패')
        }
        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])
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

fetch가 오류 응답에서 거부하지 않는 이유

fetch가 이렇게 동작하는 이유에 대해 자세히 알고 싶다면, Artem Zakharchenko의 [이 훌륭한 글](https://kettanaito.com한 글](https://kettanaito.com/blog/why-fetch-promise-doesnt-reject-on-error-responses)을 확인해보세요.


"데이터를 가져오는 게 얼마나 어려울까요?"라고 생각했던 우리의 작은 useEffect 훅은 예외 상황과 상태 관리를 고려하자 거대한 스파게티 코드 🍝가 되어버렸습니다. 여기서 우리는 무엇을 배울 수 있을까요?

데이터 가져오기는 간단합니다.

비동기 상태 관리는 그렇지 않습니다.

바로 여기서 React Query가 빛을 발합니다. React Query는 데이터 가져오기 라이브러리가 아닙니다. 비동기 상태 관리자입니다. 그래서 단순히 엔드포인트에서 데이터를 가져오는 것처럼 간단한 작업에는 React Query가 필요 없다고 말할 때, 여러분은 옳습니다. React Query를 사용해도 이전과 동일한 fetch 코드를 작성해야 합니다.

하지만 그 상태를 애플리케이션에서 예측 가능하게, 가능한 한 쉽게 사용할 수 있게 만들기 위해서는 여전히 React Query가 필요합니다. 솔직히 말해서, React Query를 사용하기 전에는 저도 그 ignore 불리언 코드를 작성해본 적이 없었고, 여러분도 마찬가지일 겁니다. 😉

React Query를 사용하면 위의 코드가 다음과 같이 간단해집니다:

function Bookmarks({ category }) {
  const { isLoading, data, error } = useQuery({
    queryKey: ['bookmarks', category],
    queryFn: () =>
      fetch(`${endpoint}/${category}`).then((res) => {
        if (!res.ok) {
          throw new Error('가져오기 실패')
        }
        return res.json()
      }),
  })
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

이는 위의 스파게티 코드의 약 50%에 불과하며, 원래의 버그 투성이 코드와 거의 같은 양입니다. 그리고 네, 이는 우리가 발견한 모든 버그를 자동으로 해결합니다:

🐛 버그들

  • 🏎️ 상태가 항상 입력(카테고리)에 따라 저장되므로 경쟁 조건이 없습니다.
  • 🕐 로딩, 데이터, 오류 상태를 자동으로 제공하며, 타입 수준에서 구별된 유니온까지 포함됩니다.
  • 🗑️ 빈 상태가 명확히 구분되며, placeholderData와 같은 기능으로 더욱 향상될 수 있습니다.
  • 🔄 직접 설정하지 않는 한 이전 카테고리의 데이터나 오류를 받지 않습니다.
  • 🔥 StrictMode에 의해 발생하는 것을 포함하여 여러 번의 가져오기가 효율적으로 중복 제거됩니다.

그래도 여전히 React Query를 원하지 않는다고 생각한다면, 다음 프로젝트에서 한번 시도해보길 권합니다. 예외 상황에 더 강하고 유지보수와 확장이 더 쉬운 코드를 작성하게 될 뿐만 아니라, 제공하는 모든 기능을 맛보고 나면 아마 다시는 뒤돌아보지 않을 것입니다.

보너스: 요청 취소

트위터에서 많은 사람들이 원래 코드에서 요청 취소가 빠졌다고 언급했습니다. 이는 버그라기보다는 빠진 기능에 가깝습니다. 물론 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('가져오기 실패')
        }
        return res.json()
      }),
  })
 
  // data와 error 상태에 따라 JSX 반환
}
 
javascript

queryFn에서 받은 signalfetch에 전달하기만 하면 카테고리가 변경될 때 요청이 자동으로 중단됩니다. 🎉