MAKE IT SIMPLE

React로 동적 피벗 테이블 만들기: 트리 모델링부터 rowSpan까지 본문

React

React로 동적 피벗 테이블 만들기: 트리 모델링부터 rowSpan까지

빈빠끄 2026. 5. 30. 18:50

동적 데이터셋 테이블, 어디까지 만들어봤니

행/열/값을 사용자가 마음대로 끌어다 놓으면 알아서 피벗되고, 소계·합계·총계까지 끼워 넣는 테이블을 React로 만들면서 정리한 기록.

1. 들어가며 — 단순해 보였던 요구사항

"엑셀 피벗 같은 거 화면에서 보여주면 돼요."

처음 받은 한 줄. 가볍게 시작했는데, 막상 펴 보니 정적 테이블과는 완전히 다른 영역이었다. 사용자는 같은 데이터셋을 가지고 어떨 땐 단순 목록으로 보고 싶고, 어떨 땐 부서×월로 묶어서 보고, 또 어떨 땐 거기에 소계와 총계까지 끼워서 보고 싶어 한다. 그리고 모든 모드가 같은 컴포넌트에서 매끄럽게 전환돼야 한다.

이 글은 그 요구사항을 풀면서 만든 <Grid /> 컴포넌트와 useCreateTableV2 훅의 설계·구현을 정리한 회고다. 일부 헬퍼는 재귀가 두 번 꺾이는 구간이 있어서 코드를 그대로 옮기되, 핵심 아이디어를 먼저 설명하는 식으로 풀어 본다.

2. 요구사항 — 정확히 무엇을 만들어야 했나

만들어야 했던 동작을 한 줄로 줄이면 이렇다.

  • 라인 아이템(ILineItem[]) 하나를 입력으로
  • 사용자가 선택한 행 그룹 / 열 그룹 / 값 그룹에 따라
  • 다음 세 가지 렌더링 모드 중 하나로 출력한다(나머지는 fallback).
    1. 그룹 없음 → 기본 표
    2. 행 그룹만 → 계층형 행 테이블
    3. 행+열+값 → 피벗 테이블

여기에 옵션으로 세 가지 집계를 켜고 끌 수 있어야 했다.

  • 소계(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.

요약하면 세 단계로 일한다.

  1. traverse — 객체 트리를 깊이 우선으로 돌면서 GridGroup 노드를 만든다. 키 경로는 parent_child 식으로 누적해서 leaf까지 고유한 key를 가지도록.
  2. 소계 삽입showTotal: true로 표시된 그룹의 index와 현재 traverse depth가 일치하는 시점에 '소계' 노드를 children 마지막에 끼워 넣는다. axis가 'col'이면 그냥 push, 'row'면 추가로 colSpan을 계산해서 단일 셀로 펼친다.
  3. 총계 삽입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" 가 자연스럽게 한 줄에 펼쳐진다. 그리고 나머지 자식들은 renderRowslice(1)로 떼서 다음 줄로 보낸다.

처음엔 nested <tr> 만들어서 처리하려다가 HTML 스펙상 그게 안 돼서 "한 행에 들어갈 td들을 하나의 평면 배열로 만든 뒤 그 다음 행을 별도로 push" 하는 패턴으로 바꿨다. 코드는 평범한 재귀로 보이지만, 결정적으로 renderFirstChildrenderRow가 책임을 나눠 가지는 게 핵심 구조다.

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 colItemsid 교집합으로 묶고, 그 교집합 안에서 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') 같은 문자열 판별이 자라났다. 처음엔 빠르고 깔끔해 보였지만, "총계 행만 색을 다르게"처럼 정책이 늘어날 때마다 판별 위치를 또 추가하게 된다. GridGroupkind: 'data' | 'subtotal' | 'semiTotal' | 'total' 같은 명시적 필드를 둔 뒤 스타일·집계 분기를 그 위에서 하는 게 옳은 방향이다.
  • getGroupedData의 비용
    지금 구조는 사실상 rowLeaf × colLeaf × value × itemScan이 곱해진다. 라인 아이템이 수만 건 넘어가면 체감된다. 라인 아이템을 id → item Map으로, 또는 그룹 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 참고.

Comments