Photo by Jacob Aguilar-Friend
TkDodo의 React Query and React Context를 번역한 글입니다.
React Query의 좋은 특징 중 하나는, 쿼리를 컴포넌트 트리 내에서 원하는 곳 어디든 사용할 수 있다는 것입니다. 아래의 <ProductTable>
컴포넌트는 필요한 곳에 병치(co-location)된 자신의 데이터를 fetch 할 수 있습니다. (필요한 데이터를 prop으로 전달받는 게 아니라 내부에 병치(co-location)하고 직접 fetch 할 수 있음 - 옮긴이)
// ProductTable
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
가 있습니다.
// useCurrentUserQuery
export const useUserQuery = (id: number) => {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchUserById(id),
})
}
export const useCurrentUserQuery = () => {
const id = useCurrentUserId()
return useUserQuery(id)
}
이 쿼리는 로그인한 사용자가 어떤 사용자 권한을 가졌는지 검사하고, 더 나아가 페이지를 실제로 볼 수 있는지 결정하기 위해 컴포넌트 트리의 꽤 위쪽에서 사용되었을 것입니다. 페이지의 모든 곳에서 필요한 필수적인 정보이죠.
트리의 더 아래쪽에는 userName
을 표시하는 컴포넌트가 있고, userName
을 가져오는 useCurrentUserQuery
훅이 있습니다.
// UserNameDisplay
function UserNameDisplay() {
const { data } = useCurrentUserQuery()
return <div>User: {data.userName}</div>
}
물론 타입스크립트는 data
가 undefined일 수 있다며 허용하지 않습니다. 하지만 우리는 data
가 undefined일 수 없다는 걸 타입스크립트보다 더 잘 압니다. 쿼리가 더 위쪽 트리에서 이미 초기화되지 않았다면 UserNameDisplay
가 렌더링될 일이 없을테니까요.
이건 약간 딜레마입니다. data
가 undefined라는 것을 아니까 data!.userName
을 해서 TS를 조용히시켜야 할까요? 아니면 안전을 위해 data?.userName
을 사용할까요(여기선 가능하지만, 다른 상황에선 그리 쉽지 않을 수 있음)? 그냥 타입 가드로 if (!data) return null
를 추가할까요? 아니면 useCurrentUserQuery
를 호출하는 25곳 전부에 적절한 로딩과 오류 처리를 추가할까요?
솔직히 이 방법들은 전부 차선책이라고 생각합니다. (제 현재 지식으로는) "절대 실행될 수 없는" 검사들로 코드 베이스를 가득 채우고 싶지는 않습니다. 하지만 (언제나 그랬듯) 타입스크립트가 옳기 때문에 무시하고 싶지도 않습니다.
문제는 암시적 의존성이 있다는 사실에서 비롯됩니다. 암시적 의존성은 우리의 머릿속, 애플리케이션 구조에 대한 지식 내에 존재할 뿐 코드 자체에서는 보이지 않는 의존성입니다.
데이터가 정의되었는지 확인할 필요없이 useCurrentUserQuery
를 안전하게 호출할 수 있다는 것을 우리는 알지만, 이걸 검증할 수 있는 정적 분석은 없습니다. 동료들이 이 사실을 모를 수 있고, 저 또한 3개월이 지나면 모르게 될 수 있습니다.
가장 위험한 건 지금은 맞을지 몰라도 미래에는 아닐 수 있다는 것입니다. 앱의 어딘가에 UserNameDisplay의 다른 인스턴스를 렌더링했는데, 그곳에 사용자 데이터가 캐시에 없거나 조건부로(예. 전에 다른 페이지를 방문) 있을 수도 있죠.
이건 <ProductTable>
컴포넌트와 정반대입니다. 변화에 탄력적인 게 아니라 리팩터링으로 인해 오류가 발생하기 쉽죠. UserNameDisplay
컴포넌트 근처의 관련 없어 보이는 컴포넌트를 다른 곳으로 옮기는 것 때문에 UserNameDisplay
가 고장날 거라 예상하지는 않을 겁니다.
물론 해결책은 의존성을 명시적으로 만드는 것입니다. 그리고 여기에 React Context
보다 좋은 방법은 없습니다.
React Context에 대한 오해가 많으니 바로잡아 보겠습니다. React Context는 상태 관리자가 아닙니다. useState
나 useReducer
와 함께 사용하면 상태 관리의 좋은 해결책으로 보일 수 있지만, 솔직히 저는 아래와 같은 상황에 너무 많이 데어서 이 접근 방식을 절대 좋아하지 않습니다.
🕵️이번 주에 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는 실제로 첫 검사를 하는 부모에 의해 채워집니다.
// CurrentUserContext
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를 안전하게 읽을 수 있습니다.
// UserNameDisplay-with-React-Context
function UserNameDisplay() {
const data = useCurrentUserContext()
return <div>User: {data.username}</div>
}
이를 통해 암시적 의존성(데이터가 트리에서 일찍이 fetch 된 것을 앎)을 명시적으로 만들었습니다. 누군가 UserNameDisplay
를 볼 때마다, CurrentUserContextProvider
가 제공하는 데이터가 필요하다는 걸 알 수 있을 것입니다. 리팩터링할 때 이 점을 기억해 둘 수 있죠. 그리고 Provider가 렌더링 되는 위치를 변경하면 해당 context를 사용하는 모든 자식에게 영향을 미칠 것도 알 수 있습니다. 컴포넌트가 쿼리만 사용할 때는 알 수 없는데, 쿼리는 보통 앱 전체에 전역적이며 데이터는 존재할 수도, 아닐 수도 있기 때문입니다.
아직도 타입스크립트는 그닥 좋아하지 않을 것입니다. React Context는 context의 기본값을 제공하는 Provider 없이도 동작하도록 설계되었고, 우리의 경우에는 그 기본값이 null
이기 때문입니다. useCurrentUserContext
가 Provider 외부에서는 동작하지 않길 원하니까 커스텀 훅에 불변성을 추가하겠습니다.
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에서 (일시적으로) 중지되어서 관련 없는 하위 컴포넌트까지 렌더링이 되지 않고, 그로 인해 해당 하위 컴포넌트의 네트워크 요청이 실행되지 않기 때문입니다.
저는 주로 하위 트리에서 반드시 필요한 데이터에 대해 이 접근법을 고려합니다. 사용자 정보가 좋은 예인데, 이 정보가 없으면 무엇을 렌더링할지 알 수 없기 때문입니다.
네, React Suspense로 위와 유사한 구조를 구현할 수 있지만, 요청 폭포를 일으킬 수 있다는 동일한 단점이 있습니다. 저는 이에 대해 이미 #17: Seeding the Query Cache에서 작성한 바 있습니다.
현재 메이저 버전(v4)에는 문제가 하나 있습니다. 쿼리에 suspense: true
를 사용하면 data
의 타입을 좁힐 수 없다는 것입니다. 쿼리를 비활성화시켜서 실행되지 않게 하는 방법이 여전히 존재하기 때문이죠.
하지만 v5부터는 컴포넌트가 렌더링되기만 한다면 데이터가 정의될 것을 보장하는 명시적인 useSuspenseQuery
훅이 있습니다. 이 훅을 사용하면 아래와 같이 할 수 있습니다.
function UserNameDisplay() {
const { data } = useSuspenseQuery(...)
return <div>User: {data.username}</div>
}
그리고 타입스크립트도 행복해 하겠죠. 🎉
잘 읽었습니다. 좋은 정보 감사드립니다.