[프로젝트] 복잡한 table 데이터 관리

9/3/2024
이미지와 같은 관리자용 테이블을 td, th와 같은 Cell에서부터 Page까지 구현하며, 고려한 3가지의 이야기를 나누려합니다.
notion image

⭐️ table을 revalidate하라

  1. tableFilters
    1. table은 검색 조건인 이름(nickname), 키워드(title), 생성일(createdAfter, createdBefore), 이메일(email), 페이지네이션 조건인 현재 페이지(page), n개씩 보기(limit)에 따라 새로운 테이블 데이터를 가져와야 합니다.
    2. type TableQueries = Partial<{ nickname: string; title: string; createdAfter: string; createdBefore: string; email : string; page: number; limit : string | number }>
       
      b. 따라서 table을 필터링하고 refetch하는 트리거로 사용하기 위하여, tableFilters 객체를 state로 사용합니다. 이 state는 테이블의 검색 UI, 테이블 UI, 페이지네이션 UI 3개가 공유해야 하므로, 이를 감싸는 상위 컴포넌트에서 Provider로 제공하여 사용성을 높입니다.
      export function TableProvider({ children }: PropsWithChildren) { const [tableFilters, setTableFilters] = useState<TableQueries>({ page: TABLE_DEFAULT_PAGE, limit: TABLE_DEFAULT_LIMIT, }); return ( <TableContext.Provider value={{ tableFilters, setTableFilters, }} > {children} </TableContext.Provider> ); }
      c. tableFilters 객체는 앞서 말했듯 테이블 데이터를 fetch할 때 params로 곧바로 사용되며, 관리하는 queryKey에서 params의 인자로 사용되어 invalidation 또한 트리거합니다. membersQueryKeys.filters(params ← tableFilters)
      //사용 const { tableFilters, tableSort } = useTableContext(); const { data, isLoading } = useGetMembers(tableFilters); export const useGetMembers = (params: { page: number; limit: string | number; nickname?: string; title?: string; createdAfter?: string; createdBefore?: string; }) => { return useQuery({ queryKey: membersQueryKeys.filters(params), queryFn: () => memberApi.getMemberList(params), select: (data) => data.data, }); };
 

⭐️ table의 행들을 관리하라

notion image
  1. 행으로 나열된 테이블 데이터는 하나의 회원, 그룹, 게시글 아이템과 같습니다. 관리자는 각 아이템을 복수 선택하여 삭제할 수 있어야 합니다. 이를 위해 각 행은 고유한 id와 isChecked 값을 가진 별도의 객체로 관리됩니다
    1. 💡
      왜 별도의 checklist 클라이언트 상태가 있어야 하죠? 서버 상태 그대로 쓸 수 없나요? → 하나의 체크 박스 컴포넌트가 있고, 이를 제어 컴포넌트로 사용할 때 클라이언트 상태를 선언하여 사용합니다. 체크리스트도 마찬가지입니다. 체크박스의 복수 형태로 해당 체크 박스의 고유 식별자와 체크 유무 값을 활용해 체크박스들을 제어합니다.
  1. 테이블은 엔티티에 상관없이 ids 배열 {id: number, isChecked:boolean}[] 을 state로 가집니다. 이 체크리스트 상태는 Table과 삭제 기능을 가진 tabelToolBar이 공유해야 하므로 상위 컴포넌트에 Provider로 제공합니다
interface TableCheckListProps<T extends WithId> extends PropsWithChildren { tableData: T[]; } export function TableCheckListProvider<T extends WithId>({ children, tableData, }: TableCheckListProps<T>) { const [checkList, setCheckList] = useState< { id: number; isChecked: boolean; }[] >(mapTableDataForCheckList(tableData)); useEffect(() => { setCheckList(mapTableDataForCheckList(tableData)); }, [tableData]); return ( <TableCheckListContext.Provider value={{ checkList, updateCheckList: setCheckList, }} > {children} </TableCheckListContext.Provider> ); } function mapTableDataForCheckList<T extends WithId>(tableData: T[]) { return tableData.map((table) => ({ id: table.id, isChecked: false, })); }
  1. 주의할 점은 checklist 상태의 초기값, 테이블 데이터가 삭제된 이후의 checklist 상태의 최신값입니다. tableData는 서버 상태로 관리되는 members, groups, posts를 (정렬 계산만 된 채로) 받아 ids 배열로 매핑하여 사용합니다. (title, nickname 등의 값은 체크리스트가 알고싶은 정보가 아니니까요)
  1. 그런데! 여기서 서버 상태와 클라이언트 상태의 동기화가 필요해요. props로 새로운 tableData(한 아이템이 사라진 후 다시 10개 limit으로 채워진)가 주입되었지만, 이를 ids배열로 매핑한 checklist 상태는 그대로입니다. 새로운 props를 감지했지만, 이는 리렌더링의 조건이 아닙니다. 리렌더링이 되려면 부모 컴포넌트가 리렌더링 되어야 합니다. props는 부모 컴포넌트가 렌더링될 때 주입되기 때문입니다.
props의 경우, 통상 props가 교체될 때 해당 props를 받은 컴포넌트가 렌더링되기는 한다. 그런데 이건 결과에 불과하고 사실 리액트가 props의 교체를 주시(subscribe)하고 있는 것은 아니다.
  1. 다시 삭제 이후로 돌아가봅시다. table 데이터에서 id가 1,3,7인 목록을 삭제했습니다.(deleteMembers) 이후 revalidation을 거쳐 newIds[2,4,5,6,8,9,10,11,12,13]으로 구성된 한 페이지가 새 데이터로 refetch 됩니다. useGetMembers를 호출한 MemberManagementPage 컴포넌트는 리렌더링 됩니다. 자식 컴포넌트인 tableCheckListProvider도 리렌더링되며, tableData props에도 newIds가 주입됩니다.
💡
  1. React Query의 내부 동작:
      • React Query는 내부적으로 상태를 관리합니다. 쿼리의 결과, 로딩 상태, 에러 상태 등을 자체적으로 저장하고 업데이트합니다.
      • 이 과정에서 React Query는 내부적으로 useState와 유사한 메커니즘을 사용하여 상태를 관리합니다.
  1. 상태 변경 감지:
      • 쿼리가 실행되고 새로운 데이터가 fetch되면, React Query는 이 변경사항을 감지하고 내부 상태를 업데이트합니다.
      • 이 상태 업데이트는 React의 상태 업데이트 메커니즘을 트리거합니다.
  1. 컴포넌트 리렌더링:
      • React Query 훅(예: useQuery)이 반환하는 값이 변경되면, 이 훅을 사용하는 컴포넌트는 리렌더링됩니다.
      • 이는 마치 useState의 값이 변경되었을 때 컴포넌트가 리렌더링되는 것과 유사한 메커니즘입니다.
[예시 - 회원 관리 페이지 (MemberManagementPage)]
export function MemberManagementPage() { const { data: members, isLoading } = useGetMembers(tableFilters); const { mutate: deleteMembers } = useDeleteMembers(); const handleDelete = (ids: number[]) => { (...) deleteMembers(ids); } return ( <div> (...) <TableCheckListProvider tableData={members}> <TableToolBarWithCheck onDelete={handleDelete} searchedCount={data.searchedCount} totalCount={data.totalCount} /> <MembersTable members={members} /> </TableCheckListProvider> </div> ); }
 
  1. 그러나 tableCheckListProvider의 내부 state인 checkList는 영향을 받지 않습니다. 최초 렌더링이 아닌 리렌더링이기 때문에, 아무리 newIds를 받아도 이를 반영하지 않습니다. 그래서 이를 연결해줄 무언가가 필요합니다. useEffect를 활용하여, props인 tableData가 변경되는 걸 감지하게 하고, 새 객체가 들어올 때마다 이를 활용하여 다시금 setCheckList를 실행하게 합니다.
 

⭐️ table의 열을 기준으로 정렬하라

notion image
  1. id, 관리설정 등의 열을 제외하고, ‘회원’이라는 엔티티와 연관 있는 name, email, createdAt, updatedAt, groupCount 를 활용해서, 관리자는 데이터를 정렬해서 볼 수 있어야 합니다.
  1. 즉 테이블의 th 헤드는 정렬 기능을 추가할지 여부(withSort)와 정렬을 한다면 사용할 기준(criteria)이 필요합니다. 이를 반영하여 회원 테이블의 헤드를 관리할 객체를 생성합니다
  1. criteria에 따라 정렬은 알파벳, 한글, 숫자, 날짜를 오름차/내림차 순으로 정렬(sortBy)합니다.
  1. 최종 렌더링 되는 테이블은 이러한 정렬을 거친 sortedMembers로, 서버에서 받은 members 상태를 받지만, 이와 별도로 클라이언트 상태로 저장됩니다.
  1. sorted된 회원의 상태는 페이지 전 영역이 아닌, 테이블 자체만 알고 있으면 되므로 바로 상단에서 프로바이더로 제공합니다.

reference

©JIYOUNG CHOI, All rights reserved