Thumbnail

7분

JavaScript Eventing Deep dive

https://web.dev/eventing-deepdive/

들어가면서

이벤트란 시스템에서 일어나는 사건(action) 혹은 발생(occurrence)입니다.
원하는 대로 어떠한 방식으로 응답할 수 있도록 시스템에 말해주는 것입니다.
- Events_mdn

자바스크립트에서 이벤트는 시스템에서 발생하는 동작입니다. 이벤트 핸들러는 웹 애플리케이션에서 발생한 이벤트에 대한 응답을 처리하는 함수로, 이벤트 리스너라고도 불립니다. 이벤트 핸들러를 등록하면 프로그래머가 원하는 동작을 이벤트가 발생했을 때 실행할 수 있습니다.

window.addEventlistner("click", function (e) {
  console.log(e)
})

window-event

웹 이벤트(DOM 이벤트)는 코어 자바스크립트 언어의 일부가 아닙니다.
브라우저에 내장된 API의 일부로서 정의됩니다.
browser

preventDefault와 stopPropagation

단순한 HTML 구조에 이벤트 처리는 간단하게 처리할 수 있습니다. 그러나 HTML 요소가 중첩되어 있을 때 이벤트 처리는 복잡해질 수 있습니다. 이때 stopPropagation()이나 preventDefault() 메서드를 사용하여 이벤트 전파를 제어할 수 있습니다.

먼저 자바스크립트에서 두 가지 종류의 이벤트 처리에 대해 간략히 보겠습니다.

  • 이벤트 캡쳐링(Capturing)
  • 이벤트 버블링(Bubbling)

이벤트 캡쳐링과 이벤트 버블링

자바스크립트에서는 이벤트 캡쳐링과 이벤트 버블링이라는 두 가지 전파 스타일이 있습니다. 이벤트 캡쳐링은 상위 요소에서 하위 요소로 이벤트를 전파하는 방식이고, 이벤트 버블링은 하위 요소에서 상위 요소로 이벤트를 전파하는 방식입니다.

모든 최신 브라우저는 이벤트 캡쳐링을 지원하지만 개발자는 거의 사용하지 않습니다.

이벤트 캡쳐링은 유일하게 넷스케이프가 지원한 이벤트였습니다.

그에 비해 MS Internet Explore는 이벤트 캡쳐링을 전혀 지원하지 않고 이벤트 버블링만 지원했습니다.

W3C가 만들어졌을 때 두 가지 이벤트 스타일에서 모두 장점을 발견했고 브라우저가 addEventListener 메서드에 대해 세 번째 매개변수를 통해 두 가지 모두를 지원해야 한다고 정했습니다.

element.addEventListner("click", handleClick, { capture: true | false })

options에 capture 속성이 만들어졌고, 기본값은 false 로 이벤트 버블링이 사용됨을 의미합니다.

캡쳐링과 버블링을 이해하기에 앞서서 브라우저에서 이벤트를 진행하는 순서(phase)에 대해서 이해 할 필요가 있습니다.

이벤트 전파 단계

https://developer.mozilla.org/ko/docs/Web/API/Event/eventPhase

event-phase

var phase = event.eventPhase

이벤트 전파는 4가지 단계로 나뉩니다:

  1. Event.NONE(value: 0): 아무런 이벤트도 발생하지 않았을 때입니다.
  2. Event.CAPTURING_PHASE(value: 1): 이벤트가 상위 개체에서 아래 개체로 전파됩니다. window, document 에서 시작하여 대상 Element의 부모에 도달할 때까지 요소들을 통과할 때 입니다. 이 단계에서 EventTarget.addEventListener()가 호출될 때 캡쳐 모드(capture: true)에 있는 Event listeners가 트리거 됩니다.
  3. Event.AT_TARGET(value: 2): 이벤트가 EventTarget에 도작했을 때입니다. 이 단계에서 등록된 이벤트 리스너가 호출됩니다. Event.bubblesfalse 라면 이 단계가 완료된 다음 이벤트 Phase는 종료됩니다.
  4. Event.BUBBLING_PHASE(value: 3): 이벤트가 역순으로 대상의 조상을 통해 전파될 때입니다. EventTarget의 부모부터 시작해서, window를 포함하는 단계까지 도달합니다. 이 부분이 이벤트 버블링으로 알려져 있으며, Event.bubblestrue일 때만 발생합니다. 이 프로세스 중에 이 단계에 등록된 Event listeners가 트리거 됩니다.

예제를 통한 이해

예제를 통해 이벤트 전파가 어떻게 이루어지는지 확인할 수 있습니다. capture 속성을 변경하여 이벤트 캡쳐링과 이벤트 버블링의 동작을 관찰할 수 있습니다.

<h4>Event Propagation Chain</h4>
<input type="checkbox" id="chCapture" />
<label for="chCapture">Use Capturing</label>
<div id="d1">
  d1
  <div id="d2">
    d2
    <div id="d3">
      d3
      <div id="d4">d4</div>
    </div>
  </div>
</div>
<div id="divInfo"></div>
var clear = false,
  divInfo = null,
  divs = null,
  useCapture = false
 
window.onload = function () {
  divInfo = document.getElementById("divInfo")
  divs = document.getElementsByTagName("div")
  chCapture = document.getElementById("chCapture")
  chCapture.onclick = function () {
    RemoveListeners()
    AddListeners()
  }
  Clear()
  AddListeners()
}
 
function RemoveListeners() {
  for (var i = 0; i < divs.length; i++) {
    var d = divs[i]
    if (d.id != "divInfo") {
      d.removeEventListener("click", OnDivClick, true)
      d.removeEventListener("click", OnDivClick, false)
    }
  }
}
 
function AddListeners() {
  for (var i = 0; i < divs.length; i++) {
    var d = divs[i]
    if (d.id != "divInfo") {
      if (chCapture.checked) d.addEventListener("click", OnDivClick, true)
      else d.addEventListener("click", OnDivClick, false)
      d.onmousemove = function () {
        clear = true
      }
    }
  }
}
 
function OnDivClick(e) {
  if (clear) {
    Clear()
    clear = false
  }
  if (e.eventPhase == 2) e.currentTarget.style.backgroundColor = "red"
  var level =
    e.eventPhase == 0
      ? "none"
      : e.eventPhase == 1
        ? "capturing"
        : e.eventPhase == 2
          ? "target"
          : e.eventPhase == 3
            ? "bubbling"
            : "error"
  divInfo.innerHTML += e.currentTarget.id + "; eventPhase: " + level + "<br/>"
}
 
function Clear() {
  for (var i = 0; i < divs.length; i++) {
    if (divs[i].id != "divInfo") divs[i].style.backgroundColor = i & 1 ? "#f6eedb" : "#cceeff"
  }
  divInfo.innerHTML = ""
}

ex1 d1부터 d4까지 id를 가진 계층적 구조를 가진 div 요소를 만들었습니다.

eventPhase를 확인하여 Phase Chain을 확인해보면 좀 더 이해가 될 수 있을 것 같습니다.

capture: false

이 경우, 이벤트 캡쳐링 단계에서 이벤트가 발생하지 않고, 이벤트 대상에서 이벤트가 발생한 후 버블링 단계에서 이벤트가 발생합니다.

d.addEventListener("click", OnDivClick, false)

capturing을 사용하지 않고 d4를 눌러보면 어떻게 될까요? capturing을 사용하지 않기 때문에 위에서 아래로 내려오는 capturing 단계에서는 event가 트리거 되지 않고, target phase에서 트리거 되고 bubbling 시에 이벤트가 트리거 됩니다.

ex2

capture: true

d.addEventListener("click", OnDivClick, true)

capturing을 사용하면 위에서 아래로 내려오는 capturing 단계에서 등록된 event가 트리거되고, target까지 내려간 다음 bubbling 시에는 이벤트가 트리거 되지 않습니다. ex3

전체적으로

d.addEventListener("click", OnDivClick, true)
d.addEventListener("click", OnDivClick, false)

ex4

capture: true, capture: false를 각각 가진 이벤트를 2번 등록해두었을 땐 위와 같이 예상한 대로 chain에 따라 이벤트가 propagation되는 걸 볼 수 있습니다.

위 예시를 통해서 DOM Event가 어떻게 이벤트가 propagation 되는지 조금은 더 이해할 수 있었던 것 같습니다.

이벤트 캡쳐링

위 예시도 보았지만 모든 이벤트는 window에서 시작하여 먼저 캡쳐링 단계를 거칩니다.

document.getElementById("C").addEventListener(
  "click",
  function (e) {
    console.log("#C was clicked")
  },
  true
)

위 이벤트(캡쳐를 true로 등록한)가 있다고 생각하고 차근차근 다시 살펴보겠습니다.

window => document => html => body => ... => EventTarget(여기서는 id='C'인 요소)

클릭 이벤트를 등록하지 않더라도 이벤트는 여전히 window에서 시작되고 방금 말한대로 전파(propagation)됩니다.

클릭 이벤트가 window부터 시작되고, 브라우저는 window 요소에게 다음과 같은 질문을 합니다.

캡쳐링 단계에서 이벤트 수신하는게 있습니까?

  • 아니요

있다고 한다면 해당하는 이벤트 핸들러(리스너)가 실행됩니다. (없으면 처리하지 않고 진행됩니다.)

그 다음 요소가 document이고 방금과 마찬가지로 document 요소에게 동일한 질문을 합니다.

이 과정이 이벤트 대상 요소까지 도달 할 때까지 동일하게 질문합니다.

캡쳐링 단계에서 이벤트 수신하는게 있습니까?

그리고 이벤트 대상에서는 등록한 이벤트 핸들러가 실행이 됩니다.

이벤트 버블링

target phase단계에서 이제 버블링 phase로 넘어갑니다.

위 캡쳐링 순서와는 반대로 target 요소부터 버블링이 시작됩니다.

C요소 부터 마찬가지로 비슷한 질문을 합니다.

버블링 단계에서 이벤트 수신하는게 있습니까?

  • 아니요

여기에 세심하게 주의를 하세요.

위에 예시로 등록한 이벤트는 캡쳐링 단계에서만 트리거하는 이벤트이기 때문에 버블링 단계에서는 호출되지 않습니다. (만약 캡쳐링, 버블링 단계에서 동작하도록 2번의 이벤트 핸들러를 연결했다면 2번 호출됩니다.)

하지만 서로 다른 Phase에서 실행된다는 점을 기억하세요.(하나는 캡쳐링 Phase, 다른 하나는 버블링 Phase)

window < document < html < body < ... < EventTarget(여기서는 id='C'인 요소)

버블링: 이벤트가 DOM 트리 "위로" 이동하는 것처럼 보이기 때문에 거품(버블링)이라고 합니다.

C요소 부모로 전파되고 마찬가지로 동일하게 질문하면서 window까지 전파됩니다.

버블링 단계에서 이벤트 수신하는게 있습니까?

  • 아니요

이벤트가 이런 Chain을 거치면서 동작한다고는 알아차리기 힘들었는데 꽤나 긴(?) 여정을 가지는 것 같습니다.

대부분의 경우 개발자는 일반적으로 하나의 이벤트 단계(버블링이나 캡쳐링)에만 관심이 있기 때문에(보통 버블링을 주로) 이를 알아차리기는 힘듭니다.

event.stopPropagation()

예상을 해보자면 전파를 막는거니 캡쳐링(또는 버블링) 단계에서 해당 이벤트 핸들러가 트리거되면 전파를 막는거겠지?

stopPropagation()은 (대부분의) 기본 DOM이벤트에서 호출 할 수 있습니다. (대부분이라고 하면 전부는 아니라는 말입니다.)

focus, blur, load, scroll를 포함해 몇 가지는 propagation을 하지 않습니다. stopPropagation()을 호출을 할 순 있지만 원래 전파되지 않기 때문에 별 다른 일은 일어나지 않습니다.

stopPropagation()은 문자 그대로 역할을 합니다. 우리가 호출 할 때, Phase에 따라 Chain으로 전파되는 것을 막습니다.

캡쳐링 단계에서 호출하게 된다면 타겟 요소나 버블링 단계에 도달하지 않습니다. 버블링 단계에서 호출하게 된다면 캡쳐링, 타겟 요소를 거치지만 호출한 시점에서 부터 버블링UP은 일어나지 않습니다.

event.stopImmediatePropagation()

이 이상하고 자주 사용되지 않는 방법은 무엇입니까?

stopPropagation()과 비슷하지만 하위 항목(캡쳐링) 또는 상위 항목(버블링)으로 이동을 중지하는 것 대신에 단일 요소에 연결된 이벤트 핸들러가 2개 이상인 경우에만 적용됩니다.

addEventListener는 동일 요소에 2번 이상 적용이 가능하기 때문에 보통 이런 경우 브라우저에서는 이벤트 핸들러가 연결된 순서대로 실행됩니다.

이럴 경우 stopImmediatePropagation()을 실행하면 해당 요소에 첫 번째 이벤트 핸들러를 실행 후 그 이후 핸들러는 실행되지 않습니다.

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById("A").addEventListener(
  "click",
  function (e) {
    console.log("When #A is clicked, I shall run first!")
  },
  false
)
 
document.getElementById("A").addEventListener(
  "click",
  function (e) {
    console.log("When #A is clicked, I shall run second!")
    e.stopImmediatePropagation()
  },
  false
)
 
document.getElementById("A").addEventListener(
  "click",
  function (e) {
    console.log("When #A is clicked, I would have run third, if not for stopImmediatePropagation")
  },
  false
)

ex5

위와 같은 예가 있다고 생각해봅시다.

3번째로 등록한 이벤트 핸들러는 2번째로 등록한 이벤트 핸들러에서 e.stopImmediatePropagation()을 호출했기 때문에 절대 실행되지 않습니다.

만약 e.stopImmediatePropagation() 대신 e.stopPropagation() 을 호출 했다면 3번째로 등록한 이벤트 핸들러는 계속 실행될 것입니다.

ex6

event.preventDefault()

preventDefaultstopPropagation 이 둘은 종종 헷갈리지만 실제로는 서로 별로 관련이 없습니다.

preventDefault() 메서드는 이벤트의 기본 동작을 취소합니다. 예를 들어, 링크를 클릭하면 기본적으로 해당 URL로 이동하는데, 이 기본 동작을 취소하려면 preventDefault()를 사용하면 됩니다.

<a id="link" href="https://seonest.net">Seonest</a>

위와 같은 anchor 요소가 있다고 생각해봅시다. 아무런 이벤트 핸들러가 없다면 해당 요소를 눌렀을 때 Default인 기본작업이 있습니다. 해당 요소 클릭을 하면 해당 주소로 이동하게 될 것입니다.

그런데 만약 이 기본 작업을 막아야 한다면... 그 때 preventDefault를 사용하면 됩니다.

document.getElementById("link").addEventListener(
  "click",
  function (e) {
    e.preventDefault()
    console.log("링크 클릭이 기본 동작으로 이동되지 않습니다.")
  },
  false
)

이 경우 클릭 이벤트 핸들러를 <a>요소에 연결하고 기본 작업을 하지 않도록 했습니다. 따라서 아무데도 이동하지 않고 콘솔에 해당 글자만 찍히게 됩니다.

이 기본 동작을 방지할 수 있는 다른 요소/이벤트 조합은 어떻게 있을까요? 매우 많기도 하고 때로는 테스트를 해가며 확인해야 합니다.

자주 쓰이는 다음 몇 가지가 있습니다.

  • <form> + submit 이벤트: submit이라는 기본 동작을 막고 원하는 이벤트를 하게 할 때
  • <a> + click 이벤트
  • document + mousewheel 이벤트: 이 경우 마우스 휠로 페이지 스크롤을 방지할 때 쓰입니다.(키보드를 이용한 스크롤은 여전히 작동함, {passive: false}가 필요함)
  • document + keydown 이벤트: 이 조합은 치명적입니다. 페이지를 거의 쓸모 없게 만들어 버립니다.
  • document + mousedown 이벤트: 이 조합은 마우스로 텍스트 강조 표시를 방지하고 마우스를 누른 상태에서 호출하는 "다른 기본 작업"을 방지합니다.
  • input + keypress 이벤트: 이 경우 사용자가 입력한 문자가 입력되지 않도록 방지합니다.(그러나 이렇게 하지마세요. 사용할 이유는 거의 없습니다.)
  • document + contextmenu 이벤트: 마우스 우측 클릭이나 길게 누를 때 기본 브라우저 컨텍스트 메뉴가 나타나지 않도록 방지합니다.

이벤트 버블링: 이벤트 Delegate(위임)

버튼을 동적으로 생성하고 클릭 이벤트를 부여하는 코드를 작성할 경우를 생각해봅시다.

만약 100개의 버튼을 생성하고 각각에 클릭 이벤트를 부여한다면 어떻게 될까요?

for (let i = 0; i < 100; i++) {
  const button = document.createElement("button")
  button.innerText = i
  document.body.appendChild(button)
  button.addEventListener("click", function () {
    console.log(i)
  })
}

이 코드는 잘 동작하지만 100개의 버튼에 각각 이벤트 핸들러가 등록되어 있습니다. 이 경우 버튼이 추가될 때마다 이벤트 핸들러가 추가되기 때문에 메모리 사용량이 증가하고 성능이 저하될 수 있습니다. 또한 이벤트 핸들러를 제거할 때도 각각의 이벤트 핸들러를 제거해야 합니다.

이런 경우에 사용할 수 있는 것이 이벤트 위임입니다.

document.body.addEventListener("click", function (e) {
  if (e.target.tagName === "BUTTON") {
    console.log(e.target.innerText)
  }
})

이벤트 위임은 이벤트 핸들러를 하위 요소 대신 상위 요소에 등록하여 여러 요소에 대한 이벤트 처리를 한 곳에서 관리하는 기법입니다. 이벤트 버블링을 활용하여 상위 요소에서 하위 요소를 통해 전파되는 이벤트를 처리합니다.

이벤트 위임의 장점은 다음과 같습니다:

  1. 메모리 사용량 감소: 이벤트 핸들러를 개별 요소에 등록하지 않기 때문에 메모리 사용량이 감소합니다.
  2. 동적으로 추가되는 요소에 대한 처리: 런타임에 동적으로 추가되는 요소에 대한 이벤트 핸들러를 등록할 필요가 없습니다. 상위 요소에서 처리할 수 있기 때문입니다.
  3. 코드 유지보수: 이벤트 핸들러를 한 곳에서 관리하기 때문에 코드의 유지보수가 용이합니다.

마무리하며

지금까지 이벤트 처리와 관련된 여러 주제를 다루었습니다. 이벤트 처리는 웹 애플리케이션에서 사용자 인터랙션을 관리하고 다양한 기능을 구현하는 데 필수적인 기술입니다. 이러한 지식을 활용하여 사용자에게 더 나은 웹 애플리케이션을 제공할 수 있습니다.

이전에 이벤트 처리를 이리저리 시도하며 많은 시간을 소모한 경험이 있었지만, 이제는 그 시간을 조금이라도 줄일 수 있을 것으로 기대됩니다.

reference

마지막 업데이트

3/26/2023


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.