행 순서 재배치 (Row Reorder)

행 좌측 핸들을 포인터나 키보드로 이동하고, 가상 스크롤·선택·병합 셀에서도 안전하게 순서를 저장하는 방법을 학습합니다.

#reorder#drag-handle#onReorder#pointer-drag#keyboard#virtual-scroll
검토일: 2026-08-23
GitHub
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';

interface BannerPriority {
  bannerCode: string;
  title: string;
  placement: string;
  audience: string;
  period: string;
  status: '노출 중' | '예약' | '비활성';
}

const bannerTitles = [
  '여름 시즌 최대 40% 프로모션',
  '신규 회원 웰컴 쿠폰 안내',
  '프리미엄 멤버십 오픈',
  '오늘 주문 내일 도착 캠페인',
  '카카오페이 즉시 할인',
  '리뷰 작성 포인트 두 배 적립',
  '주말 한정 타임 세일',
  '친구 초대 리워드 이벤트',
];
const placements = ['메인 히어로', '홈 중단', '카테고리 상단', '앱 팝업'];
const audiences = ['전체 고객', '신규 회원', 'VIP 회원', '최근 구매 고객'];

const initialBanners: BGridDataItem<BannerPriority>[] = Array.from({ length: 24 }, (_, index) => ({
  values: {
    bannerCode: `BNR-${String(index + 1).padStart(3, '0')}`,
    title: bannerTitles[index % bannerTitles.length],
    placement: placements[index % placements.length],
    audience: audiences[index % audiences.length],
    period: `2026-08-${String((index % 20) + 1).padStart(2, '0')} ~ 2026-09-${String((index % 9) + 1).padStart(2, '0')}`,
    status: index % 6 === 5 ? '비활성' : index % 4 === 3 ? '예약' : '노출 중',
  },
}));

export default function ReorderExample() {
  const [data, setData] = React.useState(initialBanners);
  const [columns, setColumns] = React.useState<BGridColumn<BannerPriority>[]>([
    { key: 'bannerCode', label: '배너코드', width: 100, align: 'center' },
    { key: 'title', label: '배너 제목', width: 260 },
    { key: 'placement', label: '노출 위치', width: 120, align: 'center' },
    { key: 'audience', label: '대상 고객', width: 120, align: 'center' },
    { key: 'period', label: '노출 기간', width: 210, align: 'center' },
    {
      key: 'status',
      label: '노출 상태',
      width: 100,
      align: 'center',
      itemRender: ({ values }) => <strong>{values.status}</strong>,
    },
  ]);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  return (
    <DataGridContainer ref={containerRef}>
      <BGrid<BannerPriority>
        width={width}
        height={height}
        data={data}
        columns={columns}
        rowKey='bannerCode'
        showLineNumber
        columnSortable={false}
        onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
        reorder={{
          enabled: true,
          onReorder: reorderedData => {
            setData(reorderedData);
            return true;
          },
        }}
      />
    </DataGridContainer>
  );
}

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

메뉴 관리(Menu Tree), 배너 노출 우선순위 설정, 할 일(TODO) 순서 변경 등 **“사용자가 직접 항목의 순서를 조정하여 저장해야 하는 기능”**에서 행 드래그 재배치는 필수적인 UX입니다.

BeautifulGrid의 reorder 기능을 활성화하면:

  • 행 번호 영역 좌측에 전용 드래그 핸들 아이콘(grip-vertical)이 자동 표시됩니다.
  • 이동 중 원본 행과 사이 행이 150ms 계열의 transform으로 움직여 목적지를 미리 보여 줍니다.
  • 드롭 모션이 끝난 뒤 새 배열을 한 번 commit하고 onReorder에 전달합니다.
  • 가상 스크롤의 가장자리 자동 스크롤과 화면 밖 source row용 preview를 지원합니다.
  • 핸들에 포커스한 뒤 Space 또는 Enter, 방향키, Enter를 사용해 키보드로도 이동할 수 있습니다.

2. 실무 완성형 예제: 배너 노출 순서 관리 그리드

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

interface BannerItem {
  id: number;
  title: string;
  linkUrl: string;
  active: boolean;
}

export default function BannerReorderGrid() {
  const [data, setData] = useState<BGridDataItem<BannerItem>[]>([
    { values: { id: 1, title: '메인 상단 여름 시즌 프로모션 배너', linkUrl: '/events/summer', active: true } },
    { values: { id: 2, title: '신규 회원 가입 10% 웰컴 쿠폰 안내', linkUrl: '/welcome', active: true } },
    { values: { id: 3, title: '카카오페이 결제 시 5천원 즉시 할인', linkUrl: '/events/kakaopay', active: false } },
    { values: { id: 4, title: '프리미엄 멤버십 오픈 기념 이벤트', linkUrl: '/membership', active: true } },
  ]);

  const columns: BGridColumn<BannerItem>[] = [
    { key: 'id', label: 'ID', width: 60, align: 'center' },
    { key: 'title', label: '배너 제목', width: 280 },
    { key: 'linkUrl', label: '연결 링크', width: 180 },
    {
      key: 'active',
      label: '노출여부',
      width: 90,
      align: 'center',
      itemRender: ({ values }) => (
        <span style={{ color: values.active ? '#16a34a' : '#94a3b8', fontWeight: 600 }}>
          {values.active ? '노출중' : '비활성'}
        </span>
      ),
    },
  ];

  return (
    <div>
      <div style={{ marginBottom: 10, fontSize: 13, color: '#475569' }}>
        💡 행 좌측의 드래그 핸들(점 6개 아이콘)을 마우스로 잡고 위아래로 끌어서 순서를 변경해보세요.
      </div>

      <BGrid<BannerItem>
        width={720}
        height={260}
        columns={columns}
        data={data}
        rowKey='id'
        showLineNumber={true} // 줄번호 영역 표시 (필수)
        reorder={{
          enabled: true, // 행 드래그 재배치 활성화
          onReorder: (newData: BGridDataItem<BannerItem>[]) => {
            console.log('재배치 완료:', newData.map(d => d.values.title));
            setData(newData);
            return true; // 성공 시 true 반환
          },
        }}
      />
    </div>
  );
}

3. 입력 방법

입력 동작
마우스·펜·터치 핸들을 누른 채 위아래로 이동하고 놓아 확정
Space / Enter 포커스된 핸들의 행을 집거나 현재 위치에 놓기
ArrowUp / ArrowDown 키보드 이동 중 목적지를 한 행씩 변경
Escape 변경하지 않고 원래 위치로 취소

드래그를 시작하기 전의 짧은 클릭은 데이터 순서를 바꾸지 않습니다. 이동 중에는 onReorder가 호출되지 않으며, settle이 끝난 뒤 최종 배열로 한 번만 호출됩니다. prefers-reduced-motion: reduce 환경에서는 모션과 commit 대기 시간이 함께 제거됩니다.

4. onReorder 계약과 저장 실패 처리

onReorder는 동기 callback입니다. true 또는 void를 반환하면 새 순서를 유지하고, false를 반환하면 내부 데이터·체크·활성 셀 상태를 원래 순서로 되돌립니다. 예외가 발생해도 같은 cleanup과 rollback을 먼저 수행합니다. 서버 저장 Promise를 callback에서 기다리는 pending 계약은 제공하지 않으므로, 원격 저장이 필요한 화면은 애플리케이션 상태에서 낙관적 업데이트와 실패 복구 정책을 별도로 구성하세요.

이동된 wrapper는 status: edit로 표시됩니다. 입력 data 배열과 기존 row wrapper를 직접 mutate하지 않습니다.

5. 선택·편집·행 식별자

  • rowKey를 지정하면 드래그 도중 외부 render가 발생해도 같은 행 순서인지 안정적으로 판별할 수 있습니다.
  • 체크 상태와 활성 셀은 index가 아니라 이동한 데이터 항목을 따라 재매핑됩니다. 다중 셀 range는 잘못된 범위를 남기지 않도록 성공한 reorder 뒤 초기화됩니다.
  • 셀 editor가 열려 있으면 핸들을 비활성화해 저장되지 않은 draft를 암묵적으로 버리지 않습니다.
  • 드래그 도중 외부 data 순서가 바뀌면 현재 reorder session을 취소하고 callback을 호출하지 않습니다.

6. 제한과 안전 fallback

  • showLineNumberreorder.enabled가 모두 필요합니다.
  • client-side 정렬 또는 필터가 적용된 표시 순서는 원본 배열 순서와 다르므로 reorder가 자동 비활성화됩니다.
  • frozen row가 있거나 pivot 결과를 렌더링하는 동안에도 reorder가 비활성화됩니다.
  • 셀 병합이 설정된 표에서 rowspan 셀을 transform하면 겹칠 수 있습니다. 이 경우 행 셀을 직접 이동하지 않고 가벼운 preview와 insertion guide로 목적지를 표시한 뒤 같은 데이터 permutation을 적용합니다.

테마에서는 --bgrid-row-reorder-duration, --bgrid-row-reorder-easing, --bgrid-row-reorder-guide-color, --bgrid-row-reorder-preview-bg, --bgrid-row-reorder-preview-shadow를 재정의할 수 있습니다.