[JavaScript 시리즈 11편] 프레임워크 없이 컴포넌트 스타일 코드 조직하기

English version

상태와 이벤트 흐름을 익혔다면 화면을 컴포넌트 단위로 나눠야 합니다. 이번 글은 순수 JavaScript에서 컴포넌트 패턴을 구현하는 방법을 다룹니다. 개념 → 코드 → 이유 순서를 지키며 읽어 주세요.

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

  1. 컴포넌트: 상태·템플릿·이벤트를 하나의 독립된 화면 조각으로 묶은 단위입니다.
  2. 컴포넌트 팩토리: 컴포넌트를 만들어 주는 함수로, setup, render, 이벤트 연결을 한 번에 정의합니다.
  3. 루트 스코프: 특정 컴포넌트가 책임지는 DOM 범위로, 이 안에서만 요소를 조작해 충돌을 막습니다.
  4. 이벤트 버스: EventTargetCustomEvent를 이용해 컴포넌트끼리 느슨하게 메시지를 주고받는 통로입니다.
  5. CustomEvent: 우리가 원하는 추가 데이터(detail)를 담아 전달할 수 있는 사용자 정의 이벤트입니다.

핵심 개념

  • 컴포넌트 팩토리(Component Factory, 컴포넌트를 한 번에 만드는 함수): setup, render, bindEvents를 묶어 하나의 UI 조각을 반환합니다.
  • 루트 스코프(Root Scope, 컴포넌트 전용 영역): 각 컴포넌트는 자신이 관리하는 루트 요소 안에서만 DOM을 조작해 서로 간섭을 막습니다.
  • 이벤트 버스(Event Bus, 이벤트 중계 통로): EventTargetCustomEvent를 사용해 컴포넌트 간 메시지를 느슨하게 주고받습니다.
  • 폴더 구조(Folder Structure, 책임 기반 디렉터리): components/, lib/같이 책임을 나누면 대시보드 같은 화면도 깔끔하게 유지됩니다.

코드로 확인하기

먼저 컴포넌트 팩토리를 구현해 상태, 렌더링, 이벤트 바인딩을 한곳에 모읍니다.

function createComponent({ selector, setup, render }) {
  const root = document.querySelector(selector);
  if (!root) throw new Error(`${selector} 요소를 찾을 수 없습니다.`);

  const state = setup?.() ?? {};
  let bindEvents = () => {};

  function update(partial) {
    Object.assign(state, partial);
    root.innerHTML = render(state);
    bindEvents();
  }

  function setBinder(fn) {
    bindEvents = fn;
    bindEvents();
  }

  return { root, state, update, setBinder };
}

다음으로 카드 리스트 컴포넌트를 만들어 필터·삭제 동작을 묶습니다.

const cardList = createComponent({
  selector: "#card-list",
  setup: () => ({ items: loadState([]), filter: "all" }),
  render: ({ items, filter }) => {
    const filtered = filter === "all" ? items : items.filter((item) => item.category === filter);
    return `
      <div class="card-toolbar">
        <button data-filter="all">전체</button>
        <button data-filter="design">디자인</button>
        <button data-filter="data">데이터</button>
      </div>
      <ul class="card-grid">
        ${filtered
          .map(
            (item) => `
              <li class="card">
                <h3>${item.title}</h3>
                <p>${item.summary}</p>
                <button data-id="${item.id}" class="card-remove">삭제</button>
              </li>
            `,
          )
          .join("")}
      </ul>
    `;
  },
});

cardList.setBinder(() => {
  cardList.root.querySelectorAll("[data-filter]").forEach((button) => {
    button.addEventListener("click", () => {
      cardList.update({ filter: button.dataset.filter });
    });
  });

  cardList.root.querySelectorAll(".card-remove").forEach((button) => {
    button.addEventListener("click", () => {
      const nextItems = cardList.state.items.filter((item) => item.id !== button.dataset.id);
      cardList.update({ items: nextItems });
      saveState(nextItems);
    });
  });
});

이벤트 버스는 컴포넌트 간 신호를 전달하는 가벼운 통로입니다.

const bus = new EventTarget();

bus.addEventListener("filter-change", (event) => {
  const { detail } = event;
  statsPanel.update({ filter: detail.filter });
});

cardList.root.addEventListener("click", (event) => {
  const target = event.target;
  if (target instanceof HTMLButtonElement && target.dataset.filter) {
    bus.dispatchEvent(new CustomEvent("filter-change", { detail: { filter: target.dataset.filter } }));
  }
});

폴더 구조는 다음처럼 나눕니다.

src/
  components/
    card-list.js
    stats-panel.js
    log-list.js
  lib/
    storage.js
    format.js

왜 중요한가

  • 루트 범위 안에서만 DOM을 다루면 컴포넌트끼리 스타일 충돌과 이벤트 중복을 막을 수 있습니다.
  • 팩토리 패턴을 쓰면 컴포넌트 수가 늘어나도 setuprender 구조가 같아 유지보수가 쉽습니다.
  • 이벤트 버스를 쓰면 필터 버튼 클릭 같은 신호를 다른 컴포넌트와 느슨하게 공유할 수 있습니다.

실습

  • 따라 하기: createComponent를 그대로 사용해 카드 리스트 컴포넌트를 만들고 setBinder로 이벤트를 묶습니다.
  • 확장하기: statsPanel, logList 같은 컴포넌트를 두 개 이상 추가하고 EventTarget으로 메시지를 전달합니다.
  • 디버깅: bindEvents가 중복 호출되도록 유도해 이벤트 누적 문제를 관찰한 뒤, 루트 범위 내에서만 다시 연결하도록 수정합니다.
  • 완료 기준: 서로 다른 컴포넌트가 필터/삭제 이벤트를 주고받으며 UI가 동시에 갱신되면 실습이 끝납니다.

마무리

프레임워크 없이도 컴포넌트 사고를 적용하면 코드 구조가 단단해집니다. 다음 글에서는 이 컴포넌트들을 합쳐 미니 대시보드 앱을 완성해 보겠습니다.

💬 댓글

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