github-issues-reader를 보강해 보자

초코침·2023년 10월 26일
0

React

목록 보기
11/14
post-thumbnail

github issues reader를 구현하면서 개인적으로 아쉬웠던 부분을 보완하는 작업을 했습니다.

배포 링크
소스코드(Github)
이전 작업 내용 블로깅

스크롤 유지

리스트 페이지 → 디테일 페이지 → (뒤로가기) 리스트 페이지 순서로 이동했을 때 리스트의 스크롤 위치가 유지되지 않는 상황이었습니다.

리스트나 페이지네이션을 사용할 땐 마지막에 봤던 위치가 저장되는 것이 유저 경험에 중요하다고 생각하기 때문에 꼭 고쳐야겠다고 생각했었는데요.

스크롤이 유지되지 않는 이유는 뒤로가기로 인해 ListPage가 다시 마운트되면, 서버에게 첫 번째 페이지를 요청하여 렌더링하기 때문이었습니다. 첫 번째 페이지만을 다시 보여주기 때문에 무한스크롤을 통해 추가적으로 요청했던 부분은 다시 스크롤을 내려야 받아볼 수 있는 것이죠.

따라서 ListPage에서 이전에 요청하여 렌더링했던 내용과 스크롤 위치를 캐싱하여, 재접속 시 캐싱한 내용을 모두 렌더링하여 그 지점의 스크롤 위치로 이동하도록 변경했습니다.

스크롤 위치 기억하기

스크롤 위치를 기억하는 방법은 라우팅에 사용한 react-router 라이브러리에서 ScrollRestoration 컴포넌트를 제공하고 있기 때문에 쉽게 구현할 수 있었습니다.

사용 방법은 라우트 컴포넌트에 ScrollRestoration 컴포넌트를 불러와 사용하면 됩니다.

// App.tsx

function App() {
  return (
    <>
      <Header />
      <Main>
        <Outlet />
      </Main>
      <ScrollRestoration />
    </>
  );
}

리스트의 항목 캐싱하기

스크롤을 많이 내린 상태여서 100번째 항목 즘의 스크롤 위치를 기억하고 있는 상황인데, 재접속으로 30개 정도 보여주는 첫 번째 페이지만을 다시 렌더링한다면 제일 밑의 위치가 30번이기 때문에 기억한 스크롤이 의미가 없어집니다.

따라서 100번째 항목이 보일 수 있도록 전에 요청했던 항목들을 캐싱해야 합니다.

이 프로젝트는 React-Query를 사용하지 않았기 때문에 캐싱은 Recoil을 사용했습니다.

이전 코드에서 useReducer로 묶어주었던 상태들을 recoil로 옮겨준게 전부입니다!

type IssuesState = {
  issues: IssuesResponseData | undefined;
  isFetching: boolean;
  hasNextPage: boolean;
};

export const issuesState = atom<IssuesState>({
  key: 'issuesState',
  default: {
    issues: undefined,
    isFetching: false,
    hasNextPage: false,
  },
});

이렇게 렌더링했던 데이터들을 캐싱하여 사용함으로써 스크롤의 위치도 유지되고 뒤로가기로 리스트 페이지에 접속했을 때 발생했던 api 요청도 보내지 않게 되었습니다.

다만, 새로고침이 발생하기 전까지 캐싱한 내용들이 계속 전역 상태로 남아있기 때문에 이 데이터들을 언제 지워야 하는가 라는 문제점이 있습니다. 캐싱은 expire time을 고려해야 한다는 점이 참 어려운 것 같아요. 고민해봤는데 새로고침하면 날아가니까 굳이 삭제하지 않아도 괜찮을 것 같긴 하네요..ㅎㅎ

의미없는 화면

제일 저를 괴롭게 했던 부분입니다.

데이터가 없을 때 보여주려고 한 컴포넌트(’이슈가 없습니다’)가 아주 잠깐 노출되는 현상이 있었습니다.

페이지가 마운트되면 useEffect에서 비동기적으로 데이터를 받아오기 때문에 어쩔 수 없는 상황이었는데요.. 그래도 한 번 최대한 해결해보려 했습니다.


일단 사건의 배경을 설명해보겠습니다.

issue들을 페칭하는 로직은 useIssues 훅 내부에 있고, 페칭하기 전 issues는 undefined입니다.

// useIssues.tsx

const useIssues = () => {
  const [{ issues, isLoading, hasNextPage }, setIssuesState] = useRecoilState(issuesState);
	const [pageNumber, setPageNumber] = useState(1);

	const fetchIssues = () => { /* ... */ };	

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

  return { issues, isLoading, hasNextPage, fetchNextPage };
};

undefined인 issues를 가지고 먼저 return문을 실행하게 되므로 EmptyList가 노출된 다음 effect가 실행됩니다.

따라서 effect가 실행되고 상태가 변경되어 리렌더링이 발생하기 전까지의 그 짧은 틈은 EmptyList가 자리를 차지하게 됩니다.

// IssueList.tsx

const IssueList = () => {
  const { issues, isFetching, hasNextPage, fetchNextPage } = useIssues();
  const [observerRef] = useIntersectionObserver({ threshold: 0.1 }, fetchNextPage);

  return (
    <>
      {issues.length ? (
        <>
          <Ul>
            {issues.map((issue, order) => {
              const parsedIssue = parseIssue(issue);
              return (
                <Fragment key={parsedIssue.issueId}>
                  <IssueListItem issue={parsedIssue} />
                  {(order + 1) % PER_LIST === 0 && <Ad />}
                </Fragment>
              );
            })}
          </Ul>
          {!isFetching && hasNextPage && <div ref={observerRef}></div>}
          {isFetching && <Loading />}
        </>
      ) : (
        <EmptyList />
      )}
    </>
  );
};

EmptyList의 노출 없이 마운트 순간부터 effect가 완료되고 리렌더링되기까지 로딩 컴포넌트를 보여줄 수 있는 방법에 대해 고민해 봤습니다.

첫 로딩임을 표시하는 변수

api 요청을 보내기 전까지는 issues가 undefined라는 점을 활용해, issues가 undefined면 로딩 화면을 띄우도록 했습니다.
(실은 issues의 초기값을 빈 배열로 해뒀어서 여기까지 생각해 내는데 적지 않은 시간이 걸렸습니다..ㅎㅎ ㅠ)

// useIssues.tsx

const useIssues = () => {
  const [{ issues, isLoading, hasNextPage }, setIssuesState] = useRecoilState(issuesState);
	const [pageNumber, setPageNumber] = useState(1);
	const isFirstLoad = issues === undefined;

isFetching의 초깃값을 true로 줘버릴까라는 많은 유혹이 있었는데요..

아무래도 요청을 시작하기 전부터 로딩중이라는 상태를 갖고 있는 건 아닌 것 같아서 새로운 변수를 만들기로 선택했습니다.

따라서 IssueList 컴포넌트는 다음과 같이 변경했습니다. firstLoad라면 바로 스켈레톤을 보여줍니다.

const IssueList = () => {
  const { issues, isFirstLoad, isFetching, hasNextPage, fetchNextPage } = useIssues();
  const [observerRef] = useIntersectionObserver({ threshold: 0.1 }, fetchNextPage);

  if (isFirstLoad) return <ListSkeleton />;

이렇게 작성하면 useEffect의 콜백 함수가 실행되기 전 그 사이에도 ListSkeleton으로 로딩중임을 보여줄 수 있습니다.

이 사소한 걸 정말 오랫동안 고민해서 고쳤는데 좋은 방법인지는 잘 모르겠습니다. 만약 서버에서 데이터가 없을 때 빈 배열로 주지 않고 undefined로 준다면 스켈레톤만 계속 보여주게 될테니까요..ㅎ (지금은 빈 배열로 주는 상황입니다.)

isFetching을 true로 초기화해두기, 지금처럼 변수 하나 더 만들기 이외에 더 좋은 방법이 있다면 공유 부탁드립니다 ㅠㅠ

스켈레톤 만들기

참고로 스켈레톤은 라이브러리 없이 간단하게 직접 구현해 봤습니다.

widthheight를 받고, 혹시 커스텀 스타일을 넣고 싶어질 수도 있을 것 같아 div의 어트리뷰트를 더 받을 수 있게 했습니다.

const Skeleton = ({ width, height, ...props }: Props) => {
  return <SkeletonBox width={width} height={height} {...props} />;
};

const SkeletonBox = styled.div<Props>`
  width: ${({ width }) => width};
  height: ${({ height }) => height};
  background-color: #e8e8e8;
  border-radius: 2px;
  animation: loading 2.5s infinite;
`;

애니메이션은 간단하게 div의 투명도를 변경하는 식으로 만들어 로딩 중임을 좀 더 잘 표현하게 했습니다.

/* animation.css */

@keyframes loading {
  0% {
    opacity: 1;
  }
  50% {
    opacity: 0.45;
  }
  100% {
    opacity: 1;
  }
}

이 스켈레톤 하나를 입맛대로 조합해서 전체 스켈레톤을 만들면 됩니다. 이런 식으로요..ㅎ

<LiContainer>
	<Skeleton width={'100%'} height={'36px'} />
	<Skeleton width={'40%'} height={'24px'} />
</LiContainer>
profile
블로그 이사중 🚚 (https://sungjihyun.vercel.app)

0개의 댓글