Web: 이벤트 버블링과 캡처링, 위임 알아보기

Heechan
HcleeDev
Published in
12 min readAug 17, 2022
Photo by Giu Vicente on Unsplash

지난주에 addEventListener 에 대한 글을 작성하기 위해 이런저런 자료를 찾아보다가, Event의 세계가 꽤 볼게 많다는 것을 느꼈다.

그 중에서도 웹을 시작한지 얼마 안되었을 때 내 의도대로 동작하지 않아 나를 당황스럽게 했던 개념이 있었는데, 대충 막는 방법만 배웠지 실제로 어떤 개념인지는 잘 몰랐던 것 같다.

이번주엔 Web Event의 핵심적인 특징, Bubbling과 Capturing에 대해 간단히 알아보도록 하자.

일반적인 이벤트 등록과 이상한 상황

가장 흔히 만들게 되는 이벤트는 아무래도 클릭이다. 만약 버튼에 클릭 이벤트 핸들러를 넣는다면 이런 모습일 것이다.

<button onclick="console.log("Click!")">Click</button>

이렇게 하면 버튼을 누를 때마다 콘솔에 Click! 문구가 뜨게 될 것이다.

다만 지난주 글을 떠올려보면 이렇게 직접 인라인으로 핸들러를 넣어주는 방식보다는 addEventListener 를 사용하는 방식이 권장된다고 했다.

<button class="button1">Click</button><script>
function handleClick() { console.log('Click!') }
const btn = document.querySelector('button1');
btn.addEventListener('click', handleClick);
</script>

이런 식으로 addEventListener 를 사용해 이벤트 핸들러를 등록해주는 것이 권장된다. 하지만 지금은 직관적인 설명을 위해 전자처럼 코드를 작성해보려고 한다.

이상한 경우는 바로 이런 경우에 발생한다. 예를 들어 div 로 공간을 잡고, 그 속에 있는 버튼을 눌렀을 때는 버튼에 들어있는 핸들러가, div 내의 버튼 외 공간을 클릭했을 때는 div 에 붙어있는 핸들러가 동작하기를 기대하면서 코드를 짰다고 생각해보자.

<div onclick="console.log('div handler')">
<button onclick="console.log('btn handler')">Click</button>
</div>
<style>
div {
border: 1px solid blue;
}
button {
margin: 10px;
}
</style>

그런데 실제로 이렇게 짜고 눌러보면 예상과 다르게 동작한다.

div 영역을 눌렀을 때는 ‘div handler’만 출력되지만, 버튼을 눌렀을 때는 ‘btn handler’가 나온 후 ‘div handler’도 함께 찍힌다.

이런 현상 때문에 처음에 웹 개발을 할 때 당황스러울 때가 많았다. 왜 이런걸까?

이벤트 버블링와 stopPropagation

이벤트 버블링은 한 요소에 이벤트가 발생하면, 이 요소에 있는 핸들러를 실행하고, 부모 요소로 타고 올라가면서 핸들러를 찾아 동작시키는 것을 말한다. 이 요소는 가장 루트 컴포넌트까지 반복된다.

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

위 그림에서도 잘 나타나있지만 선택된 요소로부터 계속 올라오면서 핸들러를 실행시킨다. 버블링(Bubbling)이라는 이름이 붙은 이유도 물속에서 거품이 아래에서 위로 올라오는 것 같은 느낌을 주기 때문이다.

다시 위에서 본 코드를 가지고 오자.

<div onclick="console.log('div handler')">
<button onclick="console.log('btn handler')">Click</button>
</div>

여기서 button 을 클릭했을 때, button 에 들어있는 onclick 핸들러가 실행된 후, 부모 요소인 div 로 넘어가서 div 에 달려있는 onclick 핸들러를 실행하는 과정이 진행된다고 생각할 수 있다. 이게 이벤트 버블링의 진행 과정이다.

모든 이벤트가 다 버블링이 되는 것은 아니다. 예를 들어 input 에 들어가는 onfocus 같은 이벤트는 버블링되지 않는다. 그런 특수한 이벤트를 제외하면 거의 다 버블링된다고 생각하고 확인할 수 있다.

지금처럼 원치 않는 버블링은 어떻게 막을 수 있을까? 아래처럼 해주면 된다.

<div onclick="console.log('div handler')">
<button onclick="event.stopPropagation(); console.log('btn handler')">Click</button>
</div>

이런 식으로 event.stopPropagation() 을 붙여주면 지금 핸들러만 실행하고 그 부모 요소로 이벤트가 버블링되어서 올라가는 것을 막아준다.

event.stopImmediatePropagation() 도 있다. 저번에 addEventListener 를 사용하면 한 요소에 여러 개의 이벤트 핸들러를 붙일 수 있다고 한 적이 있다. stopPropagation() 은 부모 요소로 가는 것만 막아주지만, stopImmediatePropagation() 은 같은 요소에 붙어있는 이벤트 핸들러의 동작도 막아준다. 말그대로 즉시 막아준다.

그 반대, 이벤트 캡처링

이벤트 캡처링이라는 것도 있다. 이벤트 캡처링은 버블링의 역순이라고 생각하면 된다. 타겟으로부터 거품이 보글보글 올라오는게 버블링이라면, 해당 타겟이 어디있는지 찾아 내려가는 과정이 캡처링이다. 아래 이미지를 보면 이해하기 쉬울 것이다.

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

이는 우하단의 <td> 를 눌렀을 때 어떤 과정으로 이벤트가 타겟에 전달되고 버블링되는지 설명하고 있는 그림이다.

위처럼 이벤트가 전달되는 것을 Propagation Path가 결정된다고 하는데, 이에는 세 가지 Phase가 있다.

  • Capture Phase: Window로 들어온 이벤트를 해당 타겟까지 전달하는 과정
  • Target Phase: 이벤트가 해당 타겟에 전달된 상태. 만약 버블링이 일어나지 않는 이벤트라면 여기서 멈춘다.
  • Bubbling Phase: 이벤트를 다시 부모 요소로 전달해 Window까지 전달하는 과정

DOM에서 이벤트가 전달될 때는 이런 과정을 통하고 있구나 생각하면 된다.

위 3개의 Phase 중에서 이벤트 핸들러가 실행되는 과정은 원래 Target Phase, Bubbling Phase이나, 필요에 따라서 Capture Phase와 Target Phase에서 발생하도록 할 수 있다.

elem.addEventListener(..., {capture: true})

이런 식으로 {capture: true} 옵션을 걸어주면 버블링 과정이 아니라 캡처링 과정에서 핸들러가 동작하도록 할 수 있다. 즉, 해당 이벤트 핸들러는 기존의 역순으로 돌아간다고 생각할 수 있다.

그런데 사실 이 캡처링 과정이 중요할 때는 별로 없지 않나 싶다. 사용되는 경우는 거의 찾아볼 수 없었던 것 같다.

event.target과 event.currentTarget

Event 객체에는 target 이라는 프로퍼티와 currentTarget 이라는 프로퍼티가 있다.

여기서 target 은 이벤트가 실제로 발생한 요소를 가리키고, currentTarget 은 해당 핸들러가 붙어있는 요소를 말한다.

이렇게 하고 실제로 inner 에 있는 버튼을 눌러보면 콘솔에는 이런 결과값이 찍힌다.

event.target.className 은 inner가 나와야 한다. 실제로 버튼을 누른 이벤트가 발생한 곳은 inner라는 클래스명의 버튼이기 때문이다.

event.currentTarget.className 은 outer가 나와야 한다. 이벤트는 내부에서 일어났어도, 해당 핸들러가 붙어있는 곳은 outer 클래스의 div 기 때문에 현재 타겟은 outer가 맞다.

그러면 이벤트가 발생했을 때 target 은 변경될 여지가 없지만, currentTarget 은 버블링되어서 하나하나씩 부모 요소로 올라갈 때마다 달라진다고 생각할 수 있다. 그래서 ‘current’가 붙은거 아닐까 싶다.

이벤트 위임(Delegation)

사실 이런 버블링이 왜 좋은건가 싶긴 하다. 이벤트 버블링은 왜 존재하는걸까? 나는 이게 되게 거추장스러운 존재라고 생각해서 이유를 찾아보았다.

One of JavaScript’s intentions with the creation of the Event Propagation pattern was to make it easier to capture events from one source — the parent element — rather than setting an event handler on each inner child.

오피셜은 아니지만 어떤 글에서 이렇게 설명하고 있다. 각 하위 요소에서 이벤트 핸들러를 각각 세팅해주기보다는, 부모 요소에서 이벤트를 인지해서 상위 한 곳에서 핸들링을 하기 용이하게 만들기 위한 의도라고 한다.

여기서 이벤트 위임, Delegation 패턴에 대해서 알아보자.

말그대로 이벤트에 대한 핸들링을 다른 요소에게 위임하는 방식인데, 이벤트 발생 > 이 이벤트에 대한 처리를 위임받은 상위 컴포넌트에게 이벤트 전파 > 전파 받은 컴포넌트의 핸들러가 처리하는 흐름이 된다. 이 흐름이 흘러가기 위해 필수적인건 이 이벤트에 대한 처리를 위임받은 상위 컴포넌트에게 이벤트를 전파 해주는 것이다.

이벤트 위임에서는 그 전파의 역할을 이벤트 버블링이 충분히 수행할 수 있다. 우리가 뭔가 설정해주지 않아도 알아서 이벤트가 상위 요소로 올라가기 때문에 이를 이용하면 된다.

<script>
function handler() {
const btn = event.target.closest('button');
if (!btn) return;
switch (btn.className) {
case "button1":
console.log("one");
break;
case "button2":
console.log("two");
break;
}
}
</script>
<div onclick="handler()">
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
</div>

하위의 각 버튼에 핸들러를 달아주는 것이 아니라, 상위 컴포넌트인 div 에다가 onclick="handler()" 로 핸들러를 달아줬다.

이때 각 버튼에서 처리하는 것이 아니라, 상위 컴포넌트의 핸들러에서 처리를 하고 있는 방식이고, 이를 위임받았다고 표현할 수 있다.

물론 하위 컴포넌트에 굉장히 여러가지 요소가 있을 수 있기 때문에, 여기서 event.target 을 활용해야 한다. 상기했듯 event.target 은 그 이벤트가 실제로 일어난 요소를 뜻하고, 변하지 않는다. 이를 이용하면 상위 컴포넌트에서도 어떤 컴포넌트에서 발생한 이벤트인지 구분할 수 있고, 그에 맞춰 처리할 수 있다.

위의 핸들러에서는 event.target.closest('button') 을 사용했는데, 이런 방식은 button 안에 다른 요소가 들어있을 때 필요하다. 만약 p 태그가 들어있는데 글씨를 누르면 event.target 값은 p 가 되지 button 이 아니게 된다.

하지만 우리는 button 단위로 구분하고 싶기 때문에, 클릭한 곳에서 가장 가까운 button 을 찾기 위해 closest() 를 이용했다고 생각하면 된다.

잘 동작한다.

만약 이벤트 위임 없이, 이벤트 버블링 없이 이를 구현한다면 각 버튼에 핸들러를 넣어줘야 할 것이다. 그렇게 되면 UI 요소에 로직 관련된게 너무 많이 들어가 보기 좋지 않을 수 있기도 하고, 매번 새로운 핸들러를 만들어서 넣어줘야 해, 특정 경우에는 굉장히 귀찮을 수도 있다.

이 단락 초반에 이벤트 버블링의 존재 의의에 대해 언급했는데, 이벤트 위임에 대해서 알고 나니 이벤트 버블링을 잘 활용하면 이렇게 사용할 수 있는거구나 싶다.

결론

그런데 순수 HTML JS로만 만들면 위에 내용을 쭉 신경 쓰면 되긴 하는데, React에서는 또 다르다. 물론 React도 버블링 같은거에서는 거의 똑같이 동작하긴 하지만, 내부 동작이 나의 상상과는 다른 부분이 꽤 있어보였다.

아마 다음주에는 React에서 이벤트를 처리하는 방법에 대해 작성하지 않을까 싶다.

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science