드래그앤드롭 Todo 앱 — 바닐라 JS로 만들기
왜 만들었나
Todo 앱은 가장 흔한 예제지만, 제대로 만들면 꽤 배울 게 많다. 특히 드래그앤드롭은 외부 라이브러리 없이 HTML5 Drag and Drop API만으로도 충분히 구현 가능하다. 이번에는 프레임워크 없이 바닐라 JS로 아래 기능을 모두 구현했다.
- 할 일 추가 / 완료 / 삭제
- 드래그로 순서 변경
- 필터 (전체 / 진행 중 / 완료)
- LocalStorage 영구 저장
기술 상세
HTML5 Drag and Drop API
드래그앤드롭의 핵심은 6개 이벤트다.
| 이벤트 | 발생 시점 | 사용 목적 |
|---|---|---|
dragstart |
드래그 시작 | 드래그 중인 요소 기억 |
dragover |
대상 위로 드래그 중 | preventDefault() 필수 (drop 허용) |
dragleave |
대상에서 벗어남 | 하이라이트 제거 |
drop |
대상에 드롭 | 배열 순서 재배치 |
dragend |
드래그 종료 | 스타일 정리 |
dragover에서 e.preventDefault()를 호출하지 않으면 drop 이벤트가 발생하지 않는다. 가장 흔한 실수다.
상태 관리 구조
외부 상태 라이브러리 없이 단순하게 설계했다.
let todos = JSON.parse(localStorage.getItem('todos') || '[]');
let currentFilter = 'all';
let dragSrc = null;
todos 배열 하나로 전체 상태를 관리한다. 변경이 생길 때마다 save() → render()를 호출하면 끝이다.
const save = () => localStorage.setItem('todos', JSON.stringify(todos));
핵심 소스 코드
드래그로 순서 바꾸기
function onDragStart(e) {
dragSrc = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.dataset.id);
}
function onDrop(e) {
e.stopPropagation();
if (this === dragSrc) return;
const srcId = dragSrc.dataset.id;
const destId = this.dataset.id;
const srcIdx = todos.findIndex(t => t.id === srcId);
const destIdx = todos.findIndex(t => t.id === destId);
// 배열에서 꺼내서 목적지에 삽입
const [moved] = todos.splice(srcIdx, 1);
todos.splice(destIdx, 0, moved);
save();
render();
}
splice(srcIdx, 1)로 요소를 꺼내고, splice(destIdx, 0, moved)로 목적지에 끼워넣는다. 이게 드래그앤드롭 재정렬의 전부다.
할 일 렌더링
function render() {
list.innerHTML = '';
const filtered = todos.filter(t => {
if (currentFilter === 'active') return !t.done;
if (currentFilter === 'completed') return t.done;
return true;
});
filtered.forEach(todo => {
const li = document.createElement('li');
li.className = 'todo-item' + (todo.done ? ' completed' : '');
li.draggable = true;
li.dataset.id = todo.id;
li.innerHTML = `
<span class="drag-handle">⠿</span>
<button class="check-btn"></button>
<span class="todo-text">${escHtml(todo.text)}</span>
<button class="delete-btn">×</button>
`;
// 이벤트 연결
li.querySelector('.check-btn').addEventListener('click', () => toggleDone(todo.id));
li.querySelector('.delete-btn').addEventListener('click', () => deleteTodo(todo.id));
// 드래그 이벤트
li.addEventListener('dragstart', onDragStart);
li.addEventListener('dragover', onDragOver);
li.addEventListener('dragleave', onDragLeave);
li.addEventListener('drop', onDrop);
li.addEventListener('dragend', onDragEnd);
list.appendChild(li);
});
}
매번 전체를 다시 그리는 방식이다. 규모가 작으면 이게 제일 단순하고 버그가 없다.
XSS 방어
사용자 입력을 innerHTML에 직접 넣으면 XSS 공격에 취약하다. 이스케이프 처리를 반드시 해야 한다.
function escHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
기술 선택 비교
| 방식 | 드래그앤드롭 | 상태 관리 | 학습 비용 |
|---|---|---|---|
| 바닐라 JS (이번) | HTML5 API | 배열 직접 관리 | 낮음 |
| React + react-beautiful-dnd | 라이브러리 | useState/useReducer | 중간 |
| Vue + Vuedraggable | 라이브러리 | reactive | 중간 |
| SortableJS | 라이브러리 | 자유 | 낮음 |
"바닐라 JS로 직접 구현한다"는 건 API를 이해하기 위한 목적이다. 실제 프로덕션이라면 SortableJS나 react-beautiful-dnd가 더 낫다.
삽질 기록
1. dragover에서 preventDefault 빼먹음
drop 이벤트가 전혀 발생하지 않았다. 브라우저 기본 동작이 드래그를 차단하기 때문에 dragover에서 반드시 e.preventDefault()를 호출해야 한다.
function onDragOver(e) {
e.preventDefault(); // 이게 없으면 drop이 안 됨
e.dataTransfer.dropEffect = 'move';
this.classList.add('drag-over');
}
2. 자기 자신에게 드롭 시 배열이 깨짐
같은 아이템에서 드롭하면 splice가 이상하게 동작했다. if (this === dragSrc) return; 한 줄로 해결.
3. innerHTML에 사용자 입력 그대로 넣음
<script>alert(1)</script> 입력하면 실행됐다. escHtml() 함수 추가 후 해결.
마무리
바닐라 JS Todo 앱의 핵심은 세 가지다.
splice로 배열 재정렬 → 드래그앤드롭 구현- 변경 시마다
save()+render()호출 → 단순한 상태 동기화 escHtml()→ XSS 방지
전체 코드는 index.html 하나, 약 300줄이다. 라이브러리 없이 Drag and Drop API의 동작 원리를 이해하기 좋은 예제다.
기술 스택: HTML, CSS, JavaScript (ES6+), HTML5 Drag and Drop API, LocalStorage
소스 코드: GitHub
데모: 바로가기