트리 폴딩 (Tree Folding)

평면 데이터의 부모 키 관계를 트리로 표시하고 행을 안전하게 접고 펼치는 방법을 설명합니다.

#tree#folding#parent-row-key#virtual-scroll#accessibility
지원 버전: v1.0.12+검토일: 2026-09-13
LIVE DEMO트리 폴딩 (Tree Folding) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
GitHub
import * as React from 'react';
import { ChevronDown, ChevronRight, File } from 'lucide-react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridDataQuery } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { exampleMsg, t } from './i18n';

interface OrganizationRow {
  id: string;
  parentId: string | null;
  name: string;
  type: string;
  owner: string;
}

const data: BGridDataItem<OrganizationRow>[] = [
  {
    values: {
      id: 'platform',
      parentId: null,
      name: t('플랫폼 본부', 'Platform'),
      type: t('본부', 'Division'),
      owner: 'Mina',
    },
  },
  {
    values: {
      id: 'frontend',
      parentId: 'platform',
      name: t('프론트엔드 팀', 'Frontend'),
      type: t('팀', 'Team'),
      owner: 'Alex',
    },
  },
  { values: { id: 'grid', parentId: 'frontend', name: 'BeautifulGrid', type: t('프로젝트', 'Project'), owner: 'Jin' } },
  {
    values: {
      id: 'design',
      parentId: 'frontend',
      name: t('디자인 시스템', 'Design System'),
      type: t('프로젝트', 'Project'),
      owner: 'Sora',
    },
  },
  {
    values: {
      id: 'backend',
      parentId: 'platform',
      name: t('백엔드 팀', 'Backend'),
      type: t('팀', 'Team'),
      owner: 'Noah',
    },
  },
  {
    values: {
      id: 'commerce',
      parentId: null,
      name: t('커머스 본부', 'Commerce'),
      type: t('본부', 'Division'),
      owner: 'Yuna',
    },
  },
  {
    values: { id: 'orders', parentId: 'commerce', name: t('주문 팀', 'Orders'), type: t('팀', 'Team'), owner: 'Liam' },
  },
];

// Keep the demo large enough to exercise hierarchy projection and virtual scrolling together.
const initialExpandedRowKeys: React.Key[] = ['platform', 'frontend', 'division-1', 'division-1-team-1'];
for (let divisionIndex = 1; divisionIndex <= 50; divisionIndex++) {
  const divisionId = `division-${divisionIndex}`;
  data.push({
    values: {
      id: divisionId,
      parentId: null,
      name: t(`사업 본부 ${divisionIndex}`, `Business division ${divisionIndex}`),
      type: t('본부', 'Division'),
      owner: `Owner ${divisionIndex}`,
    },
  });
  for (let teamIndex = 1; teamIndex <= 10; teamIndex++) {
    const teamId = `${divisionId}-team-${teamIndex}`;
    data.push({
      values: {
        id: teamId,
        parentId: divisionId,
        name: t(`팀 ${divisionIndex}-${teamIndex}`, `Team ${divisionIndex}-${teamIndex}`),
        type: t('팀', 'Team'),
        owner: `Lead ${divisionIndex}-${teamIndex}`,
      },
    });
    for (let projectIndex = 1; projectIndex <= 20; projectIndex++) {
      data.push({
        values: {
          id: `${teamId}-project-${projectIndex}`,
          parentId: teamId,
          name: t(
            `프로젝트 ${divisionIndex}-${teamIndex}-${projectIndex}`,
            `Project ${divisionIndex}-${teamIndex}-${projectIndex}`,
          ),
          type: t('프로젝트', 'Project'),
          owner: `Member ${projectIndex}`,
        },
      });
    }
  }
}

const columns: BGridColumn<OrganizationRow>[] = [
  {
    id: 'name',
    key: 'name',
    label: t('조직·프로젝트', 'Organization & project'),
    width: 260,
    toolbox: true,
    filter: { type: 'text' },
  },
  { id: 'type', key: 'type', label: t('구분', 'Type'), width: 120, toolbox: true, filter: { type: 'values' } },
  { id: 'owner', key: 'owner', label: t('담당자', 'Owner'), width: 120, toolbox: true, filter: { type: 'text' } },
];

export default function TreeFoldingExample() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const [expandedRowKeys, setExpandedRowKeys] = React.useState<React.Key[]>(() => [...initialExpandedRowKeys]);
  const [query, setQuery] = React.useState<BGridDataQuery>({ sortParams: [], filterParams: [] });

  return (
    <DataGridContainer ref={containerRef}>
      <BGrid
        width={width}
        height={height}
        columns={columns}
        data={data}
        rowKey='id'
        msg={exampleMsg}
        tree={{
          parentRowKey: 'parentId',
          treeColumnId: 'name',
          expandedRowKeys,
          onExpandedRowKeysChange: setExpandedRowKeys,
          icons: {
            expanded: <ChevronDown size={14} />,
            collapsed: <ChevronRight size={14} />,
            leaf: <File size={12} />,
          },
        }}
        dataControl={{ mode: 'client', query, onChange: setQuery }}
        status={{
          content: ({ visibleItems }) =>
            t(
              `원본 ${data.length.toLocaleString()}행 · 현재 ${visibleItems.toLocaleString()}행`,
              `${data.length.toLocaleString()} source rows · ${visibleItems.toLocaleString()} visible`,
            ),
        }}
        variant='vertical-bordered'
      />
    </DataGridContainer>
  );
}

평면 데이터를 트리로 표시하기

트리 모드는 별도 컴포넌트가 아니라 기존 <BGrid>의 tree prop으로 활성화합니다. rowKey는 현재 행의 고유 키 필드를 지정하고, tree.parentRowKey는 부모의 rowKey 값이 들어 있는 필드를 지정합니다.

import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';

interface Row {
  id: string;
  parentId: string | null;
  name: string;
}

const columns: BGridColumn<Row>[] = [{ id: 'name', key: 'name', label: '이름', width: 240 }];

const data: BGridDataItem<Row>[] = [
  { values: { id: 'root', parentId: null, name: '전자제품' } },
  { values: { id: 'laptop', parentId: 'root', name: '노트북' } },
];

<BGrid
  width={640}
  height={360}
  columns={columns}
  data={data}
  rowKey='id'
  tree={{
    parentRowKey: 'parentId',
    treeColumnId: 'name',
  }}
/>;

tree.treeColumnId는 데이터 필드 key가 아니라 BGridColumn.id를 참조합니다. 이 컬럼의 각 셀에 단계별 들여쓰기와 펼침·접힘 아이콘이 표시됩니다. 생략하거나 현재 숨겨진 컬럼을 지정하면 첫 번째 표시 컬럼을 사용합니다.

상단 라이브 데모는 3단계, 10,557개의 원본 행을 사용하는 예제입니다. 일부 가지를 펼친 초기 상태로 접기·펼치기 반응을 쉽게 확인할 수 있으며, 더 많은 행이 표시되어도 가상 스크롤이 실제 DOM을 현재 화면에 필요한 행으로 제한합니다.

펼침 상태 제어

defaultExpandedRowKeys는 그리드가 상태를 소유하는 초기값입니다. 외부에서 상태를 제어하려면 expandedRowKeys와 onExpandedRowKeysChange를 함께 사용합니다.

const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(['root']);

<BGrid
  {...gridProps}
  rowKey='id'
  tree={{
    parentRowKey: 'parentId',
    expandedRowKeys,
    onExpandedRowKeysChange: setExpandedRowKeys,
  }}
/>;

callback 이벤트에는 토글한 rowKey, 다음 expanded 상태, 원본 item, 접힘 상태와 무관하게 유지되는 sourceIndex가 전달됩니다.

아이콘과 간격

tree={{
  parentRowKey: 'parentId',
  treeColumnId: 'name',
  indentSize: 20,
  icons: {
    expanded: <ChevronDown />,
    collapsed: <ChevronRight />,
    leaf: <File />,
  },
}}

leaf 아이콘을 생략해도 텍스트 정렬을 위한 동일 너비의 빈 공간은 유지됩니다. 아이콘 버튼의 클릭과 키보드 동작, aria-expanded는 그리드가 처리합니다.

정렬·필터와 원본 인덱스

클라이언트 정렬은 부모와 자식을 분리하지 않고 같은 부모의 형제 안에서만 적용됩니다. 필터에 자식이 일치하면 그 자식까지의 조상 경로를 임시로 함께 표시하지만 controlled 펼침 배열은 변경하지 않습니다.

화면의 visibleIndex는 폴딩에 따라 달라질 수 있습니다. 행 체크와 편집 callback은 원본 평면 배열의 sourceIndex를 유지하며, 편집의 meta.tree.path에는 루트부터 해당 행까지의 row key 경로가 전달됩니다.

제한 사항

  • 트리 모드에서는 행 재배치와 셀 병합이 비활성화됩니다.
  • Pivot 모드가 활성화되면 트리 모드는 비활성화됩니다.
  • 비동기 자식 로딩과 부모 선택의 자식 자동 선택은 현재 지원하지 않습니다.
  • 부모를 찾을 수 없는 행은 루트로 표시합니다.
  • 누락·중복 row key 또는 순환 관계가 발견되면 개발 환경에서 경고하고 평면 그리드로 안전하게 전환합니다.