개발자들이 동적으로 생성된 요소에 클릭 이벤트를 붙일 때 흔히 하는 실수가 있다. 요소를 생성한 후 그 요소에 직접 이벤트 리스너를 달거나, 새 요소가 추가될 때마다 반복해서 리스너를 붙이는 식이다. 다만 대부분의 개발자들은 이 방식이 왜 비효율적인지, 그리고 얼마나 많은 메모리를 낭비하고 있는지 모른 채 프로젝트를 진행한다.

이번에는 JavaScript 이벤트 위임이 정확히 뭔지, 왜 필요한지, 그리고 어떻게 올바르게 구현하는지 완벽하게 정리해서 소개하겠다. 이벤트 위임을 제대로 이해하면 동적 요소 처리, 메모리 효율, 코드 간결성 세 가지를 모두 얻을 수 있다.

 

1단계 기초 개념, 왜 이벤트 위임이 필요한가

일반적인 이벤트 핸들링은 각 요소마다 리스너를 붙인다. 예를 들어 할 일 목록(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개가 되면 브라우저가 느려진다.

이벤트 위임은 이 문제를 근본적으로 해결한다. 자식 요소에 리스너를 붙이는 대신 부모 요소에 리스너를 한 번만 붙이고, 클릭이 일어나면 이벤트 버블링을 이용해 어느 자식이 클릭되었는지 파악하는 방식이다.

 

2단계 상세 방법, 부모에 리스너를 달자

✓ 올바른 방식: 부모 요소에 이벤트 위임 적용

// 부모 요소 하나에만 리스너를 붙인다
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이다. 사용자가 클릭한 실제 요소를 가리킨다. 리스너가 부모에 붙어있지만, 버블링을 통해 자식의 클릭 정보가 부모까지 올라온다. 그 정보를 활용해 원하는 요소인지 판단하는 것이다.

 

3단계 실전 예제, 복잡한 구조에서 위임하기

좀 더 복잡한 상황을 보자. 장바구니 목록에서 여러 종류의 버튼(수량 증가, 수량 감소, 삭제)이 있다고 하자.

<!-- 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개만 붙어있고, 동적으로 추가되는 모든 자식 요소도 정상 작동한다.

 

4단계 주의사항, 이벤트 위임의 함정

✗ 잘못된 것: 모든 이벤트를 최상위에 위임하기

// 절대 하지 말 것: 문서 전체에 리스너를 붙이면 모든 클릭을 처리해야 한다
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로 설정

 

5단계 성능 비교, 직접 리스너 vs 이벤트 위임

다음은 1000개의 항목을 동적으로 생성할 때의 차이다.

방식 메모리 사용량 동적 요소 처리 유지보수성
직접 리스너 높음(1000개의 리스너) 항목마다 리스너 재설정 필요 낮음
이벤트 위임 낮음(1개의 리스너) 자동 처리 높음

메모리 절약뿐 아니라 코드 복잡도도 급격히 줄어든다. 새 항목이 추가되어도 추가 로직이 필요 없다.

 

마무리

이벤트 위임은 동적 요소가 많은 현대 웹 개발에서 필수다. 부모 요소에 리스너를 한 번만 붙이고 e.targetclosest()로 필요한 요소를 찾는 방식은 메모리를 절약하고 코드를 간결하게 만든다. 이 패턴을 습관화하면 대규모 리스트, 테이블, 그리드 같은 복잡한 UI를 훨씬 효율적으로 다룰 수 있다. 지금부터 동적 요소에 이벤트를 붙일 때는 항상 위임 방식을 먼저 고려해보자. 성능과 유지보수성이 동시에 개선되는 경험을 할 것이다.