상태와 이벤트 흐름을 익혔다면 화면을 컴포넌트 단위로 나눠야 합니다. 이번 글은 순수 JavaScript에서 컴포넌트 패턴을 구현하는 방법을 다룹니다. 개념 → 코드 → 이유 순서를 지키며 읽어 주세요.
이번 글에서 새로 나오는 용어
- 컴포넌트: 상태·템플릿·이벤트를 하나의 독립된 화면 조각으로 묶은 단위입니다.
- 컴포넌트 팩토리: 컴포넌트를 만들어 주는 함수로,
setup,render, 이벤트 연결을 한 번에 정의합니다. - 루트 스코프: 특정 컴포넌트가 책임지는 DOM 범위로, 이 안에서만 요소를 조작해 충돌을 막습니다.
- 이벤트 버스:
EventTarget과CustomEvent를 이용해 컴포넌트끼리 느슨하게 메시지를 주고받는 통로입니다. - CustomEvent: 우리가 원하는 추가 데이터(
detail)를 담아 전달할 수 있는 사용자 정의 이벤트입니다.
핵심 개념
- 컴포넌트 팩토리(Component Factory, 컴포넌트를 한 번에 만드는 함수):
setup,render,bindEvents를 묶어 하나의 UI 조각을 반환합니다. - 루트 스코프(Root Scope, 컴포넌트 전용 영역): 각 컴포넌트는 자신이 관리하는 루트 요소 안에서만 DOM을 조작해 서로 간섭을 막습니다.
- 이벤트 버스(Event Bus, 이벤트 중계 통로):
EventTarget과CustomEvent를 사용해 컴포넌트 간 메시지를 느슨하게 주고받습니다. - 폴더 구조(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을 다루면 컴포넌트끼리 스타일 충돌과 이벤트 중복을 막을 수 있습니다.
- 팩토리 패턴을 쓰면 컴포넌트 수가 늘어나도
setup과render구조가 같아 유지보수가 쉽습니다. - 이벤트 버스를 쓰면 필터 버튼 클릭 같은 신호를 다른 컴포넌트와 느슨하게 공유할 수 있습니다.
실습
- 따라 하기:
createComponent를 그대로 사용해 카드 리스트 컴포넌트를 만들고setBinder로 이벤트를 묶습니다. - 확장하기:
statsPanel,logList같은 컴포넌트를 두 개 이상 추가하고 EventTarget으로 메시지를 전달합니다. - 디버깅:
bindEvents가 중복 호출되도록 유도해 이벤트 누적 문제를 관찰한 뒤, 루트 범위 내에서만 다시 연결하도록 수정합니다. - 완료 기준: 서로 다른 컴포넌트가 필터/삭제 이벤트를 주고받으며 UI가 동시에 갱신되면 실습이 끝납니다.
마무리
프레임워크 없이도 컴포넌트 사고를 적용하면 코드 구조가 단단해집니다. 다음 글에서는 이 컴포넌트들을 합쳐 미니 대시보드 앱을 완성해 보겠습니다.
💬 댓글
이 글에 대한 의견을 남겨주세요