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

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

#sorting#filtering#toolbox#multi-sort#dataControl
검토일: 2026-08-17
LIVE DEMO정렬 및 필터 툴박스 (Sorting & Filtering) 인터랙티브 예제
인터랙티브 데모를 불러오는 중입니다…
GitHub
import { t, exampleMsg } from './i18n';
import * as React from 'react';
import { useState, useCallback, useMemo } from 'react';
import { BGrid, BGridColumn } from 'beautiful-grid';
import type { BGridDataControl, BGridDataQuery, BGridToolboxIcons } from 'beautiful-grid';
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: BGridToolboxIcons = {
  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 = [
  t('React 18 새로운 기능 살펴보기', 'Exploring New Features in React 18'),
  t('TypeScript 5.0 마스터 가이드', 'TypeScript 5.0 Master Guide'),
  t('Next.js 14 App Router 실전', 'Next.js 14 App Router in Practice'),
  t('Zustand와 Recoil 상태관리 비교', 'Comparison of Zustand and Recoil State Management'),
  t('Node.js 백엔드 아키텍처 패턴', 'Node.js Backend Architecture Patterns'),
  t('PostgreSQL 인덱스 최적화 기법', 'PostgreSQL Index Optimization Techniques'),
  t('Docker와 K8s 배포 파이프라인 구축', 'Docker and K8s Deployment Pipeline Construction'),
  t('Vite 기반 번들 최적화 꿀팁', 'Tips for Vite-based Bundle Optimization'),
  t('GraphQL vs REST API 완벽 비교', 'GraphQL vs REST API Complete Comparison'),
  t('Tailwind CSS로 모던 UI 디자인하기', 'Designing Modern UI with Tailwind CSS'),
  t('Rust 기초부터 웹서버 구현까지', 'Rust from Basics to Web Server Implementation'),
  t('웹 접근성(A11y) 가이드라인 준수하기', 'Complying with Web Accessibility (A11y) Guidelines'),
  t('Redis 분산 캐시 설계 및 활용', 'Redis Distributed Cache Design and Utilization'),
  t('Kafka 대용량 메시지 브로커 실습', 'Kafka Large Scale Message Broker Practice'),
  t('Kubernetes 클러스터 모니터링 가이드', 'Kubernetes Cluster Monitoring Guide'),
  t('Elasticsearch 검색 엔진 최적화', 'Elasticsearch Search Engine Optimization'),
  t('OAuth 2.0 및 JWT 인증 아키텍처', 'OAuth 2.0 and JWT Authentication Architecture'),
  t('Microservices Event-driven 아키텍처', 'Microservices Event-driven Architecture'),
  t('Flutter 크로스 플랫폼 앱 제작', 'Flutter Cross Platform App Development'),
  t('Kotlin Coroutine 비동기 프로그래밍', 'Kotlin Coroutine Asynchronous Programming'),
];

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} (${t('심화 #', 'Advanced #')}${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<BGridDataQuery>({
    sortParams: [],
    filterParams: [],
  });

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

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

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

  const columns: BGridColumn<any>[] = useMemo(
    () => [
      {
        id: 'col_id',
        key: 'id',
        label: 'ID',
        width: 60,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_title',
        key: 'title',
        label: t('제목', 'Title'),
        width: 280,
        toolbox: {
          sort: true,
          filter: true,
          extraItems: [
            {
              id: 'copy-col',
              label: t('컬럼명 복사', 'Copy Column Name'),
              onClick: ({ column }) => {
                navigator.clipboard?.writeText(String(column.label));
                alert(t('컬럼명이 복사되었습니다.', 'Column name copied.'));
              },
            },
          ],
        },
        filter: {
          type: 'text',
        },
      },
      {
        id: 'col_category',
        key: 'category',
        label: t('카테고리', 'Category'),
        width: 120,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'values',
        },
      },
      {
        id: 'col_author',
        key: 'author',
        label: t('작성자', 'Author'),
        width: 100,
        align: 'center',
        toolbox: true,
        filter: {
          type: 'values',
        },
      },
      {
        id: 'col_views',
        key: 'views',
        label: t('조회수', 'Views'),
        width: 110,
        align: 'right',
        toolbox: true,
        itemRender: ({ value }) => Number(value).toLocaleString(),
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_price',
        key: 'price',
        label: t('가격 (원)', 'Price (KRW)'),
        width: 120,
        align: 'right',
        toolbox: true,
        itemRender: ({ value }) => `₩${Number(value).toLocaleString()}`,
        filter: {
          type: 'number',
        },
      },
      {
        id: 'col_date',
        key: 'date',
        label: t('등록일', 'Registration Date'),
        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>{t('정렬:', 'Sort:')}</strong> {query.sortParams.length}{t('개', 'ea')}
            {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>{t('필터:', 'Filter:')}</strong> {query.filterParams.length}{t('개', 'ea')}
            {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'>{t('아이콘 스타일:', 'Icon Style:')}</span>
            <Segmented
              value={iconTheme}
              onChange={val => setIconTheme(val as 'lucide' | 'default')}
              options={[
                { label: t('Lucide 벡터 아이콘 (커스텀)', 'Lucide Vector Icon (Custom)'), value: 'lucide' },
                { label: t('기본 불릿/기호 (Fallback)', 'Default Bullet/Symbol (Fallback)'), value: 'default' },
              ]}
            />
          </div>

          <Button onClick={handleResetQuery}>{t('정렬 / 필터 전체 초기화', 'Reset All Sort / Filter')}</Button>
        </div>
      </div>

      <DataGridContainer ref={containerRef}>
        <BGrid
          width={containerWidth}
          height={containerHeight}
          data={data}
          columns={columns}
          msg={exampleMsg}
          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. 언제 사용하며 왜 필요한가요?

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

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

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

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

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

import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem, type BGridDataQuery } from 'beautiful-grid';

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

export default function EmployeeFilterGrid() {
  const [query, setQuery] = useState<BGridDataQuery>({ sortParams: [], filterParams: [] });
  const [data] = useState<BGridDataItem<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: BGridColumn<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>

      <BGrid<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를 명시해주세요.