react-query는 상태 관리 도구이다. 상태 관리 도구를 사용하는 이유 중 큰 부분을 차지하는 것이 props-drilling을 피하기 위해서일 것이다.
이 포스트는 react-query를 사용할 때 props drilling을 피하기 위해 고민했던 내용을 다룬다.
서버에 부담 주지 않기
react-query는 동일 키의 쿼리를 동시에 여러 개 요청하면 내부적으로 하나의 요청으로 만든다.
export default function App() {
const { data: posts1 } = useQuery(['posts'], fetchPosts);
const { data: posts2 } = useQuery(['posts'], fetchPosts);
const { data: posts3 } = useQuery(['posts'], fetchPosts);
// ...
}
다소 비현실적인 예시로 user 리스트를 fetch 하는 훅을 3개 사용했다고 가정한다. 하지만 이 훅의 쿼리키는 모두 ["users"]
로 같다. 세 번의 요청이 발생할 것으로 보여지지만, 실제로 서버에 도착하는 요청은 하나뿐이다.
이러한 최적화에 감사할 수 있는 조금 더 현실적인 케이스는 아래와 같다.
- 유저 리스트를 fetch 한다.
- 유저의 수를 화면에 표시한다.
- 유저 리스트를 렌더링 한다.
이러한 케이스는 데이터 fetching을 container 컴포넌트에서 하고, presentational 컴포넌트는 표현만 하도록 개발하는 패턴에서 주로 발생한다.
// src/components/Container.jsx
export default function Container() {
const { data: users } = useQuery(['users'], fetchUsers);
return (
<div>
<p>{users?.length} 명의 유저가 있습니다.</p>
<UserList users={users} />
</div>
);
}
// src/components/UserList.jsx
export default function UserList({ users }) {
return users?.map(({ id, name }) => <p key={id}>{name}</p>);
}
한 번만 props를 내려주면 되기 때문에 지금으로선 문제가 없을 수 있다. 하지만 요구 사항이 변경되어 Container
컴포넌트와 UserList
컴포넌트 사이에 5개의 컴포넌트가 추가되어 props로 전달하기가 부담스러워졌다고 가정해보겠다. 점점 의문이 들기 시작한다. 과연 react-query를 잘 활용하고 있는 것일까? 이때 react-query가 당당하게 해결법을 제시한다.
// src/components/Container.jsx
export default function Container() {
const { data: users } = useQuery(['users'], fetchUsers);
return (
<div>
<p>{users?.length} 명의 유저가 있습니다.</p>
<UserList />
</div>
);
}
// src/components/UserList.jsx
export default function UserList() {
const { data: users } = useQuery(['users'], fetchUsers);
return users?.map(({ id, name }) => <p key={id}>{name}</p>);
}
동시에 두 번의 fetch를 하는 것 같지만 막상 중복 요청은 발생하지 않는다. 더 이상 props drilling은 무섭지 않다. 클라이언트와 서버에 동시에 평화가 찾아온 것이다. 이렇게 이야기가 끝나면 얼마나 좋을까? 하지만 현실은 녹록지 않다.
나도 모르게 중복 요청을?
fetch가 끝날 때까지 기다리다가 완료되면 렌더링하는 경우가 있다. 로딩 상태일 경우 placeholder(주로 loading indicator)를 보여주고, 로딩이 끝나면 실제 데이터를 보여준다.
// src/components/Container.jsx
export default function Container() {
const { data: users, isLoading } = useQuery(['users'], fetchUsers);
if (isLoading) {
return <h1>로딩</h1>;
}
return (
<div>
<p>{users.length} 명의 유저가 있습니다.</p>
<UserList />
</div>
);
}
// src/components/UserList.jsx
export default function UserList() {
const { data: users } = useQuery(['users'], fetchUsers);
return users?.map(({ id, name }) => <p key={id}>{name}</p>);
}
위 예시는 다음 순서로 동작한다.
Container
컴포넌트에서 user 리스트 fetch 시작isLoading
값 true로 초기화<h1>로딩</h1>
렌더링- user 리스트 fetch 완료
isLoading
값 false로 변경UserList
마운트UserList
컴포넌트에서 user 리스트 fetch 시작- cache 된 user 리스트 렌더링
- user 리스트 fetch 완료
- 새로운 user 리스트로 리렌더링
1번에서 한 번, 9번에서 한 번, 총 두 번의 요청이 발생한다. UserList
에서 cache 된 데이터가 일시적으로 보여 체감이 쉽지 않지만, 명백한 중복 요청이다.
중복 요청을 모아서 하나의 요청으로 만들어주는 것은 한 번의 Render Phase에서의 요청들이다. 위 예시는 다른 Render Phase에서의 요청이었기 때문에 하나의 요청으로 합쳐지지 않은 채 그대로 처리된 것이다. 컴포넌트별 데이터 불일치, 중복 에러 핸들링 등 다양한 부수 효과를 내재한 흐름이다.
다시 한번 부담 주지 않기
// src/components/Container.jsx
export default function Container() {
const { data: users, isLoading } = useQuery(['users'], fetchUsers);
if (isLoading) {
return <h1>로딩</h1>;
}
return (
<div>
<p>{users.length} 명의 유저가 있습니다.</p>
<UserList>
{users?.map(({ id, name }) => {
return <p key={id}>{name}</p>;
})}
</UserList>
</div>
);
}
// src/components/UserList.jsx
export default function UserList({ children }) {
return children;
}
컴포넌트를 합성하는 방법을 채택한다면 중복 요청이 발생하지 않는다. 그리고 props drilling도 개선할 수 있다. 하지만 기존에 다른 상태 관리 도구를 사용하다가 react-query로 마이그레이션 하는 상황이라면, 기존의 컴포넌트를 상대적으로 많이 변경해야 하므로 큰 공사가 될 수 있다.
다음 방법은 일명 "적재적소 플레이스호더" 이다.
// src/components/Container.jsx
export default function Container() {
const { data: users, isLoading } = useQuery(['users'], fetchUsers);
return (
<div>
{!isLoading && <p>{users?.length} 명의 유저가 있습니다.</p>}
<UserList />
</div>
);
}
// src/components/UserList.jsx
export default function UserList() {
const { data: users, isLoading } = useQuery(['users'], fetchUsers);
return !isLoading && users?.map(({ id, name }) => <p key={id}>{name}</p>);
}
특정 데이터가 보여질 부분에 그 데이터의 로딩 상태 조건부 렌더링을 설정하는 것이다. 같은 Render Phase에 동일 키의 쿼리가 요청되기 때문에 중복 요청이 발생하지 않는다. 그리고 이전 방법의 단점인, 컴포넌트 구조에 큰 변화도 필요하지 않다.
하지만, 이 방법에는 필요 조건이 따른다. 각 부분별로 placeholder를 잘 만드는 것이다. 위의 예시와 같이 하나의 쿼리(user 리스트)만 존재한다면, placeholder 없이도 큰 문제가 발생하지 않을 것이다. 하지만 여러 개의 쿼리 데이터가 필요하고, 비동기 fetch 작업이 완료되는 시점에 각각 렌더링이 된다면, 종잡을 수 없는 나쁜 유저 경험을 제공할 것이다. 이는 누적 레이아웃 이동(CLS)이 많기 때문이다. 그래서 데이터가 fetch 되고, 렌더링 될 때의 크기와 동일한 placeholder가 필요하다. 대표적인 예시로 skeleton loading indicator 등이 있다. 하지만 동적인 데이터(크기가 정해지지 않은 뷰)의 로딩 인디케이터 등, 명백한 한계가 존재한다. 그리고 디자인, 마크업 등 추가적인 외부의 리소스가 필요하다.
마지막 방법은 Suspense를 활용하는 것이다.
// src/components/App.jsx
export default function Container() {
return (
<Suspense fallback={<h1>로딩</h1>}>
<Container />
</div>
);
}
// src/components/Container.jsx
export default function Container() {
const { data: users } = useQuery(['users'], fetchUsers, {
suspense: true, // default 설정으로 true도 가능
});
return (
<div>
<p>{users.length} 명의 유저가 있습니다.</p>
<UserList />
</div>
);
}
// src/components/UserList.jsx
export default function UserList() {
const { data: users } = useQuery(['users'], fetchUsers, {
suspense: true
});
return users.map(({ id, name }) => <p key={id}>{name}</p>;
}
모든 쿼리의 로딩 상태를 상위 Suspense로 끌어올리는 것이다. 그러면 쿼리 중 하나만 로딩 상태여도 fallback 된다. 이는 다른 방법과 같은 원리로 중복 요청이 발생하지 않는다. "적재적소 플레이스호더" 방법과의 차이점은 로딩 상태가 단 하나로 융합된다는 것이다. 따라서, 로딩 인디케이터도 두 개 이상 필요하지 않다.
Suspense를 안정적으로 도입하기 위해서는 리액트18이 필요하다. 리액트18은 IE11를 서포트하지 않는다. 그래서 불행히도 IE11을 지원하는 서비스에서는 사용하지 못하는 방법이다. 하지만 다행히도 IE11은 지원 종료됐다.
마무리
props drilling을 피하고, react-query의 힘을 온전히 느끼고 싶다면 결국 두가지 방법만이 선택지에 남는다. "플레이스호더" 방법과 "Suspense" 방법이다.
Suspense
적재적소 플레이스호더
위 예제는 react-query-props-drilling 에서 확인 가능하다.
참고
'Web > React' 카테고리의 다른 글
react-query staleTime vs cacheTime (2) | 2022.09.18 |
---|---|
react-query로 클라이언트 상태 관리 하기 (4) | 2022.04.03 |