이벤트 흐름

우리는 사용자와의 인터렉션이나 좀 더 동적인 웹을 만들기 위해 DOM 요소에 이벤트라는 것을 등록한다. 그렇다면 이렇게 등록한 이벤트들을 브라우저는 어떻게 감지하고 이해할까?

 

 

위와 같이 DOM 노드는 계층적인 트리 구조로 이루어져 있다. 사진에서 body안에 div1이 있고, div1안에 div2가 있다. 그리고 여기서 div1div2에 클릭 이벤트가 달려있다고 가정하고 div2를 클릭하면 어떻게 이벤트가 전파될까?

 

일단, 이벤트는 우리가 클릭한 요소에서부터 시작하지 않는다. 이벤트는 최상위 요소에서 부터 시작을 한다. 즉 위에서 html부터 내려오게 되는 것이다. 이것을 Capture Phase라 한다. 그리고 타겟 요소까지 온 후, 걸려있는 이벤트를 실행하는데 이것을 Target Phase라 한다. 마지막으로 타겟 요소부터 html까지 올라가는 것을 Bubble Phase라 한다.

 

이처럼 이벤트는 일련의 규칙을 가지고 동작을 한다. 그래서 이벤트를 다룰때 해당 과정을 잘 이해하지 않고 코드를 작성하면 나중에 복잡해지거나 이벤트가 많아질 경우 디버깅도 힘들어지고 무엇보다 결과가 지멋대로 나올 것이다. 그러면 이벤트 버블링은 무엇이고, 캡처링은 무엇인지 알아보자.

 

e.target vs e.currentTarget

이벤트에 대해 알아가기 전에 e.targete.currentTarget의 차이에 대한 개념을 짚고 넘어가자. 이 둘은 비슷해보이고 실제로 같은 값을 가질 수도 있지만 의미 자체는 다르다.

 

e.target은 실제 이벤트가 일어난 요소를 가리키고 e.currentTargetthis이다. 여기서 this란 현재 요소로, 실행 중인 이벤트 핸들러가 할당된 요소를 가리킨다. 예시를 통해 살펴보자.

위의 사진에서 아래와 같이 div1에 클릭 이벤트를 달고 div2를 클릭하면 다음과 같은 결과가 나온다.

 

const div1 = document.querySelector("#div1");

div1.addEventListener("click", function (e) {
  console.log("e.target:", e.target);
  console.log("e.currentTarget:", e.currentTarget);
});

 

div2 클릭

 

e.target은 실제 이벤트가 일어난(클릭한) 요소인 div2를 가리키고 있고, e.currentTarget은 이벤트 핸들러가 할당된 요소인 div1을 가리키고 있는 것을 볼 수 있다.

만약 div2가 아니라 div1을 클릭했다면 e.targete.currentTarget이 똑같이 div1을 가리킬 것이다.

 

이벤트 버블링

 

이벤트 버블링은 위와 같이 화살표 방향으로 이벤트가 전파된다. 이벤트 버블링은 브라우저가 이벤트를 이해하는 디폴트 값으로, 직관적이라 이해하기도 쉽다.

예를 들어 bodydiv1, div2가 각각 클릭 이벤트를 갖고 있고 div2를 클릭하면 div2 > div1 > body 순으로 이벤트가 발생한다.

그럼 이벤트 버블링 동작과정을 실제로 확인해보자.

 

const body = document.body;
const div1 = document.querySelector("#div1");
const div2 = document.querySelector("#div2");

body.addEventListener("click", function () {
  console.log("body");
});

div1.addEventListener("click", function () {
  console.log("div1");
});

div2.addEventListener("click", function () {
  console.log("div2");
});

 

div2 클릭

 

위와 같이 div2를 클릭하면, 클릭한 타겟 요소부터 차례대로 이벤트가 발생한다.

당연한 말이지만, 여기서 div1이나 body에 클릭 이벤트(타겟 요소와 다른 이벤트)와는 다른 이벤트가 등록된 경우에는 발생되지 않는다.

 

이처럼 이벤트 버블링은 이벤트가 발생한 곳부터 부모 요소로 타고 올라가는 방식을 말한다. 거의 모든 이벤트가 버블링되며 브라우저의 기본 값도 버블링이다.

 

이벤트 캡처링

 

이벤트 캡처링은 이벤트 버블링과 반대 방향으로 동작한다. 버블링 때와 똑같이 bodydiv1, div2에 각각 이벤트를 등록하고 div2를 클릭해보자. 이때 이벤트 캡처링을 사용하기 위해선 addEventListenercapture옵션을 true로 설정해줘야 한다. 

 

const body = document.body;
const div1 = document.querySelector("#div1");
const div2 = document.querySelector("#div2");

body.addEventListener("click", function () {
  console.log("body");
}, true);

div1.addEventListener("click", function () {
  console.log("div1");
}, true);

div2.addEventListener("click", function () {
  console.log("div2");
}, true);

 

div2 클릭

 

똑같이 div2를 클릭했는데, 결과가 정반대로 출력되었다. 이와 같이 이벤트 캡처링에서는 우리가 어떤 요소를 클릭했는지와는 상관없이 최상위 요소부터 내려오며 이벤트를 발생시킨다. 

 

그렇다면 어떤 이벤트는 캡처링을 사용하고 어떤 이벤트는 버블링을 사용한다면 어떤 결과가 나올까?

처음에 이벤트는 우리가 클릭한 요소에서 시작하지 않고, 최상위 요소부터 시작한다고 했다. 이 말은 이벤트가 발생할 때 Capture Phase에 따라 이벤트가 캡처링을 사용하는지 먼저 확인을 하는 것이다. 그리고 Target Phase에 따라 타겟 요소의 이벤트가 발생된 후, Bubble Phase단계로 진행되는 방식이다.

 

1. Capture Phase

2. Target Phase

3. Bubble Phase

 

예시를 통해 이해해보자.

 

const body = document.body;
const div1 = document.querySelector("#div1");
const div2 = document.querySelector("#div2");

body.addEventListener("click", function () {
  console.log("body");
});

div1.addEventListener(
  "click",
  function () {
    console.log("div1");
  },
  true,
);

div2.addEventListener("click", function () {
  console.log("div2");
});

 

위와 같이 div1에서만 이벤트 캡처링을 사용하고, div2를 클릭해보면 결과가 다음과 같이 나온다.

 

div2 클릭

 

왜 이런 결과가 나오는 걸까?

이것은 위에서 설명한 이벤트가 어떤 순서로 동작하는지 알고 있다면 쉽게 이해할 수 있다.

과정은 다음과 같다. 먼저 Capture Phase로 이벤트를 감지하여 캡처링을 사용할 경우(div1) 이벤트를 발생시키고, Target Phase가 타겟 요소(div2)의 이벤트를 발생시키고 Bubble Phase가 버블링을 사용하는(body) 이벤트를 발생시키는 것이다. 이해를 돕기 위해 동작 과정을 그림에 나타내봤다.

 

 

e.stopPropagation()

위와 같이 이벤트가 전파되는 것을 원하지 않을 경우 e.stopPropagation을 사용하면 된다.

 

const body = document.body;
const div1 = document.querySelector("#div1");
const div2 = document.querySelector("#div2");

body.addEventListener("click", function () {
  console.log("body");
});

div1.addEventListener("click", function () {
  event.stopPropagation();
  console.log("div1");
});

div2.addEventListener("click", function () {
  console.log("div2");
});

 

위처럼 div1event.stopPropagation()을 넣어주면 div1이후로 이벤트가 전파되는 것을 막아준다. 따라서 body의 이벤트는 발생하지 않을 것이다.

 

 

이벤트 위임

이벤트 위임이란 요소마다 각각 이벤트 리스너를 달지않고, 요소들의 공통 조상에 리스너를 달아 여러 요소를 한꺼번에 다룰 수 있는 방법이다. 예시를 통해 이해해보자.

ul안에 li가 2개 있고, 각 li는 버튼을 갖고 있다. 이때 li의 버튼을 클릭했을때마다 어떤 요소인지 알기 위해선 다음과 같이 코드를 작성할 수 있다.

 

 

const listButtons = document.querySelectorAll("button");

listButtons.forEach(button => {
  button.addEventListener("click", function (e) {
    console.log(e.currentTarget.id);
  });
});

 

list1 버튼 클릭

 

이렇게 각 리스트에 이벤트 리스너를 다는 방식이다. 이때, 새로운 리스트가 동적으로 추가되면 어떻게 될까?

동적으로 추가된 리스트에는 이벤트 리스너가 없기 때문에 아무런 동작도 일어나지 않을 것이다.

 

const parent = document.querySelector("ul");
const listButtons = document.querySelectorAll("button");

listButtons.forEach(button => {
  button.addEventListener("click", function (e) {
    console.log(e.currentTarget.id);
  });
});

// 새로운 리스트 추가
const newList = document.createElement("li");
const newListbutton = document.createElement("button");

newListbutton.setAttribute("id", "newList");
newListbutton.innerHTML = "newList";
newList.appendChild(newListbutton);
parent.appendChild(newList);

 

 

실제로 newList를 눌러도 아무런 반응이 없다. 이러한 문제를 이벤트 위임을 사용하여 해결할 수 있다. 위와 같이 각 리스트에 이벤트 리스너를 할당하지 않고, 공통 조상인 ul에 이벤트 리스너를 달고 e.target을 사용해 이벤트가 발생한 요소를 알아내는 방식이다. 

 

const parent = document.querySelector("ul");
const listButtons = document.querySelectorAll("button");

parent.addEventListener("click", function (e) {
  console.log(e.target.id);
});

// 새로운 리스트 추가
const newList = document.createElement("li");
const newListbutton = document.createElement("button");

newListbutton.setAttribute("id", "newList");
newListbutton.innerHTML = "newList";
newList.appendChild(newListbutton);
parent.appendChild(newList);

 

차례대로 newList, list2, list1 클릭

 

위와 같이 코드도 더 간결해지고 리스트를 동적으로 추가해도 문제없이 동작하는 것을 볼 수 있다. 하지만 이렇게 하면 문제가 하나 발생하는데, 버튼이 아니라 li요소를 눌러도 부모의 이벤트가 발생하는 것이다. 이는 우리가 원하지 않는 사이드이펙트를 불러일으킬 수 있다. 

위의 예제에서의 문제를 해결하는 방법은 간단하다. 부모의 이벤트 리스너에서 타겟 요소의 노드 이름을 검사하고, 버튼이 아닐 경우 아무런 동작도 하지 않게 하는 것이다.

 

const parent = document.querySelector("ul");
const listButtons = document.querySelectorAll("button");

parent.addEventListener("click", function (e) {
  if (e.target.nodeName !== "BUTTON") return;
  console.log(e.target.id);
});

 

이렇게 하면 li를 눌러도 부모의 이벤트가 발생하지 않는다.(물론 더 복잡한 구조에서는 이런 식으로 하면 안되겠지만)

 

 

참고


생강강

,