
HTML5 드래그 앤 드롭이 웨일에서 안 된다: dnd-kit으로 해결한 이야기
HTML5 Drag and Drop API로 만든 칸반 보드가 네이버 웨일에서만 동작하지 않았다. 브라우저 제스처가 네이티브 드래그 이벤트를 가로채는 문제, dnd-kit의 Pointer Events 기반 아키텍처로 해결했다.

HTML5 Drag and Drop API로 만든 칸반 보드가 네이버 웨일에서만 동작하지 않았다. 브라우저 제스처가 네이티브 드래그 이벤트를 가로채는 문제, dnd-kit의 Pointer Events 기반 아키텍처로 해결했다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

칸반 보드를 만들었다. 할 일, 진행 중, 완료. 세 개 컬럼에 카드를 드래그해서 옮기는 간단한 기능이었다. HTML5 Drag and Drop API를 써서 구현했다. 코드도 깔끔했고, 동작도 완벽했다. Chrome에서 잘 됐다. Firefox에서도 잘 됐다. Safari에서도 잘 됐다.
QA를 돌렸더니 버그 리포트가 하나 올라왔다.
"칸반 보드 드래그가 안 됩니다. 카드를 잡으면 텍스트가 선택되기만 합니다."
브라우저가 뭐냐고 물었다. 네이버 웨일(Naver Whale)이라고 했다.
처음엔 "에이, 웨일만 안 되는 거면 무시해도 되지 않나?" 싶었다. 그런데 한국에서 웨일 점유율은 무시 못 한다. 특히 B2B 서비스라면 더더욱. 사내에서 웨일 쓰는 회사가 꽤 있다.
내가 쓴 코드는 이런 형태였다.
function KanbanCard({ task, onMoveTask }) {
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move';
};
return (
<div
draggable
onDragStart={handleDragStart}
className="kanban-card"
>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
);
}
function KanbanColumn({ status, tasks, onMoveTask }) {
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const taskId = e.dataTransfer.getData('text/plain');
onMoveTask(taskId, status);
};
return (
<div
onDragOver={handleDragOver}
onDrop={handleDrop}
className="kanban-column"
>
<h2>{status}</h2>
{tasks.map(task => (
<KanbanCard key={task.id} task={task} onMoveTask={onMoveTask} />
))}
</div>
);
}
교과서적인 HTML5 DnD 패턴이다. draggable 속성, onDragStart에서 데이터 전달, onDragOver에서 preventDefault(), onDrop에서 데이터 수신. 어디서 봐도 문제가 없어 보였다.
그런데 웨일에서는 카드를 잡고 끌면 아무 일도 안 생겼다. 텍스트가 드래그 선택되거나, 아예 반응이 없거나. 두 경우 모두 칸반 기능이 동작하지 않았다.
DevTools를 열고 이벤트를 확인했다. dragstart 이벤트는 발생했다. 여기까진 정상이다. 그런데 dragover가 안 나온다. drop은 당연히 안 나온다. 시작은 되는데 중간 과정이 통째로 사라지는 상황이었다.
처음엔 웨일의 Chromium 버전이 낮아서 그런가 싶었다. 웨일은 Chromium 기반이라 대부분의 웹 표준을 지원한다. HTML5 DnD API도 당연히 지원한다. 버전 문제가 아니었다.
그다음엔 CSS 문제인가 싶어서 user-select: none도 넣어보고, -webkit-user-drag: element도 시도했다. 안 됐다. pointer-events 속성도 건드려봤다. 역시 안 됐다.
한참을 삽질하다가 핵심 원인을 찾았다.
네이버 웨일에는 마우스 제스처(Mouse Gesture) 기능이 내장되어 있다. 마우스를 드래그하면 뒤로 가기, 앞으로 가기, 새 탭 열기 같은 브라우저 동작이 실행된다. 그뿐만 아니다. 사이드바 열기, 드래그 검색(텍스트를 드래그하면 자동으로 검색하는 기능)도 있다.
이 기능들이 문제였다. 웨일의 제스처 시스템은 네이티브 drag 이벤트보다 더 높은 우선순위로 마우스 드래그를 가로챈다. 사용자가 카드를 잡고 끌면, 웹 페이지의 dragover/drop 이벤트가 발생하기 전에 웨일이 "아, 이거 드래그 제스처구나!"하고 가져가 버리는 것이다.
이걸 비유하면 참견쟁이 경비원과 같다.
택배를 3층 사무실로 보내려고 한다. 택배 기사가 건물에 들어온다(dragstart는 발생). 그런데 1층 경비원이 택배 기사를 가로막고 이렇게 말한다. "택배요? 제가 대신 처리해드릴게요! 이건 사이드바로 보내면 되겠네요!" 택배 기사는 어리둥절하고, 3층 사무실(onDrop)에는 택배가 도착하지 않는다.
웨일의 제스처 시스템이 정확히 이 경비원 역할을 하고 있었다. 브라우저 레벨에서 드래그 이벤트를 먹어버리니, 아무리 웹 코드에서 preventDefault()를 호출해도 소용이 없었다.
사용자에게 "웨일 설정에서 마우스 제스처를 끄세요"라고 안내할 수는 있다. 하지만 사용자한테 브라우저 설정을 바꾸라고 요구하는 건 좋은 해결책이 아니다. 사용자 수가 많아질수록 그런 안내는 통하지 않는다. 근본적인 해결이 필요했다.
삽질을 계속하다가 한 가지 깨달은 게 있다. 이건 웨일 브라우저만의 문제가 아니라, HTML5 Drag and Drop API의 구조적 한계라는 점이다.
HTML5 Drag and Drop API는 원래 Internet Explorer 5에서 시작된 API를 표준화한 것이다. 2000년대 초반에 설계됐다. 그때는 스마트폰도 없었고, 터치 이벤트라는 개념 자체가 없었다.
이 API의 핵심 특징을 정리하면 이렇다.
| 특징 | 설명 |
|---|---|
| 전용 이벤트 | dragstart, drag, dragover, dragenter, dragleave, drop, dragend - 7개의 전용 이벤트 |
| 브라우저 중재 | 모든 드래그 동작이 브라우저의 네이티브 드래그 시스템을 거침 |
| dataTransfer | 데이터 전달에 DataTransfer 객체 사용 (문자열 기반) |
| 터치 미지원 | 모바일 터치 이벤트는 기본 미지원 |
| 커스터마이징 한계 | 드래그 중 유령 이미지(ghost image) 커스터마이징이 매우 제한적 |
문제의 핵심은 "브라우저 중재" 부분이다. HTML5 DnD 이벤트는 브라우저의 네이티브 드래그 시스템을 통해 발생한다. 사용자가 마우스를 누르고 끌면, 먼저 브라우저가 "이건 드래그다"라고 판단하고, 그다음에 웹 페이지로 이벤트를 전달한다.
그런데 웨일처럼 브라우저 자체에 드래그 관련 기능이 있으면? 브라우저가 이벤트를 웹 페이지로 전달하기 전에 가로챌 수 있다. 이게 바로 이벤트 하이재킹(Event Hijacking) 문제다.
여기서 Pointer Events라는 대안이 등장한다. Pointer Events는 마우스, 터치, 펜 입력을 통합한 저수준 입력 이벤트다. pointerdown, pointermove, pointerup 세 가지가 핵심이다.
Pointer Events가 HTML5 DnD와 근본적으로 다른 점은 이것이다.
Pointer Events는 "마우스 버튼이 눌렸다", "마우스가 움직였다", "마우스 버튼이 풀렸다"만 알려준다. "이건 드래그다"라는 해석은 하지 않는다. 그래서 브라우저의 제스처 시스템이 가로챌 이유가 없다.
이걸 비유하면 유선전화와 직통 전화의 차이다.
HTML5 DnD는 유선전화다. 내가 3층 사무실에 전화를 걸면, 교환대(브라우저)를 거친다. 교환대 직원이 "이 통화 내용 좀 재미있네? 내가 대신 처리해줄게!"하고 가로챌 수 있다.
Pointer Events는 직통 전화다. 교환대를 거치지 않고 바로 3층 사무실에 연결된다. 중간에 가로챌 사람이 없다. 그래서 웨일이든 다른 어떤 브라우저든, 제스처 기능이 뭐가 있든 상관없이 동작한다.
HTML5 DnD 이벤트 흐름:
사용자 입력 → 브라우저 네이티브 드래그 시스템 → (가로챌 수 있음!) → 웹 페이지
Pointer Events 이벤트 흐름:
사용자 입력 → 웹 페이지 (직통)
dnd-kit은 React용 드래그 앤 드롭 라이브러리다. HTML5 DnD API를 쓰지 않는다. Pointer Events 기반의 센서 아키텍처를 사용한다.
dnd-kit의 핵심은 센서라는 개념이다. 센서는 사용자의 입력을 감지하고, 그걸 드래그 동작으로 변환하는 역할을 한다.
| 센서 | 입력 방식 | 용도 |
|---|---|---|
PointerSensor | Pointer Events (pointerdown, pointermove) | 마우스/터치/펜 통합 |
KeyboardSensor | 키보드 이벤트 (keydown) | 접근성 지원 |
TouchSensor | Touch Events (touchstart, touchmove) | 터치 전용 (레거시) |
MouseSensor | Mouse Events (mousedown, mousemove) | 마우스 전용 (레거시) |
기본으로 사용하는 PointerSensor가 Pointer Events 기반이기 때문에, 앞서 설명한 대로 브라우저의 제스처 시스템을 우회한다. HTML5 DnD 이벤트 체인에 의존하지 않으니, 웨일이 아무리 마우스 제스처를 가로채도 dnd-kit의 드래그 동작에는 영향이 없다.
앞서 HTML5 DnD로 만들었던 칸반 보드를 dnd-kit으로 다시 작성하면 이렇게 된다.
import {
DndContext,
closestCorners,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// 센서 설정: 클릭과 드래그를 구분하기 위해 distance 설정
function KanbanBoard({ columns, onMoveTask }) {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px 이상 움직여야 드래그 시작
},
}),
useSensor(KeyboardSensor) // 접근성: 키보드로도 드래그 가능
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
onMoveTask(active.id, over.id);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
<div className="kanban-board">
{columns.map(column => (
<KanbanColumn key={column.id} column={column} />
))}
</div>
</DndContext>
);
}
// 정렬 가능한 컬럼
function KanbanColumn({ column }) {
return (
<div className="kanban-column">
<h2>{column.title}</h2>
<SortableContext
items={column.tasks.map(t => t.id)}
strategy={verticalListSortingStrategy}
>
{column.tasks.map(task => (
<SortableCard key={task.id} task={task} />
))}
</SortableContext>
</div>
);
}
// 드래그 가능한 카드
function SortableCard({ task }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="kanban-card"
>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
);
}
코드량은 HTML5 DnD 버전보다 좀 더 많다. 하지만 얻는 게 훨씬 크다.
| 비교 항목 | HTML5 DnD | dnd-kit |
|---|---|---|
| 웨일 브라우저 | ❌ 동작 안 함 | ✅ 정상 동작 |
| 터치 디바이스 | ❌ 미지원 | ✅ 기본 지원 |
| 키보드 접근성 | ❌ 직접 구현 필요 | ✅ KeyboardSensor 내장 |
| 드래그 미리보기 | 제한적 (ghost image) | 완전한 커스터마이징 |
| 애니메이션 | 직접 구현 필요 | CSS transform 기반 자동 처리 |
| 정렬(Sortable) | 직접 구현 필요 | @dnd-kit/sortable 내장 |
dnd-kit을 실제 프로젝트에 넣을 때 몇 가지 함정이 있었다. 삽질한 것들을 정리했다.
이게 생각보다 중요하다. PointerSensor를 아무 설정 없이 쓰면, 카드를 클릭만 해도 드래그가 시작된다. 카드에 링크가 있거나 클릭 이벤트가 있으면 제대로 동작하지 않는다.
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 마우스를 8px 이상 움직여야 드래그 시작
},
})
distance: 8이면 사용자가 8px 이상 끌어야 드래그로 인식한다. 그 이하로 움직이면 일반 클릭으로 처리된다. 내 경험상 5~10px 사이가 적당하다. 너무 크면 드래그 반응이 둔하게 느껴지고, 너무 작으면 클릭할 때 의도치 않게 드래그가 시작된다.
모바일에서는 단순 터치와 드래그를 구분하는 게 더 어렵다. 스크롤과 드래그가 충돌하기 때문이다. 이때는 delay와 tolerance를 조합한다.
useSensor(PointerSensor, {
activationConstraint: {
delay: 250, // 250ms 동안 누르고 있어야 드래그 시작
tolerance: 5, // 누르는 동안 5px까지는 움직여도 허용
},
})
250ms 동안 손가락을 누르고 있으면 드래그 모드가 활성화된다. "롱프레스(long press)" 패턴이다. 일반 터치나 스크롤과 충돌하지 않는다. tolerance: 5는 손가락이 약간 떨려도 드래그가 취소되지 않도록 하는 안전장치다.
드래그 앤 드롭에서 접근성을 빼먹기 쉽다. 하지만 스크린 리더를 사용하는 사용자도 칸반 보드를 써야 할 수 있다. KeyboardSensor를 추가하면 Tab 키로 카드를 선택하고, Space로 드래그를 시작하고, 화살표 키로 이동하고, Space로 놓을 수 있다.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
coordinateGetter는 키보드 입력을 좌표 이동으로 변환하는 함수다. @dnd-kit/sortable에서 제공하는 sortableKeyboardCoordinates를 쓰면 정렬 목록에 맞게 자동으로 처리된다.
dnd-kit은 드래그 중 요소 이동을 CSS transform으로 처리한다. top/left 같은 레이아웃 속성을 바꾸지 않는다. 이게 성능에 큰 차이를 만든다.
레이아웃 속성을 바꾸면 브라우저가 전체 레이아웃을 다시 계산(reflow)한다. 드래그 중에 매 프레임마다 reflow가 발생하면 버벅거린다. CSS transform은 GPU 가속이 적용되어 컴포지팅 단계에서만 처리되니, 60fps를 안정적으로 유지할 수 있다.
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
dnd-kit의 CSS.Transform.toString()이 이 변환을 자동으로 해준다. useSortable 훅에서 반환되는 transform 값을 넣으면 된다.
HTML5 DnD에서 가장 짜증났던 부분이 드래그 중 표시되는 "유령 이미지(ghost image)"였다. 반투명한 복사본이 기본으로 뜨는데, 커스터마이징이 거의 불가능했다.
dnd-kit은 DragOverlay 컴포넌트로 완전한 커스터마이징을 제공한다.
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* ... 기존 컬럼들 ... */}
<DragOverlay>
{activeTask ? (
<div className="drag-preview">
<h3>{activeTask.title}</h3>
<span className="badge">이동 중</span>
</div>
) : null}
</DragOverlay>
</DndContext>
아무 React 컴포넌트나 넣을 수 있다. 드래그 중에 아이콘을 바꾸거나, 크기를 줄이거나, 완전히 다른 UI를 보여줄 수 있다. HTML5 DnD의 setDragImage()와는 차원이 다른 유연성이다.
이걸 비유하면 dnd-kit은 만능 어댑터와 같다. 해외여행 갈 때 각 나라마다 콘센트 모양이 다른데, 만능 어댑터 하나면 어디서든 충전할 수 있다. dnd-kit도 마찬가지다. 웨일이든 Chrome이든 Safari든 모바일이든, 브라우저(콘센트)가 뭐든 상관없이 동일하게 동작한다. 어댑터(dnd-kit)가 중간에서 입력 방식의 차이를 흡수해주니까.
HTML5 Drag and Drop API는 브라우저의 네이티브 드래그 시스템을 경유한다. 웨일 같은 브라우저가 자체 제스처 기능으로 이 이벤트를 가로챌 수 있다.
Pointer Events는 저수준 입력 이벤트라서 브라우저 제스처에 영향받지 않는다. pointerdown/pointermove/pointerup은 "해석" 없이 입력 그대로를 전달한다.
dnd-kit은 Pointer Events 기반 센서 아키텍처를 사용한다. HTML5 DnD에 의존하지 않기 때문에 웨일을 포함한 모든 브라우저에서 동작한다.
실전에서는 activationConstraint 설정이 필수다. distance(데스크톱)와 delay + tolerance(모바일)로 클릭/터치/스크롤과 드래그를 구분해야 한다.
접근성(KeyboardSensor)과 성능(CSS transform)은 공짜로 따라온다. dnd-kit을 쓰면 별도 구현 없이 키보드 드래그와 GPU 가속 애니메이션을 얻는다.
웨일 하나 때문에 라이브러리를 바꿨지만, 결과적으로 터치 지원, 접근성, 커스터마이징까지 전부 해결됐다. 오히려 웨일한테 고맙다. 덕분에 더 나은 방향으로 갈 수 있었으니까.