정렬 및 필터 툴박스 (Sorting & Filtering)

컬럼 헤더의 다중 정렬(Multi-sort)과 강력한 툴박스 필터링(Toolbox Filter)을 실무에 적용하는 방법을 학습합니다.

#sorting#filtering#toolbox#multi-sort#dataControl
검토일: 2026-08-17
GitHub
import * as React from 'react';
import { useState, useCallback, useMemo } from 'react';
import { AXDataGrid, AXDGColumn } from '@axboot/datagrid';
import type { AXDGDataControl, AXDGDataQuery, AXDGToolboxIcons } from '@axboot/datagrid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
import { Button, Segmented, Space, Tag } from 'antd';

import { ChevronDown, ArrowUp, ArrowDown, Filter, X } from 'lucide-react';

const lucideToolboxIcons: AXDGToolboxIcons = {
  dropdown: <ChevronDown size={13} strokeWidth={2} />,
  sortAsc: <ArrowUp size={13} strokeWidth={2.2} />,
  sortDesc: <ArrowDown size={13} strokeWidth={2.2} />,
  filter: <Filter size={13} strokeWidth={2} />,
  filterBadge: <Filter size={9} strokeWidth={2} />,
  sortClear: <X size={13} strokeWidth={2} />,
};

const categories = ['Frontend', 'Backend', 'Database', 'DevOps', 'Design', 'Language', 'Security', 'Cloud', 'Mobile'];
const authors = ['Tom', 'Jerry', 'Alice', 'Bob', 'Charlie', 'David', 'Emma'];
const topics = [
  'React 18 새로운 기능 살펴보기',
  'TypeScript 5.0 마스터 가이드',
  'Next.js 14 App Router 실전',
  'Zustand와 Recoil 상태관리 비교',
  'Node.js 백엔드 아키텍처 패턴',
  'PostgreSQL 인덱스 최적화 기법',
  'Docker와 K8s 배포 파이프라인 구축',
  'Vite 기반 번들 최적화 꿀팁',
  'GraphQL vs REST API 완벽 비교',
  'Tailwind CSS로 모던 UI 디자인하기',
  'Rust 기초부터 웹서버 구현까지',
  '웹 접근성(A11y) 가이드라인 준수하기',
  'Redis 분산 캐시 설계 및 활용',
  'Kafka 대용량 메시지 브로커 실습',
  'Kubernetes 클러스터 모니터링 가이드',
  'Elasticsearch 검색 엔진 최적화',
  'OAuth 2.0 및 JWT 인증 아키텍처',
  'Microservices Event-driven 아키텍처',
  'Flutter 크로스 플랫폼 앱 제작',
  'Kotlin Coroutine 비동기 프로그래밍',
];

const mockData = Array.from({ length: 60 }, (_, index) => {
  const id = index + 1;
  const topic = topics[index % topics.length];
  const category = categories[index % categories.length];
  const author = authors[index % authors.length];
  const views = Math.floor(500 + Math.sin(index * 1.5 + 1) * 3000 + 4000);
  const price = Math.floor(15000 + (index % 12) * 5000);
  const month = String((index % 12) + 1).padStart(2, '0');
  const day = String((index % 28) + 1).padStart(2, '0');
  const date = `2023-${month}-${day}`;

  return {
    values: {
      id,
      title: index >= topics.length ? `${topic} (심화 #${Math.floor(index / topics.length) + 1})` : topic,
      category,
      author,
      views,
      price,
      date,
    },
  };
});

export default function ToolboxExample() {
  const [data, setData] = useState(mockData);
  const [iconTheme, setIconTheme] = useState<'lucide' | 'default'>('lucide');
  const [query, setQuery] = useState<AXDGDataQuery>({
    sortParams: [],
    filterParams: [],
  });

  const handleQueryChange = useCallback((nextQuery: AXDGDataQuery, action: any) => {
    console.log('[ToolboxExample] Query Change:', action, nextQuery);
    setQuery(nextQuery);
  }, []);

  const handleResetQuery = useCallback(() => {
    setQuery({
      sortParams: [],
      filterParams: [],
    });
  }, []);

  const dataControl: AXDGDataControl = {
    mode: 'client',
    query,
    onChange: handleQueryChange,
    multiSort: true,
  };

  const columns: AXDGColumn<any>[] = useMemo(
    () => [
      {
        id: 'col_id',
        key: 'id',
        label: 'ID',
        width: 60,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_title',
        key: 'title',
        label: '제목',
        width: 280,
        toolbox: {
          sort: true,
          filter: true,
          extraItems: [
            {
              id: 'copy-col',
              label: '컬럼명 복사',
              onClick: ({ column }) => {
                navigator.clipboard?.writeText(String(column.label));
                alert('컬럼명이 복사되었습니다.');
              },
            },
          ],
        },
        filter: {
          type: 'text',
        },
      },
      {
        id: 'col_category',
        key: 'category',
        label: '카테고리',
        width: 120,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'values',
        },
      },
      {
        id: 'col_author',
        key: 'author',
        label: '작성자',
        width: 100,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'values',
        },
      },
      {
        id: 'col_views',
        key: 'views',
        label: '조회수',
        width: 110,
        align: 'right',
        toolbox: true,
        itemRender: ({ value }) => Number(value).toLocaleString(),
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_price',
        key: 'price',
        label: '가격 (원)',
        width: 120,
        align: 'right',
        toolbox: true,
        itemRender: ({ value }) => `₩${Number(value).toLocaleString()}`,
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_date',
        key: 'date',
        label: '등록일',
        width: 110,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'text',
        },
      },
    ],
    [],
  );

  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);

  return (
    <>
      <div className='flex flex-wrap items-center justify-between gap-2 p-3 bg-slate-50 border border-slate-200 rounded-lg text-sm'>
        <div className='flex items-center gap-3 text-slate-600'>
          <span>
            <strong>정렬:</strong> {query.sortParams.length}개
            {query.sortParams.length > 0 && (
              <span className='text-blue-600 ml-1'>
                ({query.sortParams.map(s => `${s.columnId}:${s.orderBy}`).join(', ')})
              </span>
            )}
          </span>
          <span>|</span>
          <span>
            <strong>필터:</strong> {query.filterParams.length}개
            {query.filterParams.length > 0 && (
              <span className='text-emerald-600 ml-1'>
                ({query.filterParams.map(f => `${f.columnId}(${f.type})`).join(', ')})
              </span>
            )}
          </span>
        </div>

        <div className='flex items-center gap-3'>
          <div className='flex items-center gap-2'>
            <span className='text-xs text-slate-500 font-medium'>아이콘 스타일:</span>
            <Segmented
              value={iconTheme}
              onChange={val => setIconTheme(val as 'lucide' | 'default')}
              options={[
                { label: 'Lucide 벡터 아이콘 (커스텀)', value: 'lucide' },
                { label: '기본 불릿/기호 (Fallback)', value: 'default' },
              ]}
            />
          </div>

          <Button onClick={handleResetQuery}>정렬 / 필터 전체 초기화</Button>
        </div>
      </div>

      <DataGridContainer ref={containerRef}>
        <AXDataGrid
          width={containerWidth}
          height={containerHeight}
          data={data}
          columns={columns}
          columnSortable
          frozenColumnIndex={1}
          dataControl={dataControl}
          icons={iconTheme === 'lucide' ? lucideToolboxIcons : undefined}
          rowKey='id'
          rowChecked={{
            checkedIndexes: [],
            onChange: (checkedIndexes, checkedRowKeys) => {
              console.log('[ToolboxExample] Checked:', checkedIndexes, checkedRowKeys);
            },
          }}
          onClick={({ item, index, column }) => {
            console.log('[ToolboxExample] Row Clicked:', index, item.title, column.label);
          }}
        />
      </DataGridContainer>
    </>
  );
}

1. 언제 사용하며 왜 필요한가요?

사용자가 대량의 데이터를 조회할 때 **“금액이 높은 순으로 정렬”**하거나 **“특정 부서나 상태만 빠르게 필터링”**하는 기능은 데이터 탐색의 핵심입니다.

AXBOOT DataGrid는 다음과 같은 두 가지 차원의 탐색 도구를 제공합니다:

  1. 컬럼 헤더 클릭 정렬: 컬럼 라벨 클릭 시 오름차순(ASC) ➔ 내림차순(DESC) ➔ 정렬 해제 토글
  2. 헤더 툴박스 팝오버 (Toolbox): 각 컬럼 헤더의 필터/정렬 아이콘을 클릭하여 값 목록 필터, 텍스트 검색, 다중 정렬을 손쉽게 수행
  3. 클라이언트 vs 수동 모드 (dataControl): 그리드가 현재 data를 처리하는 client 또는 부모가 조회를 수행하는 manual 방식

2. 실무 완성형 예제: 클라이언트 측 다중 필터 & 정렬

아래 코드는 헤더 툴박스를 활성화하고 클라이언트 모드로 동작하는 예제입니다:

import React, { useState } from 'react';
import { AXDataGrid, type AXDGColumn, type AXDGDataItem, type AXDGDataQuery } from '@axboot/datagrid';

interface EmployeeItem {
  id: number;
  name: string;
  department: string;
  position: string;
  salary: number;
  joinedAt: string;
}

export default function EmployeeFilterGrid() {
  const [query, setQuery] = useState<AXDGDataQuery>({ sortParams: [], filterParams: [] });
  const [data] = useState<AXDGDataItem<EmployeeItem>[]>([
    { values: { id: 1, name: '김철수', department: '개발본부', position: '팀장', salary: 85000000, joinedAt: '2020-03-01' } },
    { values: { id: 2, name: '이영희', department: '디자인팀', position: '선임', salary: 52000000, joinedAt: '2022-07-15' } },
    { values: { id: 3, name: '박민수', department: '개발본부', position: '수석', salary: 92000000, joinedAt: '2018-11-01' } },
    { values: { id: 4, name: '최지우', department: '마케팅팀', position: '책임', salary: 64000000, joinedAt: '2021-01-10' } },
    { values: { id: 5, name: '정동훈', department: '개발본부', position: '주임', salary: 45000000, joinedAt: '2024-02-01' } },
    { values: { id: 6, name: '한소희', department: '인사팀', position: '선임', salary: 55000000, joinedAt: '2023-05-10' } },
  ]);

  const columns: AXDGColumn<EmployeeItem>[] = [
    { id: 'id', key: 'id', label: '사번', width: 70, align: 'center', toolbox: true, filter: { type: 'number' } },
    { id: 'name', key: 'name', label: '이름', width: 120, align: 'center', toolbox: true, filter: { type: 'text' } },
    { id: 'department', key: 'department', label: '부서명', width: 140, toolbox: true, filter: { type: 'values' } },
    { id: 'position', key: 'position', label: '직급', width: 100, align: 'center', toolbox: true, filter: { type: 'values' } },
    {
      key: 'salary',
      label: '연봉',
      width: 140,
      align: 'right',
      toolbox: true,
      filter: { type: 'number' },
      itemRender: ({ values }) => `${values.salary.toLocaleString()}원`,
    },
    { id: 'joinedAt', key: 'joinedAt', label: '입사일', width: 120, align: 'center', toolbox: true, filter: { type: 'text' } },
  ];

  return (
    <div>
      <div style={{ marginBottom: 10, fontSize: 13, color: '#475569' }}>
        💡 각 컬럼 헤더에 마우스를 올리면 나타나는 <strong>필터/정렬 아이콘</strong>을 클릭하여 원하는 부서나 직급을 필터링해보세요.
      </div>

      <AXDataGrid<EmployeeItem>
        width={750}
        height={320}
        columns={columns}
        data={data}
        rowKey="id"
        dataControl={{
          mode: 'client',
          multiSort: true,
          query,
          onChange: setQuery,
        }}
        showLineNumber={true}
      />
    </div>
  );
}

3. dataControl 모드 선택 가이드

모드 설정값 동작 방식 권장 환경
클라이언트 모드 { mode: 'client', query, onChange } 현재 전달된 data를 대상으로 그리드가 정렬·필터 결과를 계산합니다. 이미 브라우저에 로드한 데이터 안에서 즉시 탐색할 때
수동 모드 { mode: 'manual', query, onChange } 조건 변경만 부모에 알리고, 부모가 서버 조회 후 새 data를 전달합니다. 서버 페이징이나 DB 정렬·필터가 필요할 때

4. 실무 팁 & 주의사항 (Gotchas)

[!TIP] 중복 컬럼 ID 주의: 툴박스가 활성화된 경우 각 컬럼은 고유한 key 또는 id를 가져야 합니다. 동일한 key를 여러 컬럼에서 재사용할 경우 id: 'custom_id_1' 처럼 고유 id를 명시해주세요.