본문 바로가기

프론트엔드/React

Pagination Component를 선언형으로 구현하기

  • 키워드: 선언형 프로그래밍, Pagination, React
  • 상황
    1. 기존 Pagination UI 로직이 절차형으로 되어있었다.
      Pagination UI
    2. 코드를 이해하기 어렵고 유지보수가 힘들어질거란 걱정이 들어 코드를 선언형으로 리팩토링하고자 한다.
    3. 아래는 기존 코드의 예시이다. (코드의 일부이기 때문에 이해가 되진 않을 것이다.)
        ...
      const searchSiblings = (page: number, dist: number) => {
        const left = page - dist;
        const right = page + dist;
      
        if (right >= MAX_PAGE && left <= MIN_PAGE) return;
      
        if (right < MAX_PAGE) {
          rightSiblings.push(right);
          count++;
      
        	if (count >= _pagerCount) {
        	  return;
        	}
        }
      
        if (left > MIN_PAGE) {
          leftSiblings.push(left);
          count++;
      
          if (count >= _pagerCount) {
            return;
          }
        }
          
        searchSiblings(page, dist + 1);
      };
      
      ...
      
      // >>
      if (_pagerCount >= 3 && MAX_PAGE > 3 && numberButtons.slice(-1)[0] < MAX_PAGE - 1) {
        buttons['>>'] = {
          type: 'arrow',
          step: +_pagerCount,
        };
      }
      위와 같이 280줄 가량의 절차형 코드로 되어있고, 연속적인 if 문의 나열로 되어있어 언제 어떤 버튼이 rendering 되어야하는지 알기 어렵다. 또한 pagination logic의 변경이나 버그가 발생했을 때 코드를 변경하기도 매우 어려울 것이다.
  • 해결 방법
    1. React의 취지에 맞게 선언형으로 리팩토링한다.
      UI를 중심으로 생각했을 때, 원하는 최종 rendering 형태는 다음과 같을 것이다.
        /** 참고: Pagination의 props */
      export interface PaginationProps {
        page: number; // 현재 페이지
        setPage: React.Dispatch<SetStateAction<number>>;
        total: number; // 전체 페이지 수
        visiblePages: number; // 노출되는 페이지 수
      }
      
      return (
        <div className="pagination">
          <button className="prev" onClick={() => setPage(page - 1)} disabled={page === 1}>
            {'<'}
          </button>
          {isEdgeVisible && (
            <button className="startEdge" onClick={() => setPage(1)}>
              1
            </button>
          )}
          {isLeftEllipsisVisible && (
            <button className="leftEllipsis" onClick={() => setPage(Math.max(page - visiblePages, 1))}>
              {'<<'}
            </button>
          )}
          <span className="middle">
            {middlePages.map((page) => (
              <button onClick={() => setPage(page)} key={page}>
                {page}
              </button>
            ))}
          </span>
          {isRightEllipsisVisible && (
            <button className="rightEllipsis" onClick={() => setPage(Math.min(page + visiblePages, total))}>
              {'>>'}
            </button>
          )}
          {isEdgeVisible && (
            <button className="endEdge" onClick={() => setPage(total)}>
              {total}
            </button>
          )}
          <button className="next" onClick={() => setPage(page + 1)} disabled={page === total}>
            {'>'}
          </button>
        </div>
      );
      왼쪽부터 '< ', '1', '<<', '중간 페이지들', '>>', '마지막 페이지', '>' 순서로 button들이 나열되어야한다.
      각각의 버튼은 conditional rendering을 이용해 조건에 따라 노출되어야하고,
      onClick callback 함수는 각 버튼의 역할에 맞게 미리 작성되어있다.

      즉, 절차형으로 '어떻게' 버튼을 더하고 뺄지 정하는 것이 아니라, 이미 '무슨' 버튼들이 필요한지 '선언'해두고나서 '현재 상태값'에 따라 버튼을 보이거나 숨기는 방식이다.

    2. 이미 남은 일은 isEdgeVisible, isLeftEllipsisVisible, isRightEllipsisVisible, middlePages 총 4개의 변수만 상태값에 따라 선언해주면 된다.
      아래는 몇 개의 변수에 대한 코드 예시이다.
        /** 참고: Pagination의 props */
      export interface PaginationProps {
        page: number; // 현재 페이지
        setPage: React.Dispatch<SetStateAction<number>>;
        total: number; // 전체 페이지 수
        visiblePages: number; // 노출되는 페이지 수
      }
      
      // edge는 양끝 페이지를 의미('.startEdge', '.endEdge')
      const numberOfEdge = 2;
      // middle은 중간 페이지들을 의미('.middle')
      const numberOfMiddle = visiblePages - numberOfEdge;
      // edge 페이지가 노출되는지 여부
      const isEdgeVisible: boolean = visiblePages > numberOfEdge;
      
      // .leftEllipsis('<<' 화살표)가 노출되는지 여부
      const isLeftEllipsisVisible: boolean = (() => {
        // corner case: page가 작을 경우
        if (page <= 2) return false;
        // corner case: visiblePages가 작을 경우
        if (visiblePages <= 1) return false;
      
        // corner case: visiblePages가 total만큼 클 경우
        if (total === visiblePages) return false;
        if (total === visiblePages + 1) {
          return page >= total / 2;
        }
      
        const isEvenCount = numberOfMiddle % 2 === 0;
        return (isEvenCount && page >= numberOfMiddle) || (!isEvenCount && page > numberOfMiddle);
      })();
      위의 코드와 같이 "page(현재 페이지), total(전체 페이지 수), visiblePages(눈에 보이는 페이지 수)" 3개의 상태값만 주어지면 isEdgeVisible과 isLeftEllipsisVisible, isRightEllipsisVisible 과 같이 필요한 변수값을 계산할 수 있다.

      isLeftEllipsisVisible의 return 값을 결정하는 로직은 필자가 생각한 설계에 따라 작성되어 있는데, 놓친 corner case가 있거나 더 효율적인 로직이 있다면 isLeftEllipsisVisible 함수 안의 내용만 고치면 되기 때문에 버그 수정이 쉽다.

    3. 가장 중요한 middlePages도 비슷한 방식으로 계산할 수 있다.
        // 중간 페이지들
      const middlePages: number[] = (() => {
        // corner case: visiblePages 값이 작을 경우
        if (visiblePages === 1) return [page];
        else if (visiblePages === 2) {
          // corner case: end edge 일 때
          if (page === total) {
            return [page - 1, page];
          }
          return [page, page + 1];
        }
      
        // '<<' 화살표가 보이지 않는다는 것은 middlePages가 [2, 3, ...] 이라는 뜻
        if (!isLeftEllipsisVisible) {
          return Array(numberOfMiddle)
            .fill(0)
            .map((_, index) => 2 + index);
        }
        // '>>' 화살표가 보이지 않는다는 것은 middlePages가 [..., total - 2, total - 1] 이라는 뜻
        if (!isRightEllipsisVisible) {
          return Array(numberOfMiddle)
            .fill(0)
            .map((_, index) => total - numberOfMiddle + index);
        }
      
        return Array(numberOfMiddle)
          .fill(0)
          .map((_, index) => page + (-Math.ceil(numberOfMiddle / 2) + 1) + index);
      })();
      여기서 눈여겨 볼 점은 middlePages 로직에 isLeftEllipsisVisible, isRightEllipsisVisible의 값이 사용됐다는 것이다.
      코드를 선언형으로 작성하다보면 다른 곳에서 앞서 선언한 변수를 직관적으로 그대로 사용할 수 있어 매우 편리하다.

    4. 이제 다시 렌더링부분으로 돌아와 현재 상태에 맞게 버튼이 잘 렌더링되는지 확인하면 된다!
      선언형으로 작성한 Pagination UI
  • 의의
    1. 280줄의 절차형 코드를 110줄의 선언형 코드로 줄였다.
    2. UI와 코드가 1:1로 매칭되어 코드를 이해하기 쉽고, 특정 상태에서 버그가 발생했을 경우 코드를 추적하기도 쉽다.(모든 변수와 렌더링 조건이 현재의 상태에 맞춰 결정되기 때문에)