기본 콘텐츠로 건너뛰기

이벤트 전파란 무엇인가: 캡처링, 타깃, 버블링을 DOM 예제로 이해하기

이벤트 전파란 무엇인가: 캡처링, 타깃, 버블링을 DOM 예제로 이해하기

빠른 답

  • 이벤트 전파는 상위에서 내려오는 캡처링, 실제 요소에 도달하는 타깃, 다시 상위로 올라가는 버블링 순서로 이해하면 된다.
  • 기본 이벤트 처리 코드는 대부분 버블링 단계에서 동작하며, 캡처링은 addEventListener 옵션으로 명시할 때 주로 사용한다.
  • 전파를 막고 싶을 때는 stopPropagation을, 기본 동작까지 막고 싶을 때는 preventDefault를 구분해서 써야 한다.
  • 여러 자식 요소를 한 번에 다뤄야 할 때는 개별 리스너보다 버블링을 활용한 이벤트 위임이 더 단순하고 유지보수에 유리하다.

흐름으로 보기

이벤트 전파란 무엇인가: 캡처링, 타깃, 버블링을 DOM 예제로 이해하기 흐름 다이어그램

버튼을 눌렀는데 부모의 클릭 핸들러까지 함께 실행되는 장면은 DOM 이벤트 모델을 이해할 때 가장 먼저 마주치는 사례입니다. 브라우저는 이벤트를 한 요소에서만 처리하지 않고 DOM 트리를 따라 이동시키며 다룹니다. 이 이동이 이벤트 전파이고, 위 흐름을 따라 보면 캡처링과 버블링이 왜 필요한지도 함께 보입니다.

이벤트 전파가 필요한 이유

이벤트 전파는 단순히 이벤트가 퍼지는 현상이 아니라, DOM 구조 전체가 상호작용을 나눠 처리하도록 만든 규칙에 가깝습니다. 자식 요소 하나하나에 리스너를 직접 붙이지 않아도 부모가 공통 동작을 맡을 수 있고, 반대로 특정 지점에서 전파를 끊어 의도하지 않은 반응을 막을 수도 있습니다.

이때 함께 기억할 값이 두 가지 있습니다. event.target은 실제로 이벤트가 시작된 가장 안쪽 요소이고, event.currentTarget은 지금 실행 중인 리스너가 붙어 있는 요소입니다. 부모 리스너 안에서 target은 버튼인데 currentTarget은 카드로 보이는 이유도 이 차이로 설명됩니다.

캡처링, 타깃, 버블링은 어떻게 이어지나

캡처링은 이벤트가 상위 객체에서 타깃 요소 쪽으로 내려오는 단계입니다. 타깃 단계에서는 실제 이벤트가 일어난 요소에 도달합니다. 버블링은 그 다음에 타깃에서 다시 부모 방향으로 올라가는 단계입니다. 클릭 이벤트는 보통 이 버블링을 통해 부모 리스너까지 전달됩니다.

조금 더 정확히 보면 타깃 요소에서도 순서가 있습니다. 타깃에 캡처 리스너와 버블 리스너가 모두 등록되어 있으면 둘 다 실행되며, 캡처 쪽이 먼저 호출됩니다. 또 모든 이벤트가 같은 방식으로 버블링하지는 않습니다. click은 버블링하지만 focusblur는 일반적인 버블링 이벤트가 아니어서 부모에서 감지하려면 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.targetevent.currentTarget을 함께 출력해 실제 발생 지점과 현재 리스너 위치를 구분합니다.
  • event.eventPhase를 찍어 현재가 캡처링인지, 타깃인지, 버블링인지 확인합니다.
  • 부모 리스너에 {capture: true}가 등록되어 있는지 살펴봅니다.
  • preventDefault()만 호출하고 전파까지 막혔다고 생각하고 있지 않은지 확인합니다.
  • 같은 요소의 여러 리스너까지 멈춰야 하는 상황이라면 stopPropagation() 대신 stopImmediatePropagation()이 필요한지 봅니다.
  • focus, blur, mouseenter, mouseleave처럼 일반적인 버블링 흐름과 다르게 동작하는 이벤트인지 점검합니다.

같이 보면 좋은 레퍼런스

원문 참고

https://www.maeil-mail.kr/question/39

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...