자바스크립트 실전 #6 작은 앱 빌드 — Todo 앱

자바스크립트 트랙의 마지막 글입니다. 지금까지 다룬 도구들 — DOM, 이벤트 위임, FormData, 로컬 스토리지, 작은 store — 을 묶어 라이브러리 없이 Todo 앱 한 개를 처음부터 만들어 봅니다.

만들 것 — 요구사항 #

  • 할 일을 입력해 추가
  • 항목을 클릭하면 완료/미완료 토글
  • 삭제 버튼으로 제거
  • 필터 — 전체 / 진행 중 / 완료
  • 새로고침 후에도 데이터 유지 (localStorage)
  • 다른 탭과도 동기화

라이브러리 0개. 빌드 도구 없이 HTML + JS 두 파일이면 충분합니다.

1단계 — HTML 골격 #

index.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>

핵심 포인트 두 개.

  1. <form required>#4 폼 다루기 의 빌트인 검증 활용
  2. <script type="module">기초 #7 모듈

2단계 — store 만들기 #

먼저 데이터 구조와 상태 관리를 분리해서 만듭니다.

store.js
// 상태 모양
// {
//   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 하기보다, 의도가 분명한 액션 함수들을 만들어 두면 코드가 정돈됩니다.

actions.js
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단계 — 렌더 함수 #

상태를 화면으로 그리는 단방향 흐름을 만듭니다.

ui.js
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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

escapeHtml이 핵심입니다. 실전 #1 에서 본 XSS 함정 — 사용자 입력이 <script> 같은 걸 포함했을 때를 대비해 기본 escape를 항상 거칩니다.

5단계 — 이벤트 핸들러 (이벤트 위임) #

리스트 항목마다 핸들러를 다는 게 아니라, 부모 한 곳에 위임 합니다.

main.js
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());

핵심 흐름:

  1. 사용자가 액션 → actions.xxx() 호출
  2. actionsstore.set으로 상태 변경
  3. store가 listeners 호출 → render(state) 실행
  4. DOM 갱신

단방향 데이터 흐름입니다. 리액트의 사고 모델을 작은 규모에서 그대로 따릅니다.

6단계 — CSS 한 줌 #

기능과 직접 관계는 없지만 보기 좋게.

style.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편의 흐름:

  1. 기초 (7편) — 환경부터 모듈까지 (#1)
  2. 중급 (7편) — 클래스, 비동기, 디스트럭처링, fetch (중급 #1)
  3. 고급 (7편) — 클로저, this, 프로토타입, 이벤트 루프, 메모리 (고급 #1)
  4. 실전 (6편) — DOM, 이벤트, fetch UI, 폼, 스토리지, 작은 앱 (이번 시리즈)

여기까지 잡으면 자바스크립트로 일상적인 웹 인터랙션은 자유롭게 만들 수 있고, 그 위에 TypeScript 트랙 또는 React 트랙 으로 나아가는 발판이 됩니다. 도구가 왜 필요한지에 대한 답을 가진 채로 다음 트랙을 시작하면 학습 곡선이 훨씬 부드러워집니다.

X