본문 바로가기
개발/사이드 프로젝트

리스트 페이지&컴포넌트 설계하기(무한 스크롤)

by Dahna 2022. 5. 26.

서버에서 불러온 데이터를 리스트(목록) 형태로 보여주는 페이지는 아주 자주 사용되는 UI 패턴이다.

오늘은 이러한 리스트 페이지와 구성 요소로 사용되는 컴포넌트들을 설계해 보려고 한다.  

먼저 수립해둔 컴포넌트 개발 규칙을 다시 꺼내보자.

 

[Action Item]

1. 컴포넌트를 설계할 때 이 컴포넌트가 어떻게 사용되어야 하는지 먼저 정의한다.

2. UI 요소와 데이터 기반 요소를 정리하고 분리하여 각각의 컴포넌트로 구성한다.

3. 컴포넌트의 의도를 정의하고 이를 검증할 수 있는 테스트 케이스를 작성한다.

4. 테스트 케이스를 통과할 수 있는 컴포넌트를 작성한다.

5. 컴포넌트를 리팩터링 하며 정리한다.

한 항목씩 진행해보자!

 

[어떻게 사용되어야 하는가?]

리스트 페이지는 서버에 필요한 데이터를 요청하고 관리하는 부분과 받아온 데이터를 UI로 표현하는 부분으로 구성되어 있다. 

각 파트를 사용되는 관점에서 다시 구분해보면 도메인과 관련되어 사용되는 부분과 공통으로 사용되는 부분으로 나누어 생각할 수 있다.

  도메인 관련 공통으로 사용
서버 데이터 요청/관리 api 주소
필터 파라미터
비동기 처리 로직
페이지 검색/이동
데이터를 UI로 표현 페이지 컴포넌트
리스트 아이템 내부 컨텐츠
리스트 아이템
페이지네이션

[서버 데이터 요청/관리]

api 주소 - api를 호출하는 함수로 모듈화 시킬 수 있다.

필터 파라미터 - 유저 인터렉션을 받아 api를 호출하는 함수에 파라미터를 전달한다.

비동기 처리 로직 - 앱 내부에서 비동기 처리를 할 때 공통으로 사용되는 함수 또는 훅을 제공한다. 이 프로젝트에서는 react-query에서 제공하는 훅을 적극 활용한다.

페이지 검색/이동 - 유저 인터렉션을 받아 서버에 이동된 페이지에 해당하는 데이터를 요청한다. 무한 스크롤로 구현하려고 한다.

 

[데이터를 UI로 표현]

페이지 컴포넌트 - url을 통해 도메인 정보를 드러낸다. 컴포넌트와 함수들을 적절히 실행시키는 컨테이너로 사용된다. 리스트 페이지에서 상세 페이지를 오갈 때 상태가 초기화되지 않아야 한다. 

리스트 아이템 내부 컨텐츠 - 리스트 아이템 내 표시되는 내용이다. 도메인에 종속되어 실제적인 컨텐츠를 출력한다.

리스트 아이템 - 페이지 내의 리스트 데이터를 개별 아이템으로 표시할 수 있도록 제공한다. 프로젝트에서는 카드 UI와 동일한 UI로 표현하려고 한다. 

페이지네이션 - 배열 형태의 데이터를 페이지로 분류해 사용할 수 있도록 제공한다. 무한 스크롤 페이지 UI를 제공하려고 한다.

 

[컴포넌트로 나누어보자]

위의 정의를 바탕으로 컴포넌트로 나누어보자. 

페이지 검색/이동 - 구현하려고 하는 페이지 UI는 무한 스크롤이다. 따라서 유저 스크롤 위치를 읽고 하단에 닿으면추가로 리스트를 호출하는 컴포넌트가 필요하다.

페이지 컴포넌트 - react-query를 이용하여 리스트 데이터 상태를 관리하기 때문에 useInfiniteQuery의 사용법을 익힐 필요가 있다. 또한 상세 페이지를 오갈 때 상태가 초기화되지 않도록 하는 방법을 습득해 적용한다.

리스트 아이템 내부 컨텐츠 - 도메인 종속 UI는 작은 모듈로 나누고, props와 출력되는 UI의 타입을 지정하여 특정 도메인 내에서 재사용성을 높인다.

리스트 아이템 - 카드와 동일한 스타일을 가지고 내부 컨텐츠는 컴포넌트 합성에 맡기는 컴포넌트를 작성한다.

페이지 네이션 - react-query의 useInfiniteQuery훅에서 제공하는 페이지네이션을 사용한다.

 

이를 정리하면 필요한 컴포넌트(혹은 훅)는 다음과 같다

페이지 검색/이동
(무한 스크롤)
스크롤 위치를 감지하여 하단에 도착했을 때, useInfiniteQuery의 fetchNextPage를 실행시킨다.
리스트 아이템 리스트(ul) 하단의 li 요소이며, 공통 스타일을 적용한 컴포넌트. 내부 구현은 합성을 통한다.
이 글의 접근법을 따랐다.
리스트 아이템 내부 컨텐츠 도메인 단위로 작게 나누어 모듈화한다. 
페이지 컴포넌트 next page 룰을 따라 페이지를 작성한다. 공통 컴포넌트, 훅에 도메인 종속 컴포넌트, 함수를 전달해 UI를 구성한다.  

 

[테스트 케이스 작성하기]

이번 글에서는 위에서 정의한 컴포넌트 중 페이지 검색/이동(무한 스크롤) 기능을 제공하는 공통 컴포넌트를 TDD 방식으로 개발해보려 한다. react-query와 서버 통신 관련한 테스트는 추후에 학습 후 다루어 보려고 한다. 

 

잠시 무한 스크롤 컴포넌트가 어떻게 사용될지 생각해보자. 무한 스크롤 컴포넌트는 페이지 최하단에 도착했을 때 다음 페이지로 탐색시키는 용도로 사용하려고 한다. 그러기 위해서는 리스트로 사용되는 컴포넌트에서 현재 페이지와 다음 페이지를 알아야 하며 다음 페이지로 탐색하는 기능을 제공해야 한다. 다음 페이지로 탐색하는 기능은 그 역할을 보았을 때 페이지네이션을 처리하는 부분에서 구현을 하고, 무한 스크롤 컴포넌트에서는 그 함수를 넘겨받아 실행만 시켜주는 것이 좋을 것 같다. 정리하면, 뷰포트를 관찰하면서 리스트 하단에 도달하면 다음 페이지로 탐색을 시키는 용도로 사용한다. 리스트 하단에 무한 스크롤 컴포넌트를 위치시킨다면 뷰포트 내에 컴포넌트가 그려질 때 다음 페이지로 탐색을 시키는 방법으로 구현할 수 있을 것 같다.

 

무한 스크롤 컴포넌트는 최상위 문서의 뷰포트와 대상 요소의 교차점의 변경사항을 비동기적으로 관찰할 수 있는 API인 IntersectionObserver를 사용할 필요가 있다. 하지만 Jest 환경 내에서 IntersectionObserver를 테스트할 수 있도록 설정해주는 것이 까다로워 테스트 유틸을 제공하는 라이브러리 react-intersection-observer를 사용했다.

 

페이지네이션에 react-query에서 제공하는 useInfiniteQuery 훅을 이용할 것이기 때문에 현재 페이지와 다음 페이지를 계산할 필요 없이 useInfiniteQuery훅에서 제공하는 헬퍼 함수와 값을 받아 페이지를 탐색하도록 구현한다. fetchNextPage 함수가 실행되면 다음 페이지 탐색이 이루어진 것으로 보고 테스트를 작성한다. 또한 hasNextPage는 다음 페이지 존재 여부를, isFetchingNextPage는 로딩 상태를 표현하는 UI의 토글 값으로 사용할 것이기 때문에 이를 바탕으로 테스트를 작성한다.

// components/pagination/infiniteScroll.test.jsx

import { render, screen } from '@testing-library/react'
import { InfiniteScroll } from '@/components'
import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils'
// react-intersection-observer 라이브러리는 IntersectionObserver의 mock 함수를 제공한다

describe('무한 스크롤 컴포넌트', () => {
  it('무한 스크롤 컴포넌트는 다음 페이지가 있을때만, 화면에 들어올때 다음 페이지로 이동시키는 함수를 실행시킨다.', () => {
    const fetchNextPage = jest.fn()

    const { rerender } = render(
      <InfiniteScroll fetchNextPage={fetchNextPage} hasNextPage={true} />,
    )
    const infiniteScroll = screen.queryByRole('infiniteScroll')
    expect(infiniteScroll).toBeInTheDocument()
    mockAllIsIntersecting(true) // 모든 요소가 화면 내에 있도록 한다.
    expect(fetchNextPage).toHaveBeenCalledTimes(1)

    rerender(
      <InfiniteScroll fetchNextPage={fetchNextPage} hasNextPage={false} />,
    )
    mockAllIsIntersecting(false) // 모든 요소가 화면 내에서 보이지 않도록 한다.
    expect(fetchNextPage).not.toHaveBeenCalledTimes(2)
  })
  it('로딩 상태를 나타내는 UI를 포함한다.', () => {
    const fetchNextPage = jest.fn()

    const { rerender } = render(
      <InfiniteScroll
        isFetchingNextPage={true}
        fetchNextPage={fetchNextPage}
      />,
    )
    const loadingIndicator = screen.queryByRole('loadingIndicator')
    expect(loadingIndicator).toBeInTheDocument()
    rerender(
      <InfiniteScroll
        isFetchingNextPage={false}
        fetchNextPage={fetchNextPage}
      />,
    )
    expect(loadingIndicator).not.toBeInTheDocument()
  })
})

 

[컴포넌트 작성하기]

작성해야 할 컴포넌트는 무한 스크롤 컴포넌트, 리스트 아이템 컴포넌트, 리스트 아이템 내부 컨텐츠, 페이지 컴포넌트가 있다.

무한 스크롤 컴포넌트와 리스트 아이템 컴포넌트는 공통으로 사용하는 컴포넌트이다. 리스트 아이템 내부 컨텐츠는 도메인 별로 모듈화한 컴포넌트들인데 자세한 구현은 이 글에서 다루지 않고 접근법만 다루려고 한다. 페이지 컴포넌트는 필요한 컴포넌트들을 작성한 후에, 어떻게 공통 컴포넌트와 도메인 컴포넌트를 조합해 페이지를 구성하는지에 초점을 맞추어 작성하려고 한다.

 

무한 스크롤 컴포넌트

먼저 테스트 케이스를 만족하는 무한 스크롤 컴포넌트를 작성한다. 페이지네이션에 react-query에서 제공하는 useInfiniteQuery 훅을 이용할 것이기 때문에 useInfiniteQuery훅에서 제공하는 헬퍼 함수와 값을 받아 페이지를 탐색하도록 구현한다.

// components/pagination/infiniteScroll.tsx

import { HTMLAttributes, useEffect } from 'react'
import { useInView } from 'react-intersection-observer'

interface Props extends HTMLAttributes<HTMLDivElement> {
  fetchNextPage: () => {} // 다음 페이지로 탐색하는 함수
  hasNextPage?: boolean  // 다음 페이지가 있는지 여부
  isFetchingNextPage?: boolean  // 다음 페이지 로딩중인지 여부
}
export const InfiniteScroll = ({
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
}: Props) => {
  const { ref, inView, entry } = useInView()

  useEffect(() => {
    if (!hasNextPage) return
    if (inView && !isFetchingNextPage) fetchNextPage() // inView는 ref요소가 화면 내에 있는지를 여부를 반환한다.
  }, [inView, hasNextPage])

  return (
    <div role="infiniteScroll" ref={ref}> // test를 만족하도록 role을 지정한다.
      {isFetchingNextPage && <div role="loadingIndicator">loading...</div>}
    </div>
  )
}

테스트를 통과한 것을 볼 수 있다.

 

리스트 아이템 컴포넌트

잠시 리스트 컴포넌트를 떠올려보자. 리스트 컴포넌트는 서버에서 불러온 데이터를 페이지네이션 처리를 한 뒤 페이지별로 나누어 보관하고 있을 것이다. 그렇다면 이 데이터는 어떤 데이터일까? 유저에게 보여주고자 하는 컨텐츠를 담고 있기 때문에 도메인 종속적인 데이터라고 볼 수 있다. 즉 UI관점에서 보면 공통으로 사용되는 부분이라고 보기 어렵다. 여기서 추출할 수 있는 UI 요소에는 무엇이 있을까? ul 리스트와 li 아이템이 부자관계로 묶여있는 부분을 추출할 수 있다. 이와 같은 접근법은 이 글에서 배웠다. UI 요소에서 도메인을 분리하고 일반적인 인터페이스를 가지도록 설계하는 것이다. 그리고 도메인 종속적인 내용들은 모듈화 해서 마치 레고 블록처럼 도메인별로 모아두는 것이다. 마치 레고 확장 팩을 만들 때 중세 시대 컨셉으로 만들려면 중세 시대를 표현한 레고들을 모으고 현대 시대 컨셉도 마찬가지로 꾸리는 것처럼, 확장 패키지라는 인터페이스를 하나의 공통 컴포넌트로 보고 내부 구현은 도메인 모듈을 조합하는 방식으로 개발하는 것이다.

 

결론적으로 리스트 아이템 컴포넌트는 리스트 내에서 개별 아이템을 표현하는 UI를 제공한다. ul 내부의 li로 사용될 것이며 카드 형태의 통일된 스타일을 적용하려고 한다. 작성된 컴포넌트는 다음과 같다.

//components/listItem/ListItem.tsx

import React from 'react'
import { LiHTMLAttributes } from 'react'
import { WhiteRoundedCard } from '@/components'

interface Props extends LiHTMLAttributes<HTMLLIElement> {}

export const ListItem = ({ children, ...props }: Props) => (
  <li {...props}>
    <WhiteRoundedCard>{children}</WhiteRoundedCard>
  </li>
)

 

페이지 컴포넌트

페이지 컴포넌트는 작성한 컴포넌트들을 결합시키고 도메인 정보를 주입하는 역할을 한다. 페이지네이션 처리를 하는 부분과 리스트 아이템 컴포넌트를 불러와 렌더링 하는 부분, 그리고 무한 스크롤 컴포넌트를 불러와 페이지네이션 정보를 주입하는 부분으로 구성된다.

상태관리 페이지네이션 react-query의 useInfiniteQuery 훅을 사용한다. 훅에 인자로 쿼리 키와 서버 데이터 요청 함수에 페이지 파라미터를 넘겨 콜백함수로 전달한다. 옵션이 있다면 설정한다. fetchNextPage, hasNextPage, isFetchingNextPage 등을 반환한다. 
렌더링 리스트 아이템 useInfiniteQuery훅으로부터 리턴받은 페이지네이션 된 데이터를 기준으로 리스트 아이템을 렌더링한다. 내부 데이터는 모듈들을 갈아끼우며 구현한다.
무한 스크롤 컴포넌트 리스트 하단에 무한 스크롤 컴포넌트를 위치시키고 useInfiniteQuery훅에서 리턴받은 페이지네이션 정보를 주입한다.
// pages/party/index.tsx

import _ from 'lodash'
import React, { ReactElement } from 'react'
import Link from 'next/link'
import { useInfiniteQuery } from 'react-query'
import { NextPageWithLayout, PaginatedResult, Party } from '@/type'
import { useAppSelector } from '@/utils/hooks'
import { listParty } from '@/api'
import { CategoryName, CategoryFilter } from '@/components/modules' // 도메인 정보를 가진 컴포넌트를 모듈로 구분했다.
import {
  ListItem,
  UserAvatarTooltip,
  WhiteRoundedCard,
  HomeLayout,
  InfiniteScroll,
} from '@/components'  // 공통 컴포넌트들

const PartyPage: NextPageWithLayout = () => {
  const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery<PaginatedResult<Party>>(    // 페이지 처리된 배열 타입은 공통으로 사용하고, 도메인 데이터 타입을 지정해준다.
      'party',  // 쿼리 키
      ({ pageParam = 1 }) => listParty(pageParam), // api 호출 함수에 페이지 정보를 넘긴다.
      {                            // 옵션
        keepPreviousData: true,    // 데이터 페칭중 기존 데이터를 사용한다.
        getNextPageParam: (lastPage, pages) => lastPage.next, // 데이터의 next에서 다음 페이지 파라미터를 얻는다. 장고 서버에서 next, prev로 설정.
        cacheTime: 1000 * 60 * 60,
      },
    )

  return (
    <div>
      <ul className="grid gap-4 md:grid-cols-3">
        {data?.pages?.map((group, i) => (
          <React.Fragment key={i}>  // 불필요한 요소를 만들지 않는 Fragment 사용
            {group.results.map((party: Party) => (
              <ListItem key={party.id}>
                <Link href={`/party/${party.id}`} scroll={false} key={party.id}>
                  <React.Fragment>
                    <div className="mb-1 flex items-center">
                      <CategoryName   // 도메인 컴포넌트
                        category={party.keyword?.category}
                        className="mr-2"
                      />
                      <p className="text-lg font-bold">{party.name}</p>
                    </div>

                    <span className="text-blue-500">
                      #{party.keyword?.name}
                    </span>

                    <div className="my-4 flex -space-x-1 border border-white border-y-gray-200 py-3">
                      {party.membership.map((membership) => (
                        <UserAvatarTooltip
                          user={membership.team_member}
                          key={membership.id}
                        />
                      ))}
                    </div>
                  </React.Fragment>
                </Link>
              </ListItem>
            ))}
          </React.Fragment>
        ))}
      </ul>
      <InfiniteScroll
        fetchNextPage={fetchNextPage}
        isFetchingNextPage={isFetchingNextPage}
        hasNextPage={hasNextPage}
      />
    </div>
  )
}

export default PartyPage

PartyPage.getLayout = function getLayout(page: ReactElement) {
  return <HomeLayout>{page}</HomeLayout>
}

스크롤이 하단으로 내려갔을 때 loading... 문구가 표시되며 2 페이지 데이터가 요청된 것을 확인할 수 있다.

 

[후기]

컴포넌트를 용도와 책임을 생각하면서 짜 보니 작업 단위를 나누기가 한결 수월해졌다. 예컨대 리스트 아이템을 먼저 짜고 도메인 관련 모듈 구현은 나중으로 미룬다던지 하는 방식으로 작업 관리를 할 수 있었다. 그리고 테스트를 먼저 작성하니 컴포넌트의 설계가 완성된 후에 작성을 하게 되어 브라우저를 열지 않고도 개발을 할 수 있었고, 무한 스크롤 컴포넌트가 실제로도 의도대로 작동되어 약간의 뿌듯함도 느낄 수 있었다. jest 설정을 하면서 막히는 부분이 종종 발생해서 공부가 필요하다고 느꼈다.

프로젝트 코드는 여기에서 확인 가능하다.

댓글