시작하면서

몇 가지 질문으로 시작해볼까요? 다음과 같은 상황을 가정해 봅시다.

---------------------
| parent            |
|  ---------------  |
|  | child       |  |
|  ---------------  |
|                   |
---------------------

그리고 parentchild 엘리먼트 둘 다 click 이벤트에 대한 핸들러를 가지고 있다고 합시다. 만약 사용자가 child 엘리먼트를 클릭한다면 parentchild 모두에게 클릭 이벤트가 발생하겠죠. 그렇다면 어떤 엘리먼트의 이벤트 핸들러가 먼저 실행될까요? 다른 말로 하자면, 이 상황에서 이벤트 처리 순서는 어떻게 될까요?


(고대의) 두 가지 이벤트 처리 모델

너무나 당연하게도(?), 아주 먼 옛날 넷스케이프와 마이크로소프트는 다른 답을 내렸습니다. 만악의 근원

  • 넷스케이프 모델: 상위 엘리먼트인 parent의 이벤트부터 처리해야지! => 이벤트 캡쳐링(Event Capturing)
  • 마이크로소프트 모델: 자식 엘리먼트인 child의 이벤트부터 처리해야지! => 이벤트 버블링(Event Bubbling)

이 두 모델은 이벤트를 완전히 반대 방식으로 처리합니다. Explorer는 오직 이벤트 버블링만 지원합니다. Mozilla, Opera 7 그리고 Konqueror는 두 가지 전부 지원합니다. 구버전 Opera와 iCab은 둘 다 지원하지 않습니다.

이벤트 캡쳐링 by 넷스케이프

             ||
-------------||------
| parent     ||     |
|  ----------||---  |
|  | child   \/  |  |
|  ---------------  |
|                   |
---------------------

이벤트 캡쳐링을 사용한다면 parent의 이벤트 핸들러가 먼저 실행되고 그 다음 child의 이벤트 핸들러가 실행됩니다.

이벤트 버블링 by 마이크로소프트

             /\
-------------||------
| parent     ||     |
|  ----------||---  |
|  | child   ||  |  |
|  ---------------  |
|                   |
---------------------

이벤트 버블을 사용한다면 child의 이벤트 핸들러가 먼저 실행되고 그 다음 parent의 이벤트 핸들러가 실행됩니다.


(현대의) W3C 이벤트 모델

W3C는 이 두 모델을 모두 포용하는 아주 현명한 방식을 사용하기로 했습니다. W3C 이벤트 모델은 타깃 엘리먼트에 도달할 때까지 이벤트 캡쳐링이 발생하고 그 이후 타깃 엘리먼트로부터 이벤트 버블링이 발생합니다.

           || /\
-----------||-||-----
| parent   ||-||    |
|  --------||-||--  |
|  | child \/ || |  |
|  ---------------  |
|                   |
---------------------

개발자들은 이벤트 핸들러를 캡쳐링 단계에 등록할지 버블링 단계에 등록할지 선택할 수 있습니다. addEventListener() 함수 마지막 인자를 true로 전달하면 이벤트 핸들러가 캡쳐링 단계에 등록되고, false인 경우에는 버블링 단계에 이벤트 핸들러가 등록됩니다. 아무 값도 전달하지 않는다면 기본적으로 false에 해당하는 이벤트 버블링 단계에 이벤트 핸들러가 등록됩니다.


W3C 모델 실제 코드로 살펴보기

Case 1

parent.addEventListener('click', doSomethingParent, true); // 캡쳐링 단계 등록
child.addEventListener('click', doSomethingChild, false); // 버블링 단계 등록

위와 같은 코드에서, 만약 사용자가 child 엘리먼트를 클릭한다면 이벤트 처리 순서는 다음과 같습니다.

이벤트 처리 순서
1. click 이벤트는 캡쳐링 단계를 시작합니다. 도큐먼트의 최상위 엘리먼트부터 child 엘리먼트까지 캡쳐링 단계에 등록되어 있는 onclick 이벤트 핸들러가 있는지 찾습니다.
2. 이벤트는 child의 상위 엘리먼트인 parent에 캡쳐링 단계로 등록된 doSomethingParent 함수를 찾아서 실행합니다.
3. 이벤트 캡쳐링 단계가 타깃 엘리먼트인 child에 도달합니다. 캡쳐링 단계에 등록된 다른 이벤트 핸들러가 없으므로 다음 단계인 이벤트 버블링 단계로 진입하여 child 엘리먼트부터 도큐먼트의 최상위 엘리먼트까지 버블링 단계에 등록된 이벤트 핸들러를 찾습니다.
4. child 엘리먼트의 버블링 단계로 등록된 doSomethingChild 함수를 찾아서 실행합니다.
5. 이벤트 버블링을 통해 계속해서 상위 엘리먼트의 버블링 단계에 등록된 이벤트 핸들러를 찾습니다. 현재는 상위 엘리먼트의 버블링 단계에 등록된 이벤트 핸들러가 없으므로 아무일도 일어나지 않습니다.



Case 2

parent.addEventListener('click', doSomethingParent, false); // 버블링 단계 등록
child.addEventListener('click', doSomethingChild, false); // 버블링 단계 등록

위와 같은 코드에서, 만약 사용자가 child 엘리먼트를 클릭한다면 이벤트 처리 순서는 다음과 같습니다.

이벤트 처리 순서
1. click 이벤트는 캡쳐링 단계를 시작합니다. 도큐먼트의 최상위 엘리먼트부터 child 엘리먼트까지 캡쳐링 단계에 등록되어 있는 onclick 이벤트 핸들러가 있는지 찾습니다.
2. 이벤트 캡쳐링 단계에 등록된 이벤트 핸들러가 없으므로 아무 일도 일어나지 않고 이벤트 캡쳐링 단계가 타깃 엘리먼트인 child에 도달합니다.
3. 다음 단계인 이벤트 버블링 단계로 진입하여 child 엘리먼트부터 도큐먼트의 최상위 엘리먼트까지 버블링 단계에 등록된 이벤트 핸들러를 찾습니다.
4. child 엘리먼트의 버블링 단계로 등록된 doSomethingChild 함수를 찾아서 실행합니다.
5. 이벤트 버블링을 통해 상위 엘리먼트인 parent의 버블링 단계에 등록된 doSomethingParent 함수를 찾아서 실행합니다.




참고할만한 것들

고대의 이벤트 처리 모델과의 호환성

parent.onclick = doSomethingParent;

W3C DOM을 지원하는 브라우저에서, 이전 이벤트 핸들러 등록방식인 위 코드는 W3C 모델의 버블링 단계로 등록됩니다.

이벤트 버블링 중단하기

개발을 하다보면 더 이상 이벤트 버블링으로 이벤트가 전파되는 것을 막고싶을 때가 생깁니다. 예를 들면 parentchild 모두의 버블링 단계에 이벤트 핸들러가 등록되어 있고, child를 클릭했을 때 parent의 클릭 이벤트 핸들러를 실행하지 않고 child의 클릭 이벤트 핸들러만 실행하고 싶은 경우겠죠.

마이크로소프트 모델에서는 cancelBubble 속성을 true로 설정하여 버블링을 끌 수 있습니다.

window.event.cancelBubble = true;

W3C 모델에서는 이벤트의 stopPropagation() 함수를 실행해야 합니다.

e.stopPropagation();

이 함수는 모든 단계의 이벤트 전파를 막습니다. 해당 함수가 호출된 이후의 모든 이벤트 캡쳐링, 버블링 단계의 진행을 중단시킵니다. 크로스 브라우져를 지원하기 위해 다음과 같이 사용합니다.

function doSomething(e) {
    if (!e) {
        var e = window.event;
        e.cancelBubble = true;
    }
    if (e.stopPropagation) {
        e.stopPropagation();
    }
}

근래에는 Explorer 호환을 고려하지 않는 웹 어플리케이션이 많아져서 쓸모 없는 정보일 수도 있지만 참고로 알아두시면 될 것 같습니다. 쓸모가 없기를 진심으로 바랍니다

현재 타깃 엘리먼트 파악하기

위에서 살펴본 것 처럼 이벤트는 어떤 엘리먼트에서 발생했는지에 대한 정보인 target 혹은 srcElement를 가지고 있습니다. 우리가 본 예제에서는 사용자가 클릭한 child 엘리먼트를 말하는 것이죠. 이 타겟 엘리먼트는 이벤트가 어떤 엘리먼트에서 처리되든지 바뀌지 않습니다.

parent.addEventListener('click', doSomething);
child.addEventListener('click', doSomething);

위와 같이 parentchild에 클릭 이벤트 핸들러로 같은 이벤트가 등록되어 있다면, child를 클릭했을 때 doSomething 함수는 두 번 호출 됩니다. 문제는 이 함수가 두 번 실행될 때 어떤 HTML 엘리먼트에서 처리되고 있는지 알 수 있는 방법이 없다는 것입니다. 이벤트의 target/srcElement 속성은 계속 child 엘리먼트를 가르키고 있기 때문입니다. 최초의 이벤트 타겟을 계속 바라보고 있는 것이죠. 이 문제를 해결하기 위해 W3C는 이벤트에 currentTarget이라는 속성을 추가했습니다. 이 속성은 현재 이벤트가 처리되고 있는 HTML 엘리먼트를 알려줍니다. 물론 이벤트 핸들러 안에 this를 사용할 수도 있지만, 되도록 javascript에서 this는 사용하지 않기를 권장합니다. (왜 그런지는 다음에 기회가 있다면 포스트를 작성하도록 하겠습니다.)

참고로 마이크로소프트 이벤트 모델에서는 currentTarget 속성이 존재하지 않아서 위와 같은 경우에 어떤 엘리먼트에서 이벤트를 처리하고 있는지 알 수 없습니다. 부디 이게 문제가 되지 않기를 바랍니다. Explorer 호환을 위해 개발하고 있다는 뜻이므로…


React.js 에서의 이벤트

리액트에서는 크로스 브라우저 지원을 위해 SyntheticEvent라는 래퍼(Wrapper)를 통해 이벤트 관련 정보를 전달합니다. 이 객체는 각 브라우저의 native 이벤트 객채와 같은 인터페이스를 가지고 있습니다.

이벤트 캡쳐링과 버블링

리액트에서는 jsx를 사용하여 html 태그 안에 이벤트 핸들러 함수를 등록할 수 있습니다. 기본적으로 모든 이벤트 핸들러 속성은 버블링 단계에 등록이 됩니다. 이벤트 핸들러를 캡쳐링 단계에 등록하려면 핸들러 속성 이름에 Capture를 뒤에 붙여서 등록하면 됩니다.

const SampleComponent = (props) => {
    const handleClick = (e) => {
        console.log('clicked!');
    };

    return (
        <div
            onClick={handleClick}
            onClickCapture={handleClick}
        />
    );
};

리액트에서도 동일하게 e.stopPropagation() 함수를 통해 이벤트 전파를 중단할 수 있습니다. 리액트에서 지원하는 이벤트 핸들러 속성은 여기를 참고해주세요.


마치며

원티드 기술 블로그에 올리는 첫 글이네요. 부족한 부분이 많네요. 제가 잘못 쓴 부분이나 의견은 댓글로 적어주시면 감사하겠습니다. :)

참고 문서