[React] 카테고리별 그룹화된 테이블 구현

이진우·2024년 11월 30일
0
post-thumbnail

요약
테이블 데이터에서 특정 열들을 그룹으로 묶어서 보여주는 컴포넌트를 구현하는 과정에 관한 글입니다.
테이블을 구현하는데에 그루핑한 데이터를 표현하는 방법을 찾는 분들께 아이디어가 될 수 있습니다.

이번 글에서는 엑셀 파일을 테이블로 나타내기에 이어서, 로드한 데이터를 그루핑하여 테이블로 나타내는 법을 공유드리려고 합니다.

상품 판매내역 엑셀 파일에서 상품을 그루핑하여 한번에 상품 요소들만 봐야하는 경우가 있습니다. 데이터를 그룹화하여 테이블 형태로 보여주는 React 컴포넌트를 설계하고 구현하는 방법에 대해 자세히 다루어 보겠습니다.

🤔 문제 정의

판매내역 데이터

쇼핑몰 운영자는 상품 판매내역 파일을 받아서 확인합니다. 문제는 위의 이미지처럼 상품들이 판매된 순서대로 나열되어 있어서 상품들을 구조화해서 확인하기가 어렵습니다.
그래서 같은 카테고리로 묶고 각 옵션도 상품에 따라 묶어서 아래와 같이 나타내려고 합니다.

그룹화된 테이블

단순히 나열되어 있던 형태에서 각 카테고리 및 상품에 어떤 요소가 속해있는지 더욱 보기 쉬워졌습니다.

이때, column 값은 사용자가 정의함에 따라 무엇이든 될 수 있습니다. 왼쪽에 있는 column일수록 상위 그룹입니다. 그룹화해야하는 column의 수가 최대 10개라고 가정할 때 테이블 컴포넌트를 어떻게 설계하고 구현해야 할까요?

👀 데이터를 그룹화하여 보여주는 플로우

사용자가 엑셀 파일을 업로드하고 이를 그루핑 테이블로 변환하는 과정은 다음과 같습니다.

  1. 엑셀 파일 업로드
  2. 엑셀 파일을 JSON 데이터로 변환
  3. 사용자가 그루핑하기를 원하는 column 선택
  4. 그루핑 대상 column으로 그루핑 데이터 생성
  5. 그루핑 데이터를 그루핑 테이블을 통하여 렌더링

여기서 4~5번 플로우를 구현해야 하므로 해당 부분을 좀더 구체화하도록 하겠습니다.


🔎 그루핑 테이블 스펙

그루핑 테이블과 원본 데이터 테이블에 비해서 다음의 특징을 가집니다.

  • 중복되는 값이 존재하지 않습니다.
  • 같은 그룹일 경우에는 그룹 대상 값의 셀이 하위 셀들을 모두 포함하도록 행병합되어 나타납니다.


🌚 데이터 구조 설계

기본 테이블에 보여줄 데이터는 JSON으로 변환되기 때문에 다음과 같이 정의합니다.

// table/tableData.ts

export type TableData = Record<string, string>[]; // key-value 배열

그렇다면 그루핑 데이터는 어떻게 해야할까요?
그루핑 데이터를 설계하기 위해서는 그루핑 테이블을 만들기 위해서 어떤 정보가 필요한지 살펴봐야합니다. 그루핑 데이터를 컴포넌트로 작성하면 다음과 같습니다.

function GroupedTable(): React.ReactElement {
    return (
        <table>
            <thead>
            <tr>
                <th>카테고리</th>
                <th>상품</th>
                <th>옵션</th>
            </tr>
            </thead>
            <tbody>
            <tr>
                <td rowSpan={2}>의류</td>
                <td>후드</td>
                <td>사이즈=M</td>
            </tr>
            <tr>
                <td>셔츠</td>
                <td>사이즈=L</td>
            </tr>
            <tr>
                <td rowSpan={5}>음식</td>
                <td rowSpan={2}>감바스</td>
                <td>=4</td>
            </tr>
            <tr>
                <td>=2</td>
            </tr>
            <tr>
                <td rowSpan={3}>닭강정</td>
                <td>양념=없음</td>
            </tr>
            <tr>
                <td>양념=간장</td>
            </tr>
            <tr>
                <td>양념=매운맛</td>
            </tr>
            </tbody>
        </table>
    );
}

첫번째로 테이블 헤더에 column이 필요합니다.
두번째로 테이블 바디에 보여줄 각 column에 해당하는 값들이 필요합니다.
마지막으로 각 테이블 셀에 rowSpan 값을 넣고있는 것을 확인할 수 있습니다. rowSpan은 해당 셀을 행으로 몇 칸을 차지하게 할 것인지에 대한 속성값입니다.

rowSpan값을 보면 각 그룹들의 최하위 요소들의 개수를 넣고 있습니다. (1개라면 넣지 않습니다. - 그러면 default로 1이 세팅됩니다.)

각 요소들을 그룹화하고 최하단 요소의 개수를 세어서 rowSpan 값으로 지정한다는 점에서 트리 자료구조가 적합하다고 판단했습니다.

위의 트리형태에 중복제거와 그룹별 하위 노드 조회를 빠르게 하기 위해 key value 형태로 하위 노드를 관리하도록 했습니다.

export interface IGroupedTableDataNode {
    get childrenNodes(): Record<string, IGroupedTableDataNode>;
    // key 값은 column에 해당하는 value <ex) 의류, 음식> 중복되면 안되므로 key값으로 설정
    // value는 그룹에 해당되는 하위 노드

    get values(): string[]; // 노드의 column에 해당하는 value들 배열
    get countLeafNodes(): number; // 말단 노드의 개수

    insert: (value: string) => void; // 새로운 하위 노드 삽입
}

정의한 데이터 타입 IGroupedTableDataNode를 다음의 플로우에서 활용합니다.

상위컴포넌트가 그룹화 객체 변환 함수에 원본데이터 TableData를 입력하면 함수는 IGroupedTableDataNode를 반환하고 이를 그루핑 테이블에 전달하면 그루핑 테이블 컴포넌트가 생성되게 됩니다.


🛠️ 구현

앞에서 정의한 데이터들을 종합해서 코드를 작성하면 다음과 같습니다.

// table/TableData.ts
export type TableData = Record<string, string>[];

export interface TableCellInfo {
    // 테이블 셀에 데이터
    value: string;
    rowSpan: number;
}

export type TableRow = TableCellInfo[];
export type TableRows = TableRow[];

export interface IGroupedTableDataNode {
    get childrenNodes(): Record<string, IGroupedTableDataNode>;
    // key 값은 column에 해당하는 value <ex) 의류, 음식> 중복되면 안되므로 key값으로 설정
    // value는 그룹에 해당되는 하위 노드

    get values(): string[]; // 노드의 column에 해당하는 value들 배열
    countLeafNodes(): number; // 말단 노드의 개수

    insert: (value: string) => void; // 새로운 하위 노드 삽입
}

export class GroupedTableDataNode implements IGroupedTableDataNode {
    private _childrenNodes: Record<string, IGroupedTableDataNode>;

    constructor() {
        this._childrenNodes = {};
    }

    get childrenNodes(): Record<string, IGroupedTableDataNode> {
        return this._childrenNodes;
    }

    get values(): string[] {
        return Object.keys(this._childrenNodes);
    }

    countLeafNodes(): number {
        // 트리의 말단 노드의 개수를 세어주는 메서드
        // 재귀 함수
        // 재귀 탈출 조건은 하위 노드가 없는 것 -> 없을 경우 1반환
        if (this.values.length === 0) {
            return 1;
        }

        let cnt = 0;

        for (const node of Object.values(this._childrenNodes)) {
            cnt += node.countLeafNodes();
        }

        return cnt;
    }

    insert(value: string): void {
        // 새로운 하위 노드 삽입
        if (!this._childrenNodes[value]) {
            // value에 하위 노드가 존재하지 않을 경우 새로운 노드 삽입
            this._childrenNodes[value] = new GroupedTableDataNode();
        }
    }
}

그루핑 테이블 컴포넌트를 소비하는 상위 컴포넌트를 작성하겠습니다.
테스트를 위해서 사용자가 변환하려는 원본데이터와 그루핑하려는 열 이름 순열도 같이 정의하겠습니다.

상위 컴포넌트 코드 예시는 다음과 같습니다.

// App.tsx
import React from 'react';
import './App.css';
import GroupedTable from "./table/GroupedTable";
import {IGroupedTableDataNode, TableData} from "./table/TableData";
import {groupingTableData} from "./table/groupingTableData";

const data: TableData = [
    {
        '카테고리': '의류',
        '상품': '후드',
        '옵션': '사이즈=M'
    },
    {
        '카테고리': '음식',
        '상품': '감바스',
        '옵션': '팩=4팩',
    },
    {
        '카테고리': '음식',
        '상품': '닭강정',
        '옵션': '양념=없음'
    },
    {
        '카테고리': '음식',
        '상품': '감바스',
        '옵션': '팩=2팩'
    },
    {
        '카테고리': '의류',
        '상품': '후드',
        '옵션': '사이즈=M'
    },
    {
        '카테고리': '음식',
        '상품': '닭강정',
        '옵션': '양념=간장',
    },
    {
        '카테고리': '음식',
        '상품': '닭강정',
        '옵션': '양념=매운맛',
    },
    {
        '카테고리': '의류',
        '상품': '셔츠',
        '옵션': '사이즈=L',
    },
];

const columns: string[] = ['카테고리', '상품', '옵션'];

function App() {
  	// data는 원본 데이터, columns는 그루핑하려는 열 이름 순열
    const groupedData: IGroupedTableDataNode = groupingTableData(data, columns);

    return (
        <div className="App">
            <section style={{padding: '30px'}}>
                <GroupedTable groupedData={groupedData} columns={columns} />
            </section>
        </div>
    );
}

원본데이터를 그루핑테이블 컴포넌트로 전달하기 이전에 변환을 해야합니다.
변환 로직을 수행하는 그룹화 객체 변환 함수 코드입니다.

// table/groupingTableData.ts

import {GroupedTableDataNode, IGroupedTableDataNode, TableData} from "./TableData";

export function groupingTableData(tableData: TableData, columns: string[]): IGroupedTableDataNode {
    // 루트 노드 생성
    const root: IGroupedTableDataNode = new GroupedTableDataNode();
    
    for (const row of tableData) {
        // row 행 하나씩 트리에 삽입
        let curNode: IGroupedTableDataNode = root;
        
        for (const column of columns) {
            const value = row[column];
            
            curNode.insert(value);
            curNode = curNode.childrenNodes[value];
            
            if (!curNode) {
                // curNode가 null이나 undefined일 경우
                throw new Error('IGroupedTableDataNode 노드가 생성되지 않았습니다.');
            }
        }
    }
    
    return root;
}

최종적으로 데이터를 보여주는 그루핑 테이블 컴포넌트 코드입니다.

// table/GroupedTable

import React from 'react';
import './DataTable.css';
import {IGroupedTableDataNode, TableRow, TableRows} from "./TableData";

// HTMLTableElement 의 data와 children props를 제외하고 상속
export interface GroupedTableProps extends Omit<React.HTMLProps<HTMLTableElement>, 'data' | 'children'> {
    groupedData: IGroupedTableDataNode;
    columns: string[];
}

function GroupedTable(props: GroupedTableProps): React.ReactElement {
    const {groupedData, columns} = props;

    const tableCells: TableRows = groupedDataToTableRows(groupedData);

    return (
        <table {...props}>
            <thead>
            <tr>
                {
                    columns.map(column => (
                        <th key={column}>{column}</th>
                    ))
                }
            </tr>
            </thead>
            <tbody>
            {
                tableCells.map((row, index) => (
                    <tr key={index}>
                        {
                            row.map((d, index) => (
                                <td key={index}
                                    rowSpan={d.rowSpan}>
                                    {d.value}
                                </td>
                            ))
                        }
                    </tr>
                ))
            }
            </tbody>
        </table>
    );
}

export default GroupedTable;

function groupedDataToTableRows(groupedData: IGroupedTableDataNode, initTableRow: TableRow = []): TableRows {
    // TODO 그루핑 데이터를 테이블 행들로 변경하는 함수
  	// 아래에 구현 설명이 있습니다.
}

그루핑 데이터를 테이블 view 데이터로 변환

테이블에 보여져야하는 데이터를 첫번째 행부터 순차적으로 만들면 다음과 같습니다.

// 첫번째 행
const data = [
  [
    {value: '의류', rowSpan: 2},
    {value: '후드', rowSpan: 1},
    {value: '사이즈=M', rowSpan: 1}
  ]
]

// 두번째 행
const data = [
  [
    {value: '의류', rowSpan: 2},
    {value: '후드', rowSpan: 1},
    {value: '사이즈=M', rowSpan: 1}
  ],
  [
    {value: '셔츠', rowSpan: 1},
    {value: '사이즈=L', rowSpan: 1}
  ]
]

// 세번째 행
const data = [
  [
    {value: '의류', rowSpan: 2},
    {value: '후드', rowSpan: 1},
    {value: '사이즈=M', rowSpan: 1}
  ],
  [
    {value: '셔츠', rowSpan: 1},
    {value: '사이즈=L', rowSpan: 1}
  ],
  [
    {value: '음식', rowSpan: 5},
    {value: '감바스', rowSpan: 2},
    {value: '팩=4팩', rowSpan: 1}
  ],
]

// 네번째 행
const data = [
  [
    {value: '의류', rowSpan: 2},
    {value: '후드', rowSpan: 1},
    {value: '사이즈=M', rowSpan: 1}
  ],
  [
    {value: '셔츠', rowSpan: 1},
    {value: '사이즈=L', rowSpan: 1}
  ],
  [
    {value: '음식', rowSpan: 5},
    {value: '감바스', rowSpan: 2},
    {value: '팩=4팩', rowSpan: 1}
  ],
  [
    {value: '팩=2팩', rowSpan: 1}
  ],
]

// 네번째 행
const data = [
  [
    {value: '의류', rowSpan: 2},
    {value: '후드', rowSpan: 1},
    {value: '사이즈=M', rowSpan: 1}
  ],
  [
    {value: '셔츠', rowSpan: 1},
    {value: '사이즈=L', rowSpan: 1}
  ],
  [
    {value: '음식', rowSpan: 5},
    {value: '감바스', rowSpan: 2},
    {value: '팩=4팩', rowSpan: 1}
  ],
  [
    {value: '팩=2팩', rowSpan: 1}
  ],
  [
    {value: '닭강정', rowSpan: 3},
    {value: '양념=없음', rowSpan: 1}
  ],
]

위의 데이터의 형태로 바뀌어야 GroupedTable컴포넌트에서 데이터를 의도한대로 나타낼 수 있습니다.

그루핑 데이터를 위의 코드처럼 view를 위한 데이터로 변환해주는 groupedDataToTableRows 함수입니다.

// table/GroupedTable

function groupedDataToTableRows(groupedData: IGroupedTableDataNode, initTableRow: TableRow = []): TableRows {
    // 그루핑 데이터를 테이블 행들로 변경하는 함수
    // 재귀 함수

    const result: TableRows = [];

    if (groupedData.values.length === 0) {
        // 하위 노드가 없다면 재귀 함수 탈출
        return [initTableRow];
    }

    for (let i = 0; i < groupedData.values.length; i++) {
        const val = groupedData.values[i];

        if (i === 0) {
            // 첫번째라면 initTableRow를 포함해서 행 생성
            const childNode = groupedData.childrenNodes[val];
            const newRow = groupedDataToTableRows(childNode, initTableRow.concat([{value: val, rowSpan: childNode.countLeafNodes()}]));
            result.push(...newRow);
            continue;
        }

        // 두번째 이상의 value라면 initTableRow (이전 값)들을 제외한 현재 노드와 하위노드 추가
        const childNode = groupedData.childrenNodes[val];
        const newRow = groupedDataToTableRows(childNode, [{value: val, rowSpan: childNode.countLeafNodes()}]);
        result.push(...newRow);
    }

    return result;
}


😎 결과

4개의 column으로 데이터를 만들어서 테스트를 하면 다음과 같이 나타납니다.

const data: TableData = [
    {
        '카테고리': '상의',
        '상품': '후드',
        '색상': '블랙',
        '사이즈': 'L'
    },
    {
        '카테고리': '상의',
        '상품': '셔츠',
        '색상': '화이트',
        '사이즈': 'M'
    },
    {
        '카테고리': '상의',
        '상품': '후드',
        '색상': '블랙',
        '사이즈': 'S'
    },
    {
        '카테고리': '하의',
        '상품': '슬랙스',
        '색상': '블랙',
        '사이즈': 'M'
    },
    {
        '카테고리': '하의',
        '상품': '슬랙스',
        '색상': '화이트',
        '사이즈': 'S'
    },
    {
        '카테고리': '하의',
        '상품': '청바지',
        '색상': '블루',
        '사이즈': 'M'
    },
    {
        '카테고리': '상의',
        '상품': '후드',
        '색상': '레드',
        '사이즈': 'L'
    },
    {
        '카테고리': '상의',
        '상품': '후드',
        '색상': '레드',
        '사이즈': 'M'
    },
    {
        '카테고리': '하의',
        '상품': '슬랙스',
        '색상': '블랙',
        '사이즈': 'L'
    },
];

const columns: string[] = ['카테고리', '상품', '색상', '사이즈'];



🤓 인사이트

  • 트리와 DFS를 FE개발하면서 자주 사용하지 않았는데 이번 문제와 같은 상황에서는 배열만으로 구현하는 로직에 비해 쉽게 해결할 수 있었습니다. 상위 요소와 하위 요소간의 관계를 갖는 상황에서 트리를 더 활용해볼 수 있을 것 같습니다.
  • 데이터 간의 변환이 자주 일어났습니다. 변환하는 기능들을 좀 더 쉽게 해줄 모듈(커스텀 훅 또는 라이브러리)가 필요합니다.
  • 핵심로직이 설명없이 보면 이해하기가 힘들었습니다. 동료 또는 미래의 나를 위해서 트리 구조와 DFS 알고리즘처럼 이해가 필요한 코드는 자세한 설명 문서 또는 코멘트가 필요하다는 것을 느꼈습니다.
profile
언젠가 보게 된다. 기록하자 😡🔥🔥

0개의 댓글