자바스크립트 실전 #6 작은 앱 빌드 — Todo 앱
자바스크립트 트랙의 마지막 글입니다. 지금까지 다룬 도구들 — DOM, 이벤트 위임, FormData, 로컬 스토리지, 작은 store — 을 묶어 라이브러리 없이 Todo 앱 한 개를 처음부터 만들어 봅니다.
만들 것 — 요구사항 #
- 할 일을 입력해 추가
- 항목을 클릭하면 완료/미완료 토글
- 삭제 버튼으로 제거
- 필터 — 전체 / 진행 중 / 완료
- 새로고침 후에도 데이터 유지 (localStorage)
- 다른 탭과도 동기화
라이브러리 0개. 빌드 도구 없이 HTML + JS 두 파일이면 충분합니다.
1단계 — HTML 골격 #
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>Todo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>Todo</h1>
<form id="add-form">
<input
name="text"
placeholder="할 일을 입력하세요"
required
autofocus
>
<button type="submit">추가</button>
</form>
<div class="filters">
<button data-filter="all" class="active">전체</button>
<button data-filter="active">진행 중</button>
<button data-filter="done">완료</button>
</div>
<ul id="todo-list"></ul>
<p class="stats" id="stats"></p>
</main>
<script type="module" src="main.js"></script>
</body>
</html>핵심 포인트 두 개.
2단계 — store 만들기 #
먼저 데이터 구조와 상태 관리를 분리해서 만듭니다.
// 상태 모양
// {
// todos: [{ id: string, text: string, done: boolean, createdAt: number }],
// filter: 'all' | 'active' | 'done',
// }
const KEY = 'todo-app';
function load() {
try {
const raw = localStorage.getItem(KEY);
if (raw === null) return { todos: [], filter: 'all' };
return JSON.parse(raw);
} catch {
return { todos: [], filter: 'all' };
}
}
function save(state) {
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch (err) {
console.warn('save 실패:', err);
}
}
export function createStore() {
let state = load();
const listeners = new Set();
function notify() {
listeners.forEach((fn) => fn(state));
}
function set(updater) {
state = typeof updater === 'function' ? updater(state) : updater;
save(state);
notify();
}
// 다른 탭의 변경 동기화
window.addEventListener('storage', (e) => {
if (e.key !== KEY || e.newValue === null) return;
try {
state = JSON.parse(e.newValue);
notify();
} catch {}
});
return {
get: () => state,
set,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}#5 에서 만든 패턴 그대로. 영속화 + 멀티탭 동기화까지 한 모듈에 정리.
3단계 — 액션 함수들 #
상태를 직접 set 하기보다, 의도가 분명한 액션 함수들을 만들어 두면 코드가 정돈됩니다.
import { createStore } from './store.js';
export const store = createStore();
export const actions = {
add(text) {
if (!text.trim()) return;
store.set((s) => ({
...s,
todos: [
...s.todos,
{
id: crypto.randomUUID(),
text: text.trim(),
done: false,
createdAt: Date.now(),
},
],
}));
},
toggle(id) {
store.set((s) => ({
...s,
todos: s.todos.map((t) =>
t.id === id ? { ...t, done: !t.done } : t
),
}));
},
remove(id) {
store.set((s) => ({
...s,
todos: s.todos.filter((t) => t.id !== id),
}));
},
setFilter(filter) {
store.set((s) => ({ ...s, filter }));
},
};crypto.randomUUID()가 빌트인이라 라이브러리 없이 ID를 만들 수 있습니다(요즘 브라우저/Node 모두 지원).
4단계 — 렌더 함수 #
상태를 화면으로 그리는 단방향 흐름을 만듭니다.
const list = document.querySelector('#todo-list');
const stats = document.querySelector('#stats');
const filterBtns = document.querySelectorAll('[data-filter]');
function filterTodos(todos, filter) {
if (filter === 'active') return todos.filter((t) => !t.done);
if (filter === 'done') return todos.filter((t) => t.done);
return todos;
}
export function render(state) {
// 리스트
const visible = filterTodos(state.todos, state.filter);
list.innerHTML = visible.map((t) => `
<li class="todo ${t.done ? 'done' : ''}" data-id="${t.id}">
<input type="checkbox" ${t.done ? 'checked' : ''}>
<span class="text">${escapeHtml(t.text)}</span>
<button class="remove" aria-label="삭제">✕</button>
</li>
`).join('');
// 통계
const total = state.todos.length;
const done = state.todos.filter((t) => t.done).length;
stats.textContent = total === 0
? '항목이 없습니다'
: `${done} / ${total} 완료`;
// 필터 버튼 활성화
filterBtns.forEach((btn) => {
btn.classList.toggle('active', btn.dataset.filter === state.filter);
});
}
// XSS 방지 — 사용자 입력을 innerHTML 에 넣을 때 필수
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}escapeHtml이 핵심입니다. 실전 #1 에서 본 XSS 함정 — 사용자 입력이 <script> 같은 걸 포함했을 때를 대비해 기본 escape를 항상 거칩니다.
5단계 — 이벤트 핸들러 (이벤트 위임) #
리스트 항목마다 핸들러를 다는 게 아니라, 부모 한 곳에 위임 합니다.
import { store, actions } from './actions.js';
import { render } from './ui.js';
const form = document.querySelector('#add-form');
const list = document.querySelector('#todo-list');
const filters = document.querySelector('.filters');
// 추가 — 폼 제출
form.addEventListener('submit', (e) => {
e.preventDefault();
if (!form.checkValidity()) return form.reportValidity();
const formData = new FormData(form);
actions.add(formData.get('text'));
form.reset();
form.elements.text.focus();
});
// 토글 / 삭제 — 위임
list.addEventListener('click', (e) => {
const li = e.target.closest('.todo');
if (!li) return;
const id = li.dataset.id;
if (e.target.matches('.remove')) {
actions.remove(id);
} else if (e.target.matches('input[type="checkbox"]')) {
actions.toggle(id);
} else if (e.target.matches('.text')) {
actions.toggle(id);
}
});
// 필터 — 위임
filters.addEventListener('click', (e) => {
const btn = e.target.closest('[data-filter]');
if (!btn) return;
actions.setFilter(btn.dataset.filter);
});
// 상태 변경 시 렌더
store.subscribe(render);
// 처음 한 번 그리기
render(store.get());핵심 흐름:
- 사용자가 액션 →
actions.xxx()호출 actions가store.set으로 상태 변경store가 listeners 호출 →render(state)실행- DOM 갱신
단방향 데이터 흐름입니다. 리액트의 사고 모델을 작은 규모에서 그대로 따릅니다.
6단계 — CSS 한 줌 #
기능과 직접 관계는 없지만 보기 좋게.
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
margin: 0;
background: #f5f5f5;
}
.app {
max-width: 480px;
margin: 40px auto;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
input[name="text"] {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
}
.filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filters button {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
}
.filters button.active {
background: #333;
color: white;
border-color: #333;
}
ul {
list-style: none;
padding: 0;
margin: 0 0 16px;
}
.todo {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.todo .text {
flex: 1;
cursor: pointer;
}
.todo.done .text {
text-decoration: line-through;
color: #999;
}
.remove {
background: none;
border: none;
color: #c33;
cursor: pointer;
}
.stats {
text-align: center;
color: #666;
font-size: 14px;
}동작 확인 #
브라우저로 index.html을 열면 — 입력해서 추가, 클릭으로 토글, X 버튼으로 삭제, 필터 동작, 새로고침 시 유지가 모두 됩니다. 다른 탭을 열어 같은 페이지에 접속하면 한쪽 변경이 다른 쪽에도 즉시 반영됩니다.
라이브러리 0개. 코드 길이는 200줄 정도입니다.
더 발전시킬 부분 #
이 골격 위에 얹기 좋은 것들:
- 편집 — 항목 텍스트 더블클릭으로 입력 모드
- 드래그 정렬 — HTML5 draggable 또는 SortableJS
- 검색 — 텍스트로 필터
- 카테고리/태그 — 데이터 모양 확장
- 외부 동기화 — fetch로 서버 저장 (#3 fetch와 비동기 UI)
- 서비스 워커 — 오프라인 지원
각 항목에서 이번 시리즈에 다룬 도구가 모두 사용 가능합니다.
라이브러리가 필요해지는 지점 #
이 골격이 어디까지 잘 동작합니까? 거의 모든 작은 ~ 중간 앱은 충분히 닿습니다. 다음 시점에서 라이브러리(React/Vue 등) 도입을 고려하게 됩니다.
- 컴포넌트가 깊게 중첩되어 직접 DOM 조작이 복잡해질 때
- 라우팅 / SSR / 코드 스플리팅이 필요해질 때
- 큰 팀이 같은 코드를 만질 때 (컴포넌트 인터페이스가 표준화되면 협업이 쉬움)
- 빌드/타입스크립트/테스팅 등 도구 체인을 풍부하게 쓰고 싶을 때
이 트랙의 학습 흐름은 바로 그 다음을 가리킵니다 — 자바스크립트 → 타입스크립트 → React 시리즈로. 도구가 왜 필요했는지의 답이 분명해집니다.
마무리 #
이번 글에서 다룬 내용:
- HTML + JS 만으로 Todo 앱 한 개를 처음부터 끝까지
- 단방향 데이터 흐름 — 액션 → store → 렌더
- 이벤트 위임 패턴 — 부모에 한 핸들러
- localStorage 영속화 + 다른 탭 동기화
- XSS escape 처리
- 빌트인 폼 검증 활용
- 라이브러리 0개, 빌드 도구 없이 동작
자바스크립트 트랙을 마치며 #
총 4 시리즈 27편의 흐름:
- 기초 (7편) — 환경부터 모듈까지 (#1)
- 중급 (7편) — 클래스, 비동기, 디스트럭처링, fetch (중급 #1)
- 고급 (7편) — 클로저, this, 프로토타입, 이벤트 루프, 메모리 (고급 #1)
- 실전 (6편) — DOM, 이벤트, fetch UI, 폼, 스토리지, 작은 앱 (이번 시리즈)
여기까지 잡으면 자바스크립트로 일상적인 웹 인터랙션은 자유롭게 만들 수 있고, 그 위에 TypeScript 트랙 또는 React 트랙 으로 나아가는 발판이 됩니다. 도구가 왜 필요한지에 대한 답을 가진 채로 다음 트랙을 시작하면 학습 곡선이 훨씬 부드러워집니다.