다단 그룹 헤더 (Column Groups)

컬럼 ID를 참조하는 트리로 3단 이상의 그룹 헤더를 만들고 고정 컬럼 경계에서도 안전하게 사용하는 방법을 학습합니다.

#columnGroups#AXDGColumnGroupNode#nested-header#frozenColumnIndex#headerHeight
검토일: 2026-08-19
GitHub
import * as React from 'react';
import { AXDataGrid } from '@axboot/datagrid';
import type { AXDGColumn, AXDGColumnGroupNode } from '@axboot/datagrid';
import { Select } from 'antd';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
import './ColumnsGroupExample.css';

interface Order {
  orderNo: string;
  customerName: string;
  region: string;
  productName: string;
  category: string;
  quantity: number;
  unitPrice: number;
  total: number;
}

const data = Array.from({ length: 100 }, (_, index) => {
  const quantity = (index % 8) + 1;
  const unitPrice = 12000 + (index % 5) * 3500;
  return {
    values: {
      orderNo: `ORD-${String(2401 + index).padStart(4, '0')}`,
      customerName: ['서울상사', '한빛물산', 'Northwind'][index % 3],
      region: ['서울', '부산', '대전'][index % 3],
      productName: ['Workspace Pro', 'Analytics Seat', 'Automation Pack'][index % 3],
      category: ['Software', 'License', 'Service'][index % 3],
      quantity,
      unitPrice,
      total: quantity * unitPrice,
    },
  };
});

const columnGroups: AXDGColumnGroupNode[] = [
  {
    id: 'order-overview',
    label: '주문 현황',
    className: 'column-groups-header-overview',
    children: [
      {
        id: 'order-customer',
        label: '주문·고객 정보',
        children: [
          'orderNo',
          {
            id: 'customer-detail',
            label: '고객 상세',
            className: 'column-groups-header-customer',
            children: ['customerName', 'region'],
          },
        ],
      },
      {
        id: 'product-sales',
        label: '상품·매출 정보',
        children: [
          {
            id: 'product-detail',
            label: '상품 상세',
            children: ['productName', 'category'],
          },
          {
            id: 'sales-detail',
            label: '매출 상세',
            headerStyle: {
              backgroundColor: '#ffedd5',
              color: '#9a3412',
            },
            children: ['quantity', 'unitPrice', 'total'],
          },
        ],
      },
    ],
  },
];

const initialColumns: AXDGColumn<Order>[] = [
  { id: 'orderNo', key: 'orderNo', label: '주문 번호', width: 140 },
  { id: 'customerName', key: 'customerName', label: '고객명', width: 150 },
  { id: 'region', key: 'region', label: '지역', width: 100, align: 'center' },
  { id: 'productName', key: 'productName', label: '상품', width: 170 },
  { id: 'category', key: 'category', label: '분류', width: 120, align: 'center' },
  { id: 'quantity', key: 'quantity', label: '수량', width: 90, align: 'right' },
  {
    id: 'unitPrice',
    key: 'unitPrice',
    label: '단가',
    width: 120,
    align: 'right',
    itemRender: ({ value }) => <>{Number(value).toLocaleString()}원</>,
  },
  {
    id: 'total',
    key: 'total',
    label: '합계',
    width: 140,
    align: 'right',
    headerClassName: 'column-groups-header-total',
    itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
  },
];

export default function ColumnsGroupExample() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const [columns, setColumns] = React.useState(initialColumns);
  const [groups, setGroups] = React.useState(columnGroups);
  const [frozenColumnIndex, setFrozenColumnIndex] = React.useState(4);
  const frozenBoundaryColumn = columns[frozenColumnIndex - 1];

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700'>
        <label className='inline-flex items-center gap-2 font-medium'>
          <span>틀고정 경계</span>
          <Select<number>
            aria-label='틀고정 위치'
            style={{ minWidth: 210 }}
            value={frozenColumnIndex}
            options={[
              { value: 0, label: '고정 없음' },
              ...columns.map((column, index) => ({
                value: index + 1,
                label: `${index + 1}개 · ${column.label} 뒤`,
              })),
            ]}
            onChange={setFrozenColumnIndex}
          />
        </label>
        <p className='m-0 text-slate-600' aria-live='polite'>
          {frozenColumnIndex > 0 && frozenBoundaryColumn ? (
            <>
              앞쪽 {frozenColumnIndex}개 컬럼을 고정했습니다. <strong>{frozenBoundaryColumn.label}</strong> 뒤가
              경계입니다.
            </>
          ) : (
            '현재는 고정 컬럼 없이 모든 컬럼이 함께 스크롤됩니다.'
          )}
        </p>
      </div>

      <DataGridContainer ref={containerRef} style={{ height: 560 }}>
        <AXDataGrid<Order>
          className='column-groups-example-grid'
          width={width}
          height={height}
          data={data}
          frozenColumnIndex={frozenColumnIndex}
          headerHeight={96}
          itemHeight={24}
          itemPadding={6}
          columns={columns}
          columnGroups={groups}
          columnSortable
          onChangeColumns={(_, info) => {
            setColumns(info.columns);
            if (info.columnGroups) setGroups(info.columnGroups);
          }}
          rowChecked={{ checkedIndexes: [], onChange: () => undefined }}
          showLineNumber
        />
      </DataGridContainer>
    </div>
  );
}

트리 기반 그룹 정의

columns는 렌더링 순서를 결정하는 평면 배열로 유지하고, columnGroups가 컬럼 ID를 참조하는 트리를 만듭니다. 그룹 안에는 컬럼 ID와 다른 그룹을 깊이 제한 없이 배치할 수 있습니다.

const columns: AXDGColumn<Order>[] = [
  { id: 'orderNo', key: 'orderNo', label: '주문 번호', width: 140 },
  { id: 'customerName', key: 'customerName', label: '고객명', width: 150 },
  { id: 'region', key: 'region', label: '지역', width: 100 },
  { id: 'productName', key: 'productName', label: '상품', width: 170 },
];

const columnGroups: AXDGColumnGroupNode[] = [
  {
    id: 'order-overview',
    label: '주문 현황',
    children: [
      'orderNo',
      {
        id: 'customer',
        label: '고객 정보',
        children: [
          {
            id: 'customer-detail',
            label: '고객 상세',
            children: ['customerName', 'region'],
          },
          'productName',
        ],
      },
    ],
  },
];

<AXDataGrid columns={columns} columnGroups={columnGroups} headerHeight={88} {...props} />;

헤더 셀 스타일링

leaf 컬럼은 headerClassName 또는 headerStyle, 그룹 노드는 className 또는 headerStyle로 각각 스타일링할 수 있습니다. 클래스 방식은 hover 상태나 테마처럼 여러 규칙을 함께 관리할 때 적합하고, headerStyle은 한 셀의 간단한 동적 스타일을 지정할 때 유용합니다. 고정 컬럼 영역에 복제되는 헤더에도 같은 클래스와 스타일이 적용됩니다.

const columns: AXDGColumn<Order>[] = [
  {
    id: 'total',
    key: 'total',
    label: '합계',
    width: 140,
    headerClassName: 'order-grid-header-total',
  },
];

const columnGroups: AXDGColumnGroupNode[] = [
  {
    id: 'sales',
    label: '매출 정보',
    className: 'order-grid-header-sales',
    headerStyle: { color: '#166534' },
    children: ['total'],
  },
];

<AXDataGrid className='order-grid' columns={columns} columnGroups={columnGroups} {...props} />;
.order-grid .axdg-head-group-cell.order-grid-header-sales {
  background-color: #dcfce7;
}

.order-grid .axdg-head-cell.order-grid-header-total {
  --axdg-header-hover-bg: #fef08a;

  background-color: #fef9c3;
  color: #854d0e;
}

headerAlignheaderStyle.textAlign을 모두 지정하면 정렬 전용 속성인 headerAlign이 우선합니다. 정렬 가능한 leaf 헤더의 hover 배경은 셀 클래스에서 --axdg-header-hover-bg를 재정의할 수 있습니다.

컬럼 keyid의 차이

keyid는 비슷해 보이지만 담당하는 역할이 다릅니다.

  • key: item.values에서 셀 값을 읽을 데이터 경로입니다. 최상위 필드는 문자열('status'), 중첩 필드는 문자열 배열(['customer', 'address', 'city'])로 지정합니다.
  • id: 그리드가 컬럼을 구분하는 안정적인 고유 식별자입니다. columnGroups의 leaf 참조와 정렬·필터 상태를 연결할 때 사용하며, 실제 데이터 값을 읽는 경로에는 관여하지 않습니다.

따라서 같은 데이터 필드를 서로 다른 방식으로 보여 주는 컬럼은 key가 같아도 각각 다른 id를 가질 수 있습니다. 반대로 id는 전체 컬럼에서 중복되지 않아야 합니다.

const columns: AXDGColumn<Order>[] = [
  { id: 'amount-raw', key: 'amount', label: '금액', width: 120 },
  { id: 'amount-with-tax', key: 'amount', label: '세금 포함 금액', width: 140 },
  {
    id: 'customer-city',
    key: ['customer', 'address', 'city'],
    label: '고객 도시',
    width: 120,
  },
];

id를 생략하면 라이브러리가 key를 직렬화해 내부 columnId를 만듭니다.

  • key: 'status'key:string:status
  • key: ['customer', 'name']key:array:["customer","name"]

columnGroups의 문자열 leaf는 원래 key가 아니라 이 최종 컬럼 ID를 참조합니다. 예를 들어 { key: 'status' }처럼 id를 생략한 컬럼은 children: ['key:string:status']로 참조해야 하며, children: ['status']로는 찾을 수 없습니다. 자동 생성 형식에 의존하지 않도록 그룹에 포함할 컬럼에는 명시적인 id를 지정하는 방식을 권장합니다.

const columns = [{ id: 'status', key: 'status', label: '상태', width: 100 }];

const columnGroups = [{ id: 'order-state', label: '주문 상태', children: ['status'] }];

여기서 그룹 노드의 id: 'order-state'는 그룹 자체를 식별하고, children'status'는 컬럼의 id를 가리킵니다.

행과 병합 계산

가장 깊은 그룹에 맞춰 헤더 행 수가 결정됩니다. 얕은 위치에서 끝나는 leaf 컬럼은 남은 행을 rowSpan으로 채우고, 그룹은 실제 포함 컬럼 수를 colSpan으로 사용합니다. 헤더 한 행당 22px 이상이 되도록 headerHeight를 지정하세요. 높이가 부족하면 개발 환경에서 경고합니다.

고정 컬럼 경계를 지나는 그룹

그룹이 frozenColumnIndex 경계를 지나도 별도 설정은 필요하지 않습니다. 같은 그룹 레이블이 고정 영역과 스크롤 영역에 각각 렌더링되고, 각 영역에 실제로 포함된 leaf 수로 colSpan이 계산됩니다. 위 라이브 데모는 상품 상세 그룹이 고정 경계를 지나는 사례를 포함합니다.

유효성 검사

다음 구성은 개발 환경에서 경고하고 안전한 1단 헤더로 대체합니다.

  • 존재하지 않는 컬럼 ID
  • 한 컬럼을 둘 이상의 위치에서 중복 참조
  • 비어 있는 그룹 또는 중복 그룹 ID
  • 실제 columns 순서와 다른 leaf 순서
  • 중간 컬럼을 건너뛰는 비연속 그룹

columnSortable을 함께 사용하면 같은 부모 그룹에 직접 포함된 leaf끼리만 순서를 바꿀 수 있습니다. 그룹 자체 이동과 다른 부모 그룹으로의 이동은 차단됩니다. 제어형 컬럼 배열을 사용한다면 onChangeColumns에서 info.columnsinfo.columnGroups를 함께 상태에 반영하세요.

기존 columnsGroup 호환

인덱스 범위 기반 columnsGroup은 기존 애플리케이션을 위해 계속 동작하지만 deprecated 상태입니다.

<AXDataGrid columns={columns} columnsGroup={[{ label: '문서 정보', groupStartIndex: 1, groupEndIndex: 3 }]} />

두 API를 함께 전달하면 columnGroups가 우선합니다. 신규 화면은 컬럼 순서 변경에 더 안전하고 임의 깊이를 지원하는 columnGroups를 사용하세요.