
dnd-kit로 칸반보드 구현하기 (@dnd-kit/react)
최신 버전 @dnd-kit/react는 복잡한 설정과 상태 관리 로직을 라이브러리 내부로 캡슐화해 개발자 경험(DX)을 크게 개선했다.
기존 @dnd-kit/core로 칸반보드를 구현할 때 가장 어려웠던 점은 드래그 자체보다 상태 관리 로직이 훨씬 복잡하다는 것이었다.
- 같은 컬럼 내 이동인지
- 다른 컬럼으로 이동하는지
- index 계산
- 배열 재정렬
이 모든 로직을 직접 구현해야 했다.
하지만 최신 버전인 @dnd-kit/react에서는 👉 드래그 상태 관리를 라이브러리가 대신 처리 해주면서 구현 난이도가 크게 낮아졌다.
🚀 무엇이 달라졌을까?
Migration guide - @dnd-kit
A comprehensive guide to migrate from @dnd-kit/core to @dnd-kit/react
dndkit.com
✅ DragDropProvider 하나로 끝
<DragDropProvider onDragOver={handleDragOver}>
- 기존
DndContext→DragDropProvider로 변경되었다. - 기존에는
Sensors,CollisionDetection,modifiers등을 직접 설정해DndContext에 주입해야 했으나, 이제DragDropProvider하나로 대부분의 기본 설정이 자동으로 처리된다.
✅ SortableContext 제거
다중 리스트를 구현할 때 각 리스트(Column)마다 SortableContext로 감싸고 items 배열을 넘겨줘야 했던 번거로움이 사라졌다.
<SortableContext items={items}>
const { ref, isDragging } = useSortable({
id: task.id,
index,
})
- 이제
DragDropProvider하위에 있다면 개별 아이템에서useSortable만 호출해도 연동된다. useSortable훅에 이전처럼 복잡한 인자를 넘길 필요 없이, 식별을 위한id와 현재 위치인index만 전달하면 된다.
✅ 마법 같은 move 헬퍼 함수의 등장
기존에는 onDragOver, onDragEnd 이벤트에서 active와 over의 ID를 찾고, arrayMove를 사용해 동일 컬럼 내 이동인지 타 컬럼으로의 이동인지 분기 처리하는 복잡한 로직을 직접 작성해야 했다.
이제 @dnd-kit/helpers의 move(prevTasks, event) 단 한 줄로 모든 복잡한 상태 업데이트 로직이 대체되었다.
setTasks(prev => move(prev, event))
👉 이 한 줄로 해결
- 리스트 이동
- 순서 변경
- index 계산
🧠 전체 구조 이해하기
Multiple sortable lists - @dnd-kit
Multiple sortable lists - @dnd-kit
Learn how to reorder sortable elements across multiple lists.
dndkit.com
칸반보드는 구조 이해가 핵심이다.
KanbanBoard
└── DragDropProvider
├── Column (TODO)
│ ├── TaskCard
│ ├── TaskCard
├── Column (IN_PROGRESS)
└── Column (DONE)
📦 상태 구조
type TaskList = Record<string, Task[]>
{
TODO: [...],
IN_PROGRESS: [...],
DONE: []
}
컬럼 기준으로 배열을 관리한다.
dnd-kit의 move함수를 사용해서 업데이트할건데 move 함수가 아래와 같이 {[containerId:string]: Item[]} 형태의 상태 구조를 입력받도록 되어있어 위와 같이 타입을 정해주었다.
declare function move<T extends Items | Record<UniqueIdentifier, Items>, U extends Draggable, V extends Droppable, W extends DragDropManager<U, V>>(items: T, event: Parameters<DragDropEvents<U, V, W>['dragover'] | DragDropEvents<U, V, W>['dragend']>[0]): T;
⚙️ 구현 흐름
1️⃣ 상태 생성
const [tasks, setTasks] = useState(INITIAL_TASKS)
2️⃣ DragDropProvider 적용
<DragDropProvider onDragOver={handleDragOver}>
❌ onDragEnd
✅ onDragOver
onDragOver를 써야 드래그 중에도 상태를 업데이트해야 자연스럽게 이동된다.
나중에 백엔드 API가 나온다면 onDragEnd를 써서 그 때 API를 호출하면 된다.
onDragOver → UI 반영
onDragEnd → API 호출
3️⃣ move로 상태 업데이트
const handleDragOver = (event: any) => {
setTasks(prev => move(prev, event))
}
컴포넌트별 역할
1) KanbanBoard
const [tasks, setTasks] = useState(INITIAL_TASKS)
const handleDragOver = (event: any) => {
setTasks(prev => move(prev, event))
}
- 전체 데이터 관리
- 드래그 이벤트 처리
- Column 렌더링
- move() 한 줄로 모든 상태 변경 처리
export type Task = {
id: string
content: string
}
export type TaskList = Record<string, Task[]>
const INITIAL_TASKS: TaskList = {
TODO: [
{ id: 't-1', content: '디자인 시스템 정립' },
{ id: 't-2', content: '데이터베이스 스키마 설계' },
],
IN_PROGRESS: [{ id: 't-3', content: 'API 문서 자동화' }],
DONE: [],
}
const COLUMN_TITLES: Record<string, string> = {
TODO: '할 일',
IN_PROGRESS: '진행 중',
DONE: '완료',
}
export default function KanbanBoard() {
const [tasks, setTasks] = useState<TaskList>(INITIAL_TASKS)
const handleDragOver = (event: any) => {
setTasks((prevTasks) => move(prevTasks, event))
}
return (
<div className="flex min-h-[calc(100vh-200px)] gap-4">
<DragDropProvider onDragOver={handleDragOver}>
{Object.entries(tasks).map(([columnId, columnTasks]) => (
<Column
key={columnId}
id={columnId}
title={COLUMN_TITLES[columnId]}
tasks={columnTasks}
/>
))}
</DragDropProvider>
</div>
)
}
2) Column
👉 드롭 가능한 영역 (Drop Zone)
const { ref, isDropTarget } = useDroppable({ id })
useDroppable: 이 영역을 드롭 가능한 영역으로 등록하고, 현재 드롭 상태를 알려주는 훅ref={ref}: 이 DOM이 drop 영역이다"라고 연결하는 역할 → 안 쓰면 절대 드롭이 안 된다.- React 컴포넌트 ≠ 실제 DOM
- dnd-kit은 실제 DOM 위치를 기준으로 계산함
- 그래서 ref로 DOM 연결해야 함
isDropTarget: 지금 드래그 중인 아이템이 이 영역 위에 올라와 있는가?- 난 이걸로 css 분기처리를 해주었음
const Column = ({
id,
title,
tasks,
}: {
id: string
title: string
tasks: Task[]
}) => {
const { ref, isDropTarget } = useDroppable({ id })
const colors: Record<string, { bg: string; border: string; ring: string }> = {
TODO: {
bg: 'bg-zinc-500/10',
border: 'border-zinc-500/20',
ring: 'ring-zinc-500/30',
},
IN_PROGRESS: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/20',
ring: 'ring-blue-500/30',
},
DONE: {
bg: 'bg-green-500/10',
border: 'border-green-500/20',
ring: 'ring-green-500/30',
},
}
return (
<div
ref={ref}
className={`flex w-full flex-col rounded-2xl border transition-all duration-200 ${colors[id].border} ${colors[id].bg} p-4 ${isDropTarget ? `ring-1 ${colors[id].ring}` : ''}`}
>
<div className="mb-4 flex items-center justify-between px-1">
<h3 className="mb-4 px-1 font-semibold text-zinc-600">{title}</h3>
<span className="flex size-6 items-center justify-center rounded-full border border-black/5 bg-white p-1 text-xs font-medium text-zinc-500">
{tasks.length}
</span>
</div>
<div className="flex min-h-[150px] flex-col gap-3">
{tasks.map((task, index) => (
<TaskCard key={task.id} task={task} index={index} />
))}
</div>
</div>
)
}
3) TaskCard
드래그 가능한 카드
const { ref, isDragging } = useSortable({
id: task.id,
index,
})
- 드래그 가능한 최소 단위
id→ 반드시 유니크index→ 현재 위치 (정렬 기준)isDragging→ UI 상태 처리
const TaskCard = ({ task, index }: { task: Task; index: number }) => {
const { ref, isDragging } = useSortable({
id: task.id,
index,
})
return (
<div
ref={ref}
className={`cursor-grab rounded-xl border border-zinc-200 bg-white p-4 shadow-sm transition-colors hover:border-zinc-400 active:cursor-grabbing ${isDragging ? 'scale-105 opacity-40 ring-2 ring-zinc-400' : ''} `}
>
<p className="text-sm font-medium text-zinc-800">{task.content}</p>
</div>
)
}
🔄 전체 동작 흐름
드래그가 발생하면 내부적으로
- TaskCard에서 드래그 시작
- Column이 Drop 영역으로 감지
- DragDropProvider가 이벤트 생성
- KanbanBoard에서 이벤트 수신
- move()로 상태 업데이트
🔥 이전 vs 최신 비교
| 항목 | 이전 | 최신 |
|---|---|---|
| Context | DndContext | DragDropProvider |
| 리스트 관리 | SortableContext 필요 | ❌ |
| 이동 로직 | 직접 구현 | move() |
| 코드 복잡도 | 높음 | 낮음 |


풀코드
'use client'
import { useState } from 'react'
import { DragDropProvider, useDroppable } from '@dnd-kit/react'
import { useSortable } from '@dnd-kit/react/sortable'
import { move } from '@dnd-kit/helpers'
export type Task = {
id: string
content: string
}
export type TaskList = Record<string, Task[]>
const INITIAL_TASKS: TaskList = {
TODO: [
{ id: 't-1', content: '디자인 시스템 정립' },
{ id: 't-2', content: '데이터베이스 스키마 설계' },
],
IN_PROGRESS: [{ id: 't-3', content: 'API 문서 자동화' }],
DONE: [],
}
const COLUMN_TITLES: Record<string, string> = {
TODO: '할 일',
IN_PROGRESS: '진행 중',
DONE: '완료',
}
export default function KanbanBoard() {
const [tasks, setTasks] = useState<TaskList>(INITIAL_TASKS)
const handleDragOver = (event: any) => {
setTasks((prevTasks) => move(prevTasks, event))
}
return (
<div className="flex min-h-[calc(100vh-200px)] gap-4">
<DragDropProvider onDragOver={handleDragOver}>
{Object.entries(tasks).map(([columnId, columnTasks]) => (
<Column
key={columnId}
id={columnId}
title={COLUMN_TITLES[columnId]}
tasks={columnTasks}
/>
))}
</DragDropProvider>
</div>
)
}
const Column = ({
id,
title,
tasks,
}: {
id: string
title: string
tasks: Task[]
}) => {
const { ref, isDropTarget } = useDroppable({ id })
const colors: Record<string, { bg: string; border: string; ring: string }> = {
TODO: {
bg: 'bg-zinc-500/10',
border: 'border-zinc-500/20',
ring: 'ring-zinc-500/30',
},
IN_PROGRESS: {
bg: 'bg-blue-500/10',
border: 'border-blue-500/20',
ring: 'ring-blue-500/30',
},
DONE: {
bg: 'bg-green-500/10',
border: 'border-green-500/20',
ring: 'ring-green-500/30',
},
}
return (
<div
ref={ref}
className={`flex w-full flex-col rounded-2xl border transition-all duration-200 ${colors[id].border} ${colors[id].bg} p-4 ${isDropTarget ? `ring-1 ${colors[id].ring}` : ''}`}
>
<div className="mb-4 flex items-center justify-between px-1">
<h3 className="mb-4 px-1 font-semibold text-zinc-600">{title}</h3>
<span className="flex size-6 items-center justify-center rounded-full border border-black/5 bg-white p-1 text-xs font-medium text-zinc-500">
{tasks.length}
</span>
</div>
<div className="flex min-h-[150px] flex-col gap-3">
{tasks.map((task, index) => (
<TaskCard key={task.id} task={task} index={index} />
))}
</div>
</div>
)
}
const TaskCard = ({ task, index }: { task: Task; index: number }) => {
const { ref, isDragging } = useSortable({
id: task.id,
index,
})
return (
<div
ref={ref}
className={`cursor-grab rounded-xl border border-zinc-200 bg-white p-4 shadow-sm transition-colors hover:border-zinc-400 active:cursor-grabbing ${isDragging ? 'scale-105 opacity-40 ring-2 ring-zinc-400' : ''} `}
>
<p className="text-sm font-medium text-zinc-800">{task.content}</p>
</div>
)
}
'✨FRONTEND > 📍React' 카테고리의 다른 글
| React/Next.js 마크다운 목차 구현하기 (feat. 클릭 시 스크롤) (0) | 2026.03.16 |
|---|---|
| Tailwind Breakpoint 기준으로 useResponsive 훅 만들기 (0) | 2026.02.26 |
| 패키지 관리자 비교 (0) | 2026.02.13 |
| shadcn/ui: 도입부터 커스텀까지 (0) | 2026.02.06 |
| React에서 map 쓰는 패턴 정리 (0) | 2025.12.02 |