FE
이벤트 전파
2025. 5. 17. 21:49

사용자 인터페이스를 구현하다 보면, 클릭 이벤트가 예상치 못한 요소까지 전달되는 현상을 마주할 때가 있다.

예컨대 버튼을 클릭했는데, 그 상위 div에 걸어둔 이벤트 리스너도 함께 실행되는 것이다.

이러한 문제를 더 개념적으로 파고들어보자.


 

이벤트 전파란?

이벤트 전파(Event Propagation)는 HTML 요소에서 이벤트가 발생했을 때, 그 이벤트가 DOM 트리를 따라 전파되는 흐름을 말한다.

 

이 흐름은 총 세 단계로 나뉜다:

  1. 캡처링 단계 (Capturing Phase)
  2. 타깃 단계 (Target Phase)
  3. 버블링 단계 (Bubbling Phase)

 

이벤트가 발생하면 먼저 캡처링 단계에서 최상위 요소(document)부터 타깃 요소까지 내려가고, 타깃 요소에서 이벤트가 실행된 후, 버블링 단계에서 타깃 요소부터 다시 최상위 요소까지 올라가며 이벤트가 전파된다.


캡처링과 버블링

  • 캡처링은 최상위 요소(document)에서부터 이벤트가 발생한 타깃 요소까지 내려오는 과정이다.
  • 버블링은 타깃 요소에서부터 다시 상위 요소로 올라가는 과정이다.

아래 코드는 캡처링과 버블링의 흐름을 순서대로 출력해준다.

<div id="parent">
  <button id="child">Click me</button>
</div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// 버블링 단계
parent.addEventListener('click', () => console.log('버블링: parent'));
document.body.addEventListener('click', () => console.log('버블링: body'));
document.addEventListener('click', () => console.log('버블링: document'));

// 이벤트 발생 요소에 도착
child.addEventListener('click', () => console.log('타깃: child'));

// 캡처링 단계
document.addEventListener('click', () => console.log('캡처링: document'), true);
document.body.addEventListener('click', () => console.log('캡처링: body'), true);
parent.addEventListener('click', () => console.log('캡처링: parent'), true);

 

버튼을 클릭하면 다음과 같은 순서로 출력된다:

캡처링: document
캡처링: body
캡처링: parent
타깃: child
버블링: parent
버블링: body
버블링: document

이처럼 addEventListener의 세 번째 인자 (true면 캡처링, 생략 또는 false면 버블링)를 통해 이벤트가 어느 단계에서 처리될지를 제어할 수 있다.


이벤트 위임은 왜 쓰는 걸까?

이벤트 위임(Event Delegation)은 상위 요소에 리스너 하나만 등록하고,

이벤트 전파를 활용해 하위 요소의 이벤트를 처리하는 기법이다.

 

이 방식의 장점

많은 요소에 각각 리스너를 붙일 필요가 없음
예를 들어, <li>가 수백 개 있는 경우 각각 이벤트 리스너를 등록하면 브라우저의 메모리와 성능에 부담이 된다.

아래처럼 각각 이벤트 리스너를 붙인 경우와 이벤트 위임을 쓴 경우의 코드를 나란히 비교해보면 이벤트 위임의 장점이 더 잘 드러난다:

// ul 내부의 li에 각각 이벤트 리스너를 붙이는 경우
const items = document.querySelectorAll('#list li');
items.forEach(item => {
  item.addEventListener('click', () => {
    alert(`클릭한 항목: ${item.textContent}`);
  });
});

// ul에 이벤트 리스너를 붙여 이벤트 위임을 사용하는 경우
const list = document.getElementById('list');
list.addEventListener('click', e => {
  if (e.target.tagName === 'LI') {
    alert(`클릭한 항목: ${e.target.textContent}`);
  }
});

전체 동작 흐름

1️⃣ 사용자가 <li>를 클릭한다.
2️⃣ 이벤트는 <li>에서 시작해서 부모 <ul>까지 전파(캡처링->타깃->버블링)된다.
3️⃣ <ul>에 등록된 이벤트 리스너가 실행된다.
4️⃣ 리스너 내부에서 e.target을 확인한다.
5️⃣ 클릭한 요소가 <li>인지 검사한 후, 맞으면 alert를 실행한다.

 

 

동적으로 추가되는 요소에도 자동으로 대응됨
이벤트 위임을 쓰면, 새로 추가된 요소도 별도 리스너를 달지 않아도 된다.

const list = document.getElementById('list');
const add = document.getElementById('add');

list.addEventListener('click', e => {
  if (e.target.tagName === 'LI') {
    alert(e.target.textContent);
  }
});

add.addEventListener('click', () => {
  const newItem = document.createElement('li');
  newItem.textContent = '새 과일';
  list.appendChild(newItem);
});

→ 새로운 <li>도 클릭 시 자동으로 alert가 동작한다.

이렇게 가능한 이유는 이벤트가 상위로 전파되기 때문이다!


리스너가 없어도 이벤트 전파는 일어날까?

이벤트 리스너가 등록되지 않았더라도, 이벤트 전파 자체는 항상 일어난다.

캡처링 → 타깃 → 버블링이라는 흐름은 브라우저 내부적으로 자동으로 수행된다. 단지 리스너가 없으면 우리는 그 흐름을 감지하거나 개입할 수 없을 뿐이다.


전파 차단과 기본동작 차단

이벤트 전파는 자동으로 일어나지만, 필요할 때 중단시킬 수 있다.

1. event.stopPropagation()

  • 이벤트 전파 전체(캡처링, 타깃, 버블링 포함) 중 이벤트가 발생한 요소에서 멈추고, 이후 어떤 상위 요소로도 전파되지 않음
  • 단, 이 메서드를 캡처링 단계에서 호출하면 캡처링을 중단하고, 버블링은 시작되지도 않음
  • 반대로 버블링 단계에서 호출하면 버블링만 중단되고, 이미 지나온 캡처링에는 영향 없음
// 예제 A: 버블링 단계에서 stopPropagation 호출 → 버블링만 막힘
child.addEventListener('click', e => {
  console.log('child clicked');
  e.stopPropagation();
});

parent.addEventListener('click', () => {
  console.log('parent clicked');
});

// 예제 B: 캡처링 단계에서 stopPropagation 호출 → 타깃과 버블링 모두 막힘
document.addEventListener('click', (e) => {
  console.log('캡처링: document');
  e.stopPropagation();
}, true);

child.addEventListener('click', () => {
  console.log('child clicked');
}, true);

→ 예제 A에서는 child clicked만 확인할 수 있다.

→ 예제 B에서는 캡처링: document만 출력된다.

 

2. event.stopImmediatePropagation()

  • 이벤트는 상위 요소로도 전파되지 않음 (stopPropagation 기능 포함)
  • 같은 요소에 등록된 다른 이벤트 리스너도 실행되지 않음
child.addEventListener('click', e => {
  console.log('first listener');
  e.stopImmediatePropagation();
});

child.addEventListener('click', () => {
  console.log('second listener'); // 실행 안 됨
});

 

3. event.preventDefault() (기본 동작 차단)

  • 이벤트의 기본 동작 자체를 막음
  • 하지만 preventDefault() 전파에는 영향을 주지 않음 (이벤트 전파는 O)
  • 예: <a> 태그 클릭 시 페이지 이동, <form> 제출 등

 

💡 예시: 링크 이동 막기

document.getElementById('link').addEventListener('click', e => {
  e.preventDefault();
  console.log('링크 클릭 방지됨');
});

a 태그의 기본동작은 링크로 이동이지만, 브라우저 이동이 막히고 콘솔만 출력됨

 

💡 예시: form 제출 막기

<form id="myForm">
  <input type="text" />
  <button type="submit">제출</button>
</form>
document.getElementById('myForm').addEventListener('submit', e => {
  e.preventDefault();
  console.log('폼 제출 막음');
});

→ 기본 동작은 서버로 폼 데이터 전송이지만, preventDefault() 덕분에 콘솔만 출력되고 페이지가 리로드되지 않음

 

 

참고 자료

https://javascript.info/bubbling-and-capturing

https://developer.mozilla.org/ko/docs/Web/API/Event/preventDefault