blog logo
Published on

넘블 쿠팡 클론 코딩 챌린지 4회차 회고록

들어가며

5월에 넘블에서 했던 프로젝트가 좋은 기억으로 남아서 이번에 8월 중순에 하는 넘블 챌린지를 또 신청해 보았다! 5월에 했던 것 처럼 백엔드, 디자이너 분들과 같이 하는 프로젝트가 아니라 온전히 개인이 하는 프로젝트였다.

실제 쿠팡에서 사용하는 api보다는 조금 더 단조로운 구성이지만, 유사한 api만 가지고 쿠팡 서비스의 상품 목록 페이지를 그대로 구현하는 것이 이번 챌린지의 목표이다.

지난 7월에는 사이드 프로젝트와 함께 넘치는 회사일을 다 쳐내느라 정신 없이 보냈지만, 이번 8월은 그동안 하던 스터디도 끝나고.. 사이드 프로젝트도 끝나고.. 회사일마저 여유로워져서 이 프로젝트를 신청하게 되었다!

결과적으로는 구현을 완벽히 해냈다! 퇴근하고 시간날 때마다 틈틈이 해두었더니 공부도 되었던 것 같고 안 해봤던 기능들도 구현해봐서 좋았다.



구현 내용

배포 URL 👉 쿠팡 클론 코딩 페이지

구현 내용은 정말 다른 기능들은 다 재쳐두고 오로지 상품 목록 페이지만 구현 했다. 하지만 재밌게도 넘블 측에서 몇 가지 미션(?)을 주었는데, React 18 버전에서 안정적으로(?) 도입된 Suspense 기능을 사용해서 구현하는 것이었다.

그리고 페이지네이션을 구현할 때 URL을 다루는 기능 모듈화, 데이터 fetch 기능 모듈화를 이번 챌린지 평가하는데에 있어 중점적으로 두었다. 이 부분은 실제로 회사에서 어드민 사이트를 구현하면서 구현해 봤던 기능이라 성능까지 고려해가며 고도화까지 같이 개발하는 것을 목표로 두었다.

전반적으로 데이터 fetch 후 우아하게 UI 구현하기, Suspense 적용, 페이지네이션, 상품 목록 정렬 기능, 새로 고침후에도 URL을 통해 목록 필터링한 부분 그대로 유지하기 등등을 구현하였다.



새로 시도한 부분

⭐️ Suspense

사실 react-query를 쓰면 react-query에서 isLoading, isFetching 기능을 제공해주기 떄문에 Suspense 기능 필요없이 Loading 화면을 보여줄 수 있지않나..? 하고 SuspenseisFetching의 차이점을 열심히 찾아본 것 같다.

먼저 isLoading, isFetching은 캐시 유무를 따진 후 데이터를 가지고 오고 있을 경우 true, 이후에 fullfilled 상태가 되었다면 false로 바뀐다. 이와 다르게 Suspense는 컴포넌트의 렌더링을 기다리는 기능을 제공한다. 확실히 react-query의 기능과 Suspense는 다른 목적을 가지고 있다. (더 자세한건 공식 문서 참고)

react-query는 데이터를 페칭하고 캐싱에 집중을 했다면, Suspense는 렌더링과 조금 더 밀접한 관계가 있다. 즉, 데이터를 동기적으로 처리하고 싶을 때 사용하는 것이 올바른 사용법이다. 공식 문서에도 데이터 페칭 구현을 위해 Suspense를 적용하는 것은 적합하지 않다고 나와있다.

나 또한 상품 목록 데이터를 불러올 때 동기적으로 처리하기 위해 Suspense를 사용했다. 로직 플로우는

mounted -> (api pending 상태) Suspense를 통해 상품 목록 기다리기 -> (api fullfilled 상태) 상품 목록 불러오기 완료

등 이렇게 순차적으로 화면을 보여줄 수 있도록 개발했다.

하지만 이렇게 구현하는데에 당연히 막혔던 부분이 있었다. 현재 쿠팡 챌린지의 테크 스텍 중 Next.js가 있는데, Next.js는 기본적으로 SSR을 제공한다. 그런데 Suspens는 아직 ReactDOMServer에서 지원되지 않는 이슈가 있었다.

그러다보니 데이터 fetch 후 Client Side일 때, Suspense를 적용하고 fetch가 완료되었을 경우 UI를 그려야만 했다. Suspense를 나도 이번 기회에 처음 써보는 상황이라 당황했었지만, 원인을 먼저 파악하고 개발을 시작하니 코드를 치는 시간은 길지 않았다.

Next.js에서 Client Side인지 확인하는 방법은 2가지가 있다. typeof window !== undefined를 사용하던가 useEffect를 통해 현재 컴포넌트가 mount 시점인지를 파악하는 방법을 사용하는 것이다. 나는 두번째 방법을 통해 CustomSuspense를 구현해서 이번 챌린지에 적용하였다.

// CustomSuspense.tsx
const CustomSuspense = (props: ComponentProps<typeof Suspense>) => {
  const isMounted = useMounted();

  if (isMounted) {
    return <Suspense {...props} />;
  }

  return <>{props.fallback}</>;
};


// Products.tsx
const Products = () => {
  return (
    <CustomSuspense fallback={<Spinner />}>
      <ProductList />
      // 생략...
    <CustomSuspense />
  )
}

이렇게 적용하면 ProductList에서 데이터 fetching 과정을 CustomSuspense가 감지를 하고, pending 상태라면 로딩 UI를 보여준다. pending -> fullfilled 동작을 Suspense를 통해 동기적으로 처리할 수 있다. 이 과정을 동기적으로 처리하니 확실히 가독성 측면에서도 유리하다고 생각이 들었다.


구현 화면은 다음과 같다

Suspense 구현한 화면

⭐️ URL을 통한 pagination 캐싱 (feat. shallow router)

이 부분은 회사에서도 구현을 했지만 조금 미흡한 부분이 있었다. 예를 들어 페이지가 3번이고, 필터 기능과 정렬 기능을 적용한 후 새로고침을 하면 URL에는 페이지 정보 3만 남아있거나... 상태값이 유지가 안된다거나 등등.. 이슈가 있었다. 마침 이번 챌린지에서 이 기능을 구현하라고해서 완벽하게 적용하기 위한 발판으로 삼았다.

먼저 shall router를 자주 사용할 것 같아서 공통 hook 함수로 만들어 주었다.

// hooks/useShallowRouter.ts
const useShallowRouter = (router: Router, query: IPaginationStat) => {
  router.push(
    {
      query: { ...router.query, ...query },
    },
    undefined,
    { shallow: true },
  );
};

export default useShallowRouter;

그리고 정렬, 페이지 이동, 목록 노출 수 변경 등을 할 때마다 useShallowRouter를 실행시켜주었다. 그러면 URL에는 적용이 되지만 상태값은 당연하겠지만 초기 상태값으로 돌아가는 것을 확인할 수 있었다. 그래서 새로고침할 때는 부모 컴포넌트인 Products.tsx에서 useEffect를 통해 URL값을 그대로 변경해주었다.

// Pagination.tsx
const handlePageClick = (curPage: number) => {
  // offset 상태 변경
  setOffset(curPage);

  // URL에 offset 적용
  useShallowRouter(router, { offset: curPage });
};

// Products.tsx
useEffect(() => {
  const { offset } = router.query;

  if (offset || limit || sorter) {
    // 새로 고침 이후
    // url에 적용되어 있는 offset 상태를 적용
    setOffset(curPage);
  }
}, [router]);

화면 구현에 대한 설계를 정리하자면,

page 클릭 -> 상태 변경 및 UI에 적용, url 변경 -> 새로고침 -> url에 있는 정보를 상태에 다시 주입

위와 같은 구도로 설계를 했고 적용을 했다. 구현은 잘 마무리되었지만 완벽한 방법일까? 분명 보완점이 많이 필요한 코드일 것이다. 그리고 그걸 찾는게 바로 내 몫이 되겠다... 그래도 챌린지 기간안에는 정상적으로 돌아가게 구현은 완료했다! 다만 성능 고도화를 많이 못했는데, 이 부분을 리팩터링하면서 시도해 볼 예정이다. (리팩터링할꺼면 테스트 코드도 짜야하는데 할 일이 쌓여만 가는 중~)


구현 화면은 다음과 같다. (URL이 초기화되지 않는 모습을 볼 수 있다)

Pagination 캐싱 구현 화면

마치며