[번역] 여러분이 React Query를 원하는(x) 필요로 하는 이유

이춘구·2023년 11월 7일
19

translation

목록 보기
10/11

Photo by Christian Lue

TkDodoWhy You Want Need React Query을 번역한 글입니다.


React Query는 React 애플리케이션의 비동기 상태와 상호작용하는 방식을 간소화했고, 그 간소화 방법 때문에 제가 React Query를 ❤️한다는 것은 비밀이 아닙니다. 그리고 많은 동료 개발자도 같은 생각이라는 것을 압니다.

하지만 가끔, 서버에서 데이터를 fetch 하는 것처럼 "간단한" 작업에는 React Query가 필요 없다고 주장하는 글을 마주칠 때가 있습니다.

React Query의 모든 추가 기능이 필요하지도 않고 useEffect에서 간단히 fetch 할 수 있는데 서드파티 라이브러리를 추가하고 싶진 않네요.

어느 정도는 타당한 지적이라고 생각합니다. React Query는 캐싱, 재시도, 폴링, 데이터 동기화, 프리페칭 등 이 글의 범위를 훨씬 뛰어넘는 수많은 기능들을 제공합니다. 여러분에게 이 기능들이 필요하지 않다면 완전히 괜찮지만, 그래도 그게 React Query를 사용하지 않는 이유가 되어서는 안 된다고 생각합니다.

프레임워크

데이터의 fetching이나 mutation을 위한 솔루션이 내장된 프레임워크를 사용하는 경우 React Query가 필요 없을 수도 있습니다.

대신 최근 트위터에 올라온 일반적인 fetch-in-useEffect의 예시를 살펴보고, 왜 이런 상황에서도 React Query를 사용하는 것이 좋은 생각인지 알아보겠습니다.

// fetch-in-useEffect

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

위 코드가 React Query의 추가 기능들 없이도 괜찮은 간단한 사례로 적절하다고 생각하셨나요? 그렇다면 제가 이 10줄의 코드를 보자마자 숨어 있는 🐛버그 5개🪲를 찾았다는 사실을 말씀드려야겠네요.

잠시 시간을 내서 전부 찾을 수 있는지 확인해 보세요. 기다릴게요...

힌트: 의존성 배열은 아닙니다. 그건 문제 없어요.

1. 경쟁 상태 🏎️

React 공식 문서에서 데이터 fetching에 프레임워크나 React Query 같은 라이브러리를 권장하는 데에는 이유가 있습니다. 실제 fetch 요청을 하는 것은 꽤 사소한 작업일 수 있지만, 애플리케이션에서 해당 상태를 예상대로 사용할 수 있게 만드는 것은 간단하지 않습니다.

effect는 category가 변경될 때마다 다시 fetch 하는 방식으로 설정되어 있고, 그렇게 하는 것이 맞습니다. 하지만 네트워크 응답은 요청한 순서와 다르게 도착할 수 있습니다. categorybooks에서 movies로 변경했는데 movies의 응답이 books의 응답보다 먼저 도착하면 컴포넌트에 잘못된 데이터가 있게 됩니다.

결국 일관되지 않은 상태와 함께하게 되죠. category의 상태는 movies인데 실제 렌더링하는 데이터는 books입니다.

React 문서에 따르면 클린업 함수와 ignore라는 불리언을 사용해 이 문제를 해결할 수 있다고 하니 그렇게 해보겠습니다.

// ignore-flag

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

이제 category가 변경되면 effect의 클린업 함수가 실행되고 ignore 플래그가 true로 설정됩니다. 그 후에 fetch의 응답이 들어오면 더 이상 setState를 호출하지 않을 겁니다. 쉽네요.

2. 로딩 상태 🕑

로딩 상태가 아예 없습니다. 요청이 진행되는 동안 보류 중인 UI를 표시할 방법이 없죠. 첫 번째 요청도, 그 이후에도 없습니다. 그러니 추가할까요?

// loading-state

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

3. 빈 상태 🗑️

data를 빈 배열로 초기화하면 undefined인지 매번 확인하지 않아도 되니까 좋은 생각 같습니다. 하지만 아직 등록된 게 없는 카테고리의 데이터를 fetch 했는데 실제로는 빈 배열을 반환받으면 어떻게 될까요? 데이터가 "아직 없는 것"과 "전혀 없는 것"을 구분할 방법이 없습니다. 아까 도입한 로딩 상태가 도움 되지만, 그래도 undefined로 초기화하는 것이 더 낫습니다.

// empty-state

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

4. category가 변경될 때 Data와 Error가 재설정되지 않음 🔄️

dataerror는 둘 다 별도의 상태 변수이고, category가 변경되어도 재설정되지 않습니다. 즉, 카테고리 하나를 fetch 하다가 실패하고, 다른 카테고리로 바꿔서 성공하면 상태는 아래와 같을 겁니다.

data: dataFromCurrentCategory // 현재 카테고리의 데이터
error: errorFromPreviousCategory // 이전 카테고리의 에러

이러면 결과는 상태에 따른 JSX 렌더링을 어떻게 하냐에 따라 달라집니다. error를 먼저 확인하면 유효한 데이터가 있어도 에러 UI와 이전의 메시지를 렌더링할 겁니다.

// error-first

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

data를 먼저 확인하면 두 번째 요청이 실패할 때 같은 문제가 발생합니다. 에러와 데이터를 항상 함께 렌더링하면 이전의 정보도 렌더링하게 될 것입니다. 😔

이 문제를 해결하려면 카테고리가 변경될 때 지역 상태(data, error)를 재설정해야 합니다.

// reset-state

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

5. StrictMode에서 두 번 실행됨 🔥🔥

이건 버그라기보다는 성가신 일이지만, 확실히 새로운 React 개발자들을 방심하게 만드는 문제입니다. 앱을 <React.StrictMode>로 감싼 경우, React는 개발 모드에서 의도적으로 이펙트를 두 번 호출해서 클린업 함수의 누락 등의 버그를 찾을 수 있도록 도와줍니다.

두 번 실행되는 걸 방지하려면 "ref를 사용한 대안"을 또 추가해야 하는데, 그럴만한 가치는 없다고 생각합니다.

보너스: 에러 처리 🚨

React Query에서도 같은 문제가 발생할 수 있어서 원래의 버그 목록에 넣지는 않았습니다. fetch는 HTTP 에러가 발생해도 reject를 하지 않아서 res.ok을 확인하고 직접 에러를 던져야 합니다.

// error-handling

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])

  // 데이터와 에러 상태에 따른 JSX 반환
}

fetch가 에러 응답을 reject하지 않는 이유

fetch가 그렇게 동작하는 이유에 대해 자세히 알아보려면 Artem Zakharchenko가 작성한 이 훌륭한 글을 확인해 보세요.


"그저 데이터를 fetch 하고픈 것뿐인데, 얼마나 어렵겠어?"라는 생각으로 사용한 useEffect 훅은 예외 케이스와 상태 관리를 고려해야 하게 되자 엄청 더러운 스파게티 코드🍝가 되어버렸습니다. 여기서 배울 점은 무엇일까요?

이곳이 React Query가 개입하는 지점입니다. React Query는 데이터 fetching 라이브러리가 아니라(!) 비동기 상태 관리자이니까요. 그러니 엔드포인트로부터 데이터를 fetch 하는 것 같은 간단한 작업에 React Query를 사용하고 싶지 않다고 하신다면 사실 여러분이 맞습니다. React Query를 사용해도 이전과 똑같은 fetch 코드를 작성해야 하거든요.

하지만 앱에서 해당 상태를 예상대로 사용할 수 있게 만드는 걸 최대한 쉽게 하려면 여전히 React Query가 필요합니다. 왜냐면, 우리 솔직해져 봐요, 저는 React Query를 사용하기 전에 ignore라는 불리언 값을 작성해 본 적이 없고, 아마 여러분도 마찬가지일 테니까요. 😉

React Query를 사용하면 위의 코드는 아래와 같이 됩니다.

// 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('Failed to fetch')
        }
        return res.json()
      }),
  })

  // 데이터와 에러 상태에 따른 JSX 반환
}

코드양은 위의 스파게티 코드의 약 50%이고, 이건 버그가 있던 원래 코드 조각과 거의 같은 양입니다. 그리고 이렇게 하면 저희가 발견한 모든 버그가 자동으로 해결됩니다.

🐛 버그

  • 상태는 항상 입력(카테고리)에 따라 저장되므로 경쟁 상태가 발생하지 않습니다.
  • 로딩, 데이터, 에러 상태와 타입 레벨에서의 판별(discriminated) 유니언까지 공짜로 제공됩니다.
  • 빈 상태가 명확하게 구분되고 placeholderData 같은 기능으로 더욱 향상시킬 수 있습니다.
  • 여러분이 그러기로 선택하지 않는 한, 이전 카테고리의 데이터나 에러를 받지 않을 것입니다.
  • StrictMode에 의해 실행된 것도 포함해서, 중복된 fetch가 효율적으로 제거됩니다.

아직도 React Query를 원하지 않는다고 생각하신다면 다음 프로젝트에서 사용해 보시길 감히 권해드리고 싶습니다. 예외 케이스에 더 탄력적으로 대응할 수 있을 뿐만 아니라 유지보수와 확장이 더 쉬운 코드를 작성할 수 있을 겁니다. 그리고 React Query가 제공하는 모든 기능을 한 번 맛보고 나면 아마 다시는 돌아보지 않을 거예요.

Query.gg 🔮

저는 ui.dev와 함께 React Query의 새로운 공식 강의를 준비해 왔습니다. 이 강의에서는 React Query가 내부적으로 어떻게 작동하는지 그리고 확장할 수 있는 React Query 코드를 작성하는 방법에 대한 기본 원리를 이해하게 될 겁니다. 제가 지금까지 만든 콘텐츠가 마음에 드셨다면 query.gg도 마음에 드실 겁니다.

보너스: 취소

트위터에서 많은 사람들이 원래의 코드 조각에서 요청 취소 기능이 빠졌다고 언급했습니다. 저는 이게 꼭 버그라기보단 그냥 빼먹은 기능이라고 생각합니다. 물론 React Query는 꽤 간단한 변경으로 이 문제도 해결해 줍니다.

// cancellation

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()
      }),
  })

  // 데이터와 에러 상태에 따른 JSX 반환
}

queryFn에 들어가는 signalfetch에 넘기면 카테고리 변경 시 요청이 자동으로 중단됩니다. 🎉

profile
프런트엔드 개발자

0개의 댓글