Custom Table Component
들어가며
자주 사용되지만 화면에 표현되는 것에 비해 손이 많이 가는 UI를 하나만 꼽아보라고 한다면 개인적으로 테이블을 꼽을 수 있을 거 같습니다.
최근 진행했던 한 프로젝트에도 어김없이 테이블 UI가 필요했는데요, 처음 계획은 작업을 쉽게 하기 위해 무료 라이선스의 패키지를 설치하려고 했으나 기획 상 요구되는 기능에 비해서 너무 무거운 것은 아닐까 하는 생각이 들었습니다.
그래서 마치 라이브러리처럼 사용할 수 있으면서 필요한 기능만 가진 테이블 컴포넌트를 직접 만들어보기로 했습니다.
사전 준비
먼저 기능 구현을 위해서 필요한 최소한의 기능을 정리해 보았습니다. 테이블 목록과 칼럼 sorting, 페이징 처리는 API 호출하고 결과를 바인딩 할 예정이어서 UI 적으로 풀어낼 기능만 구현하면 되었으므로 상당히 간결하게 정리될 수 있었습니다.
- 테이블 데이터의 키 string을 이용하여 칼럼 생성
- 칼럼별 sorting UI 및 이벤트 전달
- 데이터가 없거나 데이터를 로딩 중일 때까지 고려
프로젝트의 작업 환경과 사용된 패키지입니다.
- 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>
);
};
진행된 주요 작업입니다.
- 상황별 컨트롤이 필요할 때를 위해서 customClass를 props로 내려받아 테이블 container 및 각 칼럼들에 연결해 주었습니다.
- <thead>의 제목 칼럼들은 tableConfig의 columns 값만큼 생성해 줍니다. 같은 방법으로 <tbody>의 칼럼들도 생성해 줍니다.
- 테이블 본문이 되는 <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
};
}
};
진행된 주요 작업입니다.
- 칼럼 별로 sorting 가능 여부를 선택할 수 있게 하고 sorting이 가능한 경우 현재 선택된 sorting 값을 받아서 처리하도록 했습니다.
- 각 칼럼 별로 별도의 마크업이 필요한 경우를 대비해서 config props를 통해서 전달받은 값 리턴해줍니다.
- 때에 따라서 다른 칼럼의 값에 접근해야 할 수도 있으므로 필요시 다른 칼럼의 값도 받아올 수 있도록 작업해 주었습니다.
<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 홈페이지에서 실제 작업물도 확인해 보실 수 있습니다.