이번에 맡게 된 업무는 진행중인 프로젝트의 홈 페이지 스타일링 및 컴포넌트화였다. 스타일링을 먼저 마친 후에 컴포넌트 분할하는 스타일이라 모든 스타일링을 마친 후 컴포넌트화를 하는 단계 중 맞딱뜨린 고민이 있었는데...
//예시 코드
const layout = () => {
const loginId = getLoginId("loginId");
return (
<>
<Component1 loginId={loginId} />
<Component2 loginId={loginId} />
<Component3 loginId={loginId} />
</>
);
};
layout
에서 300줄이 넘던 뷰 및 데이터 패칭 코드들을 관심사별로 잘 분리해서 각 컴포넌트에 만들어주고 각 컴포넌트들은 현재 로그인된 id를 넘겨주게끔 만들었다. 그 이후 각 컴포넌트에서 공통적인 부분을 발견했다.
// loginId를 props로 받는 컴포넌트 예시
const Component1 = ({ loginId }) => {
const { isSuccess, data } = useQuery({
url: `/apis`,
params: {
loginId: loginId,
},
});
...
if(isLoading) return <div>loading...</div>
return(
<div>{ data }</div>
)
...
이런식으로 각 컴포넌트에서 useQuery
로 데이터 패칭을 하고 있었다. 또한, 각 데이터 패칭을 할 때 공통적으로 loginId를 params를 넣어 요청을 보내고 있었다.
이렇게 각 컴포넌트에서 데이터 요청을 보내는데, 같은 param을 보내고, 각 컴포넌트에서 에러와 서스펜스를 핸들링 하면서 한 페이지에 모두 렌더링 된다? 논리에 맞지 않게 느껴졌다. 결정적으로 첫 화면이 렌더될 때 버벅이는 화면이 있는것을 하여 UX적으로도 좋지 못하다는 것을 확인했다.
한 가지 더, 유지보수 측면에서도 좋지 못하다는 생각이 들었다 각 컴포넌트에서 데이터를 패칭하니 데이터 값과 에러를 확인하려면 각 컴포넌트를 일일히 확인해야하는 DX적으로 좋지 못하다고 판단이 들었다. UX, DX 어느 하나 잡지 못하는 코드를 그냥 넘어갈 수는 없었고 이를 리팩토링하면서 useQueries
를 사용하여 해결했던 경험들을 작성해보려고 한다.
먼저, 각 컴포넌트에 역할 및 관심사를 확인해보았다.
//예시 코드
const layout = () => {
const loginId = getLoginId("loginId");
return (
<>
<Component1 loginId={loginId} />
<Component2 loginId={loginId} />
<Component3 loginId={loginId} />
</>
);
};
레이아웃에서 loginId값을 얻어오고 각 컴포넌트에 뿌려준다. 각 컴포넌트는 받아온 loginId로 데이터 패칭을 시도합니다. 이 과정에서 불필요하다고 느껴진 것이 있는데,
같은 params
로 데이터를 패칭한다.같은 props
를 받는다.그렇다면 차라리 상단에서 데이터를 한꺼번에 패칭해서 패칭된 데이터들을 각 컴포넌트에 뿌려주면 어떨까라는 아이디어가 떠올랐다. 이렇게 된다면, 각 컴포넌트들은 상단에서 패칭된 데이터에만 관심을 갖게끔 분류하여 데이터에 따른 뷰를 계산하는 뷰 컴포넌트로, layout 컴포넌트는 데이터를 패칭하고 뿌려주는 액션을 수행하는 액션 컴포넌트로 변경하면 좋을 것 같다는 생각이 들었다.
문제는 각 컴포넌트에서 사용하는 useQuery
훅이 tanstack-query 라이브러리 훅이 아닌 팀에서 axios와 결합한 커스텀 훅이었다. 커스텀 훅을 사용하는 것이 팀 내부에서 유지보수도 더 좋아보였고, 커스텀 useQuery
를 어떻게 한꺼번에 병렬적으로 데이터를 패칭해올지 고민을 해보았다.
처음 생각했던 방법은 Promise.all()
로 useQuery 커스텀 훅을 사용하는 것이었다. 아주 간단하게, useQuery를 똑같이 사용하면서 Promise.all로 감싸주기만 하면 되는것이었기에, 아주 편하게 문제가 해결되나 싶었지만, 두 가지 새로운 고민거리가 생겼다.
현재 useQuery로는 throwOnError: false
로 지정하여 에러를 던지지 않고 패칭이 실패한 값은 undefined로 렌더되어 분기처리가 되어있는데, Promise.all
로 모두 병렬적으로 처리하면 하나라도 실패했을 때, 에러 핸들링 하는 코드를 작성하면서 코드가 길어졌다.
// 구현했던 예시 코드
const fetchMultipleData = async () => {
const ['/api/data1', '/api/data2', '/api/data3'] = await Promise.all([
api.get('/api/data1'),
api.get('/api/data2'),
api.get('/api/data3'),
]);
return [data1.data, data2.data, data3.data];
};
코드가 길어짐에 따라 코드를 다시 봤을 때, 이게 과연 DX적으로 좋은 코드인가? 라는 고민이 생겼다. 이렇게 커스텀 useQuery
를 사용하기 위해 굳이 Promise.all
을 사용해야 할까라는 생각이 생겼다. 그렇게 커스텀 훅에 대한 미련을 버리니 매력적인 useQueries
가 눈에 들어오기 시작했다. useQueries는 2가지의 고민거리를 모두 해결해주었다.
useQueries 출처:tanstack-query v5
커스텀 훅을 조금 살펴보니 Axios.intersceptor
로 토큰을 중간에 가로채서 새로 요청을 보내는 로직이 있었는데, 이것만 useQueries에 결합하면 굳이 커스텀 훅을 사용할 필요가 없기에 바로 로직으로 작성해보았다.
import api from 'api'
const apis = [
{url: "/api/data1", key: "data1"},
{url: "/api/data2", key: "data2"},
{url: "/api/data3", key: "data3"},
];
const results = useQueries({
queries: apis.map((url) => ({
queryKey: [url],
queryFn: () => api.get(url, { params: { loginId } })
throwOnError: false,
select: ({ data }) => data?.[key] ?? data,
})),
});
const [data1, data2, data3] = results.map((query) => query.data);
return (
<>
<Component1 data1={data1} />
<Component2 data2={data2} />
<Component3 data3={data3} />
</>
);
...
1.apis에 요청할 url들을 담아놓는다.
2.useQueries에 요청할 queries을 작성한다.
3.params가 모두 같고 요청할 url만 다르기에 apis를 순회하며 queries를 요청하게끔 작성한다.
4.각 query에서 요청 에러가 발생할 경우, 에러를 던지지 않게 설정한다.
5.select로 원하는 데이터를 가공하여 반환한다.
6.가공한 데이터를 구조분해 할당하여 props로 뿌리기 좋게 한다.
7.완성된 데이터를 각 컴포넌트에 할당해준다.
이후, 이 모든 데이터 패칭들이 완료되었을 때의 분기처리를 위해 isSuccess
프로퍼티를 확인하게끔 지정했다.
const allSuccess = results.every((query) => query.isSuccess);
if (!allSuccess) {
return (
<div>
<LoadingSpinner />
</div>
);
}
각 query가 데이터 패칭이 완료되면 isSuccess는 true를 반환하고, every
메서드를 사용함으로써 모든 요소가 true로 되어야 allSuccess도 true로 되게 로직을 작성했다.
이렇게 하여 DX적으로는 전보다 나은 코드가 된 것을 확인했고, 팀원 분들에게도 확인한 결과 전 보다는 가독성이 훨씬 좋아졌다고 피드백 받았다. 이후 UX 테스트를 위해 화면을 확인해본 결과
이번 리팩토링을 진행하면서 한층 멀리 볼 수 있는 시야를 얻었다.
첫 번째로, 한 가지 방법에 너무 매몰되지 않기
두 번째로, 결정사항에 따른 리스크 및 예외사항 생각하기
세 번째로, 액션, 계산 로직을 어떻게 분리할지 고민해보기
네 번째로, 리팩토링 이후 발생할 문제 생각해보기
이 코드를 작성하면서 api 주소를 순회하며 query 요청을 보내는 것이 과연 옳은 코드인가? 라는 생각이 들었다. 하지만, 구현하다보니 생각이 나지 않아서 일단 순회하여 맵핑해주는 형태로 작성했는데 아마 더 좋은 방법이 있을 것이다. 방식은 맞다는 것을 확인했으니 이제 코드 자체 리팩토링을 거쳐 고도화 할 생각이다.