이벤트 전파란 무엇인가: 캡처링, 타깃, 버블링을 DOM 예제로 이해하기
빠른 답
- 이벤트 전파는 상위에서 내려오는 캡처링, 실제 요소에 도달하는 타깃, 다시 상위로 올라가는 버블링 순서로 이해하면 된다.
- 기본 이벤트 처리 코드는 대부분 버블링 단계에서 동작하며, 캡처링은 addEventListener 옵션으로 명시할 때 주로 사용한다.
- 전파를 막고 싶을 때는 stopPropagation을, 기본 동작까지 막고 싶을 때는 preventDefault를 구분해서 써야 한다.
- 여러 자식 요소를 한 번에 다뤄야 할 때는 개별 리스너보다 버블링을 활용한 이벤트 위임이 더 단순하고 유지보수에 유리하다.
목차
흐름으로 보기
버튼을 눌렀는데 부모의 클릭 핸들러까지 함께 실행되는 장면은 DOM 이벤트 모델을 이해할 때 가장 먼저 마주치는 사례입니다. 브라우저는 이벤트를 한 요소에서만 처리하지 않고 DOM 트리를 따라 이동시키며 다룹니다. 이 이동이 이벤트 전파이고, 위 흐름을 따라 보면 캡처링과 버블링이 왜 필요한지도 함께 보입니다.
이벤트 전파가 필요한 이유
이벤트 전파는 단순히 이벤트가 퍼지는 현상이 아니라, DOM 구조 전체가 상호작용을 나눠 처리하도록 만든 규칙에 가깝습니다. 자식 요소 하나하나에 리스너를 직접 붙이지 않아도 부모가 공통 동작을 맡을 수 있고, 반대로 특정 지점에서 전파를 끊어 의도하지 않은 반응을 막을 수도 있습니다.
이때 함께 기억할 값이 두 가지 있습니다. event.target은 실제로 이벤트가 시작된 가장 안쪽 요소이고, event.currentTarget은 지금 실행 중인 리스너가 붙어 있는 요소입니다. 부모 리스너 안에서 target은 버튼인데 currentTarget은 카드로 보이는 이유도 이 차이로 설명됩니다.
캡처링, 타깃, 버블링은 어떻게 이어지나
캡처링은 이벤트가 상위 객체에서 타깃 요소 쪽으로 내려오는 단계입니다. 타깃 단계에서는 실제 이벤트가 일어난 요소에 도달합니다. 버블링은 그 다음에 타깃에서 다시 부모 방향으로 올라가는 단계입니다. 클릭 이벤트는 보통 이 버블링을 통해 부모 리스너까지 전달됩니다.
조금 더 정확히 보면 타깃 요소에서도 순서가 있습니다. 타깃에 캡처 리스너와 버블 리스너가 모두 등록되어 있으면 둘 다 실행되며, 캡처 쪽이 먼저 호출됩니다. 또 모든 이벤트가 같은 방식으로 버블링하지는 않습니다. click은 버블링하지만 focus와 blur는 일반적인 버블링 이벤트가 아니어서 부모에서 감지하려면 focusin, focusout이나 캡처링을 함께 살펴보는 편이 좋습니다.
브라우저는 현재 단계를 event.eventPhase로도 보여줍니다. 값은 1이 캡처링, 2가 타깃, 3이 버블링입니다. 이 값을 로그에 함께 남기면 실제 실행 순서가 더 분명해집니다.
addEventListener로 단계와 옵션을 정하는 방법
이벤트를 어느 단계에서 받을지는 addEventListener의 세 번째 인자로 정합니다. 예전 예제에서는 true 또는 false를 직접 넘기는 경우가 많았지만, 지금은 옵션 객체를 쓰는 편이 의도가 잘 드러납니다. 특히 capture, once, passive를 함께 읽을 수 있다는 점이 좋습니다.
const panel = document.querySelector('#panel');
panel.addEventListener('click', handlePanelClick, {
capture: true,
once: false,
passive: false,
});
function handlePanelClick(event) {
console.log(`phase=${event.eventPhase}`);
}
capture는 캡처링 단계에서 실행할지 정하고, once는 한 번 실행한 뒤 자동으로 제거합니다. passive는 스크롤 계열 이벤트에서 브라우저가 기본 동작을 더 빨리 처리할 수 있게 돕지만, 그 대신 preventDefault()와는 함께 쓰기 어렵습니다. 설정을 읽는 사람 입장에서는 true 하나보다 옵션 객체가 훨씬 덜 모호합니다.
콘솔로 보는 실제 실행 순서
이벤트 전파는 콘솔 로그를 직접 보는 쪽이 이해가 빠릅니다. 아래 코드는 빈 페이지의 개발자 도구 콘솔에서도 바로 재현할 수 있게 DOM 요소를 만들고, 부모와 자식에 리스너를 등록한 예시입니다.
const card = Object.assign(document.createElement('section'), { id: 'card' });
const button = Object.assign(document.createElement('button'), { id: 'save' });
button.textContent = '저장';
card.append(button);
document.body.append(card);
function logger(name) {
return (event) => {
console.log(
`${name} | phase=${event.eventPhase} | target=${event.target.id} | currentTarget=${event.currentTarget.id}`
);
};
}
card.addEventListener('click', logger('card capture'), { capture: true });
card.addEventListener('click', logger('card bubble'));
button.addEventListener('click', logger('button bubble'));
이 상태에서 버튼을 한 번 클릭하면 콘솔에는 대체로 아래와 비슷한 출력이 남습니다.
card capture | phase=1 | target=save | currentTarget=card
button bubble | phase=2 | target=save | currentTarget=save
card bubble | phase=3 | target=save | currentTarget=card
첫 줄은 캡처링 단계에서 부모가 먼저 실행된 경우입니다. 둘째 줄은 타깃인 버튼의 리스너가 실행된 순간이고, 셋째 줄은 이벤트가 다시 부모로 올라가며 버블링 단계에서 실행된 경우입니다. 세 줄 모두 target=save인 이유는 이벤트가 시작된 곳이 버튼이기 때문이고, 부모 줄에서만 currentTarget=card로 바뀌는 이유는 현재 실행 중인 리스너의 소유자가 카드이기 때문입니다.
전파 중단과 기본 동작 중단은 다르다
클릭 충돌이 생길 때 가장 자주 섞이는 메서드는 stopPropagation(), stopImmediatePropagation(), preventDefault()입니다. 이름은 비슷하지만 막는 대상이 다릅니다. stopPropagation()은 부모나 조상으로 더 올라가는 흐름을 멈추고, stopImmediatePropagation()은 그것에 더해 같은 요소에 등록된 뒤쪽 리스너 실행까지 멈춥니다. preventDefault()는 링크 이동, 폼 제출처럼 브라우저가 원래 하려던 동작을 막습니다.
const card = document.querySelector('#card');
const actionButton = document.querySelector('#card-action');
const detailLink = document.querySelector('#detail-link');
card.addEventListener('click', () => {
console.log('card click');
});
actionButton.addEventListener('click', (event) => {
event.stopPropagation();
console.log('button click: parent propagation stopped');
});
detailLink.addEventListener('click', (event) => {
event.preventDefault();
console.log(`link click: defaultPrevented=${event.defaultPrevented}`);
});
이 코드에서 버튼을 누르면 부모 카드의 클릭은 더 이상 실행되지 않습니다. 반면 링크를 누를 때 preventDefault()만 호출하면 기본 이동은 막히지만, 부모로의 전파는 그대로 남아 있을 수 있습니다. 그래서 "이벤트를 막았다"라는 표현만으로는 충분하지 않고, 전파를 막은 것인지 기본 동작을 막은 것인지 구분해서 보는 편이 흐름을 읽기 쉽습니다.
이벤트 위임으로 전파를 활용하는 방법
이벤트 전파는 막아야 할 대상이기도 하지만, 잘 활용하면 코드가 단순해집니다. 대표적인 예가 이벤트 위임입니다. 목록 안에 버튼이 여러 개 있을 때 각각에 리스너를 붙이는 대신 부모 목록 하나가 클릭을 받아 처리하도록 두는 방식입니다. 나중에 항목이 동적으로 추가되어도 부모 리스너 하나는 계속 동작한다는 점도 장점입니다.
const list = document.querySelector('#todo-list');
list.addEventListener('click', (event) => {
const removeButton = event.target.closest('[data-action="remove"]');
if (!removeButton) return;
const item = removeButton.closest('[data-id]');
console.log(`remove item: ${item.dataset.id}`);
item.remove();
});
여기서 event.target을 바로 비교하지 않고 closest()를 쓰는 이유도 중요합니다. 버튼 안쪽에 아이콘이나 텍스트 노드가 섞여 있으면 실제 클릭 대상은 버튼 자신이 아닐 수 있기 때문입니다. 이벤트 위임에서는 "어디를 눌렀는가"보다 "어떤 역할을 가진 요소 안에서 눌렸는가"를 찾는 쪽이 더 안정적입니다.
디버깅할 때 같이 보면 좋은 값
이벤트 전파 문제는 코드를 오래 읽는 것보다 로그를 조금 더 정확하게 남기는 편이 빨리 풀리는 경우가 많습니다. 특히 부모가 왜 실행되는지, 전파가 어디서 끊겼는지, 기본 동작이 왜 살아 있는지 같은 질문은 아래 값들만 확인해도 정리가 되는 편입니다.
event.target과event.currentTarget을 함께 출력해 실제 발생 지점과 현재 리스너 위치를 구분합니다.event.eventPhase를 찍어 현재가 캡처링인지, 타깃인지, 버블링인지 확인합니다.- 부모 리스너에
{capture: true}가 등록되어 있는지 살펴봅니다. preventDefault()만 호출하고 전파까지 막혔다고 생각하고 있지 않은지 확인합니다.- 같은 요소의 여러 리스너까지 멈춰야 하는 상황이라면
stopPropagation()대신stopImmediatePropagation()이 필요한지 봅니다. focus,blur,mouseenter,mouseleave처럼 일반적인 버블링 흐름과 다르게 동작하는 이벤트인지 점검합니다.
같이 보면 좋은 레퍼런스
- MDN: EventTarget.addEventListener()
- MDN: Event bubbling
- MDN: Event.stopPropagation()
- MDN: Event.preventDefault()
원문 참고
https://www.maeil-mail.kr/question/39
댓글
댓글 쓰기