[JavaScript 시리즈 12편] 중간 점검: 폼·저장소·컴포넌트를 한 화면으로 묶기

English version

12편은 시리즈 전반부를 정리하는 중간 합본입니다. 입력 검증, 저장소, 이벤트 제어, 컴포넌트 구조를 한 화면에 모아 흐름을 다시 확인하고, 20편 캡스톤을 향해 무엇을 다듬어야 하는지 체크합니다. 읽는 순서는 “핵심(저장소 ↔ 이벤트 ↔ 컴포넌트) → 선택(통계·활동 로그·BrowserMock)”입니다.

이번 글에서 새로 나오는 용어

  1. 저장소 래퍼: loadTasks, saveTasks처럼 로컬 저장소 접근을 감싸 재사용하는 헬퍼 함수 묶음입니다.
  2. CRUD: Create/Read/Update/Delete 네 단계로 데이터를 다루는 기본 작업 흐름입니다.

핵심 개념

  • 모듈 구조(Module Structure, 폴더 책임 분리): lib/에는 저장소, 이벤트 버스, 공용 유틸을 두고 components/에는 폼, 리스트, 통계, 로그를 둡니다.
  • 이벤트 버스(Event Bus, EventTarget 기반 신호 통로): task:add, task:updated, filter:change 같은 메시지를 중앙에서 주고받습니다.
  • 저장소 래퍼(Storage Wrapper, 표준화된 로컬 저장소 접근): loadTasks, saveTasks, listenStoragelocalStorage 접근을 통일하고 탭 간 동기화도 처리합니다.
  • 실시간 피드백(Real-Time Feedback, 즉시 반응 UI): 폼 검증과 자동저장, 통계 패널, 활동 로그, 스로틀된 스크롤 진행 바를 동시에 연결합니다.

조립 순서 미리 보기

  1. 데이터 축: lib/storage.js로 저장·동기화 기준을 세웁니다.
  2. 이벤트 축: lib/bus.js에서 emit, on을 만들고 주요 이벤트 이름을 상수로 적습니다.
  3. 폼 → 리스트 → 통계: 입력이 이벤트를 내보내고, 리스트가 상태를 갱신하며, 통계·로그가 그 결과를 구독합니다.
  4. UI 피드백: 진행 바, 활동 로그, 토스트 같은 시각 요소를 붙여 사용자가 어디에 있는지 알게 합니다.

아래 코드는 이 순서를 그대로 따라갑니다. 챕터 20의 캡스톤에서는 이 블록을 확장해 라우팅·배포까지 집어넣게 됩니다. 본격 코드에 들어가기 전, 최소 구성을 눈으로 익혀 둡니다.

// 핵심 뼈대 미니 예시
const tasks = [];
function addTask(title) {
  tasks.push({ title, done: false });
  console.log(tasks);
}
addTask("숙제");

➡️ “배열을 업데이트하고, 다른 함수가 그 배열을 읽는다”는 기본 감각만 잡혀 있으면 이후 모듈 분리가 훨씬 수월합니다.

코드로 확인하기

먼저 저장소 모듈을 만들어 데이터 읽기·쓰기·동기화를 묶습니다.

// lib/storage.js
const STORAGE_KEY = "starter-dashboard";

export function loadTasks() {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
  } catch {
    return [];
  }
}

export function saveTasks(tasks) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}

export function listenStorage(update) {
  window.addEventListener("storage", (event) => {
    if (event.key !== STORAGE_KEY || !event.newValue) return;
    update(JSON.parse(event.newValue));
  });
}

한 문장으로 정리하면 “모든 데이터 I/O는 lib/storage.js 한 곳을 통한다”입니다. 이렇게 하면 어디서 데이터를 저장하는지 찾느라 헤매지 않습니다.

다음은 이벤트 버스로 컴포넌트 간 메시지를 정리합니다. 저장소처럼 “신호도 한 통로로” 묶는 셈입니다.

// lib/bus.js
export const bus = new EventTarget();

export function emit(type, detail) {
  bus.dispatchEvent(new CustomEvent(type, { detail }));
}

export function on(type, handler) {
  bus.addEventListener(type, handler);
  return () => bus.removeEventListener(type, handler);
}

짧게 말하면 emit("task:add", detail)은 “새 소식을 외치기”, on("task:add", handler)는 “그 소식을 듣기”입니다.

이제 폼 컴포넌트를 만들어 검증과 초안 저장을 처리합니다.

// components/task-form.js

const constraints = {
  title: { required: true, minLength: 3 },
  owner: { required: true },
};

export function initTaskForm(root) {
  const form = root.querySelector("form");
  const feedback = root.querySelector(".form-feedback");

  const persistDraft = debounce(() => {
    const data = Object.fromEntries(new FormData(form));
    sessionStorage.setItem("task-draft", JSON.stringify(data));
  }, 300);

  function validateField(name, value) {
    const rules = constraints[name];
    if (!rules) return { valid: true };
    if (rules.required && !value.trim()) return { valid: false, message: "필수 입력입니다." };
    if (rules.minLength && value.length < rules.minLength)
      return { valid: false, message: `${rules.minLength}자 이상 입력해주세요.` };
    return { valid: true };
  }

  form?.addEventListener("input", (event) => {
    const target = event.target;
    if (!(target instanceof HTMLInputElement)) return;
    const { valid, message } = validateField(target.name, target.value);
    target.classList.toggle("is-invalid", !valid);
    const hint = target.nextElementSibling;
    if (hint) hint.textContent = message ?? "";
    persistDraft();
  });

  form?.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    const invalid = Object.entries(data).find(([name, value]) => !validateField(name, value).valid);
    if (invalid) {
      if (feedback) feedback.textContent = "입력을 다시 확인해주세요.";
      return;
    }
    emit("task:add", { task: { ...data, id: crypto.randomUUID(), done: false, createdAt: Date.now() } });
    form.reset();
    sessionStorage.removeItem("task-draft");
    if (feedback) feedback.textContent = "작업이 추가되었습니다.";
  });
}

폼은 “입력마다 검증 + 초안 저장, 제출 시 task:add 이벤트 발행”이라는 두 단계만 기억하면 충분합니다.

리스트 컴포넌트는 저장소와 이벤트 버스를 묶어 UI를 갱신합니다.

// components/task-list.js

export function initTaskList(root) {
  let tasks = loadTasks();
  let filter = "all";

  function render() {
    const filtered = filter === "all" ? tasks : tasks.filter((task) => (filter === "done" ? task.done : !task.done));
    root.querySelector(".task-list").innerHTML = filtered
      .map(
        (task) => `
        <li class="task ${task.done ? "task--done" : ""}">
          <label>
            <input type="checkbox" data-id="${task.id}" ${task.done ? "checked" : ""} />
            <span>${task.title}</span>
          </label>
          <button class="task-remove" data-id="${task.id}">삭제</button>
        </li>`,
      )
      .join("");
    root.querySelector(".task-count").textContent = `${tasks.filter((task) => !task.done).length}개 남음`;
  }

  function update(newTasks) {
    tasks = newTasks;
    saveTasks(tasks);
    render();
    emit("task:updated", { tasks });
  }

  root.addEventListener("change", (event) => {
    const target = event.target;
    if (target instanceof HTMLInputElement && target.dataset.id) {
      update(tasks.map((task) => (task.id === target.dataset.id ? { ...task, done: target.checked } : task)));
    }
  });

  root.addEventListener("click", (event) => {
    const target = event.target;
    if (target instanceof HTMLButtonElement) {
      if (target.matches("[data-filter]")) {
        filter = target.dataset.filter ?? "all";
        render();
        emit("filter:change", { filter });
      }
      if (target.classList.contains("task-remove")) {
        update(tasks.filter((task) => task.id !== target.dataset.id));
      }
    }
  });

  on("task:add", ({ detail }) => {
    update([...tasks, detail.task]);
  });

  listenStorage((nextTasks) => {
    tasks = nextTasks;
    render();
  });

  render();
}

요약하면, 리스트는 “이벤트를 듣고 배열을 갱신한 뒤 저장소에 기록하고 다시 렌더한다”는 순서를 계속 반복합니다.

통계 패널은 이벤트를 받아 진행률을 계산합니다.

// components/stats-panel.js

export function initStatsPanel(root) {
  function render(detail) {
    const { tasks } = detail;
    const total = tasks.length || 1;
    const progress = Math.round((tasks.filter((task) => task.done).length / total) * 100);
    root.querySelector(".stat-progress").style.setProperty("--progress", progress + "%");
    root.querySelector(".stat-text").textContent = `완료율 ${progress}%`;
  }

  on("task:updated", ({ detail }) => render(detail));
}

핵심 블록과 달리 통계 패널은 선택 확장입니다. 이벤트를 듣는 방법만 이해하면 부담 없이 건너뛰어도 됩니다.

활동 로그는 사용자 행동을 시간순으로 남깁니다.

// components/activity-log.js

export function initActivityLog(root) {
  const list = root.querySelector(".log-list");

  function push(message) {
    list.insertAdjacentHTML("afterbegin", `<li>${new Date().toLocaleTimeString()} · ${message}</li>`);
  }

  on("task:add", ({ detail }) => push(`작업 추가: ${detail.task.title}`));
  on("task:updated", ({ detail }) => {
    push(`상태 업데이트 (${detail.tasks.filter((task) => task.done).length}/${detail.tasks.length})`);
  });
}

활동 로그 역시 선택 요소지만, “로그 = 문자열 쌓기”라는 단순 패턴이라 가볍게 붙일 수 있습니다.

엔트리 포인트에서 모든 컴포넌트를 연결하고 스크롤 진행 바를 스로틀합니다.

// index.js

initTaskForm(document.querySelector("#task-form"));
initTaskList(document.querySelector("#task-list"));
initStatsPanel(document.querySelector("#stats-panel"));
initActivityLog(document.querySelector("#activity-log"));

const updateProgress = throttle(() => {
  const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document.querySelector(".page-progress").style.transform = `scaleX(${progress})`;
}, 100);

window.addEventListener("scroll", updateProgress, { passive: true });

index.js를 보면 "초기화 순서"가 또렷해집니다. 화면마다 init 함수를 호출하고, 마지막에 UX 피드백(진행 바)을 붙이는 방식입니다.

BrowserMock로 상태·이벤트 체인 검증

대시보드가 의도한 순서로 동작하는지 빠르게 검토하려면 BrowserMock으로 상태 → 이벤트 → UI 순환을 콘솔에서 재현할 수 있습니다. BrowserMock은 “짧은 HTML 문자열 + 가짜 브라우저” 조합이라 핵심 로직만 빠르게 확인할 때 유용합니다.


const { document, window } = mock(`
  <section id="task-form">
    <form>
      <input name="title" />
      <input name="owner" />
      <button>추가</button>
      <p class="form-feedback"></p>
    </form>
  </section>
  <section id="task-list">
    <ul class="task-list"></ul>
    <p class="task-count"></p>
  </section>
`);

initTaskForm(document.querySelector("#task-form"));
initTaskList(document.querySelector("#task-list"));

document.querySelector("input[name=title]").value = "회의 준비";
document.querySelector("form").dispatchEvent(new window.Event("submit"));

console.log(document.querySelector(".task-count").textContent);

실제 브라우저 없이도 조립 순서가 맞는지, 이벤트 버스가 값을 전달하는지 확인할 수 있습니다. BrowserMock은 “코드로 상태를 재생한 뒤 HTML을 눈으로 확인한다”는 방식이라, 테스트보다는 가볍고 콘솔 로그보다는 직관적인 중간 대안입니다.

왜 중요한가

  • 명확한 폴더 구조와 이벤트 버스를 도입하면 Vite·Parcel 같은 빌드 도구를 붙일 때도 구조를 그대로 유지할 수 있습니다.
  • 저장소 래퍼는 데이터 보존, 탭 동기화, 에러 처리 흐름을 일관되게 만들며 코드 중복을 줄입니다.
  • 활동 로그와 통계 패널 같은 피드백 요소는 사용자에게 “내 입력이 저장됐다”는 신뢰를 줍니다.

실습

  • 핵심 따라 하기: libcomponents 파일을 그대로 만들어 CRUD 흐름이 작동하도록 조립합니다.
  • 선택 확장하기: 저장소 동기화, 이벤트 버스, 스로틀 진행 바를 모두 붙여 여러 탭에서 동일한 데이터를 확인합니다.
  • 디버깅: 이벤트 이름을 일부러 틀려 보고 로그를 통해 어디서 끊겼는지 추적한 뒤 상수로 정리합니다.
  • 완료 기준: 폼 검증 → 저장 → 완료율 → 로그 업데이트를 1분 안에 재현하고 새 탭에서도 같은 데이터가 뜨면 실습이 끝납니다.

마무리

이번 중간 합본을 통해 데이터·이벤트·UI를 어떤 순서로 조립할지 명확해졌다면, 이후 장(13~19편)에서 다룰 접근성·라우팅·성능을 붙이기만 하면 됩니다. 20편 캡스톤에서는 여기서 만든 흐름을 기반으로 최종 배포본을 완성하니, 지금 상태를 체크리스트처럼 기록해 두세요.

💬 댓글

이 글에 대한 의견을 남겨주세요