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 취소, 모바일 폭, 저장 시점을 체크리스트에 넣으면 “마우스로만 그럴듯한” 버전을 빨리 걸러낼 수 있습니다.
키보드와 접근성 확인
드래그 앤 드롭은 “마우스로 움직인다”만으로는 부족합니다. 공개 전에 다음 관점을 확인합니다. Tab 키로 각 카드의 핸들로 이동할 수 있는지, Space 또는 Enter로 카드를 들어 올릴 수 있는지, 방향키로 이동하고 Space 또는 Enter로 놓을 수 있는지, Escape로 조작을 취소할 수 있는지, 포커스 링이 보이는지, 설명문이 aria-describedby로 조작 영역과 연결되어 있는지, 그리고 움직임이 불편한 사용자를 위해 prefers-reduced-motion을 배려했는지입니다. 파일 업로드라면 드래그 조작에만 의존하지 말고 반드시 <input type="file">를 남겨 키보드로도 선택할 수 있게 합니다. Claude Code에 의뢰할 때 pointer 입력과 keyboard 입력을 모두 지원하라고 명시하면 운영 품질에 가까운 코드가 나옵니다.
실전 검증 메모
검증 메모로 남기면, 이 샘플 구성에서는 먼저 리스트와 컬럼의 state 형태를 정한 뒤 Claude Code에 의뢰한 쪽이 나중에 고칠 곳이 확실히 줄었습니다. 특히 빈 컬럼 드롭, 키보드 조작, Escape 취소를 체크리스트에 넣어 두면 “보기에는 동작하지만 공개 품질은 아닌” 코드를 이른 단계에서 찾아낼 수 있습니다. 드래그 앤 드롭은 task 완료, form 제출, 오류율, 모바일 이탈 같은 실제 비즈니스 지표와 연결되므로, 파일 드롭이나 이미지 정렬로 응용할 때도 state 형태, 저장 시점, 키보드 대체 조작을 먼저 정하는 것이 가장 효과적이었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code에게 파일 하나만 고치게 하는 지시문 작성법
'더 좋게 만들어줘'로 40줄이 바뀐 실패에서 배운, 수정 범위·검증·되돌리기를 묶은 Claude Code용 요청문 템플릿을 소개합니다.
Claude Code 권한 거절에서 복구하기: guardrail 을 약하게 만들지 않는 법
거절된 Claude Code 명령을 이유, 안전한 대안, 증거 명령, 재시도 조건으로 나누는 복구 workflow.
Claude Code Harness Smoke Test: 에이전트를 믿기 전 15분 검증 루프
Claude Code 작업 전에 범위, 금지 영역, 증거 명령, 공개 URL, 수익 CTA를 확인하는 실무 체크입니다.