FEInterview Prep

browser

이벤트 버블링·캡처링·위임 완전 이해

이벤트 위임은 수백 개의 DOM 요소에 이벤트 리스너를 달 때 메모리와 성능을 최적화하는 핵심 패턴입니다. "리스트 아이템 1000개에 클릭 이벤트를 어떻게 달겠습니까?"라는 면접 질문이 실제로 자주 나오며, 버블링/캡처링 메커니즘을 모르면 event.stopPropagation()을 무분별하게 사용하는 안티패턴을 범하게 됩니다.

시작 전
이해도
매우 낮음

학습 개요

탄생 배경

해결하려 했던 문제

초기 웹에서는 각 DOM 요소에 직접 이벤트 핸들러를 추가했습니다. 동적으로 추가되는 요소(예: 무한 스크롤의 새 아이템)는 핸들러를 매번 다시 달아야 했고, 메모리 누수와 성능 저하가 발생했습니다. 또한 동일한 핸들러를 수백 개 요소에 복제하는 것은 메모리 낭비였습니다.

역사적 맥락

Netscape와 IE가 각각 다른 이벤트 모델을 구현했습니다. Netscape는 위→아래 흐름(캡처링), IE는 아래→위(버블링)를 선택했습니다. W3C DOM Level 2(2000년)에서 두 방식을 모두 표준화하고, addEventListener의 세 번째 매개변수(useCapture)로 위상을 선택할 수 있게 됐습니다. 이후 이벤트 위임 패턴이 jQuery를 통해 대중화됐습니다.

이전에는 어떻게 했나

이벤트 위임 이전에는 각 요소에 onclick 어트리뷰트를 직접 추가하거나, for 루프로 모든 요소에 addEventListener를 호출했습니다. 동적 요소의 경우 MutationObserver로 DOM 변경을 감지한 뒤 핸들러를 추가하는 방법도 있었지만 복잡했습니다.

멘탈 모델

동작 원리

이벤트가 발생하면 DOM 트리를 따라 세 단계로 전파됩니다: 1. 캡처링 단계 (window → 타깃): 이벤트가 루트에서 타깃 요소로 내려갑니다. addEventListener(event, handler, true) 또는 { capture: true }로 이 단계 수신 가능 2. 타깃 단계: 이벤트가 실제 클릭된 요소에 도달합니다. 3. 버블링 단계 (타깃 → window): 이벤트가 타깃에서 다시 루트로 올라갑니다. 대부분의 이벤트 핸들러는 이 단계에서 실행됩니다. 이벤트 위임 패턴: 부모 요소에 하나의 리스너만 달고, event.target으로 실제 클릭된 자식 요소를 판별합니다. DOM이 동적으로 변해도 부모 리스너가 모든 자식 이벤트를 처리합니다. event.target: 실제 이벤트가 발생한 요소 event.currentTarget: 현재 핸들러가 바인딩된 요소 (위임 시 부모)

핵심 구성 요소

버블링 (Bubbling)

타깃 → 루트 방향으로 이벤트 전파. 대부분 이벤트의 기본 동작

캡처링 (Capturing)

루트 → 타깃 방향. useCapture: true 또는 세 번째 인자로 수신

event.target

이벤트가 실제 발생한 최초 요소 (위임의 핵심)

event.currentTarget

현재 핸들러가 등록된 요소 (항상 this와 동일)

stopPropagation()

이벤트 전파 중지. 남용 시 부모 핸들러 차단으로 버그 유발

stopImmediatePropagation()

같은 요소의 다른 핸들러도 실행 차단

흐름 설명


[이벤트 위임 흐름]
HTML: <ul id="list"><li>항목1</li><li>항목2</li></ul>

1. 사용자가 <li>항목1</li> 클릭
2. 캡처링: window → document → html → body → ul#list → li
3. 타깃 단계: li에서 이벤트 처리
4. 버블링: li → ul#list → body → html → document → window

[위임 처리]
document.getElementById('list').addEventListener('click', (e) => {
  const item = e.target.closest('li'); // closest로 안전하게 타깃 찾기
  if (!item) return;
  console.log('클릭된 항목:', item.textContent);
});

장점: 동적으로 li를 추가해도 핸들러 재등록 불필요
    

코드 예제


// ❌ 비효율적 방식: 각 요소에 개별 핸들러
const items = document.querySelectorAll('li');
items.forEach(item => {
  item.addEventListener('click', handleClick);
  // 1000개라면 1000개 핸들러 생성
});

// ✅ 이벤트 위임: 부모 하나에 핸들러
document.querySelector('ul').addEventListener('click', (e) => {
  const item = e.target.closest('li');
  if (!item) return;
  handleClick(item);
});

// ✅ 동적 요소도 자동 처리
const ul = document.querySelector('ul');
ul.addEventListener('click', (e) => {
  if (e.target.matches('.delete-btn')) {
    e.target.closest('li').remove();
  }
  if (e.target.matches('.edit-btn')) {
    // 편집 로직
  }
});
    

비교 분석

이벤트

vs

직접 이벤트 바인딩

비교 관점
이 방식
직접 이벤트 바인딩
메모리
핸들러 1개 (부모)
핸들러 N개 (각 자식)
동적 요소
자동 처리됨
추가 시마다 핸들러 재등록 필요
코드 복잡도
closest/matches 로직 필요
단순, 직관적
성능
이벤트 버블링 비용 있음 (무시할 수준)
즉각 처리

이벤트

vs

stopPropagation() 남용

비교 관점
이 방식
stopPropagation() 남용
버블링 제어
불필요 — 위임으로 자연 처리
강제 중단 — 부모 핸들러도 차단
디버깅
예측 가능한 이벤트 흐름
이벤트 흐름이 끊겨 추적 어려움
재사용성
컴포넌트 독립적
전파 중단이 다른 컴포넌트에 영향
권장도
권장
최소화 권장

트레이드오프

이상적인 사용 사례

면접 질문