글 작성자: 망고좋아
반응형

 

📖 엘리스에서 2차 프로젝트를 마치고

🌱 GitFarm이란?

  • GitFarm은 Git + Farm의 합성어로 농장을 컨셉으로 하여 Git 잔디 관리를 즐겁게 할 수 있도록 도와주는 서비스다.
  • 개발자는 꾸준한 공부를 해야 하는데, 공부한 내용을 기록으로 남길 때 1일 1커밋을 주로 이용하곤 한다.
  • 커밋 습관을 만들고 유지하도록 도와주어, 장기간 목표를 달성하고 성취감을 주기 위하여 GitFarm 프로젝트를 기획하게 되었다.
 

GitHub - h1jun/GitFarm: GitFarm

GitFarm. Contribute to h1jun/GitFarm development by creating an account on GitHub.

github.com

 

🌟 GitFarm만의 차별점

  • 기존 GitHub 페이지에서 제공하는 잔디 그래프 형태 대신에, 캘린더와 차트 형태로 데이터를 시각화하여 제공하기 때문에 유저가 좀 더 쉽게 데이터를 확인할 수 있다.
  • 커밋 수에 따라 채워지는 농장 꾸미기, 랭킹 시스템, 배지 획득 등으로 유저의 흥미를 자극하고, 게임하는 듯한 재미와 성취감을 느낄 수 있다.
  • 랭킹 시스템을 도입하여 유저들 간의 선의의 경쟁을 유도

 

✍ 프로젝트에서 담당한 기능 - FrontEnd

  • Recharts를 이용한 커밋 통계 페이지 구현(월간 커밋 추이, 사용 언어 비율)
  • 랭킹 페이지 구현(스켈레톤 ui 도입)
  • 목표 커밋 수에 따른 메인 페이지 농장 완성 로직 작성
  • 웹폰트 최적화
  • 마크업 및 반응형 작업(header, nav, graph, rank)
  • Unit 테스트 코드 작성

 

📝 무엇을 배웠는가?

📕 Suspense 도입 (feat. 공유의 가치)

  • 렌더링 된 이후 비동기 작업이 처리되기 전 로딩 스피너를 구현하려고 검색하던 중 suspense를 알게 되었고 이 기능을 사용하면 사용자 경험뿐만 아니라 개발자에게도 좋을 것 같아 도입하기로 결정했다.
import React, { useCallback, useEffect, useState, Suspense } from "react";
....
const LineGraph = React.lazy(() => import("./LineGraph"));

function Graph() {
  ...
  ...
  return (
    <Container>
      <Suspense fallback={<LoadingModal />}>
        <DateControllerWrapper>
          ...
        </DateControllerWrapper>

        <MonthYearBtn
          isClick={clickButtonColor}
          handlYearBtn={handlYearBtn}
          handleMonthBtn={handleMonthBtn}
        />
        <LineGraph graphTitle={graphTitle} commitData={commitData} />
        <PieChartComponent />
      </Suspense>
    </Container>
  );
}
  • 이렇게 로딩 창을 suspense로 구현하고 신세계를 경험해서 팀원분들에게 공유했다.
  • 팀원분이 적용하시던 중 페이지 내에서 비동기 요청 시 로딩 컴포넌트가 동작되지 않는다는 문제점을 말씀해 주셨다.
  • 공식 문서를 보면 Suspense는 렌더링 시점에만 감지해 주며, 데이터 불러오기는 추후 지원할 계획이라고 한다.
  • 기존에 있는 isLoding을 완전히 대체할 수 없다. 첫 페이지 로딩은 suspense를 사용하고, 페이지 내에서 비동기 처리를 하는 부분은 isLoading을 사용하여 로딩 창을 보여주는 방식으로 가기로 했다.
import React, { Suspense, lazy } from "react";
...
const Header = lazy(() => import("./components/Header"));
...
...
const Badge = lazy(() => import("./pages/badge"));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingModal />}>
        <Header />
        <Nav />
        <Routes>
          <Route path="/" element={<Login />} />
          <Route path="/main" element={<Main />} />
          ...
          <Route path="/badge" element={<Badge />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
  • 따라서 suspense를 앱 전체에 적용해 줘서 로딩 창을 한 번에 구현하고 초기 렌더링을 빠르게 해주며, 페이지 내에서 데이터를 요청하는 부분은 isLoading이라는 상태를 만들어줘서 로딩 컴포넌트를 구현하기로 했다.
  • suspense를 공부하다 빠뜨린 내용이었는데 지식을 공유함으로써 지나쳐버린 지식을 얻을 수 있어서 좋았고 집단지성의 힘을 느끼게 되었다.
  • 앞으로 새로운 지식을 습득하고 유용하다고 판단되면 팀원분들에게 공유하고 함께 성장하고 싶다.
  • 아래 포스팅은 공부하면서 정리했던 내용이다.
 

[React] 리액트 React.lazy와 Suspense란?

🎯 리액트 React.lazy와 Suspense란? 📝 React.lazy const SomeComponent = React.lazy(() => import('./SomeComponent')); const OtherComponent = React.lazy(() => import('./OtherComponent')); function MyC..

lakelouise.tistory.com

 

📕 타입스크립트의 필요성 (feat. PropTypes)

  • 초반 환경 세팅이 잘 작동하는 줄 알았지만 팀원 모두 eslint가 적용되지 않은 채 코드를 작성하고 있었음을 발견하게 되었다. 뒤늦게 eslint-config-airbnb를 적용하고 에러를 수정해 나가는데 적지 않은 시간을 할애하였다.
  • eslint-config-airbnb에는 defaultProps와 PropTypes를 명시하라는 규칙이 있다. 어떤 형태의 props가 넘어오는지 알 수 있어서 좋았지만 생각보다 불편했고 코드의 양이 많아지는 것을 느꼈다. 그리고 이 부분을 타입스크립트로 해결할 수 있다고 생각했다.
  • 차량 이동 중 공부 및 아이디어를 얻기 위해 다른 팀원분들의 코드를 태블릿으로 읽었다.
  • proptypes를 적용하기 전에는 받아오는 props를 뭐지? 자료형이 뭐지? 이러면서 props를 따라 올라가고 그랬다. 하지만 proptypes 적용 이후에는 어떤 형태로 넘어와서 이렇게 뿌려주고 동작하는구나라는 것이 눈으로 읽히니까 이해하기 수월했다. 이러한 부분을 타입스크립트가 도와주니까 많은 개발자와 회사가 원하는구나....!!! 개발 시 실수도 줄여주고!!! 개발자를 위한 언어구나라는 것을 느낄 수 있었다.
  • 이러한 상황을 겪어보니 왜 타입스크립트가 나왔으며, 왜 타입스크립트가 필요한지, 왜 많은 회사와 개발자들이 타입스크립트를 선호하는지에 대해 절실히 느끼다 보니 앞으로 내가 필요로 하니까 흥미를 가지고 더 재밌게 공부할 있을 거 같다는 생각이 들었다.
  • 이전에는 단순히 TS가 좋다 하니까 배워야지 또는 채용 시장에서 TS를 많이 사용하고 사용할 줄 아는 개발자를 원하다 보니 단순 채용을 위해서 TS를 배워야지라고 했다면,
  • 이제는 나 스스로가 특정 상황을 겪으며 해당 기술의 필요성을 느끼고 접근하다 보니 즐겁고 재밌게 공부할 수 있을 거 같다!

 

📕 Recharts 라이브러리 도입

  • 그래프 이용하여 데이터 시각화해주는 부분을 Recharts 라이브러리를 통해 구현하였다.
  • Recharts는 심플하고 사용법이 간단하고 사람들이 많이 사용하여 공식 문서뿐만 아니라 커뮤니티에 자료가 많이 있어서 선택하게 되었다.

  • 공식 문서를 보면서 사용하니까 원하는 기능들을 사용할 수 있었고 사용법이 간단해서 만족도가 높았다!

 

📕 웹폰트

  • 웹폰트를 cdn을 이용하여 적용하였지만, 불필요한 파일들을 다운로드하는 시간만큼 로딩 속도가 느려지는 것을 보고 최적화 작업을 진행하였다.
  • subset font를 설정하여 불필요한 폰트를 제거하고, WOFF2 형식으로 변환하여 용량을 최대한 줄였다.
  • font-display: swap 속성을 사용하여 웹 폰트의 로딩 상태에 따른 동작을 설정해 줬다.
    • swap은 먼저 폴백 폰트로 글자를 렌더링 하고, 웹 폰트 로딩이 완료되면 웹 폰트를 적용한다.

 

🛠 웹폰트 cdn 사용 시

 

🛠 웹폰트 최적화 후

  • 작업을 진행하니 생각보다 복잡했으며 새롭게 알게 된 내용이 많아 블로그 포스팅으로 정리하였다.
 

[React] 리액트 웹폰트 적용 방법과 최적화 방법 (feat. 2022년)

🎯 리액트 웹폰트 적용 방법과 최적화 방법 윈도우와 맥에서 동일한 폰트를 보여주기 위해 웹폰트를 적용하기로 결정했다. 웹폰트는 용량이 커서 다운로드 시간만큼 로딩 속도가 느려진다. 따

lakelouise.tistory.com

 

📝 프로젝트 중 마주친 고민사항

📕 스켈레톤 ui 도입

  • 이렇게 초기 로드 시 ui가 깨지는 현상이 발생했다.
  • 이와 같은 문제를 해결하고 스피너를 이용해 로딩 중이라는 것을 보여주려고 했지만 스피너보다 스켈레톤 ui가 사용자 측면에서 더 적합하다고 생각하여 도입하기로 결정했다.
  • 직접 만들 수 있겠지만 시간이 부족해서 react-content-loader를 설치해서 사용하기로 결정했다.

 

코드 바로보기

 

GitHub - h1jun/GitFarm: GitFarm

GitFarm. Contribute to h1jun/GitFarm development by creating an account on GitHub.

github.com

  • 로딩 시간이 3초 이상일 때 약 30%, 5초 이상은 약 90% 이상의 유저가 사이트 이탈한다고 한다.
  • 랭크 페이지 같은 경우 스피너보다 스켈레톤이 적합하다고 판단하고 적용했다. 개발하고 나서 해당 페이지의 첫인상과 기다림의 답답함이 조금이나마 사라졌다.
  • 사용자 입장을 생각하고 상황에 따라 어떤 ui가 적합한지 판단하여 잘 선택해야 된다는 것을 배울 수 있었다.

 

📕 조건부 다중 렌더링

  • 목표 커밋 수에 따른 메인 페이지 농장 완성 로직 작성하고 있었다.
export function FarmPicture({ ratio }) {
  const Render = () => {
    if (ratio >= 20) {
      return <FarmPictures.Default />;
    } else if (ratio >= 40) {
      return (
        <FarmPictures.Container>
          <FarmPictures.Default />
          <FarmPictures.Plat1 />
        </FarmPictures.Container>
      );
    } else if (ratio >= 60) {
      return (
        <FarmPictures.Container>
          <FarmPictures.Default />
          <FarmPictures.Plat1 />
          <FarmPictures.Plat2 />
        </FarmPictures.Container>
      );
    }
        ...
        ...
  };
  return Render();
}
  • 이런 식으로 조건에 따라 렌더링을 달리해주는 코드를 작성하고 있었다.
  • 저렇게 작성하면 마지막 조건에서는 Container 안에 10개의 아이콘들이 들어가게 된다.
  • 구현은 하겠지만 중복되는 코드를 줄이고 싶었다.
  • 고민하다가 아이디어가 안 떠올라 팀원분에게 조언을 요청했다. 그리고 얼마 안 지나 갑자기 아이디어가 떠올라서 아래와 같은 코드로 수정했다.
export function FarmPicture({ ratio }) {
  const Render = () => {
    return (
      <FarmPictures.Container>
        <FarmPictures.Default />
        {ratio >= 20 && <FarmPictures.Plat1 />}
        {ratio >= 40 && <FarmPictures.Plat2 />}
        {ratio >= 60 && (
          <>
            <FarmPictures.House />
            <FarmPictures.Tree1 />
            <FarmPictures.Tree2 />
          </>
        )}
        {ratio >= 80 && (
          <>
            <FarmPictures.Tree3 />
            <FarmPictures.Tree4 />
          </>
        )}
        {ratio >= 100 && (
          <>
            <FarmPictures.Petal />
            <FarmPictures.Goose />
            <FarmPictures.Dog />
            <FarmPictures.Chicken />
            <FarmPictures.Chick1 />
            <FarmPictures.Chick2 />
            <FarmPictures.Chick3 />
            <FarmPictures.Rabbit1 />
            <FarmPictures.Rabbit2 />
          </>
        )}
      </FarmPictures.Container>
    );
  };

  return Render();
}

  • 조건문 대신 &&을 사용해서 조건부 렌더링을 해줬더니 코드의 양이 확 줄어들었다.
  • 혼자 고민했을 때는 아이디어가 안 떠오르다가 팀원분들에게 공유를 하고 나면 갑자기 떠오른다.
  • 고민 사항을 정리해서 전하다 보니 그 사이에 해결 방법이 떠오르는 거 같다.
  • 문제를 함께 고민하는 것도 협업의 재미라고 생각한다. 더 나은 방법을 찾고 견고하고 탄탄한 코드를 완성해 나가는 과정 중 하나라고 생각한다!

 

📕 useEffect cleanup 오류

Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
언마운트된 컴포넌트에서는 상태를 추적할 수 없고, 상태를 추적하지 않기에 작업이 수행되지는 않지만, 메모리 누수가 발생할 수 있으니, useEffect의 cleanup 함수를 이용해라
  • 실제 api를 연결하고 테스트하던 중 개발자 도구에서 이러한 에러를 만날 수 있었다.
  • fetch 등 비동기를 실행하는 실행 컨텍스트가 완료되기 전에 컴포넌트가 unmount가 된 후 setState가 실행이 될 때 발생한다.
  • router 이동 후, 이동 전 컴포넌트에서 state를 바꾸려는 시도가 있을 때 발생했다.
useEffect(() => {
    getUsersReposLanguage();
    return () => {
        setReposLanguage();
        setLoading(false);
    };
}, []);
  • 따라서 useEffect에 return을 해줘서 clean up을 해줬다.

 

📕 React.memo, useCallback을 통해 최적화

  • 마크업을 완료하고 개발자 도구로 보니 불필요한 컴포넌트들이 렌더링 되는 것을 발견하였다.
  • 파이 차트는 달력에 상관없이 모든 기간의 전체 레포의 언어 사용 비율을 보여주는 컴포넌트라서 변경될 필요가 없다. 또한, 연도를 옮길 때마다 월간 연간 버튼도 바뀔 필요가 없는데 렌더링 되고 있었다.

 

🛠 pages/graph/PieChart/index.jsx

function PieChartComponent({ codeRatioArray }) {
  ...
  ... 
  return (
    <PieCharts.Container>
      ...
    </PieCharts.Container>
  );
}

export default React.memo(PieChartComponent);
  • React.memo를 사용하여 최적화

 

🛠 pages/graph/index.jsx

function Graph() {
  ...
  ... 

  const handleMonthBtn = useCallback(() => {
    if (monthButton) {
      setClickButtonColor(monthButton);
      setCheckMonth(false);
      setGraphTitle(checkMonth ? "월간" : "년간");
    }
  }, [graphTitle]);

  const handlYearBtn = useCallback(() => {
    if (!yearButton) {
      setClickButtonColor(!monthButton);
      setCheckMonth(true);
      setGraphTitle(checkMonth ? "월간" : "년간");
    }
  }, [graphTitle]);

  return (
    <Container>
      ... 
      <MonthYearBtn
        isClick={clickButtonColor}
        handlYearBtn={handlYearBtn}
        handleMonthBtn={handleMonthBtn}
      />
      <LineGraph graphTitle={graphTitle} />
      <PieChartComponent />
    </Container>
  );
}
  • <MonthYearBtn> 컴포넌트에 넘겨주고 있는 handlYearBtn, handleMonthBtn 상태가 변경되어 있어서 리 렌더가 발생하고 있었다. useCallback을 이용해서 graphTitle가 변경될 때마다 콜백을 반환하도록 하여 함수가 재생성되는 부분을 방지해 주었다.

  • 이렇게 최적화 작업을 했지만 깃허브 api는 1시간에 5000번의 리밋이 걸려있다. 최근 3개년 커밋 추이 그래프에서 약 1500번의 요청을 쓰고 월간 커밋 추이에서도 많은 요청을 사용하기 때문에 다른 데이터를 못 받아올 수 있는 상황이 생겨 서비스를 정상적으로 운영하기 힘들다고 판단하여 회의를 통해 해당 기능을 제거하기로 결정했다.

 

📕 기획의 중요성, 사전에 철저한 조사

  • 위에서 말했듯 깃허브 api 제한 때문에 정상적인 서비스 운영이 힘들어 많은 기능들을 후반에 가서 제거하였다.
  • 실무에 가면 구현 완료했는데 기획 변경으로 인하여 코드를 삭제하고 새롭게 작성하는 경우도 있다고 하는데 이런 느낌이구나!라는 것을 받았다. 그래도 월간에서 사용하는 LineGraph 컴포넌트를 재사용하기 때문에 내가 작성한 코드가 완전히 삭제되는 건 아니다. tab 부분을 삭제하고 연간 월간 커밋 추이를 보여주는 부분을 해당 연도만 보여주기로 결정했다.
  • 이번 경험으로 인해 기획 부분에서의 api의 limit과 우리가 필요한 기능을 구현하는데 얼마나 많은 요청을 하는지 사전에 확인하고 기획을 했다면 프로젝트 후반부에 이런 상황이 발생하는 것을 방지할 수 있을 거 같다.
  • 이 또한 값진 경험이라고 생각하고 다음번에는 기획 단계에서 꼼꼼하게 조사하고 지난번의 실수가 발생되지 않게 보완할 수 있으니까!

 

🛠 유지보수를 하면서 느낀 점 (feat. 세미나)

  • 새로운 관점, 더 나은 코드, 퀄리티 있는 코드, 어떻게 하면 개발적인 인사이트를 얻고 기술적으로 성장할 수 있을까? 에 대한 고민을 항상 하고 있었다.
  • 이런 고민 중 프로젝트 기간이 끝나고 최종 발표한 뒤 우아한형제들에서 주최한 우아한테크세미나: React Query와 상태관리를 실시간으로 참가하게 되었다.
  • 참가하게 된 계기는 프로젝트에서 상태 관리로 Recoil과 React Query를 도입하려고 잠깐 알아본 적이 있었지만 시간과 비용적인 이유로 도입하지 않았다.
  • 프로젝트가 끝나고 유지 보수를 하면서 리팩터링 및 공부 목적으로 상태 관리 라이브러리를 도입하려고 생각하던 차에 세미나에 참석했다.
  • 리액트쿼리의 장단점을 알 수 있었으며 개발 인사이트를 얻을 수 있었던 시간이었다.
  • 다른 사람은 코드를 어떻게 짜는지 알 수 있고 Q&A를 통해 각자의 궁금증에 대한 질문을 듣다 보면 나도 몰랐던 지식에 대해 알 수 있었다.
  • 이번 세미나를 통해서 얻은 인사이트로는 server state와 client state의 분리관심사의 분리에 따른 view/logic 분리이다.
  • 얻은 인사이트를 토대로 프로젝트에 바로 적용하려고 리팩터링을 시작했다.

 

📕 관심사의 분리에 따른 view/logic 분리 (feat. Custom Hooks, 삼항 연산자 제거)

  • Q&A 시간에 "Ui test 등을 위해서 view 컴포넌트와 로직을 분리해야 되는 경우가 있을 텐데 어떤 방법을 사용하시나요? 컨테이너 컴포넌트를 만드시는 편인지 궁금합니다”라는 질문이 나왔었다.
  • 답변으로는 "view 컴포넌트는 완전히 view로만 동작하도록 분리한다. 로직 수정이 필요할 때 view 컴포넌트는 작업 범위에서 제외한다.. 최대한 영향을 줄이기 위해서..”라고 말씀하셨다.
  • 이 말을 듣고 프로젝트를 뒤돌아 봤을 때 view와 logic이 합쳐져 있다는 생각이 들었다.
  • 현재 pages/graph/index.js에서 LineGraph 컴포넌트로 date(월 month)를 넘겨주고, LineGraph 내에서 date를 이용해 api 요청을 하고 데이터를 받은 뒤 데이터를 가공하고 상태를 저장하고 화면에 뿌려주고 있다.
  • 저 관점으로 말하자면 api 요청을 통해 데이터를 받고 가공하는 작업은 pages/graph/index.js에서 수행하고 LineGraph 컴포넌트는 가공된 데이터만 받아서 화면에 뿌려줘야 한다고 생각이 들었다.
  • 생각해 보면 컴포넌트를 만드는 이유는 재사용성을 위해 만든 건데 그 안에서 api 콜을 보내 데이터를 받아오는 건 그 위 부모에서 이루어지는 것이 맞다고 생각했다. 왜 저렇게 짰는지....
  • 그리고 이러한 궁금증을 구글링 하고 코치님께 dm으로 질문드렸다.

  • 위와 같은 답변을 얻을 수 있었으며 react presentational & container pattern과 관심사의 분리라는 키워드를 얻을 수 있었다.
  • 클래스형 컴포넌트 시절에는 관심사의 분리를 presentational & container pattern을 사용하여 로직 부분은 Container에서 처리해 주고, view는 Presentational에 작성해 줬다.
  • 하지만 함수형으로 넘어오고 Custom Hook이 생기면서 로직 부분을 커스텀 훅으로 분리해 줌으로써 관심사를 분리해 줄 수 있어서 presentational & container pattern을 이용하지 않아도 된다.
  • 한 컴포넌트에서 api를 호출, 데이터를 가공, useState로 상태를 저장하고 view까지 해주는 코드를 한 곳에 담았다면 api를 호출하고 데이터를 가공하는 부분을 logic으로 생각하여 커스텀 훅으로 빼주었다.
  • view와 logic을 분리하면서 삼항 연산자를 중첩하면서 코드의 가독성이 떨어져서 함께 리팩터링을 진행했다. 삼항 연산자는 어디에서 끝나는지 파악하기 힘들어서 && 연산자로 바꿔주면서 가독성을 높여줬다.

 

🚧 이전 코드 pages/graph/index.jsx

더보기
function Graph() {
  const [date, setDate] = useState(toDay);
  const [reposLanguage, setReposLanguage] = useState();
  const [loading, setLoading] = useState(false);

  const goToday = () => {
    setDate(toDay);
  };

  const getUsersReposLanguage = async () => {
    setLoading(true);

    const res = await api.getReposLanguage();
    if (res.success) {
      setReposLanguage(res.languages);
    } else {
      setReposLanguage([]);
    }
    setLoading(false);
  };

  useEffect(() => {
    getUsersReposLanguage();
  }, []);

  return (
    <Graphs.Container>
      <Graphs.DateControllerWrapper>
        <DateController date={date} goToday={goToday} month={false} />
      </Graphs.DateControllerWrapper>
      <Graphs.ResponsiveDiv>
        <LineGraph date={date} />
        <PieChartComponent reposLanguage={reposLanguage} loading={loading} />
      </Graphs.ResponsiveDiv>
    </Graphs.Container>
  );
}

export default Graph;

 

🚧 이전 코드 pages/graph/LineGraph/index.jsx

더보기
function LineGraph({ date }) {
  const [commitData, setCommitData] = useState([]);
  const [loading, setLoading] = useState(false);

  const getCommitsPerMonth = async () => {
    setLoading(true);
    const { year, month, thisMonth } = checkMonth(date);

    const data = await api.getCommitsTotalPerMonth(year);
    if (data.success) {
      let { commitEachMonth } = data;

      if (month === thisMonth) {
        commitEachMonth = commitEachMonth.slice(0, thisMonth + 1);
      }

      const checkEmptyArray = commitEachMonth.every((it) => it === 0);
      if (checkEmptyArray) {
        setCommitData([]);
      } else {
        const createData = commitEachMonth.slice(1).map((commitCnt, index) => ({
          name: `${index + 1}월`,
          commit: commitCnt,
        }));
        setCommitData(createData);
      }
    } else {
      setCommitData([]);
    }
    setLoading(false);
  };

  useEffect(() => {
    getCommitsPerMonth();
  }, []);

  return (
    <LineGraphs.Container>
      {loading ? (
        <LoadingModal />
      ) : (
        <LineGraphs.LineGraphContainer>
          {commitData.length ? (
            <>
              <LineGraphs.Title>월간 커밋 추이</LineGraphs.Title>
              <LineGraphs.Wrapper>
                <LineChart width={350} height={280} data={commitData}>
                  <CartesianGrid strokeDasharray="3 3" />
                  <XAxis dataKey="name" />
                  <YAxis />
                  <Tooltip />
                  <Line
                    type="monotone"
                    dataKey="commit"
                    stroke="#6ABD8C"
                    activeDot={{ r: 2 }}
                    isAnimationActive={false}
                  />
                </LineChart>
              </LineGraphs.Wrapper>
            </>
          ) : (
            <LineGraphs.NoData>데이터가 없습니다.</LineGraphs.NoData>
          )}
        </LineGraphs.LineGraphContainer>
      )}
    </LineGraphs.Container>
  );
}

LineGraph.propTypes = {
  date: PropTypes.instanceOf(Date).isRequired,
};

export default LineGraph;

 

🛠 리팩터링 코드 hooks/useCommitsPerMonth.jsx

더보기
import { useState, useEffect } from "react";
import { getCommitsTotalPerMonth } from "@/api";
import { sliceDate } from "@/utils/graph";
import { useAuth } from "../contexts/auth";

function useCommitsPerMonth() {
  const { isLogin } = useAuth();
  const [commitData, setCommitData] = useState([]);
  const [commitsLoading, setLoading] = useState(false);
  const { year, month } = sliceDate(new Date());

  function makeCommitEachMonthData(commitEachMonth) {
    const commitEachMonthArray = commitEachMonth.slice(0, month + 1);
    const checkEmptyArray = commitEachMonthArray.every((it) => it === 0);

    if (checkEmptyArray) {
      setCommitData([]);
    } else {
      const createData = commitEachMonthArray
        .slice(1)
        .map((commitCnt, index) => ({
          name: `${index + 1}월`,
          commit: commitCnt,
        }));
      setCommitData(createData);
    }
  }

  const getCommitsPerMonth = async () => {
    setLoading(true);
    const { success, commitEachMonth } = await getCommitsTotalPerMonth(year);
    if (success) {
      makeCommitEachMonthData(commitEachMonth);
    } else {
      setCommitData([]);
    }
    setLoading(false);
  };

  useEffect(() => {
    if (isLogin) {
      getCommitsPerMonth();
    }
    return () => {
      setCommitData([]);
      setLoading(false);
    };
  }, []);

  return [commitData, commitsLoading];
}
export default useCommitsPerMonth;

 

🛠 리팩터링 코드 pages/graph/LineGraph/index.jsx

더보기
function LineGraph({ commitData, loading }) {
  return (
    <LineGraphs.Container>
      <LineGraphs.LineGraphContainer>
        {loading && <LoadingModal />}
        {!loading && commitData.length > 0 && (
          <>
            <LineGraphs.Title>월간 커밋 추이</LineGraphs.Title>
            <LineGraphs.Wrapper>
              <LineChart width={350} height={280} data={commitData}>
                <CartesianGrid strokeDasharray="3 3" />
                <XAxis dataKey="name" />
                <YAxis />
                <Tooltip />
                <Line
                  type="monotone"
                  dataKey="commit"
                  stroke="#6ABD8C"
                  activeDot={{ r: 2 }}
                  isAnimationActive={false}
                />
              </LineChart>
            </LineGraphs.Wrapper>
          </>
        )}
        {!loading && !commitData.length && (
          <LineGraphs.NoData>데이터가 없습니다.</LineGraphs.NoData>
        )}
      </LineGraphs.LineGraphContainer>
    </LineGraphs.Container>
  );
}

LineGraph.propTypes = {
  commitData: PropTypes.arrayOf(
    PropTypes.shape({
      name: PropTypes.string.isRequired,
      commit: PropTypes.number.isRequired,
    }),
  ).isRequired,
  loading: PropTypes.bool.isRequired,
};

export default LineGraph;

 

🛠 리팩터링 코드 pages/graph/index.jsx

더보기
import React, { useState } from "react";
import DateController from "@/components/DateController";
import { Navigate } from "react-router-dom";
import PieChartComponent from "./PieChart";
import LineGraph from "./LineGraph";
import * as Graphs from "./style";
import useUsersReposLanguage from "../../hooks/useUsersReposLanguage";
import useCommitsPerMonth from "../../hooks/useCommitsPerMonth";
import { useAuth } from "../../contexts/auth";

function Graph() {
  const { isLogin } = useAuth();
  const [date, setDate] = useState(new Date());
  const [reposLanguage, reposLanguageLoading] = useUsersReposLanguage();
  const [commitData, commitsLoading] = useCommitsPerMonth();

  const goToday = () => {
    setDate(new Date());
  };

  if (!isLogin) {
    return <Navigate to="/" />;
  }

  return (
    <Graphs.Container>
      <Graphs.DateControllerWrapper>
        <DateController date={date} goToday={goToday} month={false} />
      </Graphs.DateControllerWrapper>
      <Graphs.ResponsiveDiv>
        <LineGraph commitData={commitData} loading={commitsLoading} />
        <PieChartComponent
          reposLanguage={reposLanguage}
          loading={reposLanguageLoading}
        />
      </Graphs.ResponsiveDiv>
    </Graphs.Container>
  );
}

export default Graph;
  • 이렇게 하니까 logic, view의 분리가 확실해지면서 코드의 가독성도 높아졌다.
  • 이후 logic의 수정이 필요하면 커스텀 훅에서만 수정하면 되고 view는 그대로 내버려 둬도 되는 유지 보수의 장점이 생겼다.
  • 이렇게 리팩터링 해주고 테스트 코드를 작성하면서도 view/logic을 분리해 주길 잘했다고 느꼈다. 만약 한 곳에서 모든 처리를 해줬다면 테스트 코드 작성하기 막막했을 거 같다.

 

⚡ 느낀 점

  • 이제는 단순 구현을 하는 것이 아닌 좋은 설계는 무엇인가에 대해서 궁금증이 생겨났다.
  • 자연스럽게 디자인 패턴에 대해 관심을 가지게 되었다.
  • 배울게 너무나 많은 지금 한숨이 나오기보다는 배울 것이 이렇게나 많이 있다는 생각에 재밌어진다!

 

📕 test code 작성하기

  • 이번 프로젝트 때 테스트 코드를 작성하려고 했지만 시간 관계상 작성하지 못했었다.
  • 그래서 리팩터링 과정 중 테스트 코드를 작성하기로 결정했다.
  • Jest, react-testing-library을 이용했으며 Custom Hooks은 react-hooks-testing-library을 이용하여 테스트 코드를 작성했다.
  • util -> hooks -> component 순으로 작은 단위부터 시작하여 Unit 테스트를 진행했다.
// pieChart util 함수 테스트 코드
test("makeLanguageRatioArray function test", () => {
  const output = makeLanguageRatioArray(mockReposLanguage);
  expect(output).toEqual(languageRatioArray);
});

test("languageColor function test", () => {
  const output = languageColor(languageRatioArray);
  const expected = ["#f1e05a", "#2b7489", "#e44b23", "#563d7c"];
  expect(output).toEqual(expected);
});


// useCommitsPerMonth 테스트 코드
describe("useCommitsPerMonth succes가 true && 데이터가 정상일 때", () => {
  beforeEach(() => {
    jest
      .spyOn(api, "getCommitsTotalPerMonth")
      .mockImplementation(() => mockCommitsTotalPerMonthData);
  });

  test("success가 true && 데이터 정상", async () => {
    const { result, waitForNextUpdate } = renderHook(
      () => useCommitsPerMonth(),
      { wrapper },
    );
    await waitForNextUpdate();
    expect(result.current[0]).toStrictEqual(mockCommitData);
    expect(result.current[1]).toStrictEqual(false);
  });
});


// LineGraph 컴포넌트 테스트 코드
describe("LineGraphContainer 렌더링 확인", () => {
  test("로딩 중일 때", () => {
    render(<LineGraph commitData={mockCommitData} loading />);
    const outputElement = screen.queryByText("월간 커밋 추이");
    expect(outputElement).toBeNull();
  });

  test("로딩 끝 && commitData가 잘 들어왔을 때", () => {
    render(<LineGraph commitData={mockCommitData} loading={false} />);
    const outputElement = screen.getByText("월간 커밋 추이");
    expect(outputElement).toBeInTheDocument();
  });

  test("로딩 끝 && commitData가 빈 배열로 들어와서 예외 처리된 경우", () => {
    render(<LineGraph commitData={[]} loading={false} />);
    const outputElement = screen.getByText("데이터가 없습니다.");
    expect(outputElement).toBeInTheDocument();
  });
});
  • 자세한 코드는 아래 깃허브에 있다.
 

GitHub - h1jun/GitFarm: GitFarm

GitFarm. Contribute to h1jun/GitFarm development by creating an account on GitHub.

github.com

 

 

GitHub - h1jun/GitFarm: GitFarm

GitFarm. Contribute to h1jun/GitFarm development by creating an account on GitHub.

github.com

 

⚡ 느낀 점

  • 테스트 코드를 작성하면서 한 개 함수 안에서 두 가지 일을 하도록 작성한 부분을 발견하여 또 한 번의 리팩터링 작업을 같이 진행해 줬다.
  • 테스트 코드를 작성하면서 지난 코드에 대한 회고를 할 수 있어서 뜻깊은 경험이었다.
  • 그리고 이전에 관심사의 분리에 따른 view와 logic을 Custom Hooks을 이용하여 분리해 주니까 테스트 코드를 작성하는데도 한결 수월해졌다.
  • 다음 리액트로 앱을 구성할 때 view, logic을 관심사의 분리에 따라 확실하게 구분해 줄 수 있을 것 같다.

 

📕 리덕스로 상태 관리 라이브러리 도입 예정 (feat. server state와 client state의 분리)

  • 우아한테크세미나: React Query와 상태관리세미나에서 얻었던 또 다른 인사이트는 server state와 client state의 분리이다.
  • 이전에 server, client 구분 없이 그냥 같은 state 라고 생각했었는데 세미나를 듣고 난 뒤에는 2가지 관점으로 나뉘는군..이라고 생각했다. 다시 생각해 보면 저렇게 2가지로 나뉘는구나! 가 아니라 내가 몰랐었구나.. 고 자각하게 되었다. 그리고 상태 관리라는 단어에 재조명하게 되었다.
  • 현재 진행하고 있는 프로젝트를 빗대어 보면 깃팜은 GitHub api를 백에서 통신하여 db에 넣어주고 프론트에서는 db의 값을 가져와 사용하고 있다.
  • 그리고 api 통신은 limit 제한 때문에 1시간에 1번만 진행하고 있다. 즉, 프론트에서는 1시간 내에 데이터 재요청을 하면 db에 있는 같은 값을 내려받고 있다.
  • 리액트 쿼리의 캐싱 기능을 이용하면 1시간 이내인지 판단하여 api 요청을 보내거나 캐싱된 값을 가져와 사용할 수 있다. 따라서 1시간 내의 요청에 대해서는 같은 데이터니까 캐싱을 통해 중복 호출 제거할 수 있다.
  • 하지만 GitHub api 자체에 오류가 생겨서 데이터를 못 가져오는 상황을 대비하여 이전 데이터를 db에 저장하여 값을 보여주는 방법을 채택해서 어찌 되었든 db에 값을 저장하긴 해야 된다.
  • 그래서 db에서 값을 가져오는 건 서버에 큰 부담이 안 가는 거 같아서 리액트 쿼리 도입의 이점이 없다고 생각했다.
  • 원래의 계획은 server state는 리액트쿼리로 관리하고 client는 리코일로 관리하면 보다 효과적으로 리팩터링 할 수 있다고 생각했다.
  • 상태 관리 라이브러리를 떠올리면 리덕스가 먼저 떠오르기 마련이다. 리덕스를 처음부터 고려하지 않은 것은 아니다.
  • 하지만 복잡한 보일러 플레이트로 인해 초기 세팅에 시간이 들고 복잡하였고 현 프로젝트에서 상태 관리 라이브러리가 크게 필요하지 않았던 상황이었다. 그래서 도입하지 않았었다.
  • 그리고 리덕스의 이러한 단점들을 극복하고 비교적 간단하고 파워풀한 react-query, Mobx , recoil 등과 같은 상태 관리 라이브러리가 나오고 사람들에게 인기가 많아지고 있다.
  • 이전에 리덕스를 배우긴 했지만 프로젝트에 적용해 본 경험은 없다. 그래도 많은 기업에서 리덕스를 사용하고 있고 경험자를 원하고 있다.
  • 앞으로 살면서 리덕스를 피해 다닐 수는 없고.. 리덕스의 복잡함을 직접 느껴봐야 왜 react-query, Mobx , recoil 등이 나왔으며, 왜 사람들에게 인기가 많은지? 왜 필요한지를 직접 느낄 수 있을 거 같았다. 그래서 팀원분들과 긴 회의를 끝에 공부 목적 겸 불편함을 느끼기 위해? 상태 관리 라이브러리로 리덕스를 도입하기로 결정했다.
  • 그래도 redux-toolkit을 사용하니까 코드도 깔끔해지고 간단해졌다고 생각이 든다.
  • 현재로서는 why를 찾아가는 과정을 끝내고 리덕스를 공부하여 프로젝트에 적용 중이다.

 

🏆️ 데모데이

  • 프로젝트를 마감하고 최종 발표하는 날 내부 코치님들의 점수로 1~3등이 정해졌는데 우리 팀은 수상하지 못하였다.
  • 그렇지만 외부 심사위원분들을 통해 우수 프로젝트를 뽑는 데모데이서 우리 GitFarm 팀이 수상을 하게 되었다!

  • 진짜 기적이다! 우리 모두 예상하지 못했던 일이었다. 이전 최종 발표회 때 순위 안에 들었다면 조금의 기대는 했겠지만 기대 안 하고 있다 이렇게 발표가 되어서 깜짝 수상을 하게 되었다.

  • 최우수상을 수상하게 되었다!! 팀원 모두 예상하지 못했던 결과였다. 프로젝트를 진행했던 3주 동안 밤을 새워가며 회의하고 문제를 해결해 나갔던 시간들이 떠올랐다.
  • 혼자가 아닌 함께 했기에 이렇게 좋은 결과를 낼 수 있었다. 또한, 프로젝트가 끝나고 주인의식을 가지며 같이 유지 보수해 나가는 팀원 모두에게 감사하다.
  • 앞으로는 리덕스를 적용하고 타입스크립트로 마이그레이션을 진행하고 싶다!

 

💡 총 회고

  • 프로젝트를 진행하는 기간 동안 너무 재밌게 개발하였다.

  • 무엇보다 우리 팀이 당면한 문제에 대해서 새벽까지 함께 고민하고 다양한 의견을 내면서 그 안에서 해결 방법을 찾아나가는 과정이 빈말이 아니라 너무 재밌었다. 때로는 멘토가 있거나 사수가 있다면 경험을 통한 조언을 얻어서 금방 해결할 수도 있겠지만 서로 함께 공부해 나가면 고민하며 문제를 헤쳐 나가고 그 과정 속에 얻는 배움의 가치가 크게 느껴졌다.
  • 이번 프로젝트 때 why를 찾아나가는 과정을 즐겼다. why를 찾으니 해당 기술에 대한 이해도가 높아지며 적용하는 이유 또는 적용 안 하는 이유를 확실하게 알고 있으니 다른 사람에게 자신 있게 설명할 수 있고 내 코드에 대한 자신감이 생기는 거 같았다.
  • 그리고 이번 한 번의 세미나 참석으로 많은 인사이트를 얻을 수 있었고 프로젝트에 바로 적용할 수 있었던 소중한 경험이었다.
  • 앞으로 컨퍼런스와 세미나에 참석해서 새로운 시야와 인사이트를 얻어 성장하는 개발자가 되고 싶다. 그렇게 할 거다. 더 나아가 세션을 이해하고 Q&A 시간에 질문을 던지는 한 사람이 되고 싶다.
  • 그리고 유지 보수 및 리팩터링 과정은 필수라고 생각한다. 기간 내에 프로젝트를 진행하면서 배웠던 점도 많지만 리팩터링을 진행하면서 내 코드를 뒤돌아 보고 무엇을 실수했고 보완할 수 있는지 알 수 있었던 시간이었다.
  • 이러한 과정을 한 번 겪으면 다음 개발 시에는 해당 내용을 바로 적용할 수 있고, 그 적용한 코드를 다시 리팩터링을 진행하면 한 번 더 성장하며 견고하고 탄탄한 코드를 작성할 수 있는 습관을 가지게 될 거라고 믿고 있다.

 

📌 기록

반응형