본문 바로가기
✨FRONTEND/📍React

dnd-kit로 칸반보드 구현하기 (@dnd-kit/react)

by 짱돌보리 2026. 3. 24.
728x90

dnd-kit로 칸반보드 구현하기 (@dnd-kit/react)

최신 버전 @dnd-kit/react는 복잡한 설정과 상태 관리 로직을 라이브러리 내부로 캡슐화해 개발자 경험(DX)을 크게 개선했다.

기존 @dnd-kit/core로 칸반보드를 구현할 때 가장 어려웠던 점은 드래그 자체보다 상태 관리 로직이 훨씬 복잡하다는 것이었다.

  • 같은 컬럼 내 이동인지
  • 다른 컬럼으로 이동하는지
  • index 계산
  • 배열 재정렬

이 모든 로직을 직접 구현해야 했다.

 

하지만 최신 버전인 @dnd-kit/react에서는 👉 드래그 상태 관리를 라이브러리가 대신 처리 해주면서 구현 난이도가 크게 낮아졌다.

🚀 무엇이 달라졌을까?

Migration guide - @dnd-kit

 

Migration guide - @dnd-kit

A comprehensive guide to migrate from @dnd-kit/core to @dnd-kit/react

dndkit.com

 

✅ DragDropProvider 하나로 끝

<DragDropProvider onDragOver={handleDragOver}>
  • 기존 DndContextDragDropProvider로 변경되었다.
  • 기존에는 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 이벤트에서 activeover의 ID를 찾고, arrayMove를 사용해 동일 컬럼 내 이동인지 타 컬럼으로의 이동인지 분기 처리하는 복잡한 로직을 직접 작성해야 했다.

이제 @dnd-kit/helpersmove(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>
  )
}

 

🔄 전체 동작 흐름

드래그가 발생하면 내부적으로

  1. TaskCard에서 드래그 시작
  2. Column이 Drop 영역으로 감지
  3. DragDropProvider가 이벤트 생성
  4. KanbanBoard에서 이벤트 수신
  5. 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>
  )
}