본문 바로가기
튜토리얼/웹

[HTML/CSS/JS] 드래그앤드롭 Todo 앱 만들기

by 루까(Luka) 2026. 2. 26.
반응형

드래그앤드롭 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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

기술 선택 비교

방식 드래그앤드롭 상태 관리 학습 비용
바닐라 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 앱의 핵심은 세 가지다.

  1. splice로 배열 재정렬 → 드래그앤드롭 구현
  2. 변경 시마다 save() + render() 호출 → 단순한 상태 동기화
  3. escHtml() → XSS 방지

전체 코드는 index.html 하나, 약 300줄이다. 라이브러리 없이 Drag and Drop API의 동작 원리를 이해하기 좋은 예제다.


기술 스택: HTML, CSS, JavaScript (ES6+), HTML5 Drag and Drop API, LocalStorage
소스 코드: GitHub
데모: 바로가기

반응형