동적으로 추가된 DOM 요소에 이벤트 핸들러를 붙였는데 작동하지 않는 경험을 해봤을까. 대부분의 개발자가 처음엔 각 요소마다 addEventListener()를 반복해서 붙이다가, 나중에 요소가 추가될 때마다 이벤트를 다시 등록해야 하는 악순환에 빠진다. 이번엔 이벤트 위임의 정확한 동작 원리와 실제 구현 방법, 그리고 흔한 실수까지 완벽하게 정리해서 소개하겠다.

 

이벤트 위임이란 뭔가

이벤트 위임은 자식 요소들의 이벤트를 부모 요소에서 일괄 처리하는 패턴이다. DOM에 이벤트가 발생하면 버블링 단계를 거쳐 상위 요소로 전파되는데, 이 특성을 활용해 부모에 리스너 하나만 붙이고 event.target으로 실제 클릭된 요소를 판별하는 방식이다.

예를 들어 쇼핑몰 상품 목록에서 버튼이 100개 있다고 해보자. 각 버튼마다 addEventListener()를 붙이면 메모리 낭비고, 나중에 AJAX로 상품이 추가될 때 새 버튼에도 다시 리스너를 붙여야 한다. 하지만 부모 컨테이너에만 한 번 리스너를 붙이면 버튼이 100개든 1000개든 상관없다. 이게 이벤트 위임의 강점이다.

 

동적 요소에 이벤트를 직접 붙이는 방식의 문제

✗ 잘못된 방법. 초기 HTML에만 이벤트가 등록된다.

// 처음에 있는 버튼들에만 이벤트 등록
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
  btn.addEventListener('click', () => {
    console.log('버튼 클릭됨');
  });
});

// 나중에 AJAX로 버튼 추가
const newBtn = document.createElement('button');
newBtn.className = 'btn';
newBtn.textContent = '새 버튼';
document.body.appendChild(newBtn);
// 이 새 버튼은 이벤트가 등록되지 않음!

왜 이렇게 될까. querySelectorAll()은 실행되는 그 순간의 DOM 요소들만 선택한다. 나중에 추가된 요소는 이미 지나간 일이기에 새 버튼에 리스너가 붙지 않는다. 그래서 매번 요소가 추가될 때마다 addEventListener()를 다시 해야 하는 악순환이 생긴다.

 

이벤트 위임으로 올바르게 처리하기

✓ 올바른 방법. 부모에 한 번 등록하고 모든 자식 이벤트를 처리한다.

// 부모 요소에만 이벤트 등록
const container = document.getElementById('container');

container.addEventListener('click', (event) => {
  // 클릭된 요소가 .btn 클래스인지 확인
  if (event.target.matches('.btn')) {
    console.log('버튼 클릭됨:', event.target.textContent);
  }
});

// 나중에 추가되는 버튼도 동작함
const newBtn = document.createElement('button');
newBtn.className = 'btn';
newBtn.textContent = '새 버튼';
document.getElementById('container').appendChild(newBtn);
// 이제 이 버튼도 클릭이 작동한다!

핵심은 event.target.matches('.btn')이다. matches()는 요소가 주어진 CSS 선택자와 일치하는지 확인한다. 버튼이 몇 개든 추가되든, 부모의 리스너 하나가 모든 클릭을 받아서 처리한다.

 

정확한 타겟 확인하기

중첩된 요소가 있을 땐 주의가 필요하다. 클릭된 게 버튼인지, 아니면 버튼 안의 아이콘인지 구분해야 한다.

// ✗ 잘못된 방법: event.target만 사용하면 아이콘 클릭 시 버튼을 못 찾음
container.addEventListener('click', (event) => {
  if (event.target.className === 'btn') {  // 정확한 클래스명 비교는 위험
    console.log('버튼 클릭');
  }
});

// ✓ 올바른 방법: closest()로 부모 요소까지 탐색
container.addEventListener('click', (event) => {
  const btn = event.target.closest('.btn');
  if (btn) {
    console.log('버튼 클릭:', btn.textContent);
  }
});

// HTML 예시
// <button class="btn">
//   <i class="icon"></i>
//   <span>클릭</span>
// </button>

closest()는 해당 요소부터 시작해서 부모로 올라가면서 조건에 맞는 첫 번째 요소를 찾는다. 아이콘을 클릭하든 텍스트를 클릭하든 버튼을 정확히 포착할 수 있다.

 

실전 예제: 투두리스트 앱

새 항목이 추가되고 삭제되는 투두리스트로 이벤트 위임을 실제로 구현해보자.

class TodoApp {
  constructor(containerId) {
    this.container = document.getElementById(containerId);
    this.todos = [];
    this.setupEventDelegation();
  }

  setupEventDelegation() {
    this.container.addEventListener('click', (event) => {
      // 완료 체크박스
      if (event.target.matches('.todo-check')) {
        this.toggleTodo(event.target.closest('.todo-item'));
      }
      // 삭제 버튼
      if (event.target.matches('.btn-delete')) {
        event.target.closest('.todo-item').remove();
      }
    });
  }

  addTodo(text) {
    const item = document.createElement('div');
    item.className = 'todo-item';
    item.innerHTML = `
      <input type="checkbox" class="todo-check">
      <span class="todo-text">${text}</span>
      <button class="btn-delete">삭제</button>
    `;
    this.container.appendChild(item);
  }

  toggleTodo(item) {
    item.classList.toggle('completed');
  }
}

// 사용 예
const app = new TodoApp('todo-container');
app.addTodo('장보기');
app.addTodo('빨래하기');
// 버튼 클릭, 체크박스 체크 모두 정상 작동

 

주의사항: 버블링하지 않는 이벤트

모든 이벤트가 버블링되진 않는다. focus, blur, scroll, resize 같은 이벤트는 버블링되지 않으므로 이벤트 위임이 불가능하다.

// ✗ 작동하지 않음: focus는 버블링되지 않음
container.addEventListener('focus', (event) => {
  console.log('포커스됨');
});

// ✓ 올바른 방법: useCapture 플래그 사용 (캡처 단계에서 처리)
container.addEventListener('focus', (event) => {
  if (event.target.matches('input')) {
    console.log('입력창 포커스');
  }
}, true);  // true = 캡처 단계에서 리스너 실행

 

성능 비교: 위임 vs 직접 등록
방식 메모리 사용 동적 요소 추가 코드 복잡도
직접 등록 (querySelectorAll) 높음 (요소마다 리스너) 매번 다시 등록 필요 낮음 (단순함)
이벤트 위임 (closest) 낮음 (부모 리스너 1개) 자동 처리됨 중간 (selector 로직)

 

흔한 실수 피하기

✗ 실수 1: 잘못된 selector 문법으로 인한 무음 실패

// ✗ 잘못됨: matches()는 여러 선택자를 같이 쓸 수 없음
if (event.target.matches('.btn, .link')) {  // 일부 브라우저에서 오류
  // ...
}

// ✓ 올바름: 각각 확인하거나 :is() 사용
if (event.target.matches('.btn') || event.target.matches('.link')) {
  // ...
}

// 또는
if (event.target.matches(':is(.btn, .link)')) {
  // ...
}

✗ 실수 2: 부모에 이미 이벤트 리스너가 있는데 또 등록

// ✗ 잘못됨: 같은 부모에 여러 번 등록하면 중복 트리거
container.addEventListener('click', handler1);
container.addEventListener('click', handler2);  // 두 핸들러 모두 실행됨

// ✓ 올바름: 하나의 리스너에서 모든 로직 처리
container.addEventListener('click', (event) => {
  if (event.target.matches('.btn')) {
    handleBtnClick();
  }
  if (event.target.matches('.link')) {
    handleLinkClick();
  }
});

 

이벤트 위임 사용이 적합하지 않은 경우

모든 상황에 이벤트 위임이 답은 아니다. 다음 경우들은 직접 이벤트 등록이 낫다.

1. 정적 요소만 있는 경우: 요소가 절대 추가/제거되지 않으면 직접 등록이 더 명확하다.
2. 버블링하지 않는 이벤트: focus, scroll 등은 위임 자체가 불가능하다.
3. 매우 깊은 중첩 구조: 많은 부모를 거쳐 올라가면서 성능 저하가 미미하지만 코드 가독성이 떨어진다.

 

마무리

이벤트 위임은 JavaScript의 필수 패턴이다. 단순해 보이는 이 기법이 메모리 효율, 유지보수성, 동적 콘텐츠 처리 측면에서 얼마나 강력한지 일단 써보면 안다. 특히 리스트나 테이블 같이 요소가 자주 추가되는 UI를 다룬다면 더욱 그렇다. 이 글의 closest()와 matches() 패턴을 기억해서 다음 프로젝트에 적용하면, 불필요한 리스너 등록과 메모리 누수 걱정 없이 깔끔한 코드를 유지할 수 있을 것이다.