모든 강의를 무료로 볼 수 있어요. 회원가입 없이도 학습 가능합니다.

29강 / 전체 30강

미니 프로젝트 — 할 일 앱

21분 읽기 조회 0

1강~28강 학습 내용을 통합해 할 일 앱을 처음부터 완성합니다. HTML 구조 설계, 할 일 추가·삭제·완료 토글, localStorage 영구 저장, 필터링(전체·진행중·완료), 카운터·전체 완료 기능까지 실전 미니 프로젝트를 단계별로 구현합니다.

🎯 학습 목표

이 강을 마치면 다음을 할 수 있습니다.

  • HTML 구조와 자바스크립트를 연결해 완전히 동작하는 앱을 만들 수 있습니다.
  • 할 일 추가, 완료 토글, 삭제 기능을 구현할 수 있습니다.
  • localStorage로 새로 고침해도 데이터가 유지되는 앱을 만들 수 있습니다.
  • 필터(전체·진행중·완료)와 통계 카운터를 구현할 수 있습니다.

🏗️ 프로젝트 개요와 HTML 구조

이 섹션에서는 완성할 할 일 앱의 기능 목록과 HTML 기본 구조를 살펴보겠습니다.

29강에서 만들 할 일 앱(Todo App)은 자바스크립트 입문 프로젝트의 고전입니다. DOM 조작, 이벤트 처리, 배열 메서드, localStorage까지 1강부터 27강의 핵심을 모두 녹여 실전 경험을 쌓을 수 있습니다. 완성 기능 목록은 다음과 같습니다. 할 일 추가(Enter 키 또는 버튼), 완료 여부 토글(체크박스), 항목 삭제, localStorage로 새로 고침 후에도 유지, 필터(전체/진행중/완료), 남은 할 일 카운터, 완료된 항목 일괄 삭제입니다.

/* === HTML 구조 ===
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>할 일 앱</title>
  <style>
    body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 16px; }
    .todo-app { background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,.1); padding: 24px; }
    h1 { margin: 0 0 20px; font-size: 1.5rem; }
    .input-row { display: flex; gap: 8px; margin-bottom: 16px; }
    .input-row input { flex: 1; padding: 10px 14px; border: 1.5px solid #ddd; border-radius: 8px; font-size: 15px; }
    .input-row button { padding: 10px 16px; background: #2563eb; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
    .filters { display: flex; gap: 6px; margin-bottom: 12px; }
    .filters button { padding: 6px 12px; border: 1.5px solid #ddd; border-radius: 20px; background: #fff; cursor: pointer; font-size: 13px; }
    .filters button.active { background: #2563eb; color: #fff; border-color: #2563eb; }
    .todo-list { list-style: none; padding: 0; margin: 0; }
    .todo-item { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
    .todo-item input[type=checkbox] { width: 18px; height: 18px; cursor: pointer; }
    .todo-item label { flex: 1; font-size: 15px; cursor: pointer; }
    .todo-item.done label { text-decoration: line-through; color: #aaa; }
    .todo-item button { border: none; background: none; cursor: pointer; font-size: 16px; color: #999; }
    .footer { display: flex; justify-content: space-between; margin-top: 14px; font-size: 13px; color: #888; }
    .empty { text-align: center; padding: 24px; color: #aaa; }
  </style>
</head>
<body>
  <div class="todo-app">
    <h1>✅ 할 일 목록</h1>
    <div class="input-row">
      <input type="text" id="todo-input" placeholder="할 일을 입력하세요..." />
      <button id="add-btn">추가</button>
    </div>
    <div class="filters">
      <button class="active" data-filter="all">전체</button>
      <button data-filter="active">진행중</button>
      <button data-filter="done">완료</button>
    </div>
    <ul class="todo-list" id="todo-list"></ul>
    <div class="footer">
      <span id="count"></span>
      <button id="clear-done">완료 삭제</button>
    </div>
  </div>
  <script src="app.js"></script>
</body>
</html>
*/

HTML은 크게 네 영역으로 구성됩니다. 입력 영역: 텍스트 입력창과 추가 버튼. 필터 영역: 전체/진행중/완료 탭. 목록 영역: 할 일 항목들이 렌더링될 <ul>. 푸터 영역: 남은 개수 카운터와 완료 항목 일괄 삭제 버튼. CSS는 미리 작성해 두었으므로 자바스크립트만 집중합니다.

📦 데이터 구조와 상태 관리

이 섹션에서는 앱의 핵심 데이터 구조와 상태를 설계합니다.

코드를 작성하기 전에 데이터 구조를 먼저 설계하는 습관이 중요합니다. 할 일 항목 하나를 나타내는 객체와 앱 전체 상태를 정의해 보겠습니다.

// app.js

const STORAGE_KEY = 'todo_app_v1';

// 앱 상태 — 단일 진실 공급원(Single Source of Truth)
let todos = [];       // 할 일 목록: { id, text, done, createdAt }[]
let filter = 'all';   // 현재 필터: 'all' | 'active' | 'done'

// localStorage에서 초기 데이터 로드
function loadTodos() {
  try {
    const saved = localStorage.getItem(STORAGE_KEY);
    return saved ? JSON.parse(saved) : [];
  } catch {
    return [];
  }
}

// localStorage에 저장
function saveTodos() {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  } catch (err) {
    console.warn('저장 실패:', err.message);
  }
}

// 앱 시작 시 저장된 데이터 복원
todos = loadTodos();

데이터를 한 곳(todos 배열)에서 관리하고, 화면은 이 데이터를 읽어 렌더링하는 패턴을 단일 진실 공급원(Single Source of Truth)이라고 합니다. 화면을 직접 수정하는 대신, 데이터를 바꾼 뒤 화면을 다시 그리는 방식입니다. 이 패턴은 React, Vue 같은 모던 프레임워크의 핵심 철학이기도 합니다.

➕ 할 일 추가 구현

이 섹션에서는 입력값을 검증하고 할 일을 추가하는 기능을 구현합니다.

const input  = document.querySelector('#todo-input');
const addBtn = document.querySelector('#add-btn');

// 고유 ID 생성 (현재 시간 기반)
function createId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2);
}

// 할 일 추가
function addTodo() {
  const text = input.value.trim();

  if (!text) {
    input.focus();
    input.style.borderColor = '#ef4444';  // 빨간 테두리
    setTimeout(() => (input.style.borderColor = ''), 1000);
    return;
  }

  // 중복 검사 (선택)
  if (todos.some(t => t.text === text)) {
    alert('이미 있는 할 일입니다.');
    return;
  }

  const newTodo = {
    id: createId(),
    text,
    done: false,
    createdAt: new Date().toISOString(),
  };

  todos.unshift(newTodo);  // 맨 앞에 추가
  saveTodos();
  render();

  input.value = '';
  input.focus();
}

// Enter 키 지원
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') addTodo();
});

addBtn.addEventListener('click', addTodo);

🎨 목록 렌더링 — 핵심 render 함수

이 섹션에서는 todos 배열과 현재 필터를 바탕으로 화면을 다시 그리는 render 함수를 구현합니다.

render 함수는 상태가 바뀔 때마다 호출됩니다. 이전 목록을 지우고 현재 데이터를 기반으로 다시 그리는 방식입니다. 성능이 걱정될 수 있지만, 할 일 앱 정도의 규모에서는 전혀 문제없습니다.

const todoList = document.querySelector('#todo-list');
const countEl  = document.querySelector('#count');

function render() {
  // 필터 적용
  const filtered = todos.filter(todo => {
    if (filter === 'active') return !todo.done;
    if (filter === 'done')   return  todo.done;
    return true;  // 'all'
  });

  // 목록 렌더링
  if (filtered.length === 0) {
    todoList.innerHTML = '<li class="empty">할 일이 없습니다 🎉</li>';
  } else {
    todoList.innerHTML = filtered.map(todo => {
      const checked = todo.done ? 'checked' : '';
      const doneClass = todo.done ? 'done' : '';
      return (
        '<li class="todo-item ' + doneClass + '" data-id="' + todo.id + '">' +
          '<input type="checkbox" ' + checked + ' />' +
          '<label>' + escapeHtml(todo.text) + '</label>' +
          '<button class="delete-btn">✕</button>' +
        '</li>'
      );
    }).join('');
  }

  // 카운터 업데이트
  const activeCount = todos.filter(t => !t.done).length;
  countEl.textContent = activeCount + '개 남음';
}

// XSS 방지: 사용자 입력을 HTML로 삽입할 때 이스케이프 필수
function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

// 초기 렌더링
render();

escapeHtml 함수는 매우 중요합니다. 사용자가 입력한 텍스트를 그대로 innerHTML에 삽입하면 XSS(Cross-Site Scripting) 공격에 취약합니다. 예를 들어 사용자가 <img src=x onerror="alert('해킹')">를 입력하면 스크립트가 실행됩니다. escapeHtml로 특수문자를 HTML 엔티티로 변환하면 이를 방지할 수 있습니다.

✅ 완료 토글과 삭제 — 이벤트 위임

이 섹션에서는 21강에서 배운 이벤트 위임 패턴을 활용해 동적으로 생성된 항목의 이벤트를 처리합니다.

할 일 항목은 동적으로 추가되므로 각 항목에 직접 이벤트 리스너를 달 수 없습니다. 부모 요소에 하나의 리스너를 달고 event.target으로 클릭된 요소를 구분하는 이벤트 위임을 사용합니다.

// 이벤트 위임 — 부모(todoList)에 하나의 리스너
todoList.addEventListener('click', (e) => {
  const item = e.target.closest('.todo-item');  // 가장 가까운 .todo-item 찾기
  if (!item) return;

  const id = item.dataset.id;

  // 체크박스 클릭 → 완료 토글
  if (e.target.type === 'checkbox') {
    todos = todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    );
    saveTodos();
    render();
  }

  // 삭제 버튼 클릭
  if (e.target.classList.contains('delete-btn')) {
    const todo = todos.find(t => t.id === id);
    if (confirm(todo.text + '을(를) 삭제할까요?')) {
      todos = todos.filter(t => t.id !== id);
      saveTodos();
      render();
    }
  }
});

// 라벨 클릭으로도 완료 토글 (체크박스와 함께)
todoList.addEventListener('change', (e) => {
  if (e.target.type === 'checkbox') {
    const item = e.target.closest('.todo-item');
    const id = item.dataset.id;
    todos = todos.map(t =>
      t.id === id ? { ...t, done: e.target.checked } : t
    );
    saveTodos();
    render();
  }
});

🔍 필터와 완료 일괄 삭제

이 섹션에서는 필터 탭 전환과 완료 항목 일괄 삭제 기능을 구현합니다.

const filterBtns = document.querySelectorAll('.filters button');
const clearDoneBtn = document.querySelector('#clear-done');

// 필터 버튼
filterBtns.forEach(btn => {
  btn.addEventListener('click', () => {
    filter = btn.dataset.filter;

    // 활성 버튼 스타일
    filterBtns.forEach(b => b.classList.remove('active'));
    btn.classList.add('active');

    render();
  });
});

// 완료 항목 일괄 삭제
clearDoneBtn.addEventListener('click', () => {
  const doneCount = todos.filter(t => t.done).length;
  if (doneCount === 0) {
    alert('완료된 항목이 없습니다.');
    return;
  }
  if (confirm(doneCount + '개의 완료 항목을 삭제할까요?')) {
    todos = todos.filter(t => !t.done);
    saveTodos();
    render();
  }
});

🚀 완성 코드 전체 + 개선 아이디어

이 섹션에서는 지금까지 작성한 코드를 하나로 합치고, 추가로 도전해볼 기능을 살펴보겠습니다.

// app.js 완성 코드 (모든 기능 통합)

const STORAGE_KEY = 'todo_app_v1';
let todos = [];
let filter = 'all';

// 요소 참조
const input      = document.querySelector('#todo-input');
const addBtn     = document.querySelector('#add-btn');
const todoList   = document.querySelector('#todo-list');
const countEl    = document.querySelector('#count');
const filterBtns = document.querySelectorAll('.filters button');
const clearBtn   = document.querySelector('#clear-done');

// 유틸
function createId() { return Date.now().toString(36) + Math.random().toString(36).slice(2); }
function escapeHtml(str) {
  return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function loadTodos() {
  try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
}
function saveTodos() {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); } catch(e) { console.warn(e); }
}

// 렌더링
function render() {
  const visible = todos.filter(t =>
    filter === 'active' ? !t.done : filter === 'done' ? t.done : true
  );
  todoList.innerHTML = visible.length === 0
    ? '<li class="empty">할 일이 없습니다 🎉</li>'
    : visible.map(t =>
        '<li class="todo-item ' + (t.done ? 'done' : '') + '" data-id="' + t.id + '">' +
        '<input type="checkbox" ' + (t.done ? 'checked' : '') + ' />' +
        '<label>' + escapeHtml(t.text) + '</label>' +
        '<button class="delete-btn">✕</button></li>'
      ).join('');
  countEl.textContent = todos.filter(t => !t.done).length + '개 남음';
}

// 추가
function addTodo() {
  const text = input.value.trim();
  if (!text) { input.focus(); return; }
  todos.unshift({ id: createId(), text, done: false, createdAt: new Date().toISOString() });
  saveTodos(); render(); input.value = '';
}
addBtn.addEventListener('click', addTodo);
input.addEventListener('keydown', e => e.key === 'Enter' && addTodo());

// 토글 / 삭제 (이벤트 위임)
todoList.addEventListener('click', (e) => {
  const item = e.target.closest('.todo-item');
  if (!item) return;
  const id = item.dataset.id;
  if (e.target.type === 'checkbox') {
    todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
    saveTodos(); render();
  }
  if (e.target.classList.contains('delete-btn')) {
    todos = todos.filter(t => t.id !== id);
    saveTodos(); render();
  }
});

// 필터
filterBtns.forEach(btn => btn.addEventListener('click', () => {
  filter = btn.dataset.filter;
  filterBtns.forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
  render();
}));

// 완료 일괄 삭제
clearBtn.addEventListener('click', () => {
  todos = todos.filter(t => !t.done);
  saveTodos(); render();
});

// 초기화
todos = loadTodos();
render();

이 코드에서 배울 수 있는 핵심 패턴들을 정리해보겠습니다. 데이터 중심 설계(todos 배열이 진실 공급원), 이벤트 위임(목록 전체에 하나의 리스너), 순수 함수형 배열 조작(map·filter, 원본 불변), XSS 방지(escapeHtml), localStorage 연동(변경 시마다 저장)이 그것입니다. 개선 도전 아이디어: 드래그로 순서 변경(drag-and-drop API), 우선순위 설정(높음/보통/낮음), 마감일 추가 및 정렬, 검색 기능, 카테고리별 분류, fetch로 서버에 동기화(26강 활용).

📝 핵심 요약

29강에서 구현한 할 일 앱에 사용된 개념들을 정리해 보겠습니다.

기능사용한 개념
할 일 추가DOM 조작(20강), 이벤트 처리(18강), 배열 메서드(11~12강)
목록 렌더링innerHTML, 배열 map(13강), 조건 연산자(7강)
완료 토글/삭제이벤트 위임(21강), 배열 filter/map(13강)
필터 탭이벤트 처리(18강), classList(17강)
영구 저장localStorage(27강), JSON 직렬화
XSS 방지에러 처리(28강), HTML 이스케이프

다음 강인 30강 — 다음 단계에서는 자바스크립트 기초 30강의 대장정을 마무리합니다. 지금까지 배운 내용의 요약, 다음으로 공부할 로드맵(React, TypeScript, Node.js), 실력을 키우는 방법, 자주 참고할 문서와 커뮤니티까지 — 여러분의 자바스크립트 여정의 다음 챕터를 안내합니다!

관련 주제

  • 할 일 앱
  • 완료 토글
  • localStorage 영구 저장
  • 필터링 전체 진행중 완료
  • 통계 카운터
  • 개발·프로그래밍
  • 개발·프로그래밍 강의
  • 자바스크립트 기초 30강 — 웹을 움직이는 언어
  • 무료강의
  • 무료 온라인 강의
  • NUGUNA
  • 누구나

📚 시리즈 전체 공유

자바스크립트 기초 30강 — 웹을 움직이는 언어

이 강의가 속한 시리즈는 총 30강, 모두 무료입니다. 처음부터 배우려는 동료에게 시리즈 전체를 알려 주세요.

댓글

0/1000

불러오는 중...