칸반 보드 드래그 앤 드롭 화면
일반적으로 칸반 보드는 드래그앤드롭을 통해 컬럼이나 태스크 카드의 순서를 자유롭게 변경할 수 있다. 이러한 드래그앤드롭 상호작용은 dnd-kit 이라는 라이브러리에서 제공하는 Sortable 프리셋을 이용하면 더 쉽게 구현할 수 있다. dnd-kit은 코어 크기가 10kb 정도로 가볍고 외부 의존성이 없는 장점이 있다. 드래그 제한, 애니메이션, 충돌 감지 등을 커스터마이징 할 수도 있다.
dnd-kit을 이용해 칸반 보드를 구현하면서 비교적 까다롭다고 느꼈던 부분들을 정리해봤다. 코드는 아래 레포지토리에서 확인할 수 있다.
ID 기반 참조 구조를 사용하면 엔티티 간 관계를 유지하면서 빠른 조회 성능을 보장할 수 있다. 각 엔티티는 고유한 id
를 가지며, 외래 키(boardId
, columnId
)를 사용하여 참조 무결성을 유지한다. 칸반 보드는 여러 개의 컬럼을 포함하고, 각 컬럼은 다시 여러개의 태스크로 구성되는 계층적인 구조를 가진다.
erDiagram
BOARD ||--o{ COLUMN : contains
COLUMN ||--o{ TASK : contains
BOARD {
string id PK "보드 고유 식별자"
string title "보드 제목"
string createdAt "보드 생성 시간"
string updatedAt "보드 업데이트 시간"
array columnIds FK "보드에 속한 컬럼 ID 리스트"
}
COLUMN {
string id PK "컬럼 고유 식별자"
string boardId FK "컬럼이 속한 보드 ID"
string title "컬럼 제목"
string createdAt "컬럼 생성 시간"
string updatedAt "컬럼 업데이트 시간"
array taskIds FK "컬럼에 속한 Task ID 리스트"
}
TASK {
string id PK "태스크 고유 식별자"
string columnId FK "태스크가 속한 컬럼 ID"
string title "태스크 제목"
string description "태스크 설명 (선택)"
string createdAt "태스크 생성 시간"
string updatedAt "태스크 업데이트 시간"
}
Record<TaskId, TaskDef>
형태로 저장하면 O(1) 시간 복잡도로 데이터 조회 가능boardId
, columnId
같은 FK(Foreign Key, 외래 키)를 사용하여 참조 무결성 보장assigneeId
, priority
같은 필드를 쉽게 추가할 수 있음리스트를 SortableContext
로 감싸고, 리스트의 각 아이템에 useSortable
훅 반환값을 적용하면 드래그앤드롭으로 아이템 순서를 변경 할 수 있다. 참고로 useSortable
훅은 useDraggable
, useDroppable
훅을 결합하여 만든 프리셋이다.
Task는 부모 컨테이너(속해있는 컬럼)를 벗어나 다른 컬럼으로 이동할 수 있어야 하므로, 드래그 아이템을 DragOverlay
로 감싸야 한다. DragOverlay
는 기존 문서 흐름에서 분리되어 뷰포트를 기준으로 드래그 가능한 오버레이를 렌더링한다.
// ...
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
const Board = () => {
// ...
// onDragStart, onDragEnd 등 핸들러 로직을 정의한 커스텀 훅
const { handlers, dragColumnId, dragTaskId, /* ... */ } = useKanbanDnd();
return (
<div className="...">
{/* 리스트를 DndContext, SortableContext로 감싸준다 */}
<DndContext {...handlers} id={...} sensors={...} modifiers={...}>
<SortableContext items={board.columnIds} id={board.id}>
{board.columnIds.map((columnId) => (
<Column key={columnId} columnId={columnId} />
))}
</SortableContext>
<DragOverlay>
{/* 드래그 중인 아이템 */}
{dragColumnId && <Column columnId={toColumnId(dragColumnId)} />}
{dragTaskId && <Task taskId={toTaskId(dragTaskId)} />}
</DragOverlay>
</DndContext>
</div>
);
};
드래그를 시작할 때 현재 요소가 컬럼인지 태스크인지 판별한 후, DragOverlay
에서 조건부 렌더링해야 한다. 이를 위해 onDragStart
핸들러에서 각 타입(task, column)에 해당하는 id
를 별도 상태로 관리한다.