[번역] React Query와 React Context

이춘구·2023년 7월 23일
16

translation

목록 보기
8/11
post-thumbnail

Photo by Jacob Aguilar-Friend

TkDodoReact Query and React Context를 번역한 글입니다.


React Query 최고의 특징 중 하나는 컴포넌트 트리에서 원하는 곳 어디든 쿼리를 사용할 수 있다는 것입니다. 아래의 <ProductTable> 컴포넌트는 필요한 곳에 병치(co-location)된 자신의 데이터를 fetch 할 수 있습니다. (필요한 데이터를 prop으로 전달받는 게 아니라 내부에 병치(co-location)하고 직접 fetch 할 수 있음 - 옮긴이)

function ProductTable() {
  const productQuery = useProductQuery()

  if (productQuery.data) {
    return <table>...</table>
  }

  if (productQuery.isError) {
    return <ErrorMessage error={productQuery.error} />
  }

  return <SkeletonLoader />
}

저는 이게 훌륭하다고 생각합니다. ProductTable을 분리해 독립적으로 만들 수 있기 때문이죠. ProductTable은 자신이 의존하는 Product 데이터를 읽어오는 데 책임이 있습니다. 해당 데이터가 캐시에 있으면 그냥 읽어올 것이고 아니라면 fetch 할 것입니다. 그리고 React Server Component에서도 비슷한 패턴이 나타나는 것을 볼 수 있습니다. React Server Component도 컴포넌트 내부에서 바로 데이터를 fetch 할 수 있게 하죠. 이로써 유상태무상태, 똑똑한 컴포넌트와 멍청한 컴포넌트라는 자의적인 구분이 없어집니다.

그러니 컴포넌트에게 필요한 데이터를 해당 컴포넌트의 내부에서 fetch 할 수 있다는 것은 매우 유용합니다. ProductTable 컴포넌트를 말 그대로 App의 어디로든 옮길 수 있고, 그냥 동작할 것입니다. 이 컴포넌트는 변화에 매우 탄력적이며 이것이 제가 #10: React Query as a State Manager#21: Thinking in React Query에 적었듯 필요한 곳에서 (커스텀 훅을 사용해) 쿼리에 직접 접근할 것을 주장하는 주된 이유입니다.

하지만 이 방법이 만병통치약(silver bullet)은 아니고 트레이드-오프가 있습니다. 결국 모든 것이 트레이드-오프이기 때문에 놀라운 일은 아니죠. 하지만 여기서 우리는 정확히 무엇을 트레이딩 하고 있을까요?

자급자족

어떤 컴포넌트가 자율적이라는 것은 쿼리 데이터를 (아직) 사용할 수 없는 상황, 즉 로딩과 에러 상태를 처리해야 한다는 것을 의미합니다. <ProductTable> 컴포넌트는 최초로 로딩될 때 <SkeletonLoader />를 보여주기 때문에 큰 문제가 되지 않습니다.

하지만 트리의 위쪽에서 이미 사용된 것을 아는 쿼리에서 일부 정보만 읽어오고 싶은 상황도 자주 있습니다. 예를 들자면 로그인한 사용자에 대한 정보를 담고 있는 useUserQuery가 있겠네요.

export const useUserQuery = (id: number) => {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUserById(id),
  })
}
export const useCurrentUserQuery = () => {
  const id = useCurrentUserId()

  return useUserQuery(id)
}  

이 쿼리는 로그인한 사용자가 어떤 사용자 권한을 가졌는지 확인하고, 실제로 페이지를 볼 수 있는지 결정하기 위해 컴포넌트 트리의 꽤나 위쪽에서 사용되었을 것입니다. 사용자 권한은 페이지의 모든 곳에서 필요한 필수적인 정보이죠.

트리의 더 아래쪽에는 userName을 보여주는 컴포넌트가 있고, userNameuseCurrentUserQuery 훅으로 가져올 수 있습니다.

function UserNameDisplay() {
  const { data } = useCurrentUserQuery()
  return <div>User: {data.userName}</div>
}

물론 타입스크립트는 data가 undefined일 수 있다며 이걸 허용하지 않습니다. 하지만 data는 undefined일 수 없다는 걸 우리가 타입스크립트보다 더 잘 압니다. 왜냐하면 쿼리가 트리의 더 위쪽에서 먼저 초기화 되지 않았다면 UserNameDisplay가 렌더링 될 일은 없을테니까요.

이건 약간의 딜레마입니다. 정의될 것을 아니까 여기서는 타입스크립트를 조용히시키고 data!.userName을 해야 할까요? 아니면 안전을 위해 data?.userName을 사용할까요(여기서는 가능하지만, 다른 상황에서는 쉽지 않을 수 있음)? 그냥 타입 가드로 if (!data) return null를 추가할까요? 아니면 useCurrentUserQuery를 호출하는 25곳 전부에 적절한 로딩과 에러 처리를 추가할까요?

솔직히 말해서 이 모든 방법은 차선책이라고 생각합니다. (제가 지금까지 아는 한) "절대 일어날 수 없는" 검사들로 코드 베이스를 가득 채우고 싶지는 않습니다. 하지만 (언제나 그랬듯) 타입스크립트가 옳으므로 무시하고 싶지도 않습니다.

암묵적 의존성

문제는 암묵적 의존성이 있다는 사실에서 비롯됩니다. 암묵적 의존성은 애플리케이션 구조에 대한 우리의 지식과 머릿속에만 존재할 뿐 코드 자체에서는 보이지 않는 의존성입니다.

데이터가 정의되었는지 확인하지 않아도 안전하게 useCurrentUserQuery를 호출할 수 있다는 것을 알고 있지만, 정적 분석으로는 이것을 확인할 수 없습니다. 동료들도 이 사실을 모를 수 있고 저도 3개월 후에는 모를 수 있습니다.

가장 위험한 건 지금은 맞을지 몰라도 미래에는 아닐 수 있다는 것입니다. 캐시에 사용자 데이터가 없거나 또는 이전에 다른 페이지를 방문한 경우처럼 조건부로 있을 수도 있는 앱의 어딘가에서 UserNameDisplay의 또다른 인스턴스를 렌더링 할 가능성이 있습니다.

이건 <ProductTable> 컴포넌트와는 정반대입니다. 변화에 탄력적인 게 아니라 리팩터링으로 오류가 발생하기 쉽죠. 관련 없어 보이는 컴포넌트를 다른 곳으로 옮긴다고 해서 UserNameDisplay 컴포넌트가 망가질 것이라고 예상하지는 않을 것입니다.

명시적으로 만들기

물론 해결책은 의존성을 명시적으로 만드는 것입니다. 그리고 React Context를 사용하는 것보다 좋은 방법은 없습니다.

React Context

React Context에 대한 오해가 많으니 바로잡아 보겠습니다. React Context는 상태 관리자가 아닙니다. useStateuseReducer와 함께 사용하면 상태 관리의 좋은 해결책으로 보일 수 있지만, 솔직히 저는 아래와 같은 상황에 너무 많이 데어서 이 접근 방식을 절대 좋아하지 않습니다.

🕵️이번 주에 useState + context를 zustand로 바꿔서 큰 성능 문제를 해결했습니다. 코드양은 같았습니다. zustand는 1kb 미만입니다.

⚛️상태관리에 context를 사용하지 마세요. 의존성 주입에만 사용하세요. 이 작업에 적합한 도구입니다!

https://zustand.surge.sh
-2022. 2. 19
TkDodo의 트윗

따라서 그냥 전용 도구를 사용하는 것이 더 나을 수 있습니다. Redux의 메인테이너이자 장문의 블로그 글을 작성하는 마크 에릭슨이 이 주제에 대한 좋은 글을 작성했습니다. Blogged Answers: Why React Context is Not a "State Management" Tool (and Why It Doesn't Replace Redux).

제 트윗에서도 이미 React Context는 의존성 주입 도구라고 언급한 바 있습니다. React Context로 컴포넌트가 동작하는 데 필요한 "것"을 정의할 수 있으며, 모든 부모는 해당 정보를 제공할 책임이 있습니다.

이건 여러 계층을 통해 prop을 전달하는 과정인 prop-drilling과 개념적으로 같습니다. context도 어떤 값을 자식에게 prop으로 전달할 수 있지만, 몇 개의 계층을 생략할 수 있다는 점에서 다릅니다.

context를 사용하면 중간자를 건너뛸 수 있습니다. useCurrentUserQuery를 사용하는 위의 예시에서 의존성을 명시적으로 만드는 데 도움이 될 수 있습니다. 데이터 사용 가능성의 확인 검사를 생략하고 싶은 모든 컴포넌트에서 직접 useCurrentUserQuery를 읽는 게 아니라, React Context에서 읽어옵니다. 그리고 그 context는 첫 번째로 검사를 하는 부모에 의해 채워집니다.

const CurrentUserContext = React.CreateContext<User | null>(null)

export const useCurrentUserContext = () => {
  return React.useContext(CurrentUserContext)
}

export const CurrentUserContextProvider = ({
  children,
}: {
  children: React.ReactNode
}) => {
  const currentUserQuery = useCurrentUserQuery()

  if (currentUserQuery.isLoading) {
    return <SkeletonLoader />
  }

  if (currentUserQuery.isError) {
    return <ErrorMessage error={currentUserQuery.error} />
  }

  return (
    <CurrentUserContext.Provider value={currentUserQuery.data}>
      {children}
    </CurrentUserContext.Provider>
  )
}

여기서 currentUserQuery를 가져와서 (로딩과 에러 상태를 미리 제거함으로써) 결과 데이터가 존재한다면 React Context에 넣습니다. 그렇게 하면 UserNameDisplay 같은 자식 컴포넌트에서 그 context를 안전하게 읽을 수 있습니다.

function UserNameDisplay() {
  const data = useCurrentUserContext()
  return <div>User: {data.username}</div>
}

이를 통해 암묵적 의존성(트리의 앞부분에서 일찍이 데이터를 fetch 한 것을 알고 있음)을 명시적으로 만들었습니다. 누군가가 UserNameDisplay를 볼 때마다 CurrentUserContextProvider에서 제공된 데이터가 필요하다는 것을 알 것입니다. 리팩터링할 때 이 점을 염두에 두면 됩니다. Provider가 렌더링 되는 위치를 변경한다면 해당 context를 사용하는 모든 자식에도 영향을 미칠 것 또한 알 수 있습니다. 컴포넌트가 쿼리만 사용할 때는 알 수 없었는데, 쿼리는 일반적으로 전체 앱에서 전역적이며 데이터가 존재할 수도 있고 아닐 수도 있기 때문입니다.

타입스크립트 만족시키기

타입스크립트는 여전히 이것을 별로 좋아하지 않을 것입니다. 왜냐하면 React Context는 context의 기본값을 제공하는 Provider 없이도 작동하도록 설계되었고, 우리의 경우에는 그 기본값이 null이기 때문입니다. Provider 외부에 있는 상황에서는 useCurrentUserContext가 작동하지 않기를 원하므로 커스텀 훅에 불변성을 추가하겠습니다.

export const useCurrentUserContext = () => {
  const currentUser = React.useContext(CurrentUserContext)
  if (!currentUser) {
    throw new Error('CurrentUserContext: No value provided')
  }

  return currentUser
}

이렇게 하면 실수로 잘못된 위치에서 useCurrentUserContext에 접근하는 경우 적절한 에러 메시지와 함께 빠르게 실패할 수 있습니다. 그리고 이를 통해 타입스크립트는 커스텀 훅의 currentUser 값을 추론하므로 안전하게 사용할 수 있고 프로퍼티에 접근할 수도 있습니다.

상태 동기화

"이거 React Query에서 값 하나 복사해서 또 다른 상태 분배 방법에 넣는 '상태 동기화' 아닌가?"라고 생각할 수도 있습니다.

답하자면, 아닙니다! 진실의 단일 공급원은 여전히 쿼리입니다. 쿼리의 최신 데이터를 항상 반영하는 Provider 말고는 context 값을 변경할 방법이 없습니다. 아무것도 복사되지 않으며 동기화가 어긋날 수도 없습니다. 그리고 React Query의 data를 자식 컴포넌트에 prop으로 전달하는 것은 "상태 동기화"가 아닙니다. 또한 context는 prop drilling과 비슷하므로 "상태 동기화"가 아닙니다.

요청 폭포

모든 것엔 단점이 있고 이 기법도 그렇습니다. 구체적으로는 네트워크 폭포를 일으킬 수 있다는 것입니다. 왜냐하면 컴포넌트 트리의 렌더링이 Provider에서 (일시적으로) 중지되어서 관련 없는 하위 컴포넌트인데도 렌더링이 되지 않고, 그로 인해 해당 컴포넌트의 네트워크 요청이 실행되지 않기 때문입니다.

저는 주로 하위 트리에서 필수적인 데이터에 이 접근법의 사용을 고려합니다. 사용자 정보가 좋은 예인데, 해당 데이터가 없으면 무엇을 렌더링할지 알 수 없기 때문입니다.

Suspense

Suspense에 관해 얘기하자면, React Suspense를 사용하여 위와 유사한 아키텍처를 구현할 수 있지만 요청 폭포를 일으킬 수 있다는 동일한 단점이 존재합니다. 저는 이에 대해 이미 #17: Seeding the Query Cache에서 작성한 바 있습니다.

현재 메이저 버전(v4)에는 문제가 하나 있습니다. 쿼리에 suspense: true를 사용하면 data의 타입을 좁힐 수 없다는 것입니다. 쿼리를 비활성화하고 실행되지 않도록 하는 방법이 여전히 존재하기 때문이죠.

하지만 v5부터 컴포넌트가 렌더링 되기만 한다면 데이터가 정의될 것을 보장하는 명시적인 useSuspenseQuery 훅이 있습니다. 이 훅을 사용하면 아래와 같이 할 수 있습니다.

function UserNameDisplay() {
  const { data } = useSuspenseQuery(...)
  return <div>User: {data.username}</div>
}

그러면 타입스크립트도 행복해할 것입니다. 🎉

profile
프런트엔드 개발자

1개의 댓글

comment-user-thumbnail
2023년 7월 23일

잘 읽었습니다. 좋은 정보 감사드립니다.

답글 달기