본문 바로가기

Programming/JavaScript

JavaScript의 Eventing이란? Eventing 전파를 막는 세 가지 기법

최근 동아리에서 FE 스터디를 진행하고 있다. web.dev 사이트에서 글을 하나 골라서 읽고 자료조사 후 설명하기가 스터디의 목적이다. 나의 발표 차례가 되었고, 어떤 내용을 공부해볼까? 하다가 흥미로운 글을 하나 발견했다. JavaScript의 eventing에 대한 글이었다.

Eventing Style capturing, bubbling에 대해 알아보고 이벤트 전파를 막는 세 가지 기법 stopPropagation, stopImmediatePropagation, preventDefault에 대해 자세히 적혀있다. 이 글은 해당 내용을 다룬다.

 

아래 부분은 web.dev에 기재된 JavaScript eventing deep dive를 번역한 내용이다. (모든 내용을 번역하진 않았고, 핵심 내용만 진행했다.)

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

 

JavaScript eventing deep dive

 

web.dev

JavaScript의 preventDefaultstopPropagation을 언제 쓰고, 각 메소드가 무슨 일을 어떻게 하는지 알아보자

JavaScript의 이벤트 핸들링은 의외로 간단하다. 특히 단순한 HTML 구조를 다룰 때는 더더욱 간단하다. 하지만 이벤트가 여러 엘리먼트의 구조를 이동하거나 전파될 때 조금 복잡해진다. 개발자들이 문제를 해결하기 위해 stopPropagation()preventDefault()를 사용할 때 주로 발생한다. 문제를 해결하려고 두 함수를 번갈아가면서 써봤거나, 둘 다 써봤다면 이 글을 읽어봤으면 한다.

Eventing Styles (capturing, bubbling)

모든 모던 브라우저는 event caputuring을 지원하지만, 개발자들은 잘 사용하지 않는다. Netscape가 원래 지원했던 유일한 형태의 이벤트이다. 브라우저의 숙적, IE 브라우저는 event capturing을 지원하지 않는 대신, event bubbling을 지원했다.

W3C는 두 스타일의 이벤트의 장점을 찾았고, addEventListener 메서드의 세번째 파라미터를 통해 브라우저가 두 가지 모두를 지원해야 된다고 선언했다. 원래 해당 파라미터는 boolean 형태였는데, 모든 모던 브라우저는 options 객체를 지원해서 event capturing을 사용할지 말지를 명시할 수 있다.

someElement.addEventListner('click', myClickHandler, { capture: true | false});

options 객체는 선택사항이며 둘 중 하나를 생략하면 캡쳐의 기본값이 false이므로 event bubbling이 사용된다.

Event Capturing

이벤트 핸들러가 "capturing 구문을 listening하고 있다."라는 문장이 무슨 뜻일까? 이를 이해하려면 event가 어떻게 생기고 어떻게 이동하는지 알아야 한다. 모든 event에 해당하는 내용이다.

이벤트가 dispatch 되면 window부터 시작해서 타깃 엘리먼트를 향해 아래로 이동한다. bubbling phase만 리스닝 중이어도 마찬가지로 window부터 타깃 엘리먼트를 향해 아래로 이동한다. 예시를 살펴보자.

  • 참고로 window는 웹 브라우저의 window를 나타내는 객체이다. 브라우저 안의 모든 요소들이 소속된 객체로 최상위에 위치하며 전역 객체라고도 부른다.
<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

사용자가 #C 엘리먼트를 클릭하면 window에서 시작된 event가 발송된다. 그럼 이벤트가 하위 이벤트를 통해 전파된다.

window => document => html => body => ... => target

window, document, html, body 등 타겟에 이동하기 전까지의 엘리먼트들이 클릭 event를 리스닝하고 있는 여부는 전혀 상관없다. event는 항상 window에서 시작된다.

 

"capturing phase에서 window가 클릭 이벤트를 리스닝하고 있나?" 만약 그렇다면, 적절한 이벤트 핸들러가 실행될 것이다. 하지만 위의 예제에서는 아무것도 없으므로 핸들러가 실행되지 않는다.

 

그다음으로는, window에서 document, html, body로 넘어가 아까와 같이 똑같은 질문을 던진다. 마찬가지로 아무것도 없으므로 핸들러는 실행되지 않는다.

 

#A, #B 엘리먼트까지 전파되고 동일한 질문을 하게 되고, 아무것도 없으므로 핸들러는 역시 실행되지 않는다.

마지막으로 event는 타깃 엘리먼트에 도달하게 되고, 브라우저는 또 같은 질문을 던진다. 이번에는 "yes"라고 대답할 수 있다. 이때 이벤트 핸들러가 실행되고, 브라우저는 #C was clicked라는 문구를 콘솔 로그로 남긴다.

Event Bubbling

브라우저는 또 질문을 하게 된다. "bubbling phase안에서 #C 엘리먼트가 클릭 이벤트를 리스닝하고 있나?" bubbling, capturing phase 둘 다 이벤트 리스닝할 수 있다. 만약, 두 phase에서 이벤트 핸들러를 연결했다면, 두 이벤트 핸들러가 같은 엘리먼트에서 각각 실행된다.

 

그 후, 이벤트는 상위 엘리먼트 #B로 전파된다. DOM트리를 위로 이동하는 것처럼 보이기 때문에 'bubble'이라고 표현한다. 브라우저는 #B 엘리먼트에게 다시 같은 질문을 던진다. 없으므로 핸들러는 실행되지 않는다. #A, <body>, <html>, <document>, window 순으로 브라우저는 같은 질문을 한다.

 

이처럼, 모든 이벤트는 위와 같은 과정을 거친다. 대부분 개발자들은 하나의 이벤트 phase 또는 다른 이벤트 단계에만 관심이 있기 때문에 잘 모르는 영역이다.

 

두 개의 phase에서 모든 엘리먼트를 리스닝하는 예제를 살펴보자.

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

콘솔 로그에는 사용자가 어떤 엘리먼트를 클릭하는지에 따라 다른 값이 출력된다. 만약 DOM트리의 가장 하위 엘리먼트 #C를 클릭하게 된다면, 모든 이벤트 핸들러가 실행된다.

 

capturing 이벤트 핸들러가 탑다운으로 실행되고 bubbling 이벤트 핸들러가 역순으로 실행된다.

demo: https://silicon-brawny-cardinal.glitch.me/event-capturing-and-bubbling.html

capturing / bubbling

event.stopPropagation()

capturing, bubbling phase에 대해 알아봤으니, event.stopPropagation()에 대해 알아보자.

stopPropagation() 메소드는 대부분의 네이티브 DOM 이벤트에서 호출될 수 있다. '대부분'인 이유는 focus, blur, load, scroll 등과 같은 메서드들은 호출되더라도 처음부터 전파되지 않기 때문이다. stopPropagation() 메소드를 호출할 수는 있으나 이벤트가 전파되지 않으므로 아무 일도 일어나지 않는다.

stopPropagation이 하는 일?

간단하다. 호출된다면, 해당 시점부터 이벤트가 다른 엘리먼트들로 전파되는 것을 멈춘다. 따라서 capturing phase에서 호출 시, 타깃 phase 혹은 bubbling phase에 도달하지 못하게 된다. 만약 bubbling phase에서 호출 시 capturing phase는 지났으므로 호출된 시점에서 "bubbling up" 단계가 중단된다.

예시를 살펴보자. capturing phase의 #B 엘리먼트에서 stopPropagation() 메소드를 호출하게 되면 어떻게 될까?

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

capturing phase가 하위로 전파되지 않고, #B 엘리먼트에서 즉시 종료된다.

demo: https://silicon-brawny-cardinal.glitch.me/stop-propagation-capturing-B.html

 

그렇다면, bubbling phase의 #A 엘리먼트에서 stopPropagation() 메소드가 호출되면 어떻게 될까?

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

capturing phase는 모든 엘리먼트에서 실행되고, bubbling phase가 상위로 버블링 되지 않고 #A 엘리먼트에서 종료된다.

demo: https://silicon-brawny-cardinal.glitch.me/stop-propagation-bubbling-A.html

 

그렇다면, #C 엘리먼트의 capturing phase에서 stopPropagation()을 호출한다고 가정해보자. 이때, #A 엘리먼트를 클릭하게 되면 다음과 같은 결과가 출력된다.

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

demo: https://silicon-brawny-cardinal.glitch.me/stop-propagation-capturing-C.html

event.stopImmediatePropagation()

생소한 메소드일 수 있다. stopPropagation()과 유사하지만 약간 다르다. 이벤트가 하위(capturing) 또는 상위(bubbling)로 이동하는 것을 중지하는 대신 연결된 이벤트 핸들러가 두 개 이상인 경우에만 적용된다. addEventListener()가 이벤트의 멀티캐스팅 스타일을 지원하므로 단일 엘리먼트에 이벤트 핸들러 두 번 이상 연결하는 것이 가능하다. 이때 대부분의 브라우저에서 이벤트 핸들러는 연결된 순서대로 동작한다. 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,
);

위 예제를 실행해보면 다음과 같은 로그가 출력된다.

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

세 번째 이벤트 핸들러는 e.stopImmediatePropagation()에 의해 절대 도달하지 못한다.

demo: https://silicon-brawny-cardinal.glitch.me/stop-immediate-propagation.html

event.preventDefault()

preventDefault()가 하는 일을 잘 모르겠다면, action이라는 단어를 추가해봐라. "default action을 방지함"

여기서 default action은 명확하게 정의를 내릴 수 없다. 그 이유는 엘리먼트와 여러 이벤트의 조합에 의해 결정되기도 하며 때론 default action이 존재하지 않을 수도 있다.

예제로 이해해보자. 웹페이지에서 링크를 클릭하면 어떤 동작이 수행되기를 원할까? 브라우저가 명시된 URL로 이동하기를 바랄 것이다. 이때 엘리먼트는 anchor tag가 되고, 이벤트는 click 이벤트이다. <a> + click 조합이 default action이 된다. 만약 브라우저가 default action을 수행하지 못하도록 하려면 어떻게 해야 할까? preventDefault() 메소드를 사용하면 된다.

 

예제를 살펴보자.

<a id="ausg" href="https://ausg.me">AUSG</a>
document.getElementById('ausg').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

보통은 하이퍼 링크를 클릭하면 https://ausg.me로 이동하겠지만, click 이벤트 핸들러를 <a> 요소에 연결하고 default action이 동작하지 않도록 구현되어있으므로 링크로 이동하지 않는다. 아무 일도 일어나지 않고 콘솔에 로그만 출력될 것이다.

demo: https://silicon-brawny-cardinal.glitch.me/prevent-default.html (demo 내용은 위 코드와 조금 다르다)

 

default action을 방지할 수 있는 엘리먼트/이벤트 조합들을 간략하게 알아보자.

  • <form> + "submit" 이벤트: 유효성 검사에 유용하며 실패 시 조건부로 preventDefault()를 호출하여 양식 제출을 중지할 수 있다.
  • document + "mousewheel" 이벤트: 마우스 휠로 페이지 스크롤을 방지할 수 있다. 다만, 키보드로 스크롤은 동작한다. addEventListener('', handler, { passive :false }) 옵션이 필요하다.
  • document + "keydown" 이벤트: 브라우저에 치명적이다. 페이지를 쓸모없게 만들어 키보드 스크롤, 탭, 키보드 하이라이팅 기능을 방지한다.
  • document + "mousedown" 이벤트: 마우스로 텍스트 하이라이팅 및 마우스를 통해 호출되는 다른 default action을 금지한다.
  • <input> + "keypress" 이벤트: 사용자가 입력한 문자가 input 엘리먼트에 도달하지 않도록 한다. (권장하지 않는 사용법이다.)

적재적소에 preventDefault() 메소드를 활용하면 좋다.

만약에 말이야...

만약, stopPropagation()preventDefault()를 capturing phase에서 호출하게 되면 어떻게 될까?라는 상상을 해보자.

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

모든 이벤트는 window에서 시작되므로 모든 click, keydown, mousedown, contextmenu, mousewheel 이벤트를 엘리먼트가 리스닝하지 못하도록 막는다. stopImmediatePropagation()도 호출했으므로 document에 연결된 그 어떤 핸들러도 차단될 것이다.

 

stopPropagation()preventDefault()은 페이지를 사용하지 못하게 만드는 메소드들이 아니라는 점을 기억하자. 그저 이벤트가 가려고 하는 길을 방지할 뿐이다.

 

하지만 preventDefault()를 호출함으로써 default action을 차단한다. 따라서 모든 이벤트의 default aciton이 금지되므로 페이지가 쓸모없게 된다.

마무리

  • capturing은 window부터 타겟 엘리먼트까지 하위로 내려가며 이벤트가 전파된다.
  • bubbling은 타겟 엘리먼트부터 window까지 상위로 올라가며 이벤트가 전파된다.
  • stopPropagation()은 호출되는 시점부터 이벤트의 상위 엘리먼트의 이벤트가 호출되지 않는다.
  • stopImmediatePropagation()는 이벤트를 포함하여 이벤트에 연결된 상위 엘리먼트의 이벤트 핸들러가 호출되지 않는다.
  • preventDefault()는 default action(event와 엘리먼트의 조합)의 이벤트가 취소된다. 상위 엘리먼트의 이벤트는 적용되지 않는다.

참고 자료

반응형