이벤트 시스템 심화 — 캡처링, 버블링, 그리고 이벤트 위임의 원리
버튼 하나를 클릭했을 뿐인데, 그 이벤트는 어디서 시작되어 어디까지 전달될까요?
DOM 이벤트는 단순히 "요소를 클릭하면 핸들러가 실행된다"로 끝나지 않습니다. 실제로는 문서 최상위에서 시작해서 타깃을 거쳐 다시 올라가는 여정을 거칩니다. 이 흐름을 정확히 이해하면 이벤트 위임이 왜 가능한지, stopPropagation은 왜 조심해야 하는지 자연스럽게 알게 됩니다.
1. DOM 이벤트 흐름의 3단계
브라우저에서 이벤트가 발생하면 세 단계를 순서대로 거칩니다.
① 캡처링(Capturing) document → html → body → ... → 타깃의 부모
② 타깃(Target) 실제 이벤트가 발생한 요소
③ 버블링(Bubbling) 타깃의 부모 → ... → body → html → document
addEventListener의 세 번째 인자
// 버블링 단계에서 실행 (기본값)
element.addEventListener('click', handler);
element.addEventListener('click', handler, false);
// 캡처링 단계에서 실행
element.addEventListener('click', handler, true);
element.addEventListener('click', handler, { capture: true });
흐름을 눈으로 확인하기
<div id="outer">
<div id="inner">
<button id="btn">클릭</button>
</div>
</div>
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const btn = document.getElementById('btn');
// 캡처링 단계 리스너
outer.addEventListener('click', () => console.log('outer 캡처'), true);
inner.addEventListener('click', () => console.log('inner 캡처'), true);
// 버블링 단계 리스너
btn.addEventListener('click', () => console.log('btn 타깃'));
inner.addEventListener('click', () => console.log('inner 버블'));
outer.addEventListener('click', () => console.log('outer 버블'));
버튼을 클릭하면 출력 순서는 이렇습니다.
outer 캡처 ← ① 캡처링: 위에서 아래로
inner 캡처 ← ① 캡처링
btn 타깃 ← ② 타깃
inner 버블 ← ③ 버블링: 아래에서 위로
outer 버블 ← ③ 버블링
대부분의 실무 코드는 버블링 단계만 사용합니다. 캡처링은 "부모가 먼저 이벤트를 가로채야 하는" 특수한 상황에서 유용합니다.
2. event.target vs event.currentTarget
이 둘의 차이는 이벤트 위임을 이해하는 핵심입니다.
| 속성 | 의미 | 변하는가? |
|---|---|---|
event.target | 실제 이벤트가 발생한 요소 | 전파 중에도 고정 |
event.currentTarget | 리스너가 등록된 요소 | 전파 단계마다 변경 |
document.getElementById('outer').addEventListener('click', (e) => {
console.log('target:', e.target.id); // 실제 클릭된 요소
console.log('currentTarget:', e.currentTarget.id); // 항상 "outer"
});
outer 안의 btn을 클릭하면 target은 btn, currentTarget은 outer입니다. 이 차이를 활용한 것이 바로 이벤트 위임입니다.
3. stopPropagation vs stopImmediatePropagation
stopPropagation
상위 요소로의 전파를 막습니다. 하지만 같은 요소 에 등록된 다른 리스너는 정상 실행됩니다.
btn.addEventListener('click', (e) => {
e.stopPropagation();
console.log('첫 번째 리스너'); // 실행됨
});
btn.addEventListener('click', () => {
console.log('두 번째 리스너'); // 이것도 실행됨
});
// 부모의 리스너는 실행되지 않음
inner.addEventListener('click', () => {
console.log('inner'); // 실행 안 됨
});
stopImmediatePropagation
같은 요소에 등록된 나머지 리스너까지 전부 차단합니다.
btn.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('첫 번째 리스너'); // 실행됨
});
btn.addEventListener('click', () => {
console.log('두 번째 리스너'); // 실행 안 됨!
});
남용하면 안 되는 이유
stopPropagation을 남용하면 상위에서 이벤트 위임으로 처리하는 로직이 깨집니다. 분석 도구(Google Analytics 등)가 클릭을 추적하지 못하는 문제도 생길 수 있습니다. 정말 필요한 경우가 아니라면 사용을 자제하는 편이 좋습니다.
4. 이벤트 위임(Event Delegation) 심화
이벤트 위임은 버블링을 활용해 ** 부모 요소 하나에만 리스너를 등록 **하는 패턴입니다.
closest()로 안정적으로 타깃 찾기
<ul id="todo-list">
<li data-id="1">할 일 1 <button class="delete-btn">삭제</button></li>
<li data-id="2">할 일 2 <button class="delete-btn">삭제</button></li>
<li data-id="3">할 일 3 <button class="delete-btn">삭제</button></li>
</ul>
const todoList = document.getElementById('todo-list');
todoList.addEventListener('click', (e) => {
// 클릭된 요소가 삭제 버튼이거나, 삭제 버튼의 자식인지 확인
const deleteBtn = e.target.closest('.delete-btn');
if (!deleteBtn) return;
// 가장 가까운 li에서 데이터 추출
const li = deleteBtn.closest('li');
const id = li.dataset.id;
console.log(`할 일 ${id} 삭제`);
li.remove();
});
closest(selector)는 자기 자신부터 시작해서 부모 방향으로 올라가며 셀렉터에 매칭되는 첫 번째 요소를 반환합니다. 버튼 안에 아이콘 같은 자식 요소가 있어도 안전하게 동작합니다.
동적 요소에도 자동 적용
이벤트 위임의 가장 큰 장점은 ** 나중에 추가된 요소에도 별도 리스너 등록 없이 동작 **한다는 점입니다.
// 새 할 일 추가 — 별도 리스너 등록 불필요
const newLi = document.createElement('li');
newLi.dataset.id = '4';
newLi.innerHTML = '할 일 4 <button class="delete-btn">삭제</button>';
todoList.appendChild(newLi);
// 삭제 버튼 클릭 시 위의 리스너가 자동으로 처리
리스너가 부모(ul)에 등록되어 있으니, 자식이 몇 개가 추가되든 이벤트는 버블링을 타고 올라와서 처리됩니다.
5. passive 옵션과 스크롤 성능
스크롤이나 터치 이벤트에서 preventDefault()를 호출하면 브라우저는 기본 동작(스크롤)을 멈춰야 합니다. 문제는 브라우저가 "이 리스너가 preventDefault를 호출할지 말지"를 리스너 실행이 끝날 때까지 알 수 없다는 점입니다.
// 나쁜 예: 브라우저가 스크롤을 기다려야 함
window.addEventListener('scroll', (e) => {
// 무거운 작업...
});
// 좋은 예: preventDefault를 안 쓸 거라고 명시
window.addEventListener('scroll', (e) => {
// 무거운 작업...
}, { passive: true });
passive: true를 선언하면 브라우저는 리스너 실행을 기다리지 않고 ** 즉시 스크롤을 시작 **할 수 있습니다. 터치 디바이스에서 체감 성능이 크게 달라집니다.
Chrome 등 주요 브라우저는
touchstart,touchmove,wheel이벤트에 기본적으로passive: true를 적용합니다. 이 이벤트들에서preventDefault()를 쓰려면 명시적으로{ passive: false }를 전달해야 합니다.
6. CustomEvent — 컴포넌트 간 통신
DOM 내장 이벤트 외에 직접 만든 이벤트를 발생시킬 수도 있습니다. 프레임워크 없이 컴포넌트 간 느슨한 결합을 구현할 때 유용합니다.
// 커스텀 이벤트 발생
const cartEvent = new CustomEvent('item:added', {
detail: { id: 42, name: '키보드', price: 89000 },
bubbles: true, // 버블링 허용 (기본값 false)
});
document.getElementById('product').dispatchEvent(cartEvent);
// 상위 요소에서 수신
document.addEventListener('item:added', (e) => {
console.log('장바구니 추가:', e.detail.name); // "키보드"
console.log('가격:', e.detail.price); // 89000
});
bubbles: true를 설정하면 일반 DOM 이벤트처럼 버블링되므로, 상위 요소에서 이벤트 위임으로 받을 수 있습니다. detail 속성에 원하는 데이터를 자유롭게 담을 수 있다는 점도 편리합니다.
7. AbortController로 리스너 일괄 해제
컴포넌트가 제거될 때 리스너를 하나하나 removeEventListener로 정리하는 건 번거롭고 빠뜨리기 쉽습니다. AbortController를 쓰면 한 번에 정리할 수 있습니다.
const controller = new AbortController();
const { signal } = controller;
// 여러 리스너를 같은 signal로 등록
window.addEventListener('resize', handleResize, { signal });
document.addEventListener('click', handleClick, { signal });
document.addEventListener('keydown', handleKeydown, { signal });
document.addEventListener('scroll', handleScroll, { signal, passive: true });
// 정리할 때 — 한 줄이면 끝
controller.abort();
// 위에서 등록한 네 개의 리스너가 전부 해제됨
SPA에서 페이지 전환 시, 또는 Web Component의 disconnectedCallback에서 사용하면 메모리 누수를 깔끔하게 방지할 수 있습니다.
once 옵션과 조합
한 번만 실행되어야 하는 리스너도 signal과 함께 쓸 수 있습니다.
element.addEventListener('click', handler, {
once: true, // 한 번 실행 후 자동 해제
signal, // abort 시에도 해제 가능
});
정리
| 개념 | 핵심 |
|---|---|
| 이벤트 3단계 | 캡처링 → 타깃 → 버블링 순서로 전파 |
| target vs currentTarget | target은 실제 발생 요소, currentTarget은 리스너 등록 요소 |
| stopPropagation | 상위 전파 차단 (남용 주의) |
| 이벤트 위임 | 부모에 리스너 하나, closest()로 타깃 식별 |
| passive | preventDefault 포기 → 스크롤 성능 최적화 |
| CustomEvent | detail로 데이터 전달, bubbles로 위임 활용 |
| AbortController | signal로 여러 리스너 일괄 해제 |
공부하면서 느낀 건, 이벤트 시스템은 결국 "흐름"을 이해하는 게 전부라는 점이었습니다. 캡처링-버블링 흐름을 알면 위임이 왜 되는지,
stopPropagation이 왜 위험한지,passive가 왜 필요한지가 전부 연결됩니다.