[ 학습 목표 ]
MSW
를 사용하여 API 요청을 모킹하고 개발할 수 있다.- 비동기 작업(API 요청)의 상태를 관리하고, 비동기 요청 상태에 따라 적절한 UI를 보여줄 수 있다.
RTL
을 사용하여 비동기 작업에 대한 테스트를 작성할 수 있다.React Query
를 사용하여서버 상태를 관리
할 수 있다.- API 연동 과정에서 발생하는
다양한 에러 상황에 대응
하고 사용자에게 피드백을 제공할 수 있다.
레벨 2 마지막 미션이 마무리되었다. 페어 프로그래밍과 미션이 끝나서 후련하면서도 한편으로는 아쉬웠다. “코드리뷰를 더 알차게 사용할 수 없었을까? 앞으로는 우리끼리 어떻게 코드리뷰를 해나가지?” 하는 아쉬움과 걱정들도 있었지만 그 상황에서의 최선을 다했다고 생각한다. 이 글을 다음 기수가 본다면 미션을 급하게 제출하지 않고, 어떤 부분을 고민했는지, 어떤 부분을 개선해나갈 수 있을지
등을 PR 본문에 잘 담아보면 좋을 것 같다. 추가로 이번 미션에서 얻어가고 싶은 목표도 정해봤다.
이번 미션에서는 실제로 많이 사용하고, 프로젝트에 적용해볼 수 있는 라이브러리를 활용해보는 경험이 많았다. 나는 MSW와 react-query를 써봤었다. 하지만 내가 react-query를 깊이있게 공부하지 않았다는 게 금방 느껴졌다. 그래서 스터디에서도 다루고, 공식문서도 여러번 읽어보면서 어떤 방식으로 동작하는지, 어떤 원리로 동작하는지 이해하면서 사용하였다. 공부할수록 라이브러리 사용법보다 어떤 문제를 해결했는지 이해하는 게 중요하다는 걸 몸소 깨닫고 있다.
서버 데이터를 화면에 보여주거나 저장되어 있는 정보를 바탕으로 조건부 UI를 보여줄 경우, 해당 데이터를 상태로 관리하는데 이를 서버 상태 관리
라고 한다. 이전 미션에서는 비동기 selector로 서버 데이터를 관리하였는데, recoil로 클라이언트 상태와 함께 관리하다보니 명확히 구분이 안되고 동기화 문제도 고려해야 했다. 이를 react-query 의 역할로 옮기니 가독성과 유지보수성이 높아지고, 서버 데이터를 가공해야 할 때 어느 부분을 고쳐야 할지 명확해졌다. 예시로 들면 다음과 같다.
기존에 서버 상태를 관리하기 위해선 useEffect를 사용하여 API를 호출하고 응답값을 setState 하였다. 이에 대한 로딩 UI도 보여주려면 isLoading 상태, 에러 UI도 보여주려면 isError 상태도 별도로 필요했다.
const useProductList = () => {
const [productList, setProductList] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [page, setPage] = useState(0);
...
useEffect(() => {
const getProductList = async () => {
try {
setIsLoading(true);
...
const data = await fetchProductList(...);
...
} catch (error) {
if (error instanceof CustomError) {
toast.error(error.message);
}
} finally {
setIsLoading(false);
}
};
getProductList();
}, [page, ...]);
...
}
하지만 react-query로 서버 상태를 관리하면 해당 로직이 query로 옮겨가고, setState를 트리거할 때 캐싱된 데이터를 보여줌으로써 사용자의 UX를 해치지 않는다. 또한 API 호출에 대한 에러 처리, 캐싱 처리 등을 조절할 수 있어서 프로젝트의 성격에 따라 다양한 옵션을 추가할 수 있다.
// useProductListQuery.ts
import { useInfiniteQuery } from '@tanstack/react-query';
const useProductListQuery = ({ category, sortOptions, queryOptions }) => {
return useInfiniteQuery({
queryKey: ...,
queryFn: ({ pageParam }) => {
...
return fetchProductList(...);
},
initialPageParam: 0,
getNextPageParam: (lastPage, _, lastPageParam) => {
if (lastPage.last) {
return null;
}
return lastPageParam + 1;
},
...queryOptions,
});
};
// useProductList.ts
const { data, hasNextPage, fetchNextPage, isPending, isFetching } = useProductListQuery(...);
카테고리가 변경될 경우 해당 카테고리의 0번째 page를 불러와야 하기 때문에, page 상태를 0으로 setState하는 로직 때문에 dropdown과 productList 간에 의존성이 증가하였다. 카테고리가 아닌 스크롤로 productList가 추가될 때는 dropdown이 영향을 받을 필요가 없는데 의존하게 되는 문제가 있었다. 하지만 react-query를 도입하니 invalidateQueries로 쿼리키에 해당하는 query를 트리거할 수 있으므로 의존성을 낮춰 각각 독립적으로 관리할 수 있었다. 이 또한 서버 상태(page)를 react-query가 관리해주기 때문에 얻은 이점이라고 할 수 있다.
// Before ❌
const useProductList = () => {
const { page, fetchNextPage, determineLastPage, resetPage } = useProductPage();
const { category, order, handleChangeCategory, handleChangeSort } = useProductDropdown(resetPage);
...
}
// After ✅
const useProduct = () => {
const { category, order, handleChangeCategory, handleChangeSort } = useProductDropdown();
const { data, isPending, isFetching, fetchNextPage, hasNextPage } = useProductList({
category: category.value,
sortOptions: order.value,
});
...
}
리스트 렌더링을 할 때 key를 설정하지 않을 경우 발생하는 에러 메세지다. 결론적으론 unique한 key를 넣어주면 해결되지만 좀더 자세하게 알아보자. 일단 해당 문제가 발생한 이유는 서버에서 내려주는 id를 리스트 렌더링의 key값으로 사용했었는데, 서버 데이터 문제로 인해 id가 unique하게 반환되지 않아 key가 중복된다는 오류가 발생
하였다.
API 반환값이 정상적으로 들어오는 것을 확인했지만, 브라우저 렌더링이 정상적으로 동작하지 않았다. unique key 에러가 발생할 때만 문제가 생기는 것을 보고 key를 index로 바꿔봤더니 오류는 해결되었다. 하지만 product를 추가, 삭제하거나 카테고리가 변경되었을 때 상태가 index를 기준으로 유지되므로, 해당 아이템에 잘못된 상태가 저장될 수 있다. 예를 들어, 해당 제품이 장바구니에 담긴 여부에 따라 다른 UI를 보여준다면 카테고리가 변경되었을 때 문제가 발생할 수 있다. 따라서 key를 {id}_{index}
로 제공하여 문제를 해결하였다.
return (
<>
{productList.map((product, idx) => (
<div key={`${product.id}_${idx}`}>
<ProductCard>
...
</ProductCard>
</div>
))}
</>
)
재정렬로 인해 컴포넌트의 위치가 변경되더라도 key는 React가 생명주기 내내 해당 항목을 식별
할 수 있게 해준다. 따라서 값을 추가하여 setState로 인해 리렌더링되더라도 기존값과 같다고 판단하여 실제 DOM노드가 변경되지 않는다. key={Math.random()}
와 같이 설정하면 렌더링 간에 key가 일치하지 않아 모든 컴포넌트와 DOM이 매번 다시 생성될 수 있다.
무한 스크롤을 구현할 때 첫 페이지에는 20개를 불러오고, 스크롤을 내릴 때마다 4개씩 추가로 불러오는 로직을 구현하였다. 이때 스크롤을 내릴 때 Spinner를 보여주기 위해 로딩처리를 하였다. 하지만 무슨 이유에서인지 스크롤을 내려 데이터를 불러올 때마다 모든 DOM을 새로 그려, 매번 스크롤이 최상단으로 올라갔다. 위의 설명처럼 key값 때문에 문제가 생긴줄 알고 페어 프로그래밍 기간에 꽤나 많은 시간을 사용했다.
하지만 key 문제가 아니였고 loading UI 분기 처리
때문이였다. fetch가 시작되고 완료되는 동안 productList 자체가 없어졌다가 생기게 되는데, 자식 노드가 isLoading 값에 따라 리스트 전체가 바뀌기 때문에 새로 DOM을 그리게 되는 것이다. 이를 loading으로 분기처리하지 않고 data가 있으면 렌더링하도록 처리 후 로딩 UI는 별도로 분리하였다. 그 결과 isLoading 값에 따라 리스트를 새로 그리지 않으므로 문제가 해결하였다.
// Before ❌
return (
<>
{!isLoading && productList.map((product, idx) => (
<div key={`${product.id}_${idx}`}>
<ProductCard>
...
</ProductCard>
</div>
))}
</>
)
// After ✅
return (
<>
{productList && productList.map((product, idx) => (
<div key={`${product.id}_${idx}`}>
<ProductCard>
...
</ProductCard>
</div>
))}
{isFetching && <ProductCardListSkeleton />}
{isPending && <Spinner />}
</>
)