UX Engineer 이야기

Custom Table Component

hongdoyoung 2023. 10. 30. 07:50

들어가며

자주 사용되지만 화면에 표현되는 것에 비해 손이 많이 가는 UI를 하나만 꼽아보라고 한다면 개인적으로 테이블을 꼽을 수 있을 거 같습니다.

최근 진행했던 한 프로젝트에도 어김없이 테이블 UI가 필요했는데요, 처음 계획은 작업을 쉽게 하기 위해 무료 라이선스의 패키지를 설치하려고 했으나 기획 상 요구되는 기능에 비해서 너무 무거운 것은 아닐까 하는 생각이 들었습니다.

그래서 마치 라이브러리처럼 사용할 수 있으면서 필요한 기능만 가진 테이블 컴포넌트를 직접 만들어보기로 했습니다.

 

사전 준비

먼저 기능 구현을 위해서 필요한 최소한의 기능을 정리해 보았습니다. 테이블 목록과 칼럼 sorting, 페이징 처리는 API 호출하고 결과를 바인딩 할 예정이어서 UI 적으로 풀어낼 기능만 구현하면 되었으므로 상당히 간결하게 정리될 수 있었습니다.

  1. 테이블 데이터의 키 string을 이용하여 칼럼 생성
  2. 칼럼별 sorting UI 및 이벤트 전달
  3. 데이터가 없거나 데이터를 로딩 중일 때까지 고려

프로젝트의 작업 환경과 사용된 패키지입니다.

  • Next.js + typescript
  • Module SCSS + classnames

 

작업 내용

타입 선언과 마크업

요구 기능 및 최소한 필요한 타입을 먼저 정리하고 선언해 주었습니다.

// table 타입
type tableConfigProps = {
  caption: string; // 테이블 캡션
  theme?: 'theme1' | 'theme2' | 'default'; // 스타일링
  customClass?: string;
  columns: Columns[];
};

// table column 타입
type Columns = {
  key: string;
} & ColumnsProps;

type ColumnsProps = {
  sortable: boolean; // sorting 기능 가능 여부
  sorting?: 'asc' | 'desc' | 'default'; // 오름 또는 내림 차순
  customClass?: string;
  size?: number; // 컬럼 넓이
  align?: 'center' | 'right' | 'left'; // 정렬
  header: (sortOrder?: 'asc' | 'desc' | 'default', index?: number) => React.ReactNode | JSX.Element; // 각 컬럼의 th
  cell?: (value: any, item?: DataItem, index?: number) => React.ReactNode; // 컬럼별 데이터
};

type DataItem = {
  [key: string]: unknown;
};

// 컴포넌트 props
type TableComponentProps = {
  tableConfig: tableConfigProps;
  isLoading?: boolean; // 로딩중일때
  tableDatas: DataItem[]; // api로 받아오는 데이터들이 담길 값
};

컴포넌트를 생성하고 마크업 작업을 해줍니다. Module SCSS에서 필수라고 생각되는 classnames 라이브러리도 설치하고 임포트 해줍니다.

import classNames from 'classnames/bind';
const cx = classNames.bind(style);

const Table = ({ tableConfig, tableDatas, isLoading }: TableComponentProps) => {
  return (
    <div className={cx(`table-container`)}>
      <table
        className={cx(
          `table-theme-${tableData.theme}`,
          `${tableData.customClass ? tableData.customClass : ''}`
        )}
      >
        <caption className={cx(`a11y`)}>{tableConfig.caption}</caption>
        <colgroup>
          {tableConfig.columns.map((col: ColumnsProps, i: number) => (
            <col
              key={`${tableData.caption}-col-${i}`}
              style={{ width: col.size ? `${col.size}px` : 'auto' }}
            />
          ))}
        </colgroup>
        <thead className={cx(`thead`)}>
          <tr>
            {tableConfig.columns.map((column: Columns, i: number) => {
              <th
                scope="col"
                className={cx(column.customClass ? `${column.customClass}` : null, {
                  'sort-cell': column.sortable
                })}
              ></th>
            }
          </tr>
        </thead>
        <tbody className={cx(`tbody`)}>
          <tr>
            {tableDatas.map((dataObject: DataItem, i: number) => (
              <tr key={`${tableData.caption}-row-${i}`}>
                {tableConfig.columns.map((column: Columns, subIndex: number) => {
                  <td className={cx(`${customClass}`)}></td>;
                })}
              </tr>
            ))}
          </tr>
        </tbody>
      </table>
    </div>
  );
};

진행된 주요 작업입니다.

  1. 상황별 컨트롤이 필요할 때를 위해서 customClass를 props로 내려받아 테이블 container 및 각 칼럼들에 연결해 주었습니다.
  2. <thead>의 제목 칼럼들은 tableConfig의 columns 값만큼 생성해 줍니다. 같은 방법으로 <tbody>의 칼럼들도 생성해 줍니다.
  3. 테이블 본문이 되는 <tbody>는 tableDatas의 값만큼 <tr>을 생성해 줍니다.

Accessor 구현

API 응답 데이터들은 tableDatas에 담겨 전달될 것이므로 각 이름을 key로 전달받아 해당하는 칼럼에 접근할 수 있는 accessor를 생성했습니다.

export const ColumnHelper = {
  accessor(key: string, config: ColumnsProps): Columns {
    const headerSortable = (sortOrder?: 'asc' | 'desc' | 'default', index?: number) => {
      if (config.sortable && config.header) {
        return config.header(sortOrder, index);
      }
      return config.header ? config.header(undefined, index) : null;
    };

    const cellWithIndex = (value: any, item?: DataItem, index?: number) =>
      config.cell ? config.cell(value, item, index) : <>{value}</>;

    return {
      ...config,
      key,
      header: headerSortable,
      cell: cellWithIndex
    };
  }
};

진행된 주요 작업입니다.

  1. 칼럼 별로 sorting 가능 여부를 선택할 수 있게 하고 sorting이 가능한 경우 현재 선택된 sorting 값을 받아서 처리하도록 했습니다.
  2. 각 칼럼 별로 별도의 마크업이 필요한 경우를 대비해서 config props를 통해서 전달받은 값 리턴해줍니다.
  3. 때에 따라서 다른 칼럼의 값에 접근해야 할 수도 있으므로 필요시 다른 칼럼의 값도 받아올 수 있도록 작업해 주었습니다.

<th>와 <td>에 데이터 입히기

각 테이블의 칼럼은 좋은 사용자 경험을 위해서 여러 가지 데이터를 조합하여 표시해 주거나 여러 조건을 이용해서 가공하는 등의 추가 작업이 필요한 경우가 많습니다. 하지만 API로 응답받아오는 데이터는 텍스트나 숫자 등 원시 데이터인 경우가 많기 때문에 필요하다면 추가 마크업까지 넘겨받을 수 있도록 작업해 주었습니다.

<thead className={cx(`thead`)}>
  <tr>
    {tableConfig.columns.map((column: Columns, i: number) => {
      const headerContent = column.header(sortOrder[column.key], i);
      const align = column.align ? column.align : 'left';

      return (
        <th
          scope="col"
          className={cx(
            column.customClass ? `${column.customClass}` : null,
            { 'sort-cell': column.sortable },
            `align-${align}`
          )}
        >
          {headerContent}
        </th>
      )
    })}
  </tr>
</thead>
<tbody className={cx(`tbody`)}>
  {tableDatas.map((dataObject: DataItem, i: number) => (
    <tr key={`${tableData.caption}-row-${i}`}>
      {tableConfig.columns.map((column: Columns, subIndex: number) => {
        const value = dataObject[column.key];
        let cellContent: React.ReactNode = '';
        if (column.cell) cellContent = column.cell(value, dataObject, i);
        const align = column.align ? column.align : 'left';
        const customClass = column.customClass ? column.customClass : '';
        return (
          <td key={`${column.key}-tbody-${i}-${subIndex}`} className={cx(`${customClass}`, `align-${align}`)}>
            {cellContent}
          </td>
        );
      })}
    </tr>
  ))}
</tbody>

정렬 기능 추가

이제, Table 컴포넌트에 각 칼럼별 sorting 상태를 처리해 줍니다. 우선 ‘오름차순’, ‘내림차순’, ‘기본 상태’를 순환하는 토글링 이벤트를 생성해 주고 reduce를 이용해서 각 칼럼을 순회하며 새로운 값을 업데이트하도록 했습니다.

const Table = ({ tableConfig, tableDatas, isLoading }: TableComponentProps) => {
  const initialSortOrder = tableConfig.columns.reduce(
    (acc, column) => ({ ...acc, [column.key]: column.sorting || 'default' }),
    {} as Record<string, 'asc' | 'desc' | 'default'>
  );

  const [sortOrder, setSortOrder] = useState(initialSortOrder);

  const toggleSortOrder = (key: string) => {
    setSortOrder((prev) => {
      if (!tableConfig.columns.find((column) => column.key === key)?.sortable) {
        return prev;
      }

      const currentSortOrder = prev[key];

      switch (currentSortOrder) {
        case 'default':
          return { ...prev, [key]: 'asc' };
        case 'asc':
          return { ...prev, [key]: 'desc' };
        case 'desc':
          return { ...prev, [key]: 'default' };
        default:
          return { ...prev, [key]: 'asc' };
      }
    });
  };
  // ...
};

<th>에 클릭 이벤트를 연결해 주고 각 sorting 상태별 아이콘도 추가해 줍니다.

<th
  scope="col"
  className={cx(
    column.customClass ? `${column.customClass}` : null,
    { 'sort-cell': column.sortable },
    `align-${align}`,
  )}
  onClick={column.sortable ? () => toggleSortOrder(column.key) : undefined}
>
  {headerContent}
  {(sortOrder[column.key] === 'asc' || sortOrder[column.key] === 'desc') && (
    <span className={cx(`sort-icon`, `sort-${sortOrder[column.key]}`)}>{`${
      sortOrder[column.key]
    }`}</span>
  )}
</th>

남은 작업 정리

API 호출 중과 데이터가 없을 경우에 노출할 케이스도 추가해 주었습니다.

if (isLoading)
  return (
    <div className={cx(`table-container`)}>
      <div className={cx(`table-loading`)}>
        {/* loading */}
      </div>
    </div>
  );
if (tableDatas.length === 0) {
  return <div className={cx(`table-container`)}>{/* empty */}</div>;
}
return (
	// ...
)

 

사용 예시

API로 응답받는 데이터가 다음과 같은 구조라고 가정한다면

tableDatas: [
  {
    transaction: '0x0xxx....xxxx',
    create: '12분전',
    action: 'action',
    count: 100000000,
  },
];

tableConfig 객체는 다음과 같은 구조일 수 있습니다.

const tableConfig = {
  caption: 'table caption',
  theme: 'default',
  columns: [
    columnHelper.accessor('transaction', {
      sortable: false,
      customClass: 'transaction',
      header: () => {
        return <span className="first-cell">트랜잭션</span>;
      },
      cell: (value: string) => {
        return <span>{value}</span>;
      },
    }),
    columnHelper.accessor('create', {
      sortable: true,
      sorting: 'asc',
      header: () => {
        return '시간';
      },
      cell: (value: string) => {
        return <span className="date">{value}</span>;
      },
    }),
    columnHelper.accessor('action', {
      sortable: false,
      header: () => {
        return '실행 액션';
      },
      cell: (value: string) => {
        return <span className="action">{value}</span>;
      },
    }),
    columnHelper.accessor('count', {
      sortable: true,
      sorting: 'desc',
      align: 'right',
      header: () => {
        return '수량';
      },
      cell: (value: number) => {
        return <span className="count">{value.toLocaleString()}</span>;
      },
    }),
  ],
};

 

마치며

테이블 컴포넌트를 직접 구현해 본 경험을 간단하게 정리해 보았습니다. 잘 만들어진 라이브러리만 사용해 보다가 직접 만들어보니 비록 간단한 기능뿐이었지만 좋은 경험이 되었습니다. 현재까지 정리된 작업물을 기준으로 몇 가지 기능을 더 추가해 보면서 고도화해도 좋을 것 같습니다. 현재 운영 중인 wepublic 홈페이지에서 실제 작업물도 확인해 보실 수 있습니다.