개발자들이 동적으로 생성된 요소에 클릭 이벤트를 붙일 때 흔히 하는 실수가 있다. 요소를 생성한 후 그 요소에 직접 이벤트 리스너를 달거나, 새 요소가 추가될 때마다 반복해서 리스너를 붙이는 식이다. 다만 대부분의 개발자들은 이 방식이 왜 비효율적인지, 그리고 얼마나 많은 메모리를 낭비하고 있는지 모른 채 프로젝트를 진행한다.
이번에는 JavaScript 이벤트 위임이 정확히 뭔지, 왜 필요한지, 그리고 어떻게 올바르게 구현하는지 완벽하게 정리해서 소개하겠다. 이벤트 위임을 제대로 이해하면 동적 요소 처리, 메모리 효율, 코드 간결성 세 가지를 모두 얻을 수 있다.
일반적인 이벤트 핸들링은 각 요소마다 리스너를 붙인다. 예를 들어 할 일 목록(to-do list)에서 각 항목의 삭제 버튼을 클릭할 때 항목을 지우고 싶다고 하자.
<!-- HTML -->
<ul id="todoList">
<li>할 일 1 <button class="delete-btn">삭제</button></li>
<li>할 일 2 <button class="delete-btn">삭제</button></li>
<li>할 일 3 <button class="delete-btn">삭제</button></li>
</ul>
이 상태에서 새로운 할 일을 추가하면 어떻게 될까. 직접 리스너를 달았다면 새 항목의 삭제 버튼은 아무 반응이 없을 것이다.
✗ 잘못된 방식: 각 요소에 직접 리스너 붙이기
// 기존 요소에만 리스너가 붙는다
const buttons = document.querySelectorAll(".delete-btn");
buttons.forEach(btn => {
btn.addEventListener("click", (e) => {
e.target.parentElement.remove();
});
});
// 동적으로 추가된 요소는 이벤트가 안 먹는다
const newItem = document.createElement("li");
newItem.innerHTML = '할 일 4 <button class="delete-btn">삭제</button>';
document.getElementById("todoList").appendChild(newItem);
// 여기서 삭제 버튼을 눌러도 아무것도 일어나지 않음
이 문제를 해결하려면 요소가 추가될 때마다 리스너를 다시 붙여야 한다. 이건 유지보수 악몽이고, 메모리도 낭비된다. 각 리스너는 메모리 공간을 차지하기 때문에 항목이 100개, 1000개가 되면 브라우저가 느려진다.
이벤트 위임은 이 문제를 근본적으로 해결한다. 자식 요소에 리스너를 붙이는 대신 부모 요소에 리스너를 한 번만 붙이고, 클릭이 일어나면 이벤트 버블링을 이용해 어느 자식이 클릭되었는지 파악하는 방식이다.
✓ 올바른 방식: 부모 요소에 이벤트 위임 적용
// 부모 요소 하나에만 리스너를 붙인다
const todoList = document.getElementById("todoList");
todoList.addEventListener("click", (e) => {
// 클릭된 요소가 delete-btn 클래스인지 확인
if (e.target.classList.contains("delete-btn")) {
// 버튼의 부모 li 요소를 제거
e.target.parentElement.remove();
}
});
// 동적으로 추가된 요소도 정상 작동한다
const newItem = document.createElement("li");
newItem.innerHTML = '할 일 4 <button class="delete-btn">삭제</button>';
document.getElementById("todoList").appendChild(newItem);
// 이제 새 항목의 삭제 버튼도 정상적으로 작동한다
이 코드에서 핵심은 e.target이다. 사용자가 클릭한 실제 요소를 가리킨다. 리스너가 부모에 붙어있지만, 버블링을 통해 자식의 클릭 정보가 부모까지 올라온다. 그 정보를 활용해 원하는 요소인지 판단하는 것이다.
좀 더 복잡한 상황을 보자. 장바구니 목록에서 여러 종류의 버튼(수량 증가, 수량 감소, 삭제)이 있다고 하자.
<!-- HTML -->
<div id="cart">
<div class="cart-item" data-id="101">
<span>상품명: 노트북</span>
<button class="qty-increase">+</button>
<span class="qty">1</span>
<button class="qty-decrease">-</button>
<button class="delete-item">삭제</button>
</div>
<div class="cart-item" data-id="102">
<span>상품명: 마우스</span>
<button class="qty-increase">+</button>
<span class="qty">2</span>
<button class="qty-decrease">-</button>
<button class="delete-item">삭제</button>
</div>
</div>
const cart = document.getElementById("cart");
cart.addEventListener("click", (e) => {
const cartItem = e.target.closest(".cart-item");
if (!cartItem) return; // 클릭된 요소가 cart-item 내부가 아니면 무시
const productId = cartItem.dataset.id;
const qtyDisplay = cartItem.querySelector(".qty");
let currentQty = parseInt(qtyDisplay.textContent);
if (e.target.classList.contains("qty-increase")) {
currentQty++;
qtyDisplay.textContent = currentQty;
console.log(`상품 ${productId} 수량 증가: ${currentQty}`);
} else if (e.target.classList.contains("qty-decrease")) {
if (currentQty > 1) {
currentQty--;
qtyDisplay.textContent = currentQty;
console.log(`상품 ${productId} 수량 감소: ${currentQty}`);
}
} else if (e.target.classList.contains("delete-item")) {
cartItem.remove();
console.log(`상품 ${productId} 삭제됨`);
}
});
여기서 주목할 점은 e.target.closest(".cart-item")이다. 이 메서드는 클릭된 요소의 부모 요소들 중에서 .cart-item 클래스를 가진 가장 가까운 요소를 찾는다. 깊숙이 중첩된 요소를 클릭해도 정확히 대응하는 항목을 찾을 수 있다.
결과적으로 부모 요소 하나에 리스너가 1개만 붙어있고, 동적으로 추가되는 모든 자식 요소도 정상 작동한다.
✗ 잘못된 것: 모든 이벤트를 최상위에 위임하기
// 절대 하지 말 것: 문서 전체에 리스너를 붙이면 모든 클릭을 처리해야 한다
document.addEventListener("click", (e) => {
// 페이지의 모든 클릭이 여기를 통과한다
// 성능 저하, 예상치 못한 동작 발생
});
✓ 올바른 것: 영향 범위가 명확한 부모에만 위임하기
// 실제로 동적 요소가 추가될 컨테이너에만 리스너를 붙인다
const container = document.getElementById("todoList");
container.addEventListener("click", (e) => {
// 이 컨테이너 내부의 클릭만 처리
});
또 한 가지 주의할 점은 이벤트 위임이 모든 이벤트에 작동하는 것은 아니라는 점이다. click, mouseenter, mouseleave, change 등 버블링되는 이벤트는 위임이 가능하다. 하지만 focus, blur, load 같은 버블링되지 않는 이벤트는 위임할 수 없다.
✗ 버블링되지 않는 이벤트 위임 시도
// focus 이벤트는 버블링되지 않으므로 위임 불가
const form = document.getElementById("myForm");
form.addEventListener("focus", (e) => {
// 동적 input 요소의 focus는 감지 안 됨
});
✓ 버블링되지 않는 이벤트는 capture phase 사용
// capture phase를 true로 설정하면 버블링 전에 처리 가능
const form = document.getElementById("myForm");
form.addEventListener("focus", (e) => {
if (e.target.tagName === "INPUT") {
console.log("input에 포커스됨");
}
}, true); // 세 번째 인자를 true로 설정
다음은 1000개의 항목을 동적으로 생성할 때의 차이다.
| 방식 | 메모리 사용량 | 동적 요소 처리 | 유지보수성 |
|---|---|---|---|
| 직접 리스너 | 높음(1000개의 리스너) | 항목마다 리스너 재설정 필요 | 낮음 |
| 이벤트 위임 | 낮음(1개의 리스너) | 자동 처리 | 높음 |
메모리 절약뿐 아니라 코드 복잡도도 급격히 줄어든다. 새 항목이 추가되어도 추가 로직이 필요 없다.
이벤트 위임은 동적 요소가 많은 현대 웹 개발에서 필수다. 부모 요소에 리스너를 한 번만 붙이고 e.target과 closest()로 필요한 요소를 찾는 방식은 메모리를 절약하고 코드를 간결하게 만든다. 이 패턴을 습관화하면 대규모 리스트, 테이블, 그리드 같은 복잡한 UI를 훨씬 효율적으로 다룰 수 있다. 지금부터 동적 요소에 이벤트를 붙일 때는 항상 위임 방식을 먼저 고려해보자. 성능과 유지보수성이 동시에 개선되는 경험을 할 것이다.