DOM API를 통해 이벤트를 등록하고 이를 실행할 때면, 내가 원하는 예상대로 동작하지 않는 경우가 꽤 많다. 이는 이벤트 전파에 의한 예외 상황을 고려하지 않았을 때 주로 발생하며, 이에 이벤트 전파와 위임에 대해 자세히 알아볼 필요가 있다.

이벤트 전파

DOM 트리 상에 존재하는 DOM 요소의 노드에서 이벤트가 발생하면, 이벤트 객체는 DOM 트리를 통해 다른 DOM 요소 노드로 전파된다. 이를 이벤트 전파라고 한다.

쉽게 말해보자면, 트리 구조로 이루어진 DOM이니만큼, 계층적인 구조를 가지고 있어 이벤트가 일어날 경우 해당 태그에서만 하나의 이벤트를 등록하고 싶어도, 부모 요소에도 이벤트가 달려 있다면 부모 요소의 이벤트도 연쇄적으로 일어난다는 것이다.

이벤트 전파의 종류

이러한 이벤트의 전파는 전파 방향에 따라 버블링과 캡처링으로 나눌 수 있다.

  • 버블링(Bubbling): 자식 요소 바깥 부모 요소로 전파
  • 캡처링(Capturing): 부모 요소 자식 요소 순서대로 계속해서 이벤트 전파

표준 DOM 이벤트

표준 DOM 이벤트에서는 이벤트 흐름을 3단계로 정의한다.

  1. 캡처링 단계 이벤트가 하위 요소로 전파
  2. 타깃 단계 이벤트가 실제 타깃 요소에 전달
  3. 버블링 단계 이벤트가 상위 요소로 전파

위의 그림처럼 td를 클릭하면 맨 처음 캡처링 단계를 통해 이벤트가 전파되고, 이벤트가 타깃 요소에 도착하면 해당하는 이벤트가 실행되며, 다시 위로 올라가며 버블링 단계를 거치면서 요소들에 할당된 이벤트 핸들러들이 실행된다

<!doctype html>
<body>
<style>
  body * {
    margin: 10px;
    border: 1px solid blue;
  }
</style>
 
<form>FORM
  <div>DIV
    <p>P</p>
  </div>
</form>
 
<script>
  for(let elem of document.querySelectorAll('*')) {
    elem.addEventListener("click", e => alert(`캡쳐링: ${elem.tagName}`), true);
    elem.addEventListener("click", e => alert(`버블링: ${elem.tagName}`));
  }
</script>
</body>

이런 식으로 모든 요소에 이벤트를 달아주게 되면, 타깃을 눌렀을 때, 처음 해당 요소로 들어가기까지 캡처링을 통해 모든 이벤트들이 부모요소의 수만큼 실행되게 되고, 타깃에 다다른 후 다시 돌아올 때는 다시 노드를 올라가면서 버블링을 거치기 때문에 다시 부모요소의 수만큼 이벤트 핸들러가 실행된다.

이를 단계별로 정리하면

  1. HTML → BODY → FORM → DIV (캡처링)
  2. P (타깃 단계로 캡쳐링과 버블링 둘 다 리스너를 설정해서 두 번 호출됨)
  3. DIV → FORM → BODY → HTML (버블링) 으로 총 10번의 이벤트가 발생하게 되는 것이다.

왜 그럴까?

사실 부모요소와 자식 요소 둘 다 이벤트가 등록되어 있을 경우, 자식요소의 이벤트만 실행시키려고 할 때, click을 예시로 들어보자면 사실상 자식요소만을 누른다고 해도 결국은 부모 요소의 자식이기 때문에 부모 요소의 이벤트 또한 작동할 수밖에 없다. 계층적인 구조에서는 당연한 일이라고 생각한다. 어찌보면 자식 요소를 클릭한 것이 부모 요소를 클릭한 것과 같은 셈이니까.

전파 흐름 제어하기

상황에 따라서는 유용하게 사용할 수도 있지만, 캡처링의 경우는 부모 요소의 이벤트들을 따로 실행시키지 않고 내가 이벤트를 등록한 타겟요소의 이벤트핸들러만 실행시키고 싶은 때가 있을 수도 있다.

stopPropagation

그럴 때 필요한게 이벤트의 stopPropagation 메소드이다. 이 메소드는 버블링 또는 캡처링 설정에 따라 상위, 하위로 가는 이벤트 전파를 차단함으로써 내가 원하는 타겟 요소의 이벤트핸들러만 실행시킬 수 있는 환경을 제공할 수 있다.

<html>
  <script>
	const ancestor = document.querySelector("#ancestor")
	const parent = document.querySelector("#parent")
	const child = document.querySelector("#child")
	
	let count = 1;
	
	ancestor.addEventListener("click", (e) => {
	  e.stopPropagation()
	  print('ancestor')
	})
	
	parent.addEventListener("click", (e) => {
	  e.stopPropagation()
	  print('parent')
	})
	
	child.addEventListener("click", (e) => {
	  e.stopPropagation()
	  print('child')
	})
	
	
	function print(name) {
	  document.querySelector("section")
	    .insertAdjacentHTML("beforeend",`<p>${count++}. ${name} clicked</p>`);
	}
	
	document.body.addEventListener("click", (e) => {
	  [...document.querySelector("section").children].forEach(e => {
	    e.remove();
	  })
	  
	  count = 1;
	}, true)
  </script>
  <body>
    <div id="ancestor">
      <div id="parent">
        <div id="child"></div>
      </div>
    </div>
 
    <section></section>
  </body>
</html>

이렇게 한다면 각 요소에 있는 이벤트를 버블링 및 캡처링 하는 과정에서 불필요한 이벤트 핸들러의 실행을 차단시킬 수 있다. 이를 통해 각 요소의 이벤트 리스너만 동작시킬 수 있는 것이다.

stopImmediatePropagation

이러한 부모-자식 요소의 html element 뿐만 아니라, 형제 요소들의 이벤트의 실행 또한 제어할 수 있는데, 이는 stopImmediatePropagation() 메소드를 통해 여러 개의 이벤트를 실행시키지 않고 하나의 이벤트만 실행시킬 수 있다.

child.addEventListener("click", (e) => {
    
    if(조건)
    	e.stopImmediatePropagation()
        
    print('child')
})
 
child.addEventListener("click", (e) => {
    print('child 2')
})

target에 조건을 걸은 이벤트 핸들러 컨트롤

기존의 이벤트에 대해서 메소드들을 통해서 캡처링, 버블링을 중간에 차단시키는 방법도 있지만, target element에 dataset attribute를 이용해서 조작하는 방법도 있다.

html의 dataset 속성은 커스텀 사용자 속성을 DOM 요소에 직접적으로 저장할 수 있는데, 이렇게 저장한 요소의 경우 html 내에서 동작하는 자바스크립트 변수와 같은 느낌이 있어 문자열 데이터를 넣어놓고 상태를 조작하거나 특정 target의 id 등을 저장해놓는 방식에도 잘 활용된다.

<input type="text" data-country="Norway" data-code="c03" name="Country">

이런 식으로 앞에 data- prefix를 붙이고 내가 쓰고 싶은 데이터의 속성명을 기재해주고, 이를 자바스크립트의 이벤트 핸들러안에서 찾아서 특정 요소만 검색하는 방식으로도 가능하다

const $div = document.getElementById('post');
 
// 일반적인 객체 속성 접근
$div.dataset.code // "c03"
 
// 배열 인덱스로 접근
$div.dataset['name'] // "Country"
 
// data-country에서 dataset.country로 변환됨
$div.dataset.country // "Norway"

이러한 방식은 조금 더 각각의 요소에 유니크한 값을 넣어놓고 정밀하게 조정할 때 유용한데, 이 부분은 이벤트 위임에서 계속 쓸 예정이다.

이벤트 위임

이처럼 브라우저는 3단계의 과정을 거치기 때문에, 버블링과 캡처링이 내가 예상한 동작 외의 이벤트들 또한 실행될 수 있다는 단점이 있지만, 이를 활용하여 유용하게 사용할 수도 있다.

어차피 부모 요소의 이벤트 또한 자식 요소에도 영향을 끼치는데, 그렇게 한다면 여러 개의 컴포넌트가 있었을 때, 가장 상위에서 이벤트를 등록해놓는다면, 이를 통해서 여러 개가 있는 자식 요소들을 모두 하나의 이벤트 등록으로도 관리할 수 있다는 발상이 가능해진다. 이러한 방식을 이벤트 위임이라고 한다.

<table>
  <tr>
    <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  </tr>
  <tr>
    <td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders</td>
    <td class="n">...</td>
    <td class="ne">...</td>
  </tr>
  <tr>...2 more lines of this kind...</tr>
  <tr>...2 more lines of this kind...</tr>
</table>

위와 같은 예시를 javascript.info 에서 가져왔다. 해당 테이블에 있는 각각의 td 요소들을 누르면 누른 td 요소가 highlight되는 효과를 부여하려 한다.

이를 위해서는 크게 두 가지 방법이 있다.

  1. 해당하는 모든 td 요소를 querySelectorAll로 검색한 뒤 forEach를 통해 캡처링 & 버블링 이벤트 제어 하는 이벤트를 등록한다.
  2. 부모요소에서 이벤트를 등록하면서 캡처링 방식으로 넘겨주되, 특정한 data attribute를 통해 각각의 요소를 구분하고, 뽑아낸 특정 요소에 대한 이벤트 핸들링

하지만 1번의 경우 모든 요소에 대해 이벤트를 일일이 등록해주는 행위 자체는 성능성의 이슈가 있기도 하고, 새로운 요소가 추가되었을 경우에도 계속해서 이벤트를 직접 추가해줘야 하므로 상당히 비효율적이라고 생각한다.

따라서 2번의 방식을 생각하게 되는데, 이 부분의 경우 세심하게 고려할 부분이 있다.

event의 target 고려하기

이벤트 핸들러에 parameter로 오는 event 객체에는 target이라는 속성이 있다. 해당 target 속성은 이 이벤트가 이루어진 요소가 어떤 html 요소인지를 보여준다.

let selectedTd;
 
table.onclick = function(event) {
  let target = event.target; // 클릭이 어디서 발생했을까요?
 
  if (target.tagName != 'TD') return; // TD에서 발생한 게 아니라면 아무 작업도 하지 않습니다,
 
  highlight(target); // 강조 함
};
 
function highlight(td) {
  if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}

그래서 해당 타겟의 html element를 보고 해당 element에 효과를 넣어주는 방식으로 이벤트 위임을 할 수 있다.

하지만 event.target의 중요한 점은 클릭이 이루어진 해당 ‘요소’ 를 정확히 찾아내 리턴한다는 점이다. 따라서 td를 포함하는 td안의 들어있는 모든 요소 대해서 이벤트가 실행되도록 하고 싶은데, 만약에 td 요소 안의 strong 태그 안에 있는 텍스트를 누르게 되면 event.target 으로 받은 html element가 td 안에 들어있는 strong element만을 리턴한다.

따라서 내가 이벤트를 등록하려는 Html element의 범위에 대해서 생각하고, 등록하려는 html element 안의 요소를 클릭했을 때, 어떤 방식으로 해당 부모 요소까지 조작할 지에 대해 고려해야 할 필요성이 있다.

closest 메소드 활용하기

이를 쉽게 할 수 있는 방법 중 하나는 html element의 closest 메소드를 활용하는 것이다.

closest메소드를 활용하여 내가 누른 요소와 가장 가까이에 있는 타켓 요소를 선택할 수 있도록 할 수 있다.

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)
 
  if (!td) return; // (2)
 
  if (!table.contains(td)) return; // (3)
 
  highlight(td); // (4)
};

이를 통해 누른 요소의 가장 가까운 td 요소를 가져와 해당 요소에 대해서 조작을 해줌으로써 세심하게 이벤트를 제어할 수 있다.

dataset 활용하기

html 요소의 attribute에 데이터를 넣어 사용하는 dataset을 활용하는 방식은 closest만큼, 혹은 그보다 더 세심하게 자식 요소의 기능을 관리할 수 있는 방식이다.

<table>
  <tr>
    <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  </tr>
  <tr>
    <td class="td0"><strong>Northwest</strong><br>Metal<br data-action="silver" data-target="td0">Silver<br>Elders</td>
    <td class="td1">...</td>
    <td class="td2">...</td>
  </tr>
  <tr>...2 more lines of this kind...</tr>
  <tr>...2 more lines of this kind...</tr>
</table>

해당 코드에서는 dataset의 action이라는 데이터에 부모 요소의 안쪽 요소들의 세부적인 기능을 제어하기 위한 기능적인 명시를 해두었다. 이러한 div 태그에는 각각 유니크한 id가 붙어있고, 가장 상위에 있는 table이나 html의 body 자체에 이벤트를 등록해두는 것이다.

let selectedTd;
 
table.onclick = function(event) {
	if(event.target.dataset.action === "silver"){
	let target = event.target.dataset.target; 
	target = document.getElementByID(target)
	highlight(target); // 강조 함
	}
};
 
function highlight(td) {
  if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}

이벤트에 대한 세부 기능을 data-action attribute로 넣어놓고 이를 포함하는 부모 요소의 unique한 id를 data-target attribute로 부여해주었다. 이렇게 되었을 경우 보다 부모 요소에서 세부적인 기능을 발견했을 경우 해당 이벤트와 그 기능에 대해서만 처리할 수 있도록 핸들링 할 수 있는 것이 유용하다.

class를 이용하여 요소 관리하기

<div id="menu">
  <button data-action="save">저장하기</button>
  <button data-action="load">불러오기</button>
  <button data-action="search">검색하기</button>
</div>
 
<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }
 
    save() {
      alert('저장하기');
    }
 
    load() {
      alert('불러오기');
    }
 
    search() {
      alert('검색하기');
    }
 
    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }
 
  new Menu(menu);
</script>

class 컴포넌트를 이용해서 관리하는 방식은 보다 가독성이 뛰어나다고 생각한다. 클래스의 메소드를 활용하여 버튼마다 핸들러를 할당해주는 코드를 따로 작성할 필요가 없고, 다른 이벤트 핸들러의 수정 또한 메소드만 관리해주면 되기 때문에 각 기능들에 대해서도 관리하는 방식은 확실히 스마트하다.

constructor에서 onclick 이벤트에는 this를 바인딩하게 되는데, 바인딩된 요소는 해당 Menu 객체의 요소를 가리킨다. 바인딩된 onClick 이벤트는 캡처링을 통해 자식의 요소들의 onclick 이벤트 또한 감지하고, 이를 action으로 나누어 세부적인 기능들을 메소드로 구분할 수 있기 때문에 활용성을 보다 높였다고 생각한다.

장단점

이러한 이벤트위임의 장점은 앞에서도 이야기를 많이 했지만,

  • 핸들러를 일일이 할당하지 않아 초기화가 단순해지고 메모리 절약
  • 요소를 추가, 제거,수정 등에 대해서 할당된 핸들러를 따로 건들 필요가 없음
  • innerHTML이나 유사한 기능을 하는 스크립트로 요소 덩어리를 더하거나 뺄 수 있기 때문에 컴포넌트 기반 개발이 편리함 등이 있다. 하지만 그럼에도 불구하고, 이벤트 위임에도 단점은 있다.
  • 이벤트 위임을 사용하기 위해서는 이벤트의 버블링이 필수인데, 몇몇 이벤트는 버블링이 불가능한 이벤트도 있음 + 낮은 레벨에 할당한 핸들러에 stopPropagation 사용 불가
  • 부모 요소에서 핸들러를 관리하기 때문에 그 안쪽의 모든 요소에 대해서 등록된 이벤트가 실행되기 때문에 CPU 작업부하
    • 무시할만한 수준이라고는 한다.

끝내며

이벤트 전파와 위임에 대해 제대로 이해하는 것은 이번이 아마 처음이었던 것 같다. vanilla js를 거의 쓰지 않고 리액트만 주로 썼던 나에게는 어떠한 이벤트가 ‘전파’된다는 사실 자체가 생소하게 들렸다. 리액트는 주로 이벤트를 따로 등록할 때에는 attribute의 onClick 속성에 이벤트 핸들러 함수를 넣어주었고, 이러한 이벤트들은 각각의 이벤트 핸들러를 모두 가지고 있기 때문에 이벤트 위임 또한 생소하게 느껴졌다고 생각한다. 기본기가 탄탄해야 하나의 프레임워크에 의존되지 않고 여러 프레임워크를 빠르게 적응하고 사용할 수 있는 숙련도가 쌓이는 것 같다. 아무튼 이렇게 기존의 이벤트 핸들러를 보다 효율적이고 스마트하게 사용하는 법을 통해 다양하게 시도해보는 습관을 계속 쌓아나가는 것이 좋은 방식이라고 생각한다.

참조

https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-%EB%B2%84%EB%B8%94%EB%A7%81-%EC%BA%A1%EC%B3%90%EB%A7%81 https://inpa.tistory.com/entry/JS-%F0%9F%93%9A-HTML-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%85%8Bdata-%EC%86%8D%EC%84%B1 https://ko.javascript.info/bubbling-and-capturing https://ko.javascript.info/event-delegation