원티드 프리온보딩 3주차 후기(Github API를 활용한 react 레포 이슈 클론)

JYROH·2023년 7월 21일
0

이번에는 Github API를 활용하여 facebook/react 의 issues를 클론하는 작업을 해보았습니다.

이번 과제에서 중요했던 점은 API와 Context를 연동해야 한다는 점이었습니다.

Git Repo

Best Practice

📌 Context API를 활용한 API 연동 Best Practice 선정

context와 custom Hook를 활용한 issues, issue 전역상태로 관리

❓선정 이유

  • 목록 이슈 데이터와 상세 이슈 데이터를 IssuesContext, IssueContext로 각각 관리하도록 하였으며, 컴포넌트 단에서 쉽게 사용하도록 custom Hook을 활용하여 useIssues(), useIssue() 상태관리 로직들을 추상화하였습니다. 이를 통해 상태와 기능을 캡슐화하고 재사용 가능한 로직을 구축했습니다.
  • 빠른 렌더링을 위해, 데이터 통신 비용이 드는 것보다 캐싱된 데이터를 우선 확인하는 것이 성능적으로 더 좋다고 생각하여 이슈 요청 시 캐싱된 데이터가 있으면 캐싱된 데이터를, 없을 시 요청하여 처리했습니다.
// useIssue.ts
// import 생략
const pathParam: GetIssuePathParam = {
  repo: 'react',
  owner: 'facebook',
  issue_number: 0,
};

export function useIssue() {
  const context = useContext(IssueContext);

  if (!context) throw new Error('IssueContextProvider를 찾을 수 없습니다!');

  const { issue, setIssue } = context;
  const { issueList } = useIssues();
  const [isLoading, setIsLoading] = useState(false);

  const fetchIssue = async (issueNumber: number) => {
    if (!!issue && issue.number === issueNumber) {
      return;
    }

    for (const issue of issueList) {
      if (issue.number === issueNumber) {
        setIssue(issue);
        return;
      }
    }

    setIsLoading(true);
    const res = await getIssue({ ...pathParam, issue_number: issueNumber });
    setIssue(res);
    setIsLoading(false);
  };

API 요청

❓선정이유

  • 페이지 렌더링 시 빠른 렌더링을 위해, 데이터 통신 비용이 드는 것보다 캐싱된 데이터를 우선 확인하는 것이 성능적으로 더 좋다고 생각하여 이슈 요청 시 캐싱된 데이터가 있으면 캐싱된 데이터를, 없을 시 요청하여 처리했습니다.
  • query Param을 이용해 지정된 조건(open 상태, 코멘트 많은 순)에 맞게 데이터 요청하였습니다.
  • makeQueryString은 객체로 전달해서 일괄적으로 특수문자를 넣어주는게 손수 작성하는 것보다 실수할 가능성이 적어 queryString이 길어질때 등 쓰면 좋을 것 같아 구현하였습니다.
type QueryParam = {
  [key: string]: string | number; // primitive type
};

const makeQueryString = (object: QueryParam) => {
  const querystring = [];
  for (const key in object) {
    querystring.push(`${key}=${object[key]}`);
  }
  return querystring.join('&');
};

export { makeQueryString };

📌 이슈 목록 및 상세 화면 기능 구현 Best Practice 선정

❓선정이유

  • 해당 부분은 대부분의 팀원들이 비슷하게 작성하여 공통된 코드 중 특이사항과 장점 위주로 기술하였습니다.
  • 헤더에 owner/repository는 pathParam을 이용하여 작성했습니다. 특히 freeze를 이용하여 외부에서 접근하여 변경하려는 시도를 방어하였습니다.
  • IntersectionObserver을 이용하여 스크롤을 내리면 이슈 목록을 추가적으로 로딩하였습니다. 사용자가 스크롤을 조금씩 내리면서 이슈를 로딩하므로 초기 로딩 시간을 단축시킬 수 있고, 전체 이슈 목록을 한 번에 로딩하는 것보다 자원을 효율적으로 사용할 수 있습니다.
  • react-markdown을 이용하여 이슈에 적힌 마크다운 문법을 렌더링 했습니다. 따라서 사용자가 이슈 내용을 보다 직관적이고 가독성 있게 확인할 수 있습니다.
// pathParam.ts
import { GetIssuesPathParam } from '../types/issuesApi';

const pathParam: GetIssuesPathParam = { repo: 'react', owner: 'facebook' };

export default Object.freeze(pathParam);
// useIntersectionObserver.ts
import { useRef } from 'react';

export default function useIntersectionObserver(callback: () => void) {
  const observer = useRef(
    new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            callback();
          }
        });
      },
      { threshold: 1 },
    ),
  );

  const observe = (element: HTMLElement | null) => {
    element && observer.current.observe(element);
  };

  const unobserve = (element: HTMLElement | null) => {
    element && observer.current.unobserve(element);
  };

  return [observe, unobserve];
}

📌 에러 화면 구현 Best Practice 선정

❓선정이유

  • Client side에서 404(NotFoundPage) 에러가 발생했을 때 뿐만 아니라, api 요청 중 발생할 수 있는 에러들에 대해 핸들링함으로서, 에러 발생에 방어적으로 대응하는 코드가 작성된 것을 선정했습니다.

    현재 아래의 에러들에 대해 핸들링 돼있습니다.

    • Client Side의 404: ex. /hello
    • 해당 이슈번호가 없을 때 (404): 존재하지 않은 이슈 번호를 요청 ex. issues/99999
    • 해당 이슈번호가 삭제됐을 때 (410): 삭제된 이슈 번호를 요청
    • 권한 없는 API 요청 (401): 요청 시 access_token이 유효하지않은 않은 값
  • 에러를 전역적으로 관리할 것인지, 지역적으로 관리할 것인지에 대한 의견이 나뉘었습니다. 첫 번째 방식은 반복된 상태와 컴포넌트를 선언하지않아 코드 품질을 향상합니다. 두 번째 방식은 사용자 경험을 고려해 깨진 UI 일부분만을 보여준다는 장점이 있었습니다. 각 방식은 서로의 장단점을 갖고있습니다.

    따라서 저희의 상황을 고려해서, 지역적으로 관리하기로 결정했습니다. 그 이유는 UX 개선을 위해 앱 전체에 대한 에러 화면, 앱 일부에 대한 에러 화면을 구분해서 렌더링하는게 필요했기 때문입니다. 또 코드 품질 저하로 인한 단점이 크지 않았기 때문에 이 방식을 선택했습니다.

function IssueList() {
  // ...
  const [error, setError] = useState('');

  const tryToFetchData = async (func: () => void) => {
    try {
      await func();
    } catch (error) {
      if (error instanceof AxiosError) {
        setError(error.response?.data.message ?? 'Sorry, Unknown Error');
      } else {
        setError('Sorry, Unknown error');
      }
    }
  };

  useEffect(() => {
    tryToFetchData(fetchIssueCount);
    tryToFetchData(fetchIssues);
  }, []);

  if (error) {
    return (
      <>
        <ErrorComp message={error} />
      </>
    );
  }
  // ...
}

트러블 슈팅


📌 이전 데이터의 잔류로 인한 화면 깜빡임 발생

  • 저희는 상세 issue의 정보를 Context에 보관하여 useIssue로 꺼내오고 있었습니다
  • 그러나, issue페이지 진입시, 이전의 Context에 잔류하고 있는 issue 데이터가 남아있기때문에, 새로운 데이터를 받아오기 전(loading 전)에 이전의 issue가 렌더링되는 현상이 발생하였습니다
  • 이 현상은 fetchIssue 함수과 useEffect내부에 존재하고, 따라서 렌더링 후에 추가적인 fetch와 로딩이 이루어지는 것이 원인이었습니다(이전 데이터 렌더 => useEffect내의 fetchIssue 동작 => 새로운 데이터 렌더)
const { issue: data, fetchIssue, isLoading } = useIssue();

useEffect(() => {
  (async () => {
    try {
      window.scrollTo(0, 0);
      await fetchIssue(Number(params?.id));
    } catch (error) {
      if (error instanceof AxiosError) {
        setError(error.response?.data.message ?? 'Sorry, Unknown Error');
      } else {
        setError('Sorry, Unknown error');
      }
    }
  })();
}, []);

const [error, setError] = useState('');

if (error) {
  return <ErrorComp message={error} />;
}

if (data?.number !== Number(params.id))
  return <CenterLoadContainer>{isLoading && <LoadSpinner />}</CenterLoadContainer>;
  • 이러한 깜빡임으로 인한 UX의 저하를 막기 위해, 이전의 데이터의 렌더를 막아야 했습니다
  • 따라서, 이전의 데이터와 현재 방문한 페이지의 데이터가 다를때(id를 활용한 비교) 로딩을 렌더하여 early return해주는 코드를 작성하였습니다.

📌 무한스크롤시 총 페이지당 아이템의개수가 10개미만일때 중복 데이터 요청 발생

  • 저희는 페이지당 아이템개수를 10개 가져오기로 결정해서 처음 페이지에서 렌더링후 바로 스크롤데이터를 요청하게되는 로직이 적용되어있었습니다
  • 그러나 facebook/react 이슈처럼 10개이상일경우 는 상관없지만 임시로 10개미만의 이슈 레포로 테스트를 해본결과 중복된 페이지에대해 요청하는 문제가 발생했습니다
const PER_PAGE = 10;

const NEXT_PAGE = Math.floor(issueList.length / PER_PAGE) + 1;
  • 이슈리스트가 10개 미만일경우 같은 페이지의 이슈 데이터를 가져오는 문제가 발생하게됩니다 그래서 아래와 같이 수정했습니다
if (count < 10) {
  setIsEnd(true);
  setIsLoading(false);
  return;
}

const NEXT_PAGE = Math.floor(issueList.length / PER_PAGE) + 1;
  • repo/facebook/react api에서 총이슈 개수를 가지고와서 총개수가 10개미만일경우 실행하지않게 변경했습니다

📌 무한스크롤시 마지막 페이지 도달시 예외처리 적용

  • 일반적으로 무한스크롤 사이트의 경우 콘텐츠가 끊임없이 제공되어야하며 맨하단에 도달할경우가 거의없지만 저희가개발하는 github이슈의 양이라면 도달할 경우도있을 것 같아 예외사항을 고려해 개발하였습니다
  • github issue ap 에서는 데이터가 없는 페이지로 요청시 빈배열을 반환하게 적용되어있어 아래와같이 처리하였습니다
const [isEnd, setIsEnd] = useState(false);

const res = await getIssueList(pathParam, { ...queryParam, page: NEXT_PAGE });

if (res.length === 0) {
  setIsEnd(true);
  setIsLoading(false);
  return;
}
  • isEnd 라는 상태값을 추가해 isEnd값이 true일경우에는 스크롤시 핸들링함수를 실행하지않게 적용했습니다
  • 이렇게 적용한결과 마지막페이지 도달시 로딩후 더이상 페이지가없어 게속해서 api를 요청하지않고 처음 한번만 요청하게되었습니다
profile
안녕하세요 노준영입니다.

0개의 댓글