Today
-
Yesterday
-
Total
-
  • [JavaScript] Event의 이해
    Web/JavaScript 2021. 8. 15. 19:23

    Event 요?

    이벤트라고 하면 말 그대로 브라우저 환경에서 '마우스 클릭'이나 '애니메이션이 끝남' 등 브라우저 위에서 발생하는(일어나는) 상황을 말한다. JavaScript를 이용한 웹 개발은 scroll, click, hover 등 수 많은 event에 의해 기능들이 동작하는 Event-Driven 개발이라고도 한다. 기본적으로 브라우저가 지원하는 이벤트들은 여기를 참고하면 좋겠다.

    Event 등록

    이벤트를 등록 하는 방법은 두 가지가 있다.

    const $myBTN = document.querySelector(".my-button");
    
    $myBTN.addEventListener("click", () => {
      console.log("버튼이 클릭되었습니다");
    });

    1. 일반적으로 많이 사용하는 방법이다. 첫 번째 인자에는 이벤트 이름, 두번째 인자에는 해당 이벤트가 일어났을 때 실행 될 콜백 함수, 그리고 세번째 인자는 이벤트 deligation 여부인데, 이 내용은 조금 뒤에 다루겠다.

    $myBTN.onclick = () => console.log("버튼이 클릭되었습니다");

    2. 두 번째 방법은 해당 이벤트의 콜백 함수를 직접 설정해주는 것이다. 두 가지의 방법에는 차이가 있으니 잘 알아두면 좋겠다.

     

    addEventListener  vs  onclick

    $el.addEventListener('click') $el.onclick
     IE 6,7,8버전 지원 안함 모든 브라우저 지원
    한 이벤트에 여러 콜백 함수 설정 가능 한 이벤트에 하나의 콜백 함수만 설정 가능
    capturing 여부 설정 가능 capturing 설정 불가능

    1. 브라우저 호환성
    addEventListener는 ES6에 나온 문법이다. 그 덕에 IE 6, 7 등 구버전에서는 지원이 되지 않는 반면 onclick는 모든 브라우저에서 지원된다. 하지만 요즘에는 바벨같은 트랜스파일러가 있기 때문에 addEventListener도 문제 없이 사용할 수 있다!

    2. 멀티 콜백 함수 지원

    첫번째 줄에대한 설명은 넘어가고, 두번째의 차이점은 코드로 보면 이해가 쉬울 것이다.

    const $myBTN = document.getElementById("my-button");
    
    $myBTN.addEventListener("click", () => console.log("Hello"));
    
    $myBTN.addEventListener("click", () => console.log("I'm Hov"));
    
    //버튼 클릭 시 Hello, I'm Hov 둘 다 출력
    const $myBTN = document.getElementById("my-button");
    
    $myBTN.onclick = () => console.log("Hello");
    
    $myBTN.onclick = () => console.log("I'm Hov");
    
    //버튼 클릭 시 I'm Hov만 출력

    위 코드를 직접 실행해보면 onclick으로 콜백 함수를 여러번 설정하면 가장 마지막에 설정한 콜백만 작동을 하고, addEventListener에서는 모든 콜백함수들이 순차적으로 실행되는 것을 알 수 있다.

    3. Event Capturing 설정

    addEventListener에서는 세번째 인자로 Event Capturing에서 해당 콜백을 실행 시킬지 아니면 타깃이나 버블링 단계에서 실행할지를 boolean으로 설정 할 수 있다. default로 false값이 들어가고 이벤트 캡쳐링이 일어난다. (바로 아래에서 bubbling과 capturing에 대해 설명을 할 예정)

    위의 이유로 대부분의 개발자들은 addEventListener로 이벤트를 다루고 있다.

     

    이벤트 버블링 (Event Bubbling)

    브라우저에서 발생하는 이벤트는 버블링, 캡쳐링이라는 현상이 일어난다. 예를 들어 아래의 버튼이 있다고 생각해보자. 해당 제품의 영역을 선택하면 제품의 상세 정보를 볼 수 있는 페이지로 이동하는 버튼이다. 그리고 그 버튼의 우측 하단에는 찜 버튼이 있다.

        

    이 구조를 간단하게 나타내면 아래처럼 볼 수 있다.

     <div class="item">
       글 상세 보기
       <button class="like-button">
         좋아요
       </button>
     </div>
    const $itemBTN = document.querySelector(".item");
    
    const $likeBTN = $itemBTN.querySelector(".like-button");
    
    $itemBTN.addEventListener("click", () => {
      console.log("상세 게시글로 이동!");
    });
    
    $likeBTN.addEventListener("click", () => {
      console.log("해당 제품 좋아요 / 좋아요 해제");
    });

     

    자 그럼 여기서 파란 영역을 누르면 당연히 "상세 게시글로 이동!" 이라는 문구가 출력될 것이고, 내부 버튼을 클릭하면 "해당 제품 좋아요 / 좋아요 해제" 라는 문구가 나올 거라 예상한다. 하지만 결과를 보면 다음과 같다.

    바깥에 있는 div 태그는 클릭을 하면 하나의 콜백만 실행이 되는데, 내부에 있는 좋아요 버튼을 누르면 밖에 있는 itemBTN의 이벤트도 함께 발생 된다..! 마우스가 클릭한 영역이 좋아요 버튼의 영역이기도 하지만, 글 상세보기의 영역이기도 해서 두 이벤트가 모두 발생하는 것이다. 이렇게 내부의 요소에 이벤트가 발생했을 때 그 상위 요소들(최 상위까지)에도 이벤트가 발생되는 현상을 바로 이벤트 버블링 현상이라고 한다.

    이벤트 버블링 현상(빨간 화살표)으로 인해 상위 요소의 핸들러도 실행이 된다.


    ⚠️ 이벤트 캡쳐링은 대부분의 이벤트는 이러한 현상이 일어나는데, 모든 이벤트에서 버블링 현상이 일어나는 것은 아니다.
    예를 들어 focus라는 이벤트는 버블링이 일어나지 않고 현재의 요소에서만 해당 이벤트가 발생한다.

    event.stopPropagation() 버블링 중단하기

    그럼 이런 버블링 현상을 멈추기 위해서는 어떻게 해야할까? 콜백 함수에 파라미터로 전달된 event객체에서 stopPropagation()이라는 메소드를 불러주면 버블링이 멈춘다.

    $likeBTN.addEventListener("click", (e) => {
      console.log("해당 제품 좋아요 / 좋아요 해제");
      
      e.stopPropagation();
    });

    bubbling이 중단되어 handler가 실행되지 않는다.

    이벤트 캡쳐링 (Event Capturing)

    조금 전에 잠깐 언급 했듯, 이벤트가 발생 될 때, 버블링 현상만 일어나는 것은 아니다. 표준 DOM 이벤트에서 정의한 이벤트 흐름에는 다음의 3단계가 있다.

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

    이벤트가 발생 될 때 제일 먼저 버블링의 반대로 최 상위 요소의 이벤트부터 실제로 이벤트가 일어난 요소까지 내려가면서 하나씩 핸들러를 찾아내려가는데, 이를 이벤트 캡쳐링이라고 한다. 아래 코드를 보면 이해가 더 쉬울 것이다.

    <출처 : https://www.grapecity.com/en/blogs/html-and-wijmo-events/>

    addEventListener에서 capturing 설정

    addEventListner 에서 콜백 함수(이벤트 핸들러)가 어느 시점에 불릴지 결정을 지어줄 수 있는데, 세 번째 인자에 true를 설정하면 해당 콜백함수를 캡쳐링 단계에서 실행해준다. (default 값이 false다).

    조금전 우리가 좋아요 버튼에 stopPropagation() 메소드를 불러 더이상 상위 요소로 이벤트 핸들러가 발생하지 않도록 버블링을 끊어 줬다. 하지만 itemBTN에서 콜백함수를 capturing 단계에서 불러주면 어떻게 될까?

    const $itemBTN = document.querySelector(".item");
    
    const $likeBTN = $itemBTN.querySelector(".like-button");
    
    $itemBTN.addEventListener(
      "click",
      () => {
        console.log("상세 게시글로 이동!");
      },
      true
    );
    
    $likeBTN.addEventListener("click", (e) => {
      console.log("해당 제품 좋아요 / 좋아요 해제");
      e.stopPropagation();
    });

    $itemBTN.addEventListener에서 세번째 인자에 true를 넣어 줬다. 그리고 버튼을 눌러보면 다음과 같은 현상이 발생한다.

    분명히 likeBTN에서 stopPropagation을 불러서 버블링을 멈췄는데 itemBTN의 click 이벤트 핸들러가 동작한다. 바로 캡쳐링 과정에서 itemBTN의 click 이벤트핸들러가 실행되었기 때문이다.

    이벤트 캡쳐링 시에 핸들러가 발생함

    이벤트 캡쳐링은 실제 코드에서 자주 쓰이진 않지만 종종 유용한 경우가 있다고 하니 알아두자.

    e.target vs e.currentTarget

    이벤트가 발생하면, 핸들러(콜백 함수)에 이벤트 객체를 전달한다. 그리고 이벤트 핸들러에서는 이 이벤트 객체를 통해 다양한 프로퍼티에 접근할 수 있다.

    1. e.target - 이벤트가 발생한 가장 하위의 요소 (실제로 이벤트가 발생한 가장 하위 요소)
    2. e.currentTarget(this) - 이벤트를 핸들링 하는 요소, (핸들러가 할당 된 요소)
    3. e.eventPhase - 현재 이벤트 흐름 단계 (1: 캡쳐링, 2: 타겟, 3: 버블링)

    예를 들어 Div 태그 안에 button이 있고, button 을 눌렀을 때 div 태그의 클릭 이벤트 핸들러가 동작 한다면
    e.target은 <button />를 나타내고, e.currentTarget은 <div />를 나타낸다.

    e.preventDefault vs e.stopPropagation

    기본적으로 이벤트에는 브라우저가 default로 설정해둔 동작들이 있다. 예를들어 a 태그에서는 클릭이 되면 현재의 location 이 변경되어 페이지가 리랜더링이 된다. 이렇게 브라우저에 의해 기본적으로 동작되는 것들을 e.preventDefault라는 메소드를 통해 막을 수 있다. 하지만 이곳에는 버블링이나 캡쳐링과는 상관 없는 별개의 로직이기 때문에, e.preventDefault로 이벤트 버블링을 막을순 없다. 반대로 e.stopPropagation이라는 메소드는 단순히 이벤트 버블링을 중단하는 메소드기 때문에 브라우저가 기본적으로 실행하는 동작들은 막을 수 없다.

     

    이벤트 핸들러 등록 해제 (삭제)

    이벤트 핸들러는 위와 같이 캡쳐링을 통해 최 상단의 요소에서 타겟 요소 까지 내려가며 해당 엘리먼트에 이벤트 핸들러가 있는지 체크하고 만약 핸들러가 있다면 그 핸들러를 실행한다. 그래서 브라우저가 너무 많은 이벤트핸들러를 기억하지 않게 하기 위해  더이상 사용되지 않는 핸들러는 삭제를 해 줄 필요가 있다. 등록한 이벤트를 삭제하는 방법은 간단하다.

    $el.removeEventListener이라는 메소드를 사용하면 되는데, addEventListener와 동일하게 첫 번째 파라미터에는 이벤트 종류, 두번째 파라미터에는 핸들러(콜백 함수), 세번째 파라미터는 capturing 유무를 넣어주면 된다. (동일한 이벤트 명과 핸들러라도 capturing 유무가 다르면 이벤트 핸들러가 해제 되지 않는다!)

    그런데 아래와 같이 이벤트를 등록하고 해제했다면 어떻게 될까?

    $el.addEventListener("click", () => console.log("클릭"));
    
    $el.removeEventListener("click", () => console.log("클릭"));
    
    //여전히 click을 했을 때 console.log("클릭")이 실행 됨

    당연히 정상적으로 해제가 되지 않는다. 이벤트 등록시에 넣은 handler와 해제시에 입력한 handler는 생김새는 같지만 엄연히 다른 함수이기 때문이다.

    const funcA = () => console.log('a');
    const funcB = () => console.log('a');
    
    console.log(funcA === funcB); // false

    위의 이유 + 가독성 향상의 이유로 일반적으로는 핸들러를 따로 밖에서 선언 해주고 등록 해제를 한다. 이벤트 핸들러는 가능한 외부에 작성하는 습관을 들이자..!

    const handleClick = (e) => console.log(`${e.target} is clicked`);
    
    $el.addEventListener("click", handleClick);
    
    $el.removeEventListener("click", handleClick); //동일한 핸들러이기 때문에 정상 해제 됨

     

     

    이벤트 위임 (Event Deligation)

    이제 JavaScript 이벤트의 최종 보스 이벤트 위임에 대해 알아보겠다. (어려운 내용은 아니니 겁 먹을 필요는 없다)

    위 처럼 만약에 조금전에 우리가 만들었던 버튼이 여러개 있다고 생각해보자. 각 버튼 마다 클릭되었을 때 해당 아이템의 상세 정보를 띄워주는 클릭 이벤트 핸들러가 있다고 할 때, 우리는 과연 어떻게 이벤트 등록을 해줄까? 아마 가장 먼저 떠오르는 생각은 아래와 비슷할 것이다.

        <ul class="item-list">
          <li class="item">
            <span>아이템1</span>
          </li>
          <li class="item">
            <span>아이템2</span>
          </li>
          <li class="item">
            <span>아이템3</span>
          </li>
          <li class="item">
            <span>아이템4</span>
          </li>
        </ul>
    const $itemList = document.querySelector(".item-list");
    
    const $items = $itemList.querySelectorAll(".item");
    
    const handleClickItem = (e) => console.log(e.target.outerText);
    
    for (let i = 0; i < $items.length; i++) {
      $items[i].addEventListener("click", handleClickItem);
    }

    이렇게 된다면 중간에 동적으로 item 버튼의 갯수가 늘어난다거나 줄어든다면 event를 다시 지워주거나 새로 등록을 해줘야 할 것이다. 서버의 api를 받아와서 언제든지 data의 내용이 달라질 수 있는 대부분의 웹 서비스에서는 다음과 같이 이벤트를 등록하면 상당히 이벤트를 관리하기가 상당이 어려워 진다.

    이러한 문제를 해결하기 위해 이벤트 위임이라는 기법을 사용하곤 한다. 이벤트 위임은 이벤트 캡쳐링, 버블링 현상을 이용해 상위 엘리먼트에서 하위 노드들의 이벤트를 관리 하는 기법이다. 이를 통해 브라우저가 너무 많은 이벤트를 기억하지 않게 하고, 또한 DOM이 추가/삭제 될때 Event등록을 매번 해줘야 하는 수고를 없앨 수 있다.

    위에서는 각각의 item 엘리먼트에 이벤트를 하나씩 등록 해줬는데, 이벤트 버블링을 활용하면 아래와 같이 사용할 수 도 있다.

    const $itemList = document.querySelector(".item-list");
    
    const handleClickList = (e) => {
      if (e.target.nodeName === "LI") {
        console.log(e.target.outerText);
      }
    };
    
    $itemList.addEventListener("click", handleClickList);

    파라미터로 전달받은 e.target의 종류가 ul태그인지, li태그인지를 체크해서 우리는 해당 클릭이 버튼에서 이루어 진것인지 바깥의 리스트에서 클릭이 일어난 것인지 구별을 할 수 있다. 이를 통해 li태그에서 눌렸을 경우에만 이벤트가 일어나도록 할 수 있는 것이다. 이렇게 된다면 item-list의 하위에 동적으로 li가 생성 되더라도 따로 이벤트를 등록해 줄 필요가 없고, 반대로 li가 없어지더라도 이벤트 핸들러를 삭제 할 필요도 없다. 훨씬 간편한 이벤트 관리가 가능해 진 것이다.

    e.target.closest

    근데 문제가 하나 생겼다..! target.nodeName이 LI일 경우에만 핸들러가 동작하게 설정을 했다 보니, span 태그가 있는 부분을 클릭하면 e.target.nodeName이 "SPAN"이 되기 때문에 아무런 동작도 하지 않는다는 것을 알 수 있다. 

    이를 해결 하는 방법도 간단하다..!

    e.target.closest라는 메소드를 사용하면 해당 요소의 상위에 nodeName이 일치하는 태그가 있는지 확인하고 그 결과값을 반환 해준다. 이를 이용해서 다음과 같이 이벤트 핸들러를 변경해주면 된다 :)

    const $itemList = document.querySelector(".item-list");
    
    const handleClickList = (e) => {
      if (e.target.closest("LI")) {
        console.log(e.target.outerText);
      }
    };
    
    $itemList.addEventListener("click", handleClickList);

     

    이 글을 마치며

    위의 이벤트 위임 기술은 이벤트 버블링이 이루어 지기 때문에 사용 가능한 기법이다. 그래서 협업을 하다 보면 어떤 개발자가 이벤트 위임 기법을 사용한다고 했을 때, 다른 누군가가 그 내부 요소에서 e.stopPropagation()으로 이벤트 버블링을 막아버리면 해당 요소 하위에 있는 모든 이벤트들은 위임 방식에서 정상적으로 작동하지 않을 것이다. 그래서 가능하면 e.stopPropagation()을 이용해 버블링을 막는 것을 지양하자.

     

    오늘 이 글을 정리하면서 이벤트에 대한 많은 지식을 정리할 수 있게 되었다. 어렴풋이 알고있던 개념들이 또렷하게 정리가 되어 앞으로는 다른 사람들에게도 잘 전달 할 수 있을 것 같다!

     

    참고

    https://ko.javascript.info/bubbling-and-capturing

     

    버블링과 캡처링

     

    ko.javascript.info

    &

    우아한 테크캠프 4기 교육자료

    댓글