
JavaScript로 버튼 클릭 기능을 만들다 보면 보통 이렇게 시작한다.
const button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("클릭됨");
});
버튼이 하나일 때는 전혀 문제 없다.
그런데 목록이 생기고, 삭제 버튼이 여러 개 생기고, 나중에 새로운 항목이 추가되기 시작하면 점점 코드가 번거로워진다.
이럴 때 자주 등장하는 개념이 이벤트 위임(Event Delegation) 이다.
그런데 이벤트 위임을 제대로 이해하려면, 먼저 이벤트가 브라우저에서 어떻게 전달되는지부터 알아야 한다.
어떤 요소를 클릭했다고 해서 이벤트가 그 요소에서만 끝나는 건 아니다.
브라우저에서는 이벤트가 특정한 흐름을 따라 전달된다.
이때 자주 같이 나오는 개념이 3가지 있다.
이 세 단계를 거쳐 이벤트가 전달된다고 보면 된다.
캡처링은 이벤트가 바깥쪽 조상 요소에서 시작해서, 점점 안쪽 자식 요소로 내려가는 과정이다.
예를 들어 구조가 이렇게 있다고 해보자.
<div class="outer">
<div class="inner">
<button>클릭</button>
</div>
</div>
버튼을 클릭했을 때 이벤트는 먼저 가장 바깥쪽에서부터 내려온다.
대충 이런 느낌이다.
documenthtmlbody.outer.innerbutton이 내려오는 단계가 캡처링이다.
다만 우리가 평소에 addEventListener()를 사용할 때는 대부분 이 캡처링을 직접 의식하지 않는다.
기본값이 보통 버블링 단계에서 동작하도록 되어 있기 때문이다.
이벤트가 실제로 발생한 요소에 도착한 순간이 타깃 단계다.
위 예시에서는 사용자가 실제로 클릭한 요소가 button이므로,
이 이벤트의 타깃은 button이다.
여기서 중요한 것이 바로 event.target이다.
button.addEventListener("click", (event) => {
console.log(event.target);
});
event.target은 실제로 이벤트가 발생한 요소를 의미한다.
즉,
event.target은 버튼span이 있다면 그 span을 클릭했을 때는 span이 될 수 있다.
이 값은 이벤트 위임에서 정말 중요하게 쓰인다.
타깃에 도착한 이벤트는 거기서 끝나지 않고 다시 바깥쪽 부모로 올라간다.
이 과정을 버블링이라고 한다.
즉 버튼을 클릭하면 이벤트는 다시 이렇게 올라간다.
button.inner.outerbodyhtmldocument이 “위로 올라가는” 성질 덕분에, 부모 요소도 자식 요소의 클릭을 감지할 수 있다.
그리고 바로 이 버블링을 이용한 방식이 이벤트 위임이다.
이벤트 위임은 자식 요소마다 이벤트를 붙이지 않고, 부모 요소 하나에 이벤트를 등록해서 처리하는 방식이다.
예를 들어 삭제 버튼이 여러 개 있는 목록이 있다고 해보자.
<ul id="todo-list">
<li>
공부하기
<button class="delete-button">삭제</button>
</li>
<li>
산책하기
<button class="delete-button">삭제</button>
</li>
</ul>
보통은 버튼마다 하나씩 이벤트를 붙이고 싶어진다.
const buttons = document.querySelectorAll(".delete-button");
buttons.forEach((button) => {
button.addEventListener("click", () => {
console.log("삭제");
});
});
처음에는 잘 동작한다.
하지만 나중에 li를 새로 추가하면, 새로 생긴 버튼에는 이 이벤트가 자동으로 붙지 않는다.
왜냐하면 이 코드는 현재 존재하는 버튼들만 찾아서 이벤트를 등록했기 때문이다.
이벤트 위임을 사용하면 부모 요소인 ul에만 이벤트를 등록하면 된다.
const todoList = document.querySelector("#todo-list");
todoList.addEventListener("click", (event) => {
if (event.target.classList.contains("delete-button")) {
event.target.parentElement.remove();
}
});
이 코드가 가능한 이유는 간단하다.
ul까지 올라온다ul이 그 클릭을 듣고 처리한다즉, 버튼이 아니라 부모가 대신 처리하는 것이다.
event.target이 중요한 이유부모에 이벤트를 걸면 그 안에서 일어나는 모든 클릭이 올라온다.
그렇다면 부모는 어떻게 “삭제 버튼을 누른 것인지”, “그냥 리스트를 누른 것인지” 구분할까?
그때 쓰는 것이 event.target이다.
todoList.addEventListener("click", (event) => {
console.log(event.target);
});
실제로 클릭된 요소를 보고, 원하는 대상인지 검사하는 방식이다.
if (event.target.classList.contains("delete-button")) {
// 삭제 버튼일 때만 실행
}
즉 이벤트 위임은 이렇게 이해하면 쉽다.
부모는 이벤트를 듣고 있고,
event.target으로 실제 클릭된 자식을 확인한 뒤 필요한 동작만 실행한다.
실제 프로젝트에서는 버튼 안에 글자나 아이콘이 들어가는 경우가 많다.
<button class="delete-button">
<span>삭제</span>
</button>
이 상황에서 사용자가 span을 클릭하면 event.target은 button이 아니라 span이 될 수 있다.
그러면 이런 코드는 놓칠 수 있다.
if (event.target.classList.contains("delete-button")) {
...
}
그래서 이럴 때는 closest()를 자주 쓴다.
closest()를 같이 쓰면 더 안전하다todoList.addEventListener("click", (event) => {
const deleteButton = event.target.closest(".delete-button");
if (!deleteButton) return;
deleteButton.parentElement.remove();
});
closest()는 현재 요소에서 시작해서, 가장 가까운 조상 중 조건에 맞는 요소를 찾아준다.
즉,
span을 눌러도 찾을 수 있다그래서 이벤트 위임에서는 event.target과 closest()를 함께 쓰는 경우가 많다.
삭제 버튼이 100개 있어도 부모 하나만 관리하면 된다.
이게 정말 중요하다.
예를 들어 목록에 새 항목을 추가해도:
todoList.innerHTML += `
<li>
장보기
<button class="delete-button">삭제</button>
</li>
`;
삭제 버튼이 따로 다시 이벤트 등록을 하지 않아도 그대로 동작한다.
부모는 그대로이고, 이벤트는 계속 버블링되어 올라오기 때문이다.
비슷한 이벤트 처리 로직이 여기저기 퍼지지 않고, 한 곳에 모이게 된다.
그래서 유지보수도 더 쉬워진다.
이벤트 위임은 특히 이런 상황에서 자주 쓴다.
반대로 버튼 하나만 있는 간단한 화면이라면, 굳이 이벤트 위임을 쓸 필요는 없다.
이벤트는 브라우저에서 보통 다음 흐름으로 전달된다.
그리고 이벤트 위임은 이 중 버블링을 활용한 방식이다.
부모 요소 하나에 이벤트를 걸어두고,
event.target으로 실제 클릭된 요소를 확인해서 필요한 동작만 처리한다.
처음에는 버튼마다 직접 이벤트를 붙이는 방식이 더 익숙하지만,
동적으로 요소가 추가되는 화면을 만들기 시작하면 이벤트 위임이 왜 필요한지 확실히 느끼게 된다.
나도 처음엔 그냥 “부모에 걸면 편하구나” 정도로만 생각했는데,
실제로 목록 UI를 만들수록 이 방식이 훨씬 자연스럽고 관리하기 쉽다는 걸 느꼈다.
특히 삭제, 수정처럼 반복되는 버튼이 있는 화면이라면
이벤트 위임은 거의 한 번쯤 꼭 만나게 되는 개념인 것 같다.