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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// 초기 렌더링
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
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강, 모두 무료입니다. 처음부터 배우려는 동료에게 시리즈 전체를 알려 주세요.
댓글
불러오는 중...
