MAKE IT SIMPLE
React로 동적 피벗 테이블 만들기: 트리 모델링부터 rowSpan까지 본문
동적 데이터셋 테이블, 어디까지 만들어봤니
행/열/값을 사용자가 마음대로 끌어다 놓으면 알아서 피벗되고, 소계·합계·총계까지 끼워 넣는 테이블을 React로 만들면서 정리한 기록.
1. 들어가며 — 단순해 보였던 요구사항
"엑셀 피벗 같은 거 화면에서 보여주면 돼요."
처음 받은 한 줄. 가볍게 시작했는데, 막상 펴 보니 정적 테이블과는 완전히 다른 영역이었다. 사용자는 같은 데이터셋을 가지고 어떨 땐 단순 목록으로 보고 싶고, 어떨 땐 부서×월로 묶어서 보고, 또 어떨 땐 거기에 소계와 총계까지 끼워서 보고 싶어 한다. 그리고 모든 모드가 같은 컴포넌트에서 매끄럽게 전환돼야 한다.
이 글은 그 요구사항을 풀면서 만든 <Grid /> 컴포넌트와 useCreateTableV2 훅의 설계·구현을 정리한 회고다. 일부 헬퍼는 재귀가 두 번 꺾이는 구간이 있어서 코드를 그대로 옮기되, 핵심 아이디어를 먼저 설명하는 식으로 풀어 본다.
2. 요구사항 — 정확히 무엇을 만들어야 했나
만들어야 했던 동작을 한 줄로 줄이면 이렇다.
- 라인 아이템(
ILineItem[]) 하나를 입력으로 - 사용자가 선택한 행 그룹 / 열 그룹 / 값 그룹에 따라
- 다음 세 가지 렌더링 모드 중 하나로 출력한다(나머지는 fallback).
- 그룹 없음 → 기본 표
- 행 그룹만 → 계층형 행 테이블
- 행+열+값 → 피벗 테이블
여기에 옵션으로 세 가지 집계를 켜고 끌 수 있어야 했다.
- 소계(subtotal): 특정 그룹 단위 안에서의 합. 예)
부서안 직급별 행이 끝나는 자리에 들어가는 "영업팀 소계". - 합계(semi total): 행/열 그룹 한 축 전체에 대한 합. 예) 모든 부서 행 그룹을 묶은 "전체 행 합계".
- 총계(total): 행×열 매트릭스 전체에 대한 합. 표 우하단 한 셀.
셀 병합(rowSpan/colSpan)도 정확히 맞아야 했다. 행이 2단 그룹이면 1단 셀은 자식 셀 수만큼 세로 병합, 열도 마찬가지.
작은 예시 하나로 이후 설명의 기준을 잡자.
rowGroup = ['부서', '직급']
colGroup = ['월']
valueGroup = ['매출']
showRowsTotal = true
이 입력은 대략 이런 표가 된다.
| 부서 | 직급 | 1월 | 2월 | 합계 |
|---|---|---|---|---|
| 영업팀 | 사원 | 100 | 120 | 220 |
| 영업팀 | 대리 | 80 | 90 | 170 |
| 영업팀 | 소계 | 180 | 210 | 390 |
| 개발팀 | 사원 | 200 | 150 | 350 |
| ... | ... | ... | ... | ... |
| 총계 | ... | ... | ... |
이후 rowSpan, colSpan, getGroupedData 이야기를 할 때 머릿속에 이 표를 띄워두면 한결 덜 추상적이다.
3. 데이터 모델 — 트리가 답이다
요구사항을 며칠 보다가 결론이 났다. 계층은 트리로 모델링하고, 렌더링은 트리를 평면화하는 과정이다.
export type GridGroup = {
title: string;
key: string;
index?: number;
children?: GridGroup[];
items?: ILineItem[];
order?: number;
colSpan?: number;
};
GridGroup은 행/열 모두에 쓰는 공통 노드. 리프 노드는 items를 들고 있고, 내부 노드는 children을 들고 있다. 여기까지는 흔한 트리. 다만 key에 부모-자식 경로를 _ 로 이어 붙여 평면화된 고유 키를 만들어 둔 게 포인트다. 이 키가 뒤에서 데이터 매핑의 기준이 된다.
데이터는 GridData로 별도 분리한다.
export type GridData = {
division?: string;
[key: string]: ItemValueType;
};
division은 어떤 행 노드에 매핑되는 데이터인지 식별하는 키, 나머지 키는 어떤 열 노드에 들어갈 값인지 식별하는 키. 즉 트리 두 개(행/열)와 셀 데이터 한 개(행 키 → 열 키 → 값)로 모델을 완전히 분리했다.
4. 입력에서 트리까지 — groupByHierarchical
사용자가 rowGroup: [부서, 직급] 을 골랐다고 하자. 라인 아이템 배열을 이 키 순서대로 중첩 그룹화해야 한다. Array.prototype.reduce를 두 번 재귀로 돌리는 게 핵심.
export const groupByHierarchical = (data, keys) => {
const groupByRecursively = (items, remainingKeys) => {
if (remainingKeys.length === 0) return items;
const [currentKey] = remainingKeys;
return items.reduce((result, item) => {
const groupKey = item[currentKey];
(result[groupKey] ??= []).push(item);
return result;
}, {});
};
const nestedGroupBy = (groupedData, keys) => {
if (keys.length === 0) return groupedData;
const [currentKey, ...nextKeys] = keys;
for (const key in groupedData) {
if (Array.isArray(groupedData[key])) {
groupedData[key] = groupByRecursively(groupedData[key], [currentKey]);
nestedGroupBy(groupedData[key], nextKeys);
}
}
return groupedData;
};
const initial = groupByRecursively(data, [keys[0]]);
return nestedGroupBy(initial, keys.slice(1));
};
처음엔 한 번에 다 묶으려고 reduce 하나로 짰는데 코드가 너무 꼬여서 "먼저 한 단계 묶고, 그 다음에 단계별로 더 묶어 내려가는" 두 단계 구조로 풀었다. 결과는 { 영업팀: { 사원: [...], 대리: [...] }, 개발팀: {...} } 같은 중첩 객체. 이게 곧 트리 변환의 입력이 된다.
5. 핵심 변환 — transformToGridGroup
groupByHierarchical의 결과는 객체 트리지만 렌더링이 알 수 있는 모양은 아니다. 이걸 GridGroup 트리로 옮기면서 동시에 소계/합계 노드를 끼워 넣는 일을 한 함수에서 처리하는 게 transformToGridGroup.
요약하면 세 단계로 일한다.
- traverse — 객체 트리를 깊이 우선으로 돌면서
GridGroup노드를 만든다. 키 경로는parent_child식으로 누적해서 leaf까지 고유한 key를 가지도록. - 소계 삽입 —
showTotal: true로 표시된 그룹의index와 현재 traverse depth가 일치하는 시점에'소계'노드를 children 마지막에 끼워 넣는다. axis가'col'이면 그냥 push,'row'면 추가로colSpan을 계산해서 단일 셀로 펼친다. - 총계 삽입 —
showTotal: true로 들어오면 트리 루트 옆에'합계'(중간 집계) 또는'총계'(최종 집계)를 형제 노드로 추가한다.axis_total,axis_semi_total같은 prefix로 키를 만든 게 렌더링 시점에'!bg-[#C1C4CF]'회색 처리를 분기하는 신호가 된다.
여기서 한 번 헤맸던 부분이 children 정렬. 진짜 문제는 객체 key 순서의 불안정성이 아니라 자식 노드 순서가 도메인 원본 순서(라인 아이템에 들어온 순서)를 따라야 했다는 것. 그룹화 과정에서는 그 순서 정보가 자연스럽게 소실되니까 별도로 들고 다녀야 했다. 그래서 groupsOrderMap에 leaf에 도달했을 때의 RN(원본 row number) 최대값을 기록해 두고, 부모로 올라오면서 그 값으로 정렬하는 우회로를 만들었다. 우아하진 않지만 동작은 안정적이다.
6. rowSpan/colSpan — 트리를 펴면 자연스럽게 나온다
렌더링 시점의 셀 병합은 트리 구조에서 직접 계산된다.
export const getRowSpan = (row: GridGroup): number => {
if (!row.children || !row.children.length) return 1;
return row.children.reduce((depth, child) => depth + getRowSpan(child), 0);
};
리프면 1, 아니면 자식들의 rowSpan 합. 한 줄짜리 재귀인데 그림으로 그려보면 부모 셀이 자식 leaf 개수만큼 세로로 합쳐진다는 정의를 그대로 옮긴 것. 같은 방식으로 getMaxDepth가 열 헤더의 rowSpan 기준을 잡아주고, getColSpan이 첫 번째 행 헤더 자리(idx === 0)의 가로 합치기를 처리한다.
이 부분이 트리 모델로 풀길 잘했다고 느낀 지점이다. DOM 기반으로 셀 병합을 손으로 계산하기 시작하면 행/열 교차점 케이스에서 반드시 무너진다.
7. 렌더링 — renderFirstChild의 작은 트릭
행 그룹이 2단 이상이면 한 <tr> 안에 부모 셀과 자식 셀이 같이 그려져야 한다. 첫 번째 자식만 부모와 같은 행에 그리고, 두 번째 이후 자식은 다음 행으로 내려가는 패턴.
const renderFirstChild = (row) => {
if (!row) return [];
const children = row.children ?? [];
const firstChild = children[0] ?? null;
return [
<td rowSpan={getRowSpan(row)} ...>{row.title}</td>,
...renderFirstChild(firstChild),
...renderData(row),
];
};
renderFirstChild는 자기 자신을 첫 번째 자식으로 재귀한다. 이러면 어느 깊이든 "맨 왼쪽 첫 row" 가 자연스럽게 한 줄에 펼쳐진다. 그리고 나머지 자식들은 renderRow가 slice(1)로 떼서 다음 줄로 보낸다.
처음엔 nested <tr> 만들어서 처리하려다가 HTML 스펙상 그게 안 돼서 "한 행에 들어갈 td들을 하나의 평면 배열로 만든 뒤 그 다음 행을 별도로 push" 하는 패턴으로 바꿨다. 코드는 평범한 재귀로 보이지만, 결정적으로 renderFirstChild와 renderRow가 책임을 나눠 가지는 게 핵심 구조다.
8. 데이터 매핑 — getGroupedData
트리는 완성됐고, 이제 셀 값을 채울 차례. getGroupedData는 행 트리(rows)를 leaf까지 내려가면서 각 leaf의 items에서 값을 합산하고, 열 키(columns 맵의 key)에 매칭되는 라인 아이템만 골라 셀 값을 만든다.
for (const colKey of columnKeys) {
const colItems = columns[colKey];
for (const valueKey of values) {
const valueItems = colItems.filter(({ id }) =>
(items ?? []).find((item) => id === item.id),
);
const value = valueItems.reduce(/* sum */);
row[colKey] = value;
}
}
행 트리의 leaf items와 열 트리의 leaf colItems를 id 교집합으로 묶고, 그 교집합 안에서 values를 합산. 단순한데, 행/열 둘 다 트리이기 때문에 leaf만 보고 sum해도 부모 셀의 값은 비어 있는 게 아니라 — 그 자리에 자식 셀이 직접 들어가니까 — 자연스럽게 채워진다.
col_total 같은 합계 컬럼은 루프 도중 누적해서 따로 채워 넣는다. 합계 컬럼 키는 transformToGridGroup이 미리 prefix로 표시해 둔 약속이 있어서 식별이 쉬웠다.
9. 모드 분기 — useCreateTableV2
훅의 getTableData는 결국 위의 조각들을 모드별로 갈아 끼우는 디스패처다.
if (!colGroup.length && !rowGroup.length && !valueGroup.length) {
return getBasicGridData(...);
}
if (!colGroup.length && rowGroup.length && valueGroup.length) {
return getOnlyRowGroupGridData(...);
}
if (colGroup.length && rowGroup.length && valueGroup.length) {
return getPivotGridData(...);
}
return { columns: [], rows: [], data: [], amountUnit };
분기 자체는 단순하지만, 각 함수가 같은 형태(columns/rows/data) 를 반환하기 때문에 <Grid />는 어느 모드에서 왔는지 알 필요가 없다. 이게 컴포넌트를 단순하게 유지해 준 핵심 결정.
처음엔 한 함수에서 분기 다 처리하려다가 if-else가 5단 깊이로 들어가는 걸 보고 모드별로 함수를 쪼갰다. 모드를 새로 추가할 일이 (당시엔) 명확히 보이지 않아서 인터페이스를 너무 일찍 추상화하지 않은 것도 의도된 선택.
10. 회고 — 잘한 것, 다음에 다르게 할 것
잘한 선택
- 트리 모델로 통일. 행/열을 같은
GridGroup타입으로 묶은 게 rowSpan/colSpan 계산, 소계 삽입, 데이터 매핑 모두에서 일관성을 줬다. - 헬퍼와 훅, 컴포넌트의 책임 분리. 헬퍼는 순수 함수, 훅은 모드 디스패치, 컴포넌트는 트리→DOM 변환. 이 경계가 흐려졌으면 디버깅이 훨씬 어려웠을 것.
- 모드별 반환 형태 통일. 어느 모드에서 왔든
<Grid />가 받는 입력 모양이 같아서 컴포넌트 자체는 모드를 알 필요가 없었다.
다음에 다르게 할 것
- 키 prefix에 의미를 숨긴 것
_subtotal1,col_total,axis_semi_total같은 prefix로 셀의 성격을 식별하다 보니, 렌더링 쪽에서key.includes('subtotal'),key.includes('total')같은 문자열 판별이 자라났다. 처음엔 빠르고 깔끔해 보였지만, "총계 행만 색을 다르게"처럼 정책이 늘어날 때마다 판별 위치를 또 추가하게 된다.GridGroup에kind: 'data' | 'subtotal' | 'semiTotal' | 'total'같은 명시적 필드를 둔 뒤 스타일·집계 분기를 그 위에서 하는 게 옳은 방향이다. - getGroupedData의 비용
지금 구조는 사실상rowLeaf × colLeaf × value × itemScan이 곱해진다. 라인 아이템이 수만 건 넘어가면 체감된다. 라인 아이템을id → itemMap으로, 또는 그룹 leaf의 id 집합을Set으로 미리 만들어 두면 교집합 비용이 크게 줄어든다. O(n²) → O(n) 같은 단순 표현보다는 "id 인덱싱으로 교집합 비용을 줄인다"가 더 정확한 설명이다. - 암묵 의존성
transformToGridGroup의 정렬은 입력 라인 아이템에RN(row number)이 박혀 있어야 동작하는데, 함수 시그니처에는 그게 드러나지 않는다. 다음에 손대는 사람이 분명히 헤맨다. 정렬 키는 명시적 인자로 빼야 한다. - 불안정한 React key
셀 렌더링에서uuidv4()를 key로 쓰는 곳이 두 군데 있다. 매 렌더마다 새 key가 생기면 React 입장에선 매번 다른 노드다. division/colKey 조합 같은 안정 키로 바꾸는 게 맞다. - 테스트 매트릭스 부족
단위 테스트(src/__tests__/unit.test.ts) 자체는 있지만, V2 헬퍼와 렌더링 매트릭스 — 모드 3종 × 소계/합계/총계 on/off — 를 가로지르는 골든 테스트가 부족하다. 헬퍼 단위로 입력→출력 골든을 짜 두면 다음 손댈 때 안심이 다르다.
11. 마치며
"피벗 테이블 비슷한 거"의 가벼움과 실제 구현의 무게 차이는 꽤 컸다. 다만 한 번 트리 모델로 정리해 두니, 새 옵션(예: 합계 행 색상 분리, 컬럼 정렬, 셀 포맷터 주입)을 붙일 때는 의외로 손댈 부분이 적었다.
쓰면서 가장 또렷이 남은 교훈은 진부하지만 이거다. "렌더링을 잘 하려면 먼저 데이터를 잘 그려라." 트리가 정확하면 DOM은 따라온다. 트리가 어설프면 DOM에서 if문이 자란다.
다음 글에선 이 컴포넌트 위에서 가상화/내보내기까지 붙이며 만난 문제를 정리해 볼 생각이다.
이 글의 코드는 단순화를 위해 일부 타입 캐스팅과 가드를 생략했다. 실제 구현은 Github 참고.
'React' 카테고리의 다른 글
| SSE 기반 LLM 응답 스트리밍 구현기 (1) | 2026.05.19 |
|---|---|
| EventBus에 대해서 알아야 할것들 (feat. 잘쓰면 넘나 좋다) (5) | 2026.01.19 |
| 프론트엔드가 복잡해질수록 먼저 무너지는 것: 상태 관리 (2) | 2025.12.30 |
| Error: Image Optimization using Next.js' default loader is not compatible with next export. (0) | 2022.02.10 |