시작으로


Vanilla JS로 컴포넌트 구현을 공부하다가 이벤트 버블링이라는 것을 접하게 되었다. 한 번도 해본 적 없었기 때문에 공부한 내용을 정리하게 되었다.

이벤트 등록이란


이벤트 등록은 웹 애플리케이션에서 기본적으로 이해하고 있어야하는 내용이다. 이벤트 등록이란 웹 애플리케이션에서 사용자의 입력을 받기 위해 필요한 기능이다. 대표적으로 click 이벤트가 있다.

아래의 간단한 예제를 보자.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn" style="height: 20px; width: 100px;">버튼클릭</button>
    <script>
        const btn = document.querySelector('#btn')
        btn.addEventListener('click', function(){
            alert('click');
        })
    </script>
</body>
</html>

버튼을 클릭하게 되면 콜백함수로 alert()를 호출하여 실행하게 되는 간단한 예제이다. 이처럼 addEventListener()는 웹 API로 웹 개발자들이 동적인 기능들을 추가할 수 있도록 도와준다. 그럼 브라우저는 어떻게 이벤트의 발생을 감지했을까? 브라우저가 이벤트를 감지하는 방식 2가지를 알아보자.


이벤트 버블링 - Event Bubbling


이벤트 버블링이란 특정 화면 요소에서 이벤트가 발생했을 때 해당 이벤트가 더 상위의 화면 요소들로 전달되어 가는 특성을 말한다.

image

상위의 화면 요소란 HTML 요소는 기본적으로 트리 구조를 갖습니다. 여기서는 트리 구조상으로 한 단계 위에 있는 요소를 상위 요소라고 하며 body 태그를 최상위 요소라고 부른다.

밑의 예제로 이해해보자.

새 개의 div 태그가 있다. 가장 하위의 div태그에서 click이벤트가 발생했을 때 최상위 요소인 body 태그까지 전달된다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <style>
        div {
            width: 100px;
            height: 20px;
        }
        .three {
          background-color: gray;
        }
    </style>
    <body>
        <div class="one">
            <div class="two">
                <div class="three">
                </div>
            </div>
        </div>
    </body>
    <script>
        var divs = document.querySelectorAll('div');
        divs.forEach(function(div) {
            div.addEventListener('click', logEvent);
        });

        function logEvent(event) {
            console.log(event.currentTarget.className);
        }
    </script>
</body>
</html>

위 코드를 실행하면 querySelectorAlldiv 태그 모두 클릭 이벤트를 등록한다. 그리고, 클릭했을 때 logEvent()가 실행된다. 밑의 결과를 보면 three->two->one 순서로 콘솔 로그가 출력된 것을 확인할 수 있다.

image

이와 같이 div 태그 한 개만 클릭했는데 3개 이벤트가 발생한 이유는 브라우저는 특정 화면 요소에서 이벤트가 발생했을 때 그 이벤트 최상위에 있는 화면 요소까지 이벤트를 전파시킨다. 따라서, three->two->one 순서로 이벤트가 실행된 것이다.

만약, 특정 div 태그에만 이벤트를 준다면 위와 같은 동작은 일어나지 않는다.

이와 같이 하위에서 상위 요소로의 이벤트 전파 방식을 이벤트 버블링(Event Bubbling)이라고 한다.


이벤트 캡쳐 - Event Capture


이벤트 캡처는 이벤트 버블링과 반대 방향으로 진행되는 이벤트 전파 방식이다.

밑의 그림처럼 특정 이벤트가 발생하게 되면 최상위 요소인 body 태그에서 해당 태그를 찾아 내려간다.

image

밑의 예제로 이해해보자.

이벤트 캡쳐로 전파하려면 addEventListener() API에서 옵션 객체에 capture:true를 설정해주면 된다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <style>
        div {
            width: 100px;
            height: 20px;
        }
        .three {
            background-color: gray;
        }
    </style>
    <body>
        <div class="one">
            <div class="two">
                <div class="three">
                </div>
            </div>
        </div>
    </body>
    <script>
        var divs = document.querySelectorAll('div');
        divs.forEach(function(div) {
            div.addEventListener('click', logEvent, {
                capture: true // default 값은 false입니다.
            });
        });

        function logEvent(event) {
            console.log(event.currentTarget.className);
        }
    </script>
</body>
</html>

밑에 결과를 보면 아까와 다르게 <div class="three"></div> 클릭하면 최상위 요소 -> 최하위 요소 순서로 전파된다.

image


event.stopPropagation()


이벤트 버블링과, 이벤트 캡쳐 방식은 포함된 모든 요소들에게 전파시킨다(순서만 다를 뿐). 하지만, 시작지점에서 한 번만 이벤트를 발생시키고 상위 혹은 하위로 이벤트를 전달하고 싶지 않을 때 event.stopPropagation()를 사용한다.

  • 이벤트 버블링 전파를 막는다.
<body>
    <div class="one">
        <div class="two">
            <div class="three">
            </div>
        </div>
    </div>
</body>
<script>
    var divs = document.querySelectorAll('div');
    divs.forEach(function(div) {
        div.addEventListener('click', logEvent);
    });

    function logEvent(event) {
        event.stopPropagation();
        console.log(event.currentTarget.className);
    }
</script>
  • 이벤트 캡쳐 전파를 막는다.
<body>
    <div class="one">
        <div class="two">
            <div class="three">
            </div>
        </div>
    </div>
</body>
<script>
    var divs = document.querySelectorAll('div');
    divs.forEach(function(div) {
        div.addEventListener('click', logEvent, {
		      capture: true // default 값은 false입니다.
	      });
    });

    function logEvent(event) {
        event.stopPropagation();
        console.log(event.currentTarget.className);
    }
</script>


이벤트 위임 - Event Delegation


이벤트 버블, 이벤트 캡쳐로 이벤트를 전파하는 것을 배웠다. 이것은 이벤트 위임을 위한 기초 지식이라고 해도 과언이 아니다. 이벤트 위임은 실제 Vanilla Javascript로 웹 앱을 구현할 때 자주 사용하게 되는 코딩 패턴이다.

이벤트 위임을 간단하게 설명하자면, 하위 요소에 이벤트를 각각 붙이지 않고 상위 요소에서 하위 요소의 이벤트들을 제어하는 방식이다.

밑의 간단한 예제로 이해해보자.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <style>
        button {
            width: 100px;
            height: 20px;
            background-color: gray;
        }
    </style>
    <body>
        <div class="list">
            <button class="one">one</button>
            <button class="two">two</button>
            <button class="three">three</button>
        </div>
    </body>
    <script>
        var buttons = document.querySelectorAll('button');
        buttons.forEach(function(button) {
            button.addEventListener('click', function(event){
                alert(event.currentTarget.className);
            });
        });
    </script>
</body>
</html>

위의 코드는 querySelectorAll()로 모든 button 요소들을 가져와 각각 click 이벤트 리스너를 추가해줬다. 실행시킨 다음 각 버튼을 누르면 해당 버튼의 class name이 알람창으로 표시된다.

image

image

image


여기까지는 어렵지 않게 진행했지만, 만약 새로운 버튼을 추가해보면 어떻게 될까?

// (생략...)

/* 새로운 four 버튼 생성 */
var list = document.querySelector('.list');
var button = document.createElement('button');
var button_text = document.createTextNode('four');

button.appendChild(button_text);
list.appendChild(button);

image

실행시키고 four 버튼을 클릭해도 click 이벤트 리스너가 작동되지 않는다. 그 이유는 간단하다.
최초 button 요소들의 클릭 이벤트를 추가하는 시점에서는 one, two, three 3개의 버튼만 존재했기 때문에 해당 버튼들만 이벤트가 등록되었고 나중에 추가한 four 버튼은 클릭 이벤트가 등록되지 않았다. 따라서, four 버튼에도 새롭게 이벤트를 등록해줘야 한다.

하지만, 개발을 하다보면 새로운 기능, 새로운 요소들을 추가할 상황이 많아진다. 그럴 때 마다 이벤트 리스너를 등록해야 한다면 매우 번거롭게된다.
이 번거로운 작업을 해결하는 방법이 이벤트 위임이다.

위의 코드를 아래와 같이 변경해보자.

var buttons = document.querySelectorAll('.list');
buttons.forEach(function(button) {
    button.addEventListener('click', function(event){
        alert(event.currentTarget.className);
    });
});

/* 새로운 four 버튼 생성 */
var list = document.querySelector('.list');
var button = document.createElement('button');
var button_text = document.createTextNode('four');

button.appendChild(button_text);
list.appendChild(button);

모든 button요소를 일일이 이벤트 리스너를 추가할 필요없이 button요소의 상위 요소인 <div class="list">에 이벤트 리스너를 등록하면 하위에 발생한 클릭 이벤트를 감지한다.

image

특정 하위 요소만 제어

하지만 여기서 각 버튼을 클릭해도 list라는 내용의 알람창이 발생한다. 그 이유는 현재의 타겟은 최상위 요소인 <div class="list">이기 때문이다. 따라서 alert(event.currentTarget.className);list만 발생하는 것이다.

그리고 현재 <div class="list"> 태그의 하위 모든 요소들은 이벤트를 감지하기 때문에 특정 태그. 특정 클래스, 특정 ID에만 이벤트를 주고싶다면 예외처리로 구현해야 한다.

button 태그인 경우에만 이벤트가 발생하도록 수정해보자.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <style>
        button, div{
            width: 100px;
            height: 20px;
            background-color: gray;
        }
    </style>
    <body>
        <div class="list">
            <button class="one">one</button>
            <button class="two">two</button>
            <button class="three">three</button>
            <div class="four">four</div>
        </div>
    </body>
    <script>
        var buttons = document.querySelectorAll('.list');
        buttons.forEach(function(button) {
            button.addEventListener('click', function(event){
                if (event.target.tagName === 'BUTTON'){ // button 태그인 경우에만 콜백함수를 리턴한다. 
                    alert(event.target.className);
                }
            });
        });
        
        /* 새로운 five 버튼 생성 */
        var list = document.querySelector('.list');
        var button = document.createElement('button');
        var button_text = document.createTextNode('five');

        button.setAttribute('class', 'five');
        button.appendChild(button_text);
        list.appendChild(button);
    </script>
</body>
</html>

one, two, three, five인 button 태그들은 이벤트 리스너가 등록된 것을 확인할 수 있다.

image

image

image

image

하지만, div 태그는 이벤트 리스너가 등록되지 않았다.

image