Claude Code로 드래그 앤 드롭 구현하기: React dnd-kit 가이드
Claude Code와 dnd-kit으로 접근 가능한 React 드래그 앤 드롭을 구현하는 방법을 TypeScript 코드와 체크리스트로 정리합니다.
드래그 앤 드롭은 겉보기보다 까다로운 UI입니다. 마우스로 카드가 움직인다고 끝이 아니라, 터치 입력, 키보드 조작, 스크린 리더 안내, 저장 시점, 취소 동작, 모바일 레이아웃까지 확인해야 합니다.
Claude Code에 “drag and drop 만들어줘”라고만 말하면 빠른 초안은 나오지만, 운영 품질을 보장하기 어렵습니다. 데이터 구조, 입력 방식, 접근성, 검증 기준을 먼저 전달하면 훨씬 안정적인 코드를 얻을 수 있습니다.
이 글은 React + TypeScript + dnd-kit으로 실행 가능한 정렬 리스트를 만들고, 실제 유스케이스, 실수하기 쉬운 지점, 공식 문서 링크, 내부 링크, 수익화 CTA까지 함께 정리합니다.
먼저 이해할 개념
사용자 입력 → sensor 감지 → DndContext 이벤트 → React state 업데이트 → UI 재렌더링
React에서는 DOM 순서가 아니라 state가 기준입니다. 화면에서 움직였기 때문에 데이터가 바뀌는 것이 아니라, 데이터 순서가 바뀌었기 때문에 화면이 바뀐다고 생각해야 버그가 줄어듭니다.
| 용어 | 쉬운 설명 | 예시 |
|---|---|---|
| 드래그 원본 | 사용자가 잡고 이동하는 것 | 작업 카드 |
| 드롭 대상 | 놓을 수 있는 위치 | 카드 앞뒤, 컬럼 |
| sensor | 입력 방식을 감지하는 장치 | 포인터, 키보드 |
| state | 실제 정렬 순서 | React useState |
3가지 이상 유스케이스
| 유스케이스 | 예시 | 주의점 |
|---|---|---|
| 리스트 정렬 | 작업 우선순위, 메뉴 순서, 학습 목록 | drop 이후에만 저장 |
| 칸반 보드 | 개발 티켓, 영업 파이프라인, 채용 현황 | 빈 컬럼 드롭 지원 |
| 파일 업로드 | 이미지, PDF, CSV 업로드 | 파일 크기와 MIME 타입 검증 |
| 대시보드 편집 | 위젯과 차트 재배치 | 모바일에서는 편집 모드 분리 |
파일을 브라우저로 끌어오는 기능은 MDN HTML Drag and Drop API가 기준입니다. React 내부의 정렬 UI는 dnd-kit Getting started와 dnd-kit Accessibility를 따르는 편이 안전합니다.
Claude Code에 줄 프롬프트
{
"goal": "Build an accessible React drag-and-drop sortable list with TypeScript.",
"stack": ["React", "TypeScript", "@dnd-kit/core", "@dnd-kit/sortable"],
"requirements": [
"Support pointer and keyboard operation",
"Keep React state as the source of truth",
"Use a real button as the drag handle",
"Show visible focus styles",
"Save only after drag end"
],
"verification": [
"Move an item with mouse or trackpad",
"Move an item with keyboard",
"Cancel a drag with Escape",
"Check the layout on mobile width"
]
}
기초는 React 개발 가이드, 접근성은 Claude Code 접근성 구현, 테스트 관점은 테스트 전략을 함께 참고하세요.
설치
npm create vite@latest dnd-demo -- --template react-ts
cd dnd-demo
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
npm run dev
데이터 모델
src/taskModel.ts를 만듭니다.
export type Task = {
id: string;
title: string;
note: string;
};
export const initialTasks: Task[] = [
{ id: "task-1", title: "Write acceptance criteria", note: "Define the done state before coding." },
{ id: "task-2", title: "Build sortable UI", note: "Use dnd-kit sensors and React state." },
{ id: "task-3", title: "Check keyboard flow", note: "Tab, Space, Arrow keys, Enter, Escape." },
{ id: "task-4", title: "Review file fallback", note: "Keep input type=file for upload screens." },
];
React 코드
src/App.tsx를 아래 코드로 바꿉니다.
import { useState, type CSSProperties } from "react";
import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import "./App.css";
import { initialTasks, type Task } from "./taskModel";
export default function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
setTasks((currentTasks) => {
const oldIndex = currentTasks.findIndex((task) => task.id === active.id);
const newIndex = currentTasks.findIndex((task) => task.id === over.id);
if (oldIndex < 0 || newIndex < 0) {
return currentTasks;
}
return arrayMove(currentTasks, oldIndex, newIndex);
});
}
return (
<main className="appShell">
<h1>Accessible task ordering</h1>
<p id="dnd-help" className="helpText">
Use Tab to reach a handle. Press Space or Enter to lift, arrow keys to move,
Space or Enter to drop, and Escape to cancel.
</p>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={tasks.map((task) => task.id)} strategy={verticalListSortingStrategy}>
<ul className="taskList" aria-describedby="dnd-help">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} />
))}
</ul>
</SortableContext>
</DndContext>
</main>
);
}
function SortableTask({ task }: { task: Task }) {
const {
attributes,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li ref={setNodeRef} style={style} className={`taskCard ${isDragging ? "dragging" : ""}`}>
<button
ref={setActivatorNodeRef}
type="button"
className="dragHandle"
aria-label={`Move ${task.title}`}
{...attributes}
{...listeners}
>
<span aria-hidden="true">↕</span>
</button>
<div>
<h2>{task.title}</h2>
<p>{task.note}</p>
</div>
</li>
);
}
CSS
src/App.css를 추가합니다.
:root {
color: #172033;
background: #f6f7fb;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
body {
margin: 0;
}
button {
font: inherit;
}
.appShell {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px;
}
.helpText {
color: #526070;
line-height: 1.6;
}
.taskList {
display: grid;
gap: 12px;
padding: 0;
list-style: none;
}
.taskCard {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 14px;
border: 1px solid #d9deea;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.08);
}
.dragging {
opacity: 0.58;
}
.taskCard h2 {
margin: 0 0 4px;
font-size: 1rem;
}
.taskCard p {
margin: 0;
color: #526070;
line-height: 1.5;
}
.dragHandle {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #c8d0df;
border-radius: 8px;
background: #f8fafc;
cursor: grab;
}
.dragHandle:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.taskCard {
transition: none;
}
}
접근성, 실수, CTA
공개 전에는 Tab 이동, Space/Enter로 집기와 놓기, 방향키 이동, Escape 취소, 포커스 링, 모바일 폭, aria-describedby 안내를 확인합니다. 업로드 UI라면 <input type="file"> 대체 경로도 반드시 유지합니다.
흔한 실수는 dragover마다 서버 저장하기, 빈 컬럼을 놓칠 수 있게 만들기, 마우스 전용으로 만들기, Claude Code에 라이브러리 선택을 맡겨 버리기입니다. 저장은 drop 이후, 정렬 기준은 React state, 조작 대상은 실제 button으로 두는 것이 안전합니다.
팀에서 이 패턴을 반복해서 쓴다면 CLAUDE.md 템플릿과 성능 최적화를 함께 정리하세요. ClaudeCodeLab의 Claude Code training과 templates/products는 팀 표준화에 맞춰 활용할 수 있습니다.
실제로 확인할 때는 state 구조를 먼저 정하고 Claude Code에 구현을 맡긴 경우가 가장 적은 수정으로 끝났습니다. 키보드 이동, Escape 취소, 모바일 폭, 저장 시점을 체크리스트에 넣으면 “마우스로만 그럴듯한” 버전을 빨리 걸러낼 수 있습니다.
?? ??? ??
Drag and drop? ??? ???? ??? ????. ?? ??? ?? ?? ??? ???? ??? ??? ???, focus? ???? ???, screen reader? ?? ??? ??? ? ??? ?????. Claude Code? ??? ?? ? pointer ??? keyboard ??? ?? ??? ??? ?? ?????.
?? ??
Drag ???? ??? ?? ?? UI? ????. ??, ?? ???, ??? ???? ???? ?? ??? ???? ?????. task completion, form submit, error rate, mobile abandonment? ?? ???.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.