본문 바로가기
JavaScript/React

dnd-kit(Drag-and-Drop 라이브러리)

by curious week 2025. 5. 20.

https://dndkit.com/

 

dnd kit – a modern drag and drop toolkit for React

A modular, lightweight, performant, accessible and extensible drag & drop toolkit for React.

dndkit.com


1단계: 기본 구조 이해


1. DndContext의 역할

개념

DndContext는 드래그 앤 드롭을 가능하게 해주는 최상위 컨테이너입니다.
여기에 드래그/드롭 관련 이벤트 핸들러와 센서를 등록하면 자식 컴포넌트에서 드래그가 활성화됩니다.

예제

import { DndContext } from '@dnd-kit/core';

export default function App() {
  return (
    <DndContext onDragEnd={handleDragEnd}>
      <MyDraggable />
      <MyDroppable />
    </DndContext>
  );
}

핵심 역할

  • 내부의 Draggable, Droppable을 연결해줌
  • 전역 드래그 이벤트 관리 (시작, 이동, 종료, 취소 등)
  • 충돌 판정 기준 설정 (collisionDetection)
  • 드래그 센서 설정 (useSensors)
  • DndContext는 컴포넌트 트리 상 최상위 1개만 있어야 함

DndContext 주요 props 목록

<DndContext
  sensors={...}
  collisionDetection={...}
  modifiers={...}
  measuring={...}
  autoScroll={...}
  onDragStart={...}
  onDragMove={...}
  onDragOver={...}
  onDragEnd={...}
  onDragCancel={...}
/>
sensors 드래그를 감지할 입력 장치 종류 설정 (예: 마우스, 터치, 키보드 등). useSensors(...)로 생성
collisionDetection 드롭 타겟 충돌 판정 알고리즘 설정 (예: closestCenter, pointerWithin, 커스텀 함수 등)
modifiers 드래그 동작에 제약 조건 부여 (예: 수직 이동만, 그리드 스냅, 경계 제한 등)
measuring 측정 전략 설정 (예: bounding box 계산 방식). 거의 기본값 사용
autoScroll 드래그 중 자동 스크롤 여부 설정 (고급 옵션, 기본 true)
onDragStart 드래그 시작 시 실행되는 콜백 함수
onDragMove 드래그 중 이동할 때마다 실행되는 콜백
onDragOver 다른 droppable 위에 올라갔을 때 실행되는 콜백
onDragEnd 드래그 종료(마우스 놓을 때) 실행되는 콜백
onDragCancel 드래그 도중 취소되었을 때 실행되는 콜백 (예: ESC 키, 브라우저 리셋 등)
<DndContext
  sensors={useSensors(useSensor(PointerSensor))}
  collisionDetection={closestCenter}
  modifiers={[restrictToVerticalAxis]}
  onDragEnd={({ active, over }) => {
    if (over) {
      console.log(`${active.id} dropped on ${over.id}`);
    }
  }}
/>

 

자주 쓰이는 조합

기본 마우스 드래그 sensors={useSensors(useSensor(PointerSensor))}
키보드 지원 추가 KeyboardSensor + sortableKeyboardCoordinates
수직 정렬만 허용 modifiers={[restrictToVerticalAxis]}
그리드 스냅 modifiers={[snapToGrid(30)]}
커서 기준 드롭 collisionDetection={pointerWithin}

2. useDraggable 훅

개념

useDraggable은 해당 요소가 드래그 가능한 상태가 되도록 설정하는 훅입니다.

예제

import { useDraggable } from '@dnd-kit/core';

function DraggableBox() {
  const {attributes, listeners, setNodeRef, transform} = useDraggable({
    id: 'box',
  });

  const style = {
    transform: transform
      ? `translate(${transform.x}px, ${transform.y}px)`
      : undefined,
  };

  return (
    <div
      ref={setNodeRef}
      {...attributes}
      {...listeners}
      style={{...style, width: 100, height: 100, background: 'skyblue'}}
    >
      Drag me
    </div>
  );
}

주의할 점

  • 반드시 setNodeRef로 DOM 참조 설정해야 함
  • listeners는 onMouseDown, onTouchStart 등 이벤트 바인딩 역할
  • attributes는 role, aria-* 접근성 속성 바인딩
  • useDraggable, useDroppable는 여러 개 사용 가능

useDraggable({ id })

const {
  attributes,
  listeners,
  setNodeRef,
  transform,
  transition,
  isDragging,
} = useDraggable({ id: 'item-1' });

구조 분해 항목 설명

attributes object role, aria-* 등 접근성 관련 속성을 포함. ...attributes로 요소에 연결해야 키보드 등에서 인식됨
listeners object onPointerDown, onTouchStart, onKeyDown 등 드래그 시작 이벤트 바인딩용. ...listeners로 핸들링
setNodeRef (element: HTMLElement) => void 이 요소가 드래그 가능하다고 인식되게 하는 ref 연결 함수
transform { x: number; y: number } | null 드래그 중인 요소의 이동 거리(px). translate() 스타일 적용에 사용
transition string | undefined 드래그 후 애니메이션 제어용 CSS transition. 드롭 후 위치 복원 등에 사용
isDragging boolean 현재 이 요소가 드래그 중인지 여부. 스타일 조건부 렌더링에 사용

 

1. attributes

"이 요소는 키보드로도 드래그할 수 있어요"라고 브라우저에게 알려주는 접근성 정보

<div {...attributes}>
  • 내부적으로 role="button", aria-pressed, tabIndex=0 같은 속성이 들어 있어요.
  • 키보드 사용자(예: 시각장애인 등)를 위한 필수 요소예요.
  • ...attributes를 안 넣으면 키보드나 스크린 리더에서 작동 안 될 수도 있어요.

2. listeners

마우스로 "클릭하고 드래그"를 시작하는 이벤트를 연결하는 핸들러

<div {...listeners}>
  • 이걸 넣어야 드래그가 시작됩니다.
  • 내부적으로 onPointerDown, onMouseDown, onTouchStart 등이 들어있어요.
  • 커스텀 드래그 핸들을 만들 때 특정 부분에만 바인딩할 수도 있어요.

3. setNodeRef

 이 DOM 요소가 드래그 대상이라는 걸 dnd-kit에 알려주는 함수

<div ref={setNodeRef}>
  • 이걸 연결하지 않으면 dnd-kit은 이 요소를 감지하지 못해요.
  • 꼭! 드래그할 대상의 DOM에 붙여야 해요.

4. transform

드래그 중인 동안 얼마나 이동했는지 알려주는 값 (x, y 거리)

style={{
  transform: transform
    ? `translate(${transform.x}px, ${transform.y}px)`
    : undefined
}}
  • 마우스로 끌면 이 값이 계속 변해요.
  • 이걸 transform: translate(...)로 적용하면 아이템이 실제로 움직여요.
  • 없으면 드래그 시 움직이지 않아요.

5. transition

 드래그가 끝난 후, 자연스럽게 자리로 돌아가는 애니메이션 효과

style={{ transition }}
  • 드롭했을 때 아이템이 부드럽게 원래 자리로 돌아오도록 애니메이션 적용됨
  • 기본값은 부드러운 ease-in-out

6. isDragging

지금 이 아이템이 드래그 중인지 아닌지 알려주는 플래그

style={{ backgroundColor: isDragging ? 'yellow' : 'white' }}
  • 현재 내가 끌고 있는 아이템이면 true
  • 스타일 변경이나 DragOverlay 표시 등에 자주 사용됨

 

예시 코드

<div
  ref={setNodeRef}
  {...attributes}
  {...listeners}
  style={{
    transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
    transition,
    backgroundColor: isDragging ? 'yellow' : 'white',
  }}
>
  Drag Me
</div>

 


3. useDroppable 훅

개념

useDroppable은 드롭 가능한 영역을 정의합니다.

예제

import { useDroppable } from '@dnd-kit/core';

function DropZone() {
  const {isOver, setNodeRef} = useDroppable({
    id: 'zone',
  });

  return (
    <div
      ref={setNodeRef}
      style={{
        width: 200,
        height: 200,
        background: isOver ? 'lightgreen' : 'lightgray',
      }}
    >
      Drop here
    </div>
  );
}

포인트

  • isOver는 현재 드래그 대상이 이 영역 위에 있는지 여부
  • id는 고유해야 하며 event.over.id로 접근됨

useDroppable({ id })

const {
  isOver,
  setNodeRef,
  active,
  over,
} = useDroppable({ id: 'dropzone-1' });

구조 분해 항목 설명

isOver boolean 현재 드래그 중인 요소가 이 영역 위에 있는지 여부
setNodeRef (element: HTMLElement) => void 이 요소를 드롭 대상으로 지정하는 ref 연결 함수
active { id: string; ... } | null 현재 드래그 중인 요소의 정보 (active.id 등). 모든 droppable에서 접근 가능
over { id: string; ... } | null 현재 이 droppable 위에 있는 드래그 항목 정보. over.id === this.id일 때 자신 위임

예제 적용

<div
  ref={setNodeRef}
  style={{
    backgroundColor: isOver ? 'lightgreen' : 'lightgray',
    padding: 20,
  }}
>
  Drop Here
</div>

4. 드래그 상태 추적 (onDragStart, onDragMove, onDragOver, onDragEnd, onDragCancel)

이벤트 설명

<DndContext
  onDragStart={(event) => {
    console.log('start', event.active.id);
  }}
  onDragMove={(event) => {
    console.log('move', event.delta);
  }}
  onDragOver={(event) => {
    console.log('over', event.over?.id);
  }}
  onDragEnd={(event) => {
    console.log('end', event.active.id, '->', event.over?.id);
  }}
  onDragCancel={() => {
    console.log('cancelled');
  }}
>
  ...
</DndContext>
  • event.active.id: 현재 드래그 중인 요소의 id
  • event.over?.id: 현재 드롭 대상 위에 있을 때의 id
  • event.delta: 마지막 위치 대비 현재 위치의 차이

5. transform, transition 적용

목적

드래그 시 부드럽게 움직이게 하거나, 리스트 재정렬 시 자연스럽게 이동시키기 위해 사용

기본 예제

const style = {
  transform: transform
    ? `translate(${transform.x}px, ${transform.y}px)`
    : undefined,
  transition: 'transform 0.2s ease',
};
  • transform: 드래그 중 위치 계산을 위해 자동으로 제공됨
  • transition: 드롭 후 원래 자리로 돌아가는 애니메이션 설정

6. 드롭 성공 / 실패 구분

핵심 로직

onDragEnd={({ active, over }) => {
  if (over) {
    console.log('드롭 성공:', active.id, '→', over.id);
  } else {
    console.log('드롭 실패:', active.id);
  }
}}
  • event.over === null이면 아무 드롭도 되지 않은 상태 (실패)
  • event.over.id가 존재하면 정상 드롭 (성공)

예제 전체 코드 (기초 완성형)

import {
  DndContext,
  useDraggable,
  useDroppable,
} from '@dnd-kit/core';

function DraggableBox() {
  const {attributes, listeners, setNodeRef, transform} = useDraggable({ id: 'box' });

  const style = {
    transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
    width: 100,
    height: 100,
    backgroundColor: 'skyblue',
    transition: 'transform 0.2s ease',
  };

  return (
    <div ref={setNodeRef} {...listeners} {...attributes} style={style}>
      Drag Me
    </div>
  );
}

function DropZone() {
  const {isOver, setNodeRef} = useDroppable({ id: 'zone' });

  return (
    <div
      ref={setNodeRef}
      style={{
        width: 200,
        height: 200,
        background: isOver ? 'lightgreen' : 'lightgray',
        marginTop: 20,
      }}
    >
      Drop Here
    </div>
  );
}

export default function App() {
  return (
    <DndContext
      onDragStart={(e) => console.log('Start:', e.active.id)}
      onDragOver={(e) => console.log('Over:', e.over?.id)}
      onDragEnd={(e) => {
        if (e.over) {
          console.log('Dropped on:', e.over.id);
        } else {
          console.log('Dropped nowhere');
        }
      }}
      onDragCancel={() => console.log('Cancelled')}
    >
      <DraggableBox />
      <DropZone />
    </DndContext>
  );
}

실습 목표

  • 박스를 마우스로 드래그해서
  • 드롭 가능한 영역에 올리면 배경색이 초록색으로 바뀌고
  • 드롭을 하면 로그에 결과가 출력됨

코드 예제

// App.tsx

import React from 'react';
import {
  DndContext,
  useDraggable,
  useDroppable,
  DragEndEvent,
} from '@dnd-kit/core';

function DraggableBox() {
  const {attributes, listeners, setNodeRef, transform} = useDraggable({
    id: 'box',
  });

  const style = {
    transform: transform
      ? `translate(${transform.x}px, ${transform.y}px)`
      : undefined,
    width: 100,
    height: 100,
    backgroundColor: 'skyblue',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: 8,
    cursor: 'grab',
    userSelect: 'none',
  };

  return (
    <div ref={setNodeRef} {...listeners} {...attributes} style={style}>
      BOX
    </div>
  );
}

function DropZone() {
  const {isOver, setNodeRef} = useDroppable({
    id: 'dropzone',
  });

  const style = {
    width: 200,
    height: 200,
    backgroundColor: isOver ? 'lightgreen' : 'lightgray',
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    border: '2px dashed gray',
    borderRadius: 8,
    marginTop: 20,
  };

  return (
    <div ref={setNodeRef} style={style}>
      Drop Here
    </div>
  );
}

export default function App() {
  const handleDragEnd = (event: DragEndEvent) => {
    const {active, over} = event;

    if (over) {
      console.log(`${active.id} dropped on ${over.id}`);
    } else {
      console.log(`${active.id} dropped outside any droppable`);
    }
  };

  return (
    <div style={{padding: 40}}>
      <DndContext onDragEnd={handleDragEnd}>
        <DraggableBox />
        <DropZone />
      </DndContext>
    </div>
  );
}

작동 방식 요약

  • DraggableBox: useDraggable(id: 'box') 사용
  • DropZone: useDroppable(id: 'dropzone') 사용
  • DndContext: onDragEnd에서 event.active.id와 event.over?.id 확인
  • isOver를 이용해 드롭 영역의 색상을 실시간으로 반응

설치가 필요한 경우

npm install @dnd-kit/core

 

yarn add @dnd-kit/core

체크포인트

  • 박스가 마우스로 드래그 가능
  • 드롭존 위에 올라가면 색상 바뀜
  • 드롭하면 콘솔에 메시지 출력
  • 아무 데나 놓으면 실패 메시지 출력

2단계: 정렬 가능한 리스트


설치

npm install @dnd-kit/core @dnd-kit/sortable

또는

yarn add @dnd-kit/core @dnd-kit/sortable

전체 예제 코드

// SortableList.tsx

import React, {useState} from 'react';
import {
  DndContext,
  closestCenter,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';

import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
} from '@dnd-kit/sortable';

import {CSS} from '@dnd-kit/utilities';

function SortableItem({id}: {id: string}) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({id});

  const style = {
    transform: CSS.Transform.toString(transform), // translate(x, y)
    transition,
    padding: 12,
    marginBottom: 8,
    background: isDragging ? '#ffa' : '#f0f0f0',
    borderRadius: 6,
    cursor: 'grab',
    boxShadow: isDragging ? '0 0 10px rgba(0,0,0,0.2)' : undefined,
  };

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {id}
    </div>
  );
}

export default function SortableList() {
  const [items, setItems] = useState(['Apple', 'Banana', 'Carrot', 'Durian']);

  const sensors = useSensors(useSensor(PointerSensor)); // 마우스, 터치 기본 지원

  const handleDragEnd = (event: any) => {
    const {active, over} = event;
    if (active.id !== over?.id) {
      const oldIndex = items.indexOf(active.id);
      const newIndex = items.indexOf(over.id);
      setItems(arrayMove(items, oldIndex, newIndex)); // 리스트 순서 변경
    }
  };

  return (
    <div style={{padding: 40}}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={items}
          strategy={verticalListSortingStrategy} // 수직 정렬
        >
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  );
}

1. SortableContext

  • 리스트 정렬이 가능한 컨텍스트를 설정합니다.
  • SortableContext는 중첩 가능하지만 반드시 적절한 items와 strategy가 있어야 함
  • items는 string ID 배열이어야 함
  • strategy로 정렬 방향을 지정:
verticalListSortingStrategy     // 위아래 정렬
horizontalListSortingStrategy   // 좌우 정렬

2. useSortable

  • 각 아이템마다 부여
  • id를 기준으로 정렬 대상 식별
  • 반환값 주요 속성:
    • setNodeRef: DOM에 ref 연결
    • transform: 드래그 위치 계산
    • transition: 부드러운 이동 효과
    • attributes, listeners: 필수 이벤트 바인딩
const {
  setNodeRef,
  attributes,
  listeners,
  transform,
  transition,
} = useSortable({ id });

3. arrayMove

arrayMove(array, fromIndex, toIndex)
  • 배열의 요소를 한 위치에서 다른 위치로 이동시키는 유틸
  • 상태 업데이트 시 필수 사용

4. transform과 transition

transform: CSS.Transform.toString(transform),
transition,
  • transform: 현재 요소의 이동 거리 정보 (x, y)
  • transition: 드래그 후 자연스럽게 제자리로 이동하는 애니메이션

체크포인트

  • 리스트 아이템을 드래그로 재정렬 가능
  • 드래그 중인 아이템에 배경 효과 적용
  • 콘솔 없이도 정렬 반영됨
  • 수직/수평 정렬 전략 변경 가능

실습 목표

  • 여러 개의 "리스트(Column)"와 각 "리스트" 안의 "카드" 구성
  • 카드를 같은 리스트 내에서 정렬 가능
  • 커서를 이동하면 카드가 부드럽게 움직이고 커서 모양이 변경됨
  • (선택 확장 가능) 다른 리스트 간 카드 이동

예제 구조

  • 2개의 리스트 (예: TODO, Done)
  • 각 리스트는 고유한 카드 배열을 가짐
  • @dnd-kit/sortable을 사용해 카드 정렬

구현 코드

// TrelloBoard.tsx

import React, {useState} from 'react';
import {
  DndContext,
  closestCorners,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';

// 카드 컴포넌트
function Card({id}: {id: string}) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({id});

  const style: React.CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
    backgroundColor: isDragging ? '#ffe08a' : '#fff',
    padding: 12,
    marginBottom: 8,
    border: '1px solid #ddd',
    borderRadius: 6,
    cursor: 'grab',
    boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
    userSelect: 'none',
  };

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {id}
    </div>
  );
}

// 리스트 컴포넌트
function CardList({
  title,
  items,
  setItems,
}: {
  title: string;
  items: string[];
  setItems: React.Dispatch<React.SetStateAction<string[]>>;
}) {
  return (
    <div
      style={{
        width: 250,
        padding: 16,
        backgroundColor: '#f5f5f5',
        borderRadius: 8,
        boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
        marginRight: 20,
      }}
    >
      <h3 style={{marginBottom: 12}}>{title}</h3>
      <SortableContext items={items} strategy={verticalListSortingStrategy}>
        {items.map((id) => (
          <Card key={id} id={id} />
        ))}
      </SortableContext>
    </div>
  );
}

// 전체 보드
export default function TrelloBoard() {
  const [todo, setTodo] = useState(['Card A', 'Card B', 'Card C']);
  const [done, setDone] = useState(['Card D', 'Card E']);

  const sensors = useSensors(useSensor(PointerSensor));

  const handleDragEnd = ({active, over}: any) => {
    if (!over) return;

    // TODO 리스트 처리
    if (todo.includes(active.id) && todo.includes(over.id)) {
      const oldIndex = todo.indexOf(active.id);
      const newIndex = todo.indexOf(over.id);
      setTodo(arrayMove(todo, oldIndex, newIndex));
    }

    // Done 리스트 처리
    if (done.includes(active.id) && done.includes(over.id)) {
      const oldIndex = done.indexOf(active.id);
      const newIndex = done.indexOf(over.id);
      setDone(arrayMove(done, oldIndex, newIndex));
    }

    // (선택) 리스트 간 이동은 다음 단계에서 다룸
  };

  return (
    <div style={{padding: 32}}>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragEnd={handleDragEnd}
      >
        <div style={{display: 'flex', flexDirection: 'row'}}>
          <CardList title="TODO" items={todo} setItems={setTodo} />
          <CardList title="Done" items={done} setItems={setDone} />
        </div>
      </DndContext>
    </div>
  );
}

스타일 및 애니메이션 적용 요약

  • 커서 변경: cursor: 'grab' (드래그 중은 자동으로 grabbing)
  • 애니메이션: transition과 boxShadow로 드래그 중 강조
  • 위치 이동: transform: CSS.Transform.toString(transform)로 자연스러운 이동 처리

확장 아이디어

  • 다른 리스트 간 카드 이동
  • 카드 추가/삭제 기능
  • dragOverlay 사용해서 유령 카드 표시
  • Zustand 같은 글로벌 상태관리 연동

설치 명령 정리

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

3단계: 센서와 충돌 감지 커스터마이징


1. useSensor & useSensors

개념

dnd-kit에서는 **센서(sensor)**를 통해 사용자의 입력 방식(마우스, 터치, 키보드 등)을 감지하고 드래그 동작을 시작합니다.

코드 예시

const sensors = useSensors(
  useSensor(PointerSensor),
  useSensor(KeyboardSensor),
  useSensor(TouchSensor)
);

<DndContext sensors={sensors}>
  {/* ... */}
</DndContext>

차이점 요약

센서 종류 설명

PointerSensor 마우스 및 터치 (기본 센서)
TouchSensor 순수 터치 전용 (모바일 환경 전용으로 더 민감함)
KeyboardSensor 키보드로 리스트 이동 (접근성 용도)

2. PointerSensor, TouchSensor, KeyboardSensor

PointerSensor (기본)

  • 마우스 클릭, 드래그, 터치 이벤트 대응
  • 드래그 시작 감지가 부드럽고 표준적
useSensor(PointerSensor, {
  activationConstraint: {
    distance: 5, // 5px 이상 이동해야 드래그 시작
  },
});

TouchSensor

  • 모바일에서 더 빠르게 반응
  • 진동 피드백 등을 커스터마이징 가능
  • 사용 예:
useSensor(TouchSensor, {
  activationConstraint: {
    delay: 150,
    tolerance: 8,
  },
});

KeyboardSensor

  • 키보드로 이동 가능
  • 방향키 기반 정렬
  • coordinateGetter를 설정해야 함
useSensor(KeyboardSensor, {
  coordinateGetter: sortableKeyboardCoordinates,
});

3. collisionDetection 알고리즘

기본 구조

<DndContext
  collisionDetection={closestCenter} // 알고리즘 설정
>
  {/* ... */}
</DndContext>

4. 주요 충돌 판정 알고리즘

1. closestCenter

드롭 가능한 모든 요소 중 중심점 간 거리가 가장 가까운 요소

  • 기본적이며 가장 직관적인 방식
  • 일반적인 정렬 UI에 적합
<DndContext collisionDetection={closestCenter}>

2. rectIntersection

드래그된 요소와 겹치는 사각형 영역의 면적이 가장 큰 요소

  • 두 요소가 많이 겹칠수록 선택됨
  • 중첩 리스트나 박스가 겹치는 UI에 유리

3. pointerWithin

마우스 커서의 위치가 들어있는 드롭 요소만 대상이 됨

  • 정확한 위치가 중요할 때 사용
  • 작은 요소나 포인트 기반 드래그에 적합
<DndContext collisionDetection={pointerWithin}>

5. 커스텀 충돌 판정 함수

예제: 가장 왼쪽에 있는 요소를 선택하는 커스텀 판정

import type {CollisionDetection} from '@dnd-kit/core';

const leftMostCollision: CollisionDetection = ({collisionRect, droppableRects}) => {
  const entries = Object.entries(droppableRects);

  if (entries.length === 0) return [];

  const sorted = entries.sort(
    ([, a], [, b]) => a.left - b.left // 왼쪽에서 가장 가까운 순
  );

  const [id, rect] = sorted[0];

  return [
    {
      id,
      data: {
        value: rect,
      },
    },
  ];
};

<DndContext collisionDetection={leftMostCollision}>

실습 아이디어

  1. 마우스와 키보드 둘 다 지원하는 드래그 시스템
  2. 드롭 대상 위에만 반응하는 pointerWithin 예제
  3. 중첩 영역일 때 rectIntersection 비교
  4. 가장 왼쪽/오른쪽에 있는 요소 선택 (custom)
입력 장치 감지 useSensor(PointerSensor / TouchSensor / KeyboardSensor)
다중 입력 지원 useSensors(...sensors)
충돌 판정 `collisionDetection={closestCenter
고급 위치 제어 커스텀 collision 함수 직접 정의

 

실습 목표

  1. 키보드로 정렬 가능한 리스트 조작
  2. pointerWithin vs closestCenter 충돌 방식 비교
  3. 정확한 드롭 타겟 감지를 위한 pointerWithin 적용
  4. KeyboardSensor로 키보드 기반 정렬 제어
  5. 커스텀 collisionDetection으로 드롭 우선순위 지정

전체 예제 구성 (기본 리스트로 시작)

import React, { useState } from 'react';
import {
  DndContext,
  closestCenter,
  pointerWithin,
  useSensor,
  useSensors,
  PointerSensor,
  KeyboardSensor,
} from '@dnd-kit/core';

import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
  sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';

import { CSS } from '@dnd-kit/utilities';

function SortableItem({ id }: { id: string }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    padding: 12,
    marginBottom: 8,
    backgroundColor: isDragging ? '#ffe08a' : '#f5f5f5',
    border: '1px solid #ddd',
    borderRadius: 6,
    cursor: 'grab',
    userSelect: 'none',
  };

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {id}
    </div>
  );
}

export default function KeyboardSortableExample() {
  const [items, setItems] = useState(['Alpha', 'Bravo', 'Charlie', 'Delta']);

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const handleDragEnd = (event: any) => {
    const { active, over } = event;

    if (active.id !== over?.id) {
      const oldIndex = items.indexOf(active.id);
      const newIndex = items.indexOf(over.id);
      setItems(arrayMove(items, oldIndex, newIndex));
    }
  };

  return (
    <div style={{ padding: 40 }}>
      <h2>정렬 가능한 리스트 (마우스 + 키보드 지원)</h2>
      <p style={{ fontSize: 14, color: '#666' }}>↑↓ 키로 이동 가능</p>

      <DndContext
        sensors={sensors}
        collisionDetection={pointerWithin} // pointerWithin 또는 closestCenter
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={items} strategy={verticalListSortingStrategy}>
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  );
}

결과 확인

  • 마우스로도 드래그 가능하고,
  • 키보드(↑↓)로도 항목을 이동 가능함
  • 드롭 판정 기준은 pointerWithin이므로 커서가 실제 드롭 타겟 위에 있어야만 반응

collisionDetection 알고리즘 비교

// 가장 직관적인 기준: 아이템 중앙점 비교
collisionDetection={closestCenter}

// 포인터 위치만 기준 → 더 정밀
collisionDetection={pointerWithin}

 

  • pointerWithin: 커서가 겹치지 않으면 드롭이 되지 않음 → 정확도 높음
  • closestCenter: 눈에 보이는 위치가 중심점에 가까우면 드롭됨 → 직관적이지만 중첩 시 혼란 가능

커스텀 collision 함수 예제

const customCollision = ({ collisionRect, droppableRects }) => {
  const collisions = Object.entries(droppableRects).map(([id, rect]) => {
    const overlap =
      Math.max(0, Math.min(rect.left + rect.width, collisionRect.left + collisionRect.width) -
                  Math.max(rect.left, collisionRect.left)) *
      Math.max(0, Math.min(rect.top + rect.height, collisionRect.top + collisionRect.height) -
                  Math.max(rect.top, collisionRect.top));
    return { id, data: { overlap } };
  });

  return collisions
    .filter((c) => c.data.overlap > 0)
    .sort((a, b) => b.data.overlap - a.data.overlap);
};
<DndContext collisionDetection={customCollision}>
  • 겹치는 면적이 많은 드롭존 우선 선택
  • 실제 위치 + 겹침 면적 기반으로 충돌을 더 정밀하게 감지

혼합해서 사용

const hybridCollision = (args) => {
  const overlap = customCollision(args);
  if (overlap.length > 0) return overlap;
  return closestCorners(args); // fallback
};

정리 요약

키보드 정렬 KeyboardSensor + sortableKeyboardCoordinates
센서 다중 등록 useSensors(useSensor(...))
포인터 기준 충돌 collisionDetection={pointerWithin}
중심 기준 충돌 collisionDetection={closestCenter}
커스텀 충돌 기준 collisionDetection={yourFunc}

4단계: 커스터마이징과 제약 조건


 

  1. 드래그 방향 제한 (수직/수평)
  2. 위치 스냅 (센터 정렬, 그리드)
  3. 브라우저 창 밖으로 이동 방지
  4. 특정 영역 내에서만 드래그 허용
  5. 드래그 핸들 구현
  6. 드래그/드롭 영역 분리
  7. 다중 컨테이너 리스트 간 아이템 이동

1. modifiers 활용 (위치 제한)

modifiers는 드래그 중 위치를 동적으로 제한할 수 있게 도와주는 옵션입니다.

예시: 수직 이동만 허용

import { restrictToVerticalAxis } from '@dnd-kit/modifiers';

<DndContext
  modifiers={[restrictToVerticalAxis]}
  ...
>

주요 모듈

  • restrictToVerticalAxis : Y축 이동만 허용
  • restrictToHorizontalAxis : X축 이동만 허용
  • restrictToWindowEdges : 화면 밖으로 이동 금지
  • snapCenterToCursor : 드래그 아이템 중심을 커서에 맞춤

2. Snap to Grid 구현

import { Modifier } from '@dnd-kit/core';

const snapToGrid = (gridSize = 20): Modifier => ({ transform }) => {
  return {
    ...transform,
    x: Math.round(transform.x / gridSize) * gridSize,
    y: Math.round(transform.y / gridSize) * gridSize,
  };
};

// 사용
<DndContext modifiers={[snapToGrid(30)]}>
  • snapToGrid(gridSize) : 일정 단위로 위치 스냅

3. 특정 영역 내에서만 드래그 허용

import { restrictToBoundingRect } from '@dnd-kit/modifiers';

// 특정 DOM 요소 참조
const containerRef = useRef<HTMLDivElement>(null);

<DndContext modifiers={[restrictToBoundingRect(containerRef)]}>
  <div ref={containerRef} style={{ width: 300, height: 400, overflow: 'hidden' }}>
    <DraggableBox />
  </div>
</DndContext>
  • restrictToBoundingRect(ref)는 해당 영역 안으로만 제한함

4. 드래그 핸들 만들기

"아이템 전체가 아니라 버튼을 눌러야만 드래그 가능"

function CardWithHandle({ id }) {
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    backgroundColor: '#fff',
    padding: 12,
    marginBottom: 8,
    border: '1px solid #ccc',
    borderRadius: 6,
  };

  return (
    <div ref={setNodeRef} style={style}>
      <button
        {...listeners}
        {...attributes}
        style={{
          cursor: 'grab',
          background: 'lightgray',
          border: 'none',
          marginBottom: 8,
        }}
      >
        ↕ 드래그
      </button>
      {id}
    </div>
  );
}

5. 드래그/드롭 가능한 영역 분리

  • useDraggable()로 만든 요소는 이동 가능
  • useDroppable()로 만든 요소는 타겟

예: 카드만 드래그 가능하고, 특정 열에만 드롭 가능

useDroppable({ id: 'only-column-1' });

onDragEnd={({ active, over }) => {
  if (!over || over.id !== 'only-column-1') return;
}

6. 그룹 간 이동 (Multi-Container)

리스트 간 아이템 이동 구현

const [columns, setColumns] = useState({
  todo: ['A', 'B'],
  done: ['C'],
});

const handleDragEnd = ({ active, over }) => {
  const source = findColumnOf(active.id);
  const target = findColumnOf(over?.id);
  
  if (!target || source === target) return;

  setColumns((prev) => {
    const from = prev[source].filter((item) => item !== active.id);
    const to = [...prev[target], active.id];

    return {
      ...prev,
      [source]: from,
      [target]: to,
    };
  });
};
  • SortableContext를 리스트마다 따로 설정
  • items는 고유 ID를 써야 함
  • collisionDetection은 closestCorners 추천

설치가 필요한 모듈

npm install @dnd-kit/modifiers

🔍 실습 추천 조합

시나리오 적용 요소

수직 카드 정렬 restrictToVerticalAxis
트렐로 스타일 이동 multi-container + closestCorners
커서로만 이동 감지 pointerWithin
커서 중심 스냅 snapCenterToCursor
그리드 정렬 UI snapToGrid(20)
드래그 핸들 버튼 listeners만 특정 요소에 바인딩

실습 목표

  1. Y축(수직) 이동만 가능하도록 제한
  2. 특정 DOM 영역 안에서만 드래그 허용
  3. 드래그 핸들(Button)을 눌러야만 드래그 가능
  4. 드래그 위치가 30px 단위로 스냅되도록 설정
  5. 두 개의 리스트 간 카드 이동 구현 (TODO → DONE)

구현 코드 전체

import React, { useRef, useState } from 'react';
import {
  DndContext,
  closestCorners,
  useSensor,
  useSensors,
  PointerSensor,
  type Modifier,
} from '@dnd-kit/core';
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
  restrictToParentElement,
  restrictToVerticalAxis,
} from '@dnd-kit/modifiers';

const snapToGrid =
  (gridSize = 20): Modifier =>
  ({ transform }) => {
    return {
      ...transform,
      x: Math.round(transform.x / gridSize) * gridSize,
      y: Math.round(transform.y / gridSize) * gridSize,
    };
  };

// Snap to 30px grid
function DraggableCard({ id }: { id: string }) {
  const {
    setNodeRef,
    transform,
    transition,
    attributes,
    listeners,
    isDragging,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    padding: 12,
    marginBottom: 8,
    backgroundColor: isDragging ? '#ffa' : '#fff',
    border: '1px solid #ccc',
    borderRadius: 6,
    userSelect: 'none',
    cursor: 'default',
  };

  return (
    <div ref={setNodeRef} style={style}>
      <button
        {...attributes}
        {...listeners} // ✅ 이게 중요
        style={{
          cursor: 'grab',
          padding: 4,
          fontSize: 12,
          marginBottom: 6,
          backgroundColor: '#eee',
        }}>
        ↕ 드래그 핸들
      </button>
      {id}
    </div>
  );
}

function CardColumn({
  title,
  cards,
  setCards,
}: {
  title: string;
  cards: string[];
  setCards: React.Dispatch<React.SetStateAction<string[]>>;
}) {
  return (
    <div
      style={{
        width: 240,
        padding: 16,
        marginRight: 20,
        backgroundColor: '#f9f9f9',
        borderRadius: 8,
      }}>
      <h3>{title}</h3>
      {cards.map((id) => (
        <DraggableCard key={id} id={id} />
      ))}
    </div>
  );
}

export default function Test7Page() {
  const [todo, setTodo] = useState(['Task A', 'Task B']);
  const [done, setDone] = useState(['Task C']);

  const containerRef = useRef(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 5,
      },
    }),
  );

  const findListContaining = (id: string) =>
    todo.includes(id) ? 'todo' : done.includes(id) ? 'done' : null;

  const handleDragEnd = ({ active, over }: any) => {
    if (!over) return;

    const fromList = findListContaining(active.id);
    const toList = findListContaining(over.id);

    if (!fromList || !toList) return;

    if (fromList === toList) {
      const list = fromList === 'todo' ? todo : done;
      const setList = fromList === 'todo' ? setTodo : setDone;
      const oldIndex = list.indexOf(active.id);
      const newIndex = list.indexOf(over.id);
      const targetIndex = newIndex === -1 ? toList.length : newIndex;
      const newList = [...list];
      newList.splice(oldIndex, 1);
      newList.splice(targetIndex, 0, active.id);
      setList(newList);
    } else {
      const from = fromList === 'todo' ? todo : done;
      const to = toList === 'todo' ? todo : done;
      const setFrom = fromList === 'todo' ? setTodo : setDone;
      const setTo = toList === 'todo' ? setTodo : setDone;

      setFrom(from.filter((i) => i !== active.id));
      setTo([...to, active.id]);
    }
  };

  return (
    <div style={{ padding: 40 }}>
      <h2>드래그 제약 + 핸들 + 스냅 + 리스트 이동</h2>

      <div
        ref={containerRef}
        style={{
          display: 'flex',
          flexDirection: 'row',
          border: '2px dashed #aaa',
          padding: 16,
          borderRadius: 12,
          overflow: 'hidden',
        }}>
        <DndContext
          sensors={sensors}
          // active를 걸어서 상하로만 이동 가능한 핸들 생성 가능
          modifiers={[
            restrictToVerticalAxis, // 상하로만 이동 가능하도록
            restrictToParentElement, // 좌우로만 이동 가능하도록
            snapToGrid(10), // 드르륵 거리는 느낌
          ]}
          collisionDetection={closestCorners}
          onDragEnd={handleDragEnd}>
          <SortableContext
            items={[...todo, ...done]} // 두 리스트를 합쳐 전역 context로 인식됨
            strategy={verticalListSortingStrategy}>
            <CardColumn title="TODO" cards={todo} setCards={setTodo} />
            <CardColumn title="DONE" cards={done} setCards={setDone} />
          </SortableContext>
        </DndContext>
      </div>
    </div>
  );
}

수직 드래그 제한 restrictToVerticalAxis
컨테이너 안으로 제한 restrictToBoundingRect(ref)
30px 단위 스냅 snapToGrid(30) 커스텀 modifier
핸들로만 드래그 onPointerDown에서 id 저장 후 listener로 제한
리스트 간 이동 TODO ↔ DONE 상태 배열 재정렬

필요한 설치

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/modifiers

5단계: 트리 구조 & 중첩 리스트

좋습니다! 이번 실습에서는 dnd-kit을 활용한 트리 구조(계층형, Nested Sortable) UI 구현을 다룹니다.
이 패턴은 폴더 구조, 태그 트리, 메뉴 구성 UI 등에 널리 쓰입니다.


실습 목표

  1. 중첩된 리스트 구조를 드래그 & 드롭으로 정렬
  2. SortableContext를 계층적으로 사용
  3. id를 path 구조 (folder/item)로 관리
  4. 들여쓰기(indent)로 트리 표현
  5. 드래그 중 dragOverlay로 유령 아이템 표시

데이터 구조 설계

type TreeItem = {
  id: string;              // 예: "root/child1"
  parent: string | null;   // 상위 ID (없으면 root)
  depth: number;           // 트리 깊이 (스타일용)
  children?: string[];     // 자식 노드 ID 리스트
};

트리 정렬 전체 예제

// TreeSortable.tsx
import React, { useState } from 'react';
import {
  DndContext,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

type TreeItem = {
  id: string;
  parent: string | null;
  depth: number;
};

const initialItems: TreeItem[] = [
  { id: 'folder1', parent: null, depth: 0 },
  { id: 'folder1/item1', parent: 'folder1', depth: 1 },
  { id: 'folder1/item2', parent: 'folder1', depth: 1 },
  { id: 'folder2', parent: null, depth: 0 },
  { id: 'folder2/item3', parent: 'folder2', depth: 1 },
];

function TreeRow({ id, depth }: { id: string; depth: number }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    padding: 8,
    marginBottom: 4,
    backgroundColor: isDragging ? '#e0f7fa' : '#fff',
    border: '1px solid #ccc',
    borderRadius: 4,
    paddingLeft: 12 + depth * 20,
    cursor: 'grab',
  };

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {id.split('/').pop()}
    </div>
  );
}

export default function TreeSortable() {
  const [items, setItems] = useState<TreeItem[]>(initialItems);
  const [activeId, setActiveId] = useState<string | null>(null);

  const sensors = useSensors(useSensor(PointerSensor));

  const visibleItems = items.filter((item) => item.parent === null || items.find(i => i.id === item.parent));

  const handleDragStart = ({ active }: any) => {
    setActiveId(active.id);
  };

  const handleDragEnd = ({ active, over }: any) => {
    if (!over || active.id === over.id) return;

    const oldIndex = items.findIndex((i) => i.id === active.id);
    const newIndex = items.findIndex((i) => i.id === over.id);

    const newItems = [...items];
    const [moved] = newItems.splice(oldIndex, 1);
    newItems.splice(newIndex, 0, moved);

    setItems(newItems);
    setActiveId(null);
  };

  return (
    <div style={{ padding: 32 }}>
      <h2>📁 트리 구조 정렬 (Nested Sortable)</h2>

      <DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
        <SortableContext
          items={visibleItems.map((i) => i.id)}
          strategy={verticalListSortingStrategy}
        >
          {visibleItems.map((item) => (
            <TreeRow key={item.id} id={item.id} depth={item.depth} />
          ))}
        </SortableContext>

        <DragOverlay>
          {activeId && (
            <div
              style={{
                padding: 8,
                backgroundColor: '#b2ebf2',
                border: '1px solid #00796b',
                borderRadius: 4,
              }}
            >
              {activeId.split('/').pop()}
            </div>
          )}
        </DragOverlay>
      </DndContext>
    </div>
  );
}

구현된 기능

트리 계층 구조 depth 값으로 들여쓰기 조절
중첩된 아이템 id를 path (folder/item) 형태로 관리
SortableContext 단일 컨텍스트에서 visible item만 정렬
드래그 유령 표시 DragOverlay로 현재 아이템 표시
정렬 array.splice()로 위치 이동

설치 필요 모듈

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

실습 목표

  • 폴더/아이템 트리 구조로 표시
  • 각 요소는 들여쓰기(depth)로 시각적으로 표현
  • 드래그 시 같은 부모 안에서만 위치 변경
  • 계층(부모-자식 구조)은 유지하면서 위치만 바꾸기
  • DragOverlay로 유령 아이템 표시

데이터 구조 설계

type TreeNode = {
  id: string;
  parentId: string | null;
  title: string;
  depth: number;
};

전체 코드 (기초 완료 버전)

// TreeFolderSortable.tsx

import React, { useState } from 'react';
import {
  DndContext,
  DragOverlay,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

type TreeNode = {
  id: string;
  parentId: string | null;
  title: string;
  depth: number;
};

const initialData: TreeNode[] = [
  { id: 'folder-a', parentId: null, title: '📁 Folder A', depth: 0 },
  { id: 'file-a1', parentId: 'folder-a', title: '📄 File A1', depth: 1 },
  { id: 'file-a2', parentId: 'folder-a', title: '📄 File A2', depth: 1 },
  { id: 'folder-b', parentId: null, title: '📁 Folder B', depth: 0 },
  { id: 'file-b1', parentId: 'folder-b', title: '📄 File B1', depth: 1 },
];

function TreeItem({ node }: { node: TreeNode }) {
  const {
    setNodeRef,
    attributes,
    listeners,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: node.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    padding: 8,
    marginBottom: 4,
    paddingLeft: 20 * node.depth + 12,
    backgroundColor: isDragging ? '#e0f7fa' : '#fff',
    border: '1px solid #ccc',
    borderRadius: 4,
    cursor: 'grab',
  };

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {node.title}
    </div>
  );
}

export default function TreeFolderSortable() {
  const [nodes, setNodes] = useState<TreeNode[]>(initialData);
  const [activeId, setActiveId] = useState<string | null>(null);

  const sensors = useSensors(useSensor(PointerSensor));

  const activeNode = nodes.find((n) => n.id === activeId);

  const handleDragStart = ({ active }: any) => {
    setActiveId(active.id);
  };

  const handleDragEnd = ({ active, over }: any) => {
    setActiveId(null);
    if (!over || active.id === over.id) return;

    const activeIndex = nodes.findIndex((n) => n.id === active.id);
    const overIndex = nodes.findIndex((n) => n.id === over.id);
    const movingNode = nodes[activeIndex];
    const targetNode = nodes[overIndex];

    // 부모가 다르면 이동 불가 (계층 유지)
    if (movingNode.parentId !== targetNode.parentId) return;

    const updated = [...nodes];
    updated.splice(activeIndex, 1);
    updated.splice(overIndex, 0, movingNode);

    setNodes(updated);
  };

  return (
    <div style={{ padding: 32 }}>
      <h2>📂 트리 구조 정렬 (계층 유지)</h2>

      <DndContext
        sensors={sensors}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={nodes.map((n) => n.id)}
          strategy={verticalListSortingStrategy}
        >
          {nodes.map((node) => (
            <TreeItem key={node.id} node={node} />
          ))}
        </SortableContext>

        <DragOverlay>
          {activeNode && (
            <div
              style={{
                padding: 8,
                borderRadius: 4,
                backgroundColor: '#b2ebf2',
                border: '1px solid #0097a7',
              }}
            >
              {activeNode.title}
            </div>
          )}
        </DragOverlay>
      </DndContext>
    </div>
  );
}

트리 구조 시각화 depth를 기반으로 paddingLeft 설정
정렬 조건 같은 parentId를 갖는 항목끼리만 순서 변경 허용
유령 아이템 DragOverlay로 드래그 중 표시
순서 변경 array.splice() 사용한 재정렬

설치 필요한 모듈

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

6단계: 접근성과 사용자 경험 개선


실습 목표

  1. KeyboardSensor 커스터마이징 (접근성/키보드 정렬 지원)
  2. 드래그 중 DragOverlay 커스터마이징 (예쁜 유령 아이템)
  3. Hover 시 스타일 반응 추가
  4. 드래그/드롭 시 효과음 재생
  5. 렌더링 최적화: useMemo, React.memo 활용

1. KeyboardSensor 커스터마이징

import {
  DndContext,
  useSensor,
  useSensors,
  PointerSensor,
  KeyboardSensor,
} from '@dnd-kit/core';

import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';

const sensors = useSensors(
  useSensor(PointerSensor),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);

<DndContext sensors={sensors}>
  ...
</DndContext>

기능:

  • Tab, Shift+Tab으로 포커스 이동
  • Enter로 드래그 시작 / Space로 드롭
  • ↑, ↓로 항목 이동

2. DragOverlay 커스터마이징

<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-out' }}>
  {activeItem && (
    <div
      style={{
        padding: 12,
        backgroundColor: '#fffbe6',
        border: '2px dashed #facc15',
        borderRadius: 6,
        boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
        fontWeight: 'bold',
      }}
    >
      {activeItem.label}
    </div>
  )}
</DragOverlay>

기능:

  • 유령 아이템을 시각적으로 강조
  • 투명도, border, 그림자 등 설정 가능

3. Hover 시 스타일 변경

function DraggableItem({ id, label }) {
  const [hover, setHover] = useState(false);

  const style = {
    padding: 10,
    marginBottom: 6,
    border: '1px solid #ccc',
    borderRadius: 4,
    backgroundColor: hover ? '#e6f7ff' : '#fff',
    transition: 'background-color 0.2s ease',
  };

  return (
    <div
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={style}
    >
      {label}
    </div>
  );
}

기능:

  • Hover 시 부드러운 시각 피드백 적용
  • group-hover, Tailwind도 활용 가능

4. 드래그/드롭 시 효과음 재생

import useSound from 'use-sound';

const [playDrag] = useSound('/sounds/drag-start.mp3');
const [playDrop] = useSound('/sounds/drag-end.mp3');

<DndContext
  onDragStart={() => playDrag()}
  onDragEnd={() => playDrop()}
>
  • 오디오 효과는 use-sound 사용
  • 깔끔한 UX: 드래그 시작/종료 이벤트에 효과음 연결
npm install use-sound

5. 렌더링 최적화

React.memo + useMemo

const MemoizedItem = React.memo(function MemoizedItem({ id, label }) {
  console.log('Rendering:', id);
  return <div>{label}</div>;
});

리스트 렌더 최적화 예시

const items = useMemo(() => computeVisibleItems(data), [data]);

목적:

  • 대규모 리스트에서 불필요한 리렌더 방지
  • transform, hover와 같이 자주 바뀌는 값만 최소한의 범위에서 업데이트

키보드 정렬 지원 KeyboardSensor + sortableKeyboardCoordinates
유령 아이템 커스터마이징 DragOverlay
시각 피드백 onMouseEnter, hover, transition
소리 피드백 use-sound, onDragStart/onDragEnd
성능 최적화 React.memo, useMemo, useCallback

필수 설치 모듈

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/accessibility use-sound

실습 목표

  1. 시각 피드백이 좋은 드래그 컴포넌트 만들기
    • Hover 시 강조
    • 드래그 중 음영과 컬러 변화
    • DragOverlay로 유령 컴포넌트 표시
    • 드래그 시작/끝에 부드러운 전환 애니메이션
  2. 키보드 기반 정렬 UI 컨트롤
    • 방향키로 이동 (↑, ↓)
    • Enter로 드래그 시작, Space로 드롭
    • Tab/Shift+Tab으로 항목 선택 전환

실습 코드

// AccessibleSortableList.tsx
import React, { useState } from 'react';
import {
  DndContext,
  PointerSensor,
  KeyboardSensor,
  useSensor,
  useSensors,
  DragOverlay,
} from '@dnd-kit/core';
import {
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
  arrayMove,
  sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

const initialItems = ['Apple', 'Banana', 'Carrot', 'Date'];

function SortableItem({ id }: { id: string }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
    isSorting,
    over,
  } = useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    padding: 12,
    marginBottom: 8,
    borderRadius: 6,
    backgroundColor: isDragging
      ? '#ffe082'
      : isSorting
      ? '#e0f7fa'
      : over?.id === id
      ? '#c8e6c9'
      : '#fff',
    border: '1px solid #ccc',
    boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.2)' : 'none',
    cursor: 'grab',
    fontWeight: isDragging ? 'bold' : 'normal',
    outline: 'none',
  };

  return (
    <div
      ref={setNodeRef}
      {...attributes}
      {...listeners}
      style={style}
      tabIndex={0} // 키보드 탐색을 위해
    >
      {id}
    </div>
  );
}

export default function AccessibleSortableList() {
  const [items, setItems] = useState(initialItems);
  const [activeId, setActiveId] = useState<string | null>(null);

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const activeItem = items.find((i) => i === activeId);

  const handleDragStart = ({ active }: any) => {
    setActiveId(active.id);
  };

  const handleDragEnd = ({ active, over }: any) => {
    setActiveId(null);
    if (!over || active.id === over.id) return;

    const oldIndex = items.indexOf(active.id);
    const newIndex = items.indexOf(over.id);

    setItems(arrayMove(items, oldIndex, newIndex));
  };

  return (
    <div style={{ padding: 32, maxWidth: 320 }}>
      <h2 style={{ marginBottom: 12 }}>📦 정렬 가능한 키보드 접근 리스트</h2>

      <DndContext
        sensors={sensors}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={items} strategy={verticalListSortingStrategy}>
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </SortableContext>

        <DragOverlay>
          {activeItem && (
            <div
              style={{
                padding: 12,
                borderRadius: 6,
                backgroundColor: '#fff3e0',
                border: '2px dashed #ff9800',
                boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
                fontWeight: 'bold',
              }}
            >
              {activeItem}
            </div>
          )}
        </DragOverlay>
      </DndContext>

      <p style={{ marginTop: 16, fontSize: 14, color: '#666' }}>
        ↑↓ 방향키로 항목을 이동하고<br />
        Enter로 드래그 시작 / Space로 드롭
      </p>
    </div>
  );
}

드래그 시 음영 및 강조 isDragging, isSorting으로 배경색 변경
드래그 오버 시 시각적 표시 over?.id === id일 때 색상 변화
키보드 조작 KeyboardSensor + sortableKeyboardCoordinates
포커스 가능 항목 tabIndex={0} 설정 필수
유령 아이템 오버레이 DragOverlay에 커스텀 스타일 적용

설치 필요 모듈

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities @dnd-kit/accessibility

7단계: 성능 최적화 및 실전 적용


실습 목표

  1. React.memo로 불필요한 렌더링 방지
  2. useCallback, useMemo로 참조 안정성 유지
  3. react-virtual로 대규모 리스트 가상화 + drag 연동
  4. Zustand 등 상태관리와 drag-drop 연동
  5. 실제 CMS에서 사용되는 태그 정렬/편집 UI 구성

1. React.memo + useCallback + useMemo

const SortableItem = React.memo(function SortableItem({ id, label }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });

  const style = useMemo(
    () => ({
      transform: CSS.Transform.toString(transform),
      transition,
      backgroundColor: isDragging ? '#ffe082' : '#fff',
      padding: 12,
      marginBottom: 8,
    }),
    [transform, isDragging]
  );

  return (
    <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {label}
    </div>
  );
});
  • React.memo: props가 바뀌지 않으면 재렌더링 방지
  • useMemo: style 객체 재생성 방지
  • useCallback: 이벤트 핸들러 참조 안정화

2. react-virtual로 리스트 가상화 + dnd-kit 연동

dnd-kit은 리스트를 직접 렌더링하지 않기 때문에 react-virtual과 호환됩니다.

핵심 구성

npm install @tanstack/react-virtual

예시 (스크롤 최적화만 포함)

import { useVirtualizer } from '@tanstack/react-virtual';

const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 48,
});

return (
  <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
    <div style={{ height: rowVirtualizer.getTotalSize() }}>
      {rowVirtualizer.getVirtualItems().map((virtualRow) => {
        const item = items[virtualRow.index];
        return (
          <div
            key={item.id}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              left: 0,
              width: '100%',
            }}
          >
            <SortableItem id={item.id} label={item.label} />
          </div>
        );
      })}
    </div>
  </div>
);

이와 함께 SortableContext는 항상 전체 아이템을 인식하고 있어야 하므로 items.map(i => i.id)는 유지되어야 함


3. Zustand 등 상태관리와 연동

npm install zustand

예시: 태그 리스트 상태 저장소

// useTagStore.ts
import { create } from 'zustand';

type TagState = {
  tags: string[];
  setTags: (t: string[]) => void;
};

export const useTagStore = create<TagState>((set) => ({
  tags: [],
  setTags: (tags) => set({ tags }),
}));

드래그 이벤트에서 상태 반영

const tags = useTagStore((state) => state.tags);
const setTags = useTagStore((state) => state.setTags);

onDragEnd={({ active, over }) => {
  const oldIndex = tags.indexOf(active.id);
  const newIndex = tags.indexOf(over.id);
  setTags(arrayMove(tags, oldIndex, newIndex));
});

4. 실제 CMS 예제: 태그 정렬 UI

목표

  • 태그를 드래그로 순서 변경
  • 순서 변경 후 서버로 PUT /tags/reorder 요청
  • Zustand로 상태 저장

UI 예제

export default function TagManager() {
  const tags = useTagStore((s) => s.tags);
  const setTags = useTagStore((s) => s.setTags);

  const handleDragEnd = ({ active, over }) => {
    if (!over) return;
    const oldIndex = tags.findIndex((t) => t === active.id);
    const newIndex = tags.findIndex((t) => t === over.id);
    const newOrder = arrayMove(tags, oldIndex, newIndex);
    setTags(newOrder);

    fetch('/api/tags/reorder', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ tags: newOrder }),
    });
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <SortableContext items={tags} strategy={verticalListSortingStrategy}>
        {tags.map((tag) => (
          <SortableItem key={tag} id={tag} label={tag} />
        ))}
      </SortableContext>
    </DndContext>
  );
}

대규모 성능 최적화 react-virtual, React.memo
재계산 최적화 useMemo, useCallback
상태 연동 Zustand, Redux, useForm 등
실전 통합 CMS 태그 정렬, 섹션/페이지 순서 지정 등

목표

  1. 1000개 아이템 리스트를 dnd-kit으로 정렬 가능하게 구현
  2. **React.memo, useMemo, useCallback**을 이용한 렌더링 최적화
  3. Zustand를 통한 전역 상태 관리
  4. (선택 확장) react-virtual을 이용한 가상 스크롤링 연동

1단계: 초기 상태 구성

Zustand 전역 상태 선언

// useItemStore.ts
import { create } from 'zustand';
import { arrayMove } from '@dnd-kit/sortable';

type State = {
  items: string[];
  moveItem: (fromIndex: number, toIndex: number) => void;
};

export const useItemStore = create<State>((set) => ({
  items: Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`),
  moveItem: (from, to) =>
    set((state) => ({
      items: arrayMove(state.items, from, to),
    })),
}));

2단계: React.memo로 렌더링 최적화

Sortable Item (렌더링 최적화된 컴포넌트)

import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React from 'react';

export const SortableItem = React.memo(function SortableItem({ id }: { id: string }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id });

  const style = React.useMemo(() => ({
    transform: CSS.Transform.toString(transform),
    transition,
    padding: '8px 12px',
    marginBottom: 4,
    backgroundColor: isDragging ? '#fff9c4' : '#fafafa',
    border: '1px solid #ccc',
    borderRadius: 4,
  }), [transform, isDragging]);

  return (
    <div ref={setNodeRef} {...attributes} {...listeners} style={style}>
      {id}
    </div>
  );
});

3단계: 메인 정렬 UI 구성

// App.tsx
import React from 'react';
import {
  DndContext,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useItemStore } from './useItemStore';
import { SortableItem } from './SortableItem';

export default function App() {
  const items = useItemStore((state) => state.items);
  const moveItem = useItemStore((state) => state.moveItem);

  const sensors = useSensors(useSensor(PointerSensor));

  const handleDragEnd = ({ active, over }: any) => {
    if (!over || active.id === over.id) return;

    const oldIndex = items.indexOf(active.id);
    const newIndex = items.indexOf(over.id);
    moveItem(oldIndex, newIndex);
  };

  return (
    <div style={{ padding: 32, maxWidth: 400, height: '100vh', overflowY: 'scroll' }}>
      <h2>📦 1000개 아이템 정렬 (Zustand + 최적화)</h2>

      <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
        <SortableContext items={items} strategy={verticalListSortingStrategy}>
          {items.map((id) => (
            <SortableItem key={id} id={id} />
          ))}
        </SortableContext>
      </DndContext>
    </div>
  );
}

성능 팁

React.memo SortableItem 컴포넌트
useMemo style 계산 캐싱
Zustand selector useItemStore((s) => s.items) 로 리렌더 최소화
arrayMove 순서 재정렬 유틸 사용

성능 확장 (선택)

react-virtual 연동시 주의사항

  • items.map(...) 대신 virtualItems.map(...)으로 출력
  • 드래그 가능한 요소는 여전히 전체 리스트에서 참조돼야 하므로 SortableContext는 전체 id 리스트를 사용해야 함
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities zustand

'JavaScript > React' 카테고리의 다른 글

wouter  (0) 2025.06.09
React-Spring  (3) 2025.05.23
React Hook Form  (1) 2025.05.15
React Query  (1) 2025.05.15
Zustand(persist, Zukeeper, Redux DevTools 등)  (0) 2025.04.02