이벤트 전파(Event Propagation)와 이벤트 위임(Event Delegation)

DOM 트리에서 이벤트가 전파되는 캡처링과 버블링 단계를 이해하고, 이를 활용해 성능을 개선하는 이벤트 위임 패턴과 React에서의 활용법

August 21, 2025

이벤트 전파

DOM 트리 상에 존재하는 DOM 요소 노드에서 발생한 이벤트는 DOM 트리를 통해 전파된다. 이를 이벤트 전파(Event Propagation) 라고 한다.

이벤트 전파 방식

  • 캡처링 단계(capturing phase) : 이벤트가 상위 요소에서 하위 요소 방향으로 전파
  • 타깃 단계(target phase) : 이벤트가 이벤트 타깃에 도달
  • 버블링 단계(bubbling phase) : 이벤트가 하위 요소에서 상위 요소 방향으로 전파
<html>
  <body>
      <ul id="fruits">
        <li id="apple">Apple</li>
        <li id="banana">Banana</li>
        <li id="orange">Orange</li>
      </ul>
      <script>
        const $fruits = document.getElementById('fruits');

        // 하위 요소인 li를 클릭한 경우
        $fruits.addEventListener('click', e => {
          console.log(`이벤트 단계: ${e.eventPhase}`); // 3: 버블링 단계
          console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
          console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
        })
  </body>
<html />

ul 요소의 두 번째 자식 요소인 li 요소를 클릭하면 클릭 이벤트가 발생한다. 이때 생성된 이벤트 객체는 이벤트를 발생시킨 DOM 요소인 이벤트 타깃을 중심으로 DOM 트리를 통해 전파된다. 이벤트 전파는 이벤트 객체가 전파되는 방향에 따라 3단계로 구분할 수 있다.

  1. li 요소를 클릭하면 이벤트가 발생하여 클릭 이벤트 객체가 생성되고 클릭된 li 요소가 이벤트 타깃이 된다.
  2. 이때 클릭 이벤트 객체는 window에서 시작해서 이벤트 타깃 방향으로 전파된다. -> 이벤트 캡처링 단계
  3. 이후 이벤트 객체는 이벤트를 발생시킨 이벤트 타깃에 도달한다. -> 타깃 단계
  4. 이후 이벤트 객체는 이벤트 타깃에서 시작해서 window 방향으로 전파된다. -> 이벤트 버블링 단계

HTML 요소를 클릭하면, 그 이벤트가 자식 → 부모 → 조상 방향으로 거품처럼 올라간다.

어트리뷰트/프로퍼티 방식으로 등록한 이벤트 핸들러는 타킷, 버블링 단계만 캐치 가능

addEventListener 메서드 방식으로 등록한 이벤트 핸들러는 타깃, 버블링, 캡처링 단계까지 캐치 가능 (캡쳐링 단계에서 이벤트 발생을 시키고 싶다면, addEventListener 메서드의 3번째 인자에 값을 true로 주면 됨)

이벤트는 이벤트를 발생시킨 이벤트 타깃은 물론 상위 DOM 요소에서도 캐치할 수 있다. 즉, DOM 트리를 통해 전파되는 경로에 위치한 모든 DOM 요소애서 캐치할 수 있다.

대부분의 이벤트는 캡처링과 버블링을 통해 전파된다. 기본적으로는 버블링 순서로 이벤트가 발생한다. 따라서 버블링을 잘 기억해두는 게 좋다.

하지만 다음 이벤트는 버블링을 통해 전파되지 않는다. 따라서 이벤트 타깃의 상위 요소에서 이벤트를 캐치하려면 캡처링 단계의 이벤트를 캐치해야 한다. 대체할 수 있는 이벤트가 존재하지만, 캡처링 단계에서 이벤트를 캐치해야 할 경우는 거의 없다.

  • 포커스 이벤트: focus/blur
  • 리소스 이벤트: load/unload/abort/error
  • 마우스 이벤트: mouseenter/mouseleave

캡처링과 버블링 이벤트 핸들러가 혼용되는 경우

캡처링 단계의 이벤트와 버블링 단계의 이벤트를 캐치하는 이벤트 핸들러가 혼용되는 경우를 보며 어떻게 이벤트가 흘러가는지 파악해보자.

<!DOCTYPE html>
<html>
<head>
</head>
<body>
    <p>버블링과 캡처링 이벤트 <button>버튼</button></p>
    <script>
        // 버블링 단계의 이벤트 캐치
        document.body.addEventListener('click', () => {
            console.log('3')
        });

        // 캡처링 단계의 이벤트 캐치
        document.querySelector('p').addEventListener('click', () => {
            console.log('1')
        }, true);

        // 타깃 단계의 이벤트 캐치
        document.querySelector('button').addEventListener('click', () => {
            console.log('2')
        });
    </script>
</body>
</html>
  • body 요소는 버블링 단계만 캐치
  • p 요소는 캡처링 단계만 캐치

1. button 요소에서 클릭 이벤트 발생한 경우

  • 캡처링 - 타깃 - 버블링 단계로 전파되므로 먼저 캡처링 단계를 캐치하는 p 요소의 이벤트 핸들러가 호출되고, 그 후 버블링 단계의 이벤트를 캐치하는 body 요소의 이벤트 핸들러가 순차적으로 호출된다.
  • 1-2-3 출력

2. p 요소에서 클릭 이벤트 발생한 경우

  • 캡처링 단계를 캐치하는 p 요소의 이벤트 핸들러가 호출되고, 그 후 버블링 단계의 이벤트를 캐치하는 body 요소의 이벤트 핸들러가 순차적으로 호출된다.
  • 1-3 출력

이벤트 위임

이벤트 위임(event delegation)은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법을 말한다.

이벤트 전파에서도 살펴본 것처럼 이벤트는 이벤트 타깃은 물론 상위 DOM 요소에서도 캐치할 수 있다.

각각 이벤트를 등록했었다면, 등록된 개수만큼 이벤트 리스너가 실행된다. 이벤트 위임을 통해 상위 DOM 요소에 이벤트 핸들러를 등록하면 여러 개의 하위 DOM 요소에 이벤트 핸들러를 등록할 필요가 없다.

왜 쓰나요?

  1. 성능 — 리스너 수가 줄어 메모리를 아낀다.
  2. 동적 요소 처리 — 나중에 추가된 요소도 자동으로 처리된다.

만약 개별 리스너 방식이었다면, 새로 추가된 <li>에는 리스너가 없어서 클릭이 동작하지 않는다. 추가된 엘리먼트에 역시 addEventListener를 통해서 이벤트 등록을 또 해줘야 한다. 이것도 꽤 불편한 일이다. 이처럼 불편한 문제를 해결하기 위해 이벤트 위임을 쓴다.

이벤트 위임 예제

위임 없는 비효율적인 코드

// li가 100개면 리스너도 100개
document.querySelectorAll('li').forEach(li => {
  li.addEventListener('click', (e) => {
    console.log(e.target.targetName);
  });
});

요소가 늘어날수록 리스너도 똑같이 늘어나는 구조라 메모리 낭비가 발생한다. 동적으로 추가된 li에는 리스너가 붙지 않는 문제도 있다.

이벤트 버블링을 이용한 위임

li를 클릭하면 이벤트가 부모인 ul까지 버블링되어 올라온다. 이때 currentTarget은 리스너가 달린 ul이고, target은 실제로 클릭된 하위 요소다.

ul.addEventListener("click", function(evt) {
  console.log(evt.currentTarget.tagName, evt.target.tagName);
  // ul 클릭 시 → UL UL
  // img 클릭 시 → UL IMG
});

target이 실제 클릭된 요소를 가리킨다는 점을 이용하면, 부모에 리스너 하나만 달고도 모든 자식 요소의 클릭을 처리할 수 있다.

이벤트 위임 적용 코드

// 부모 하나에만 리스너 1개
document.getElementById('list').addEventListener('click', (e) => {
  if (e.target.tagName === 'LI') { // 실제로 클릭된 요소 확인
    console.log(e.target.textContent);
  }
});

tagName 조건으로 원하는 요소만 걸러내기 때문에, li가 나중에 동적으로 추가되어도 별도의 리스너 없이 정상 동작한다.

React에서의 이벤트 위임

이처럼 이벤트 위임 기법은 매우 유용하다.

이를 활용한 이벤트 등록방법은 개발단계에서 필수이며, 현대의 많은 프레임워크들도 내부적으로 이벤트 관리에 신경쓰고 있으며 이벤트 위임을 적극 활용한다.

React는 내부적으로 이벤트 위임을 자동으로 적용하기 때문에 직접 구현할 필요가 없다.

<li onClick={handleClick}>항목 1</li>
<li onClick={handleClick}>항목 2</li>
<li onClick={handleClick}>항목 3</li>

이렇게 써도 실제로 각 <li>에 리스너가 붙는 게 아니라, React가 루트 요소 하나에 모아서 처리한다.

React에서 직접 위임을 쓰는 경우

드물지만, React 외부 DOM을 다룰 때는 직접 구현하기도 한다.

ex) useEffect 안에서 직접 리스너 등록

useEffect(() => {
  const list = document.getElementById('list');

  const handleClick = (e) => {
    if (e.target.tagName === 'LI') {
      console.log(e.target.textContent);
    }
  };

  list.addEventListener('click', handleClick);

  // 클린업 필수!
  return () => list.removeEventListener('click', handleClick);
}, []);

이처럼 useEffect 안에서 직접 DOM을 조작하면 바닐라 JS와 동일하게 직접 구현이 필요하다.

하지만 일반적인 JSX 이벤트 핸들러는 React가 위임을 자동 처리하기 때문에 신경 쓸 필요 없다.

이벤트 위임 활용하는법

1. 대용량 리스트/테이블

수백~수천 개의 행이 있는 테이블에서 각 행마다 버튼이 있는 경우이다.

// ❌ 행마다 리스너 3개씩 적용하면 비효율적이다
{rows.map(row => (
  <tr key={row.id}>
    <td>{row.name}</td>
    <td>
      <button onClick={()=> handleEdit(row.id)}>수정</button>
      <button onClick={()=> handleDelete(row.id)}>삭제</button>
      <button onClick={()=> handleView(row.id)}>보기</button>
    </td>
  </tr>
))}

// ✅ 부모에서 한 번에 처리하면 효율적이다
<tbody onClick={(e)=> {
  const btn= e.target.closest('button'); // 클릭된 버튼 찾기
  if (!btn) return;

  const id= btn.closest('tr').dataset.id; // 행의 id 가져오기
  const action= btn.dataset.action;       // 어떤 버튼인지 확인

  if (action= 'edit') handleEdit(id);
  if (action= 'delete') handleDelete(id);
  if (action= 'view') handleView(id);
}}>
  {rows.map(row => (
    <tr key={row.id} data-id={row.id}>
      <td>{row.name}</td>
      <td>
        <button data-action="edit">수정</button>
        <button data-action="delete">삭제</button>
        <button data-action="view">보기</button>
      </td>
    </tr>
  ))}
</tbody>

data-* 속성으로 각 요소에 메타데이터를 심어두고, 부모에서 꺼내 쓰는 패턴을 익혀두자!

2. 동적으로 추가되는 UI 컴포넌트

댓글, 태그 입력, 할 일 목록처럼 사용자가 항목을 계속 추가하는 경우이다.

// 태그 입력 컴포넌트 예시
<div className="tag-container" onClick={(e)=> {
  // x 버튼 클릭 시 해당 태그 삭제
  if (e.target.classList.contains('tag-remove')) {
    const tag= e.target.closest('.tag').dataset.value;
    removeTag(tag);
  }
}}>
  {tags.map(tag => (
    <span key={tag} className="tag" data-value={tag}>
      {tag}
      <button className="tag-remove">×</button>
    </span>
  ))}
</div>

3. 가상 스크롤 (Virtual Scroll)

100만 개 데이터를 렌더링할 수 없으니, 화면에 보이는 것만 DOM에 그리는 기법이다. 이때 항목이 계속 교체되므로 이벤트 위임이 필수이다.

직접 구현하기보다 react-window, react-virtual 같은 라이브러리를 쓰는데, 이 라이브러리들이 내부적으로 이벤트 위임을 활용한다.

React를 쓴다면 대부분 자동으로 처리되지만, data-* 속성 + closest() 조합은 실무에서 직접 쓸 일이 꽤 있으니 익혀두면 좋다.

참고자료