2023년 4월 30일

이벤트 위임

캡처링과 버블링을 활용하면 강력한 이벤트 핸들링 패턴인 이벤트 위임(event delegation) 을 구현할 수 있습니다.

이벤트 위임은 비슷한 방식으로 여러 요소를 다뤄야 할 때 사용됩니다. 이벤트 위임을 사용하면 요소마다 핸들러를 할당하지 않고, 요소의 공통 조상에 이벤트 핸들러를 단 하나만 할당해도 여러 요소를 한꺼번에 다룰 수 있습니다.

공통 조상에 할당한 핸들러에서 event.target을 이용하면 실제 어디서 이벤트가 발생했는지 알 수 있습니다. 이를 이용해 이벤트를 핸들링합니다.

예제를 살펴봅시다. 다음은 고대 중국 철학에서 유래한 팔괘도(Ba-Gua diagram) 입니다.

그림을 보시죠.

HTML은 대략 다음과 같습니다.

<table>
  <tr>
    <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  </tr>
  <tr>
    <td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders</td>
    <td class="n">...</td>
    <td class="ne">...</td>
  </tr>
  <tr>...2 more lines of this kind...</tr>
  <tr>...2 more lines of this kind...</tr>
</table>

지금 보는 표에는 9개의 칸이 있습니다. 하지만 칸이 99개이든 9,999개이든 상관없습니다.

지금 해야 할 작업은 <td>를 클릭했을 때, 그 칸을 강조하는 것입니다.

<td>마다 onclick 핸들러를 할당하는 대신, ‘모든 이벤트를 잡아내는’ 핸들러를 <table> 요소에 할당해 보겠습니다.

<table> 요소에 할당하게 될 핸들러는 event.target을 이용해 어떤 요소가 클릭 되었는지 감지하고, 해당 칸을 강조하게 됩니다.

코드는 아래와 같습니다.

let selectedTd;

table.onclick = function(event) {
  let target = event.target; // 클릭이 어디서 발생했을까요?

  if (target.tagName != 'TD') return; // TD에서 발생한 게 아니라면 아무 작업도 하지 않습니다,

  highlight(target); // 강조 함
};

function highlight(td) {
  if (selectedTd) { // 이미 강조되어있는 칸이 있다면 원상태로 바꿔줌
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight'); // 새로운 td를 강조 함
}

이렇게 코드를 작성하면 테이블 내 칸의 개수는 고민거리가 되지 않습니다. 강조기능을 유지하면서 <td>를 언제라도 넣고 뺄 수 있게 됩니다.

하지만 단점도 있습니다.

위와 같이 구현하면 클릭 이벤트가 <td>가 아닌 <td> 안에서 동작할 수 있습니다.

팔괘도의 HTML을 살펴봅시다. <td>안에 중첩 태그 <strong>이 있는 것을 확인할 수 있습니다.

<td>
  <strong>Northwest</strong>
  ...
</td>

<strong>을 클릭하면 event.target<strong>에 해당하는 요소가 저장됩니다.

따라서 table.onclick 핸들러에서 event.target을 이용해 클릭 이벤트가 <td>안쪽에서 일어났는지 아닌지를 알아내야 합니다.

이런 단점을 반영하여 기능을 향상한 코드는 아래와 같습니다.

table.onclick = function(event) {
  let td = event.target.closest('td'); // (1)

  if (!td) return; // (2)

  if (!table.contains(td)) return; // (3)

  highlight(td); // (4)
};

설명:

  1. elem.closest(selector) 메서드는 elem의 상위 요소 중 selector와 일치하는 가장 근접한 조상 요소를 반환합니다. 위 코드에선 이벤트가 발생한 요소부터 시작해 위로 올라가며 가장 가까운 <td> 요소를 찾습니다.
  2. event.target<td>안에 있지 않으면 그 즉시 null을 반환하므로 아무 작업도 일어나지 않습니다.
  3. 중첩 테이블이 있는 경우 event.target은 현재 테이블 바깥에 있는 <td>가 될 수도 있습니다. 이런 경우를 처리하기 위해 <td>가 팔괘도 안에 있는지를 확인합니다.
  4. 이제 진짜 td를 강조해 줍니다.

이렇게 구현하면 <td>의 개수에 상관없이 원하는 <td>를 강조해주는 코드를 빠르고 효율적으로 구현할 수 있습니다.

이벤트 위임 활용하기

이벤트 위임을 다른 식으로도 활용할 수 있습니다.

‘저장하기’, ‘불러오기’, ‘검색하기’ 등의 버튼이 있는 메뉴를 구현해야 한다고 가정해봅시다. 각 버튼의 기능과 관련된 메서드 save, load, search가 있는 객체도 이미 구현한 상태입니다. 이럴 때 버튼과 메서드를 어떻게 연결할 수 있을까요?

가장 먼저 버튼 각각에 독립된 핸들러를 할당하는 방법이 떠오를 겁니다. 하지만 이 방법보다 더 우아한 해결책이 있습니다. 메뉴 전체에 핸들러를 하나 추가해주고, 각 버튼의 data-action 속성에 호출할 메서드를 할당해 주는 방법 말이죠.

<button data-action="save">저장하기</button>

핸들러는 속성값을 읽고 적절한 메서드를 실행할 겁니다. 아래 예시를 실행해봅시다.

<div id="menu">
  <button data-action="save">저장하기</button>
  <button data-action="load">불러오기</button>
  <button data-action="search">검색하기</button>
</div>

<script>
  class Menu {
    constructor(elem) {
      this._elem = elem;
      elem.onclick = this.onClick.bind(this); // (*)
    }

    save() {
      alert('저장하기');
    }

    load() {
      alert('불러오기');
    }

    search() {
      alert('검색하기');
    }

    onClick(event) {
      let action = event.target.dataset.action;
      if (action) {
        this[action]();
      }
    };
  }

  new Menu(menu);
</script>

(*)로 표시한 줄의 this.onClickthis에 바인딩했다는 점에 주의해 주세요. 이렇게 하지 않으면 thisMenu 객체가 아닌 DOM 요소(elem)를 참조하게 됩니다. 이렇게 되면 this[action]에서 원하는 것을 얻지 못합니다.

이런 식으로 이벤트 위임을 활용하면 어떤 이점이 있을까요? 아래와 같은 장점이 있습니다.

  • 버튼마다 핸들러를 할당해주는 코드를 작성할 필요가 없어집니다. 메서드를 만들고 HTML에 그 메서드를 써주기만 하면 됩니다.
  • 언제든지 버튼을 추가하고 제거할 수 있어 HTML 구조가 유연해집니다.

.action-save, .action-load 같은 클래스를 사용할 수도 있지만, data-action 속성이 좀 더 의미론적으로 낫습니다. CSS 규칙을 적용할 수도 있게 됩니다.

‘행동’ 패턴

이벤트 위임은 요소에 선언적 방식으로 '행동(behavior)'을 추가할 때 사용할 수도 있습니다. 이때는 특별한 속성과 클래스를 사용합니다.

행동 패턴은 두 부분으로 구성됩니다.

  1. 요소의 행동을 설명하는 커스텀 속성을 요소에 추가합니다.
  2. 문서 전체를 감지하는 핸들러가 이벤트를 추적하게 합니다. 1에서 추가한 속성이 있는 요소에서 이벤트가 발생하면 작업을 수행합니다.

카운터 구현하기

버튼을 '클릭하면 숫자가 증가’하는 행동을 부여해주는 속성인 data-counter를 살펴봅시다.

첫 번째 카운터: <input type="button" value="1" data-counter>
두 번째 카운터: <input type="button" value="2" data-counter>

<script>
  document.addEventListener('click', function(event) {

    if (event.target.dataset.counter != undefined) { // 속성이 존재할 경우
      event.target.value++;
    }

  });
</script>

버튼을 클릭하면 숫자가 증가합니다. 이 예시에서 집중해야 할 것은 버튼이 아니고 접근방식입니다.

data-counter 속성이 있는 요소는 원하는 만큼 만들 수 있습니다. 필요할 때마다 HTML에 추가해주면 되니까요. 예시에선 이벤트 위임을 사용해 새로운 행동을 선언해주는 속성을 추가해서 HTML을 '확장’하였습니다.

문서 레벨의 핸들러를 만들 땐 항상 addEventListener를 사용하세요.

document 객체에 핸들러를 할당할 때는 document.onclick를 사용해선 안 됩니다. document.onclick은 충돌을 일으킬 가능성이 있기 때문에 무조건 addEventListener를 사용해야 합니다. document.onclick은 새로운 핸들러가 이전의 핸들러를 덮어쓸 수 있습니다.

코드 곳곳에서 document에 다수의 핸들러를 할당할 수 있습니다. 실제 프로젝트에서 이는 자연스러운 일입니다.

토글러 구현하기

행동 패턴에 관한 예시를 하나 더 살펴봅시다. 이번엔 data-toggle-id 속성이 있는 요소를 클릭하면 속성값이 id인 요소가 나타나거나 사라지게 해보겠습니다.

<button data-toggle-id="subscribe-mail">
  구독 폼 보여주기
</button>

<form id="subscribe-mail" hidden>
  메일 주소: <input type="email">
</form>

<script>
  document.addEventListener('click', function(event) {
    let id = event.target.dataset.toggleId;
    if (!id) return;

    let elem = document.getElementById(id);

    elem.hidden = !elem.hidden;
  });
</script>

자바스크립트를 사용하지 않고도 요소에 토글 기능을 추가할 수 있다는 점에 주목합시다. 태그에 data-toggle-id 속성만 추가하면 요소를 토글할 수 있습니다.

행동 패턴을 응용하면 토글 기능이 필요한 요소 전체에 자바스크립트로 해당 기능을 구현해 주지 않아도 되기 때문에 매우 편리합니다. '행동’을 선언해 주기만 하면 되기 때문입니다. 문서 레벨에 적절한 핸들러를 구현해주기만 하면 페이지 내 모든 요소에 행동을 쉽게 적용할 수 있습니다.

한 요소에 여러 개의 행동을 조합해 적용하는 것도 가능합니다.

이런 ‘행동’ 패턴은 자바스크립트 미니 프래그먼트의 대안이 될 수 있습니다.

요약

이벤트 위임은 상당히 멋진 패턴입니다. DOM 이벤트에 적용할 수 있는 아주 유용한 패턴이죠,

이벤트 위임은 유사한 요소에 동일한 핸들러를 적용할 때 주로 사용하지만 꼭 이런 경우에만 사용할 수 있는 것은 아닙니다.

이벤트 위임은 다음과 같은 알고리즘으로 동작합니다.

  1. 컨테이너에 하나의 핸들러를 할당합니다.
  2. 핸들러의 event.target을 사용해 이벤트가 발생한 요소가 어디인지 알아냅니다.
  3. 원하는 요소에서 이벤트가 발생했다고 확인되면 이벤트를 핸들링합니다.

이벤트 위임의 장점은 다음과 같습니다.

  • 많은 핸들러를 할당하지 않아도 되기 때문에 초기화가 단순해지고 메모리가 절약됩니다.
  • 요소를 추가하거나 제거할 때 해당 요소에 할당된 핸들러를 추가하거나 제거할 필요가 없기 때문에 코드가 짧아집니다.
  • innerHTML이나 유사한 기능을 하는 스크립트로 요소 덩어리를 더하거나 뺄 수 있기 때문에 DOM 수정이 쉬워집니다.

이벤트 위임에도 물론 단점이 있습니다.

  • 이벤트 위임을 사용하려면 이벤트가 반드시 버블링 되어야 합니다. 하지만 몇몇 이벤트는 버블링 되지 않습니다. 그리고 낮은 레벨에 할당한 핸들러엔 event.stopPropagation()를 쓸 수 없습니다.
  • 컨테이너 수준에 할당된 핸들러가 응답할 필요가 있는 이벤트이든 아니든 상관없이 모든 하위 컨테이너에서 발생하는 이벤트에 응답해야 하므로 CPU 작업 부하가 늘어날 수 있습니다. 그런데 이런 부하는 무시할만한 수준이므로 실제로는 잘 고려하지 않습니다.

과제

중요도: 5

다음과 같은 메시지 목록이 있고 각 메시지엔 삭제 버튼([x])이 있습니다.

예시:

P.S. 이벤트 위임을 사용해서 단 하나의 이벤트 리스너만으로도 삭제 버튼이 동작하도록 구현해보세요.

샌드박스를 열어 정답을 작성해보세요.

중요도: 5

노드를 클릭하면 자손 노드가 보이거나 숨겨지는 트리 메뉴를 구현해보세요.

구체적인 요구사항은 다음과 같습니다.

  • 단 하나의 이벤트 핸들러(이벤트 위임 사용하기)

  • 노드(텍스트) 바깥(빈 곳)을 클릭하면 아무 일도 일어나지 않아야 합니다.

샌드박스를 열어 정답을 작성해보세요.

해답은 두 파트로 나뉩니다.

  1. 트리에 있는 모든 텍스트를 <span>이 감싸도록 합니다. 이렇게 하면 CSS :hover를 사용해 마우스 오버 시 글씨를 굴게 해주는 효과를 줄 수 있고 <span>이 차지하는 너비가 텍스트의 너비와 정확히 일치하기 때문에 텍스트에만 클릭 이벤트가 동작하도록 할 수 있습니다.
  2. 루트 노드인 tree에 핸들러를 추가하고 클릭 이벤트가 <span>으로 감싼 텍스트에만 동작하도록 합니다.

샌드박스를 열어 정답을 확인해보세요.

중요도: 4

열 제목을 나타내는 요소인 <th>를 클릭하면 열 전체가 정렬되는 표를 만들어보세요.

모든 <th> 속성엔 다음과 같이 데이터의 타입이 정의되어 있습니다.

<table id="grid">
  <thead>
    <tr>
      <th data-type="number">나이</th>
      <th data-type="string">이름</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>5</td>
      <td>일리야</td>
    </tr>
    <tr>
      <td>10</td>
      <td>보라</td>
    </tr>
    ...
  </tbody>
</table>

위 예시에선 첫 번째 열엔 숫자가, 두 번째 열엔 문자열이 들어갑니다. 구현할 정렬 함수는 데이터 타입에 맞게 정렬을 해줘야 합니다.

이 문제에선 '숫자''문자열' 타입만 다룬다고 가정하겠습니다.

제대로 해답을 작성했다면 다음 예시처럼 동작해야 합니다.

P.S. 표 크기는 예시보다 훨씬 클 수 있습니다. 열이나 행이 더 추가될 수 있다는 가정하에 답을 작성해보세요.

샌드박스를 열어 정답을 작성해보세요.

중요도: 5

툴팁(tooltip)을 보여주는 JS 코드를 작성해봅시다.

data-tooltip 속성이 있는 요소에 마우스를 가져다 대면 툴팁이 보여야 하고, 마우스 커서가 요소에서 떠나면 툴팁이 사라져야 합니다.

data-tooltip 속성은 다음 HTML처럼 추가할 수 있습니다.

<button data-tooltip="버튼 요소 길이보다 툴팁 길이가 훨씬 기네요.">짧은 버튼</button>
<button data-tooltip="두 줄짜리<br>툴팁">...또 다른 버튼...</button>

답을 잘 작성했다면 아래 예시처럼 동작해야 합니다.

data-tooltip이 있는 요소엔 텍스트만 있다고 가정하겠습니다. 요소 안에 다른 태그가 있는 경우는 생각하지 않기로 합시다.

자세한 요구사항은 다음과 같습니다.

  • 툴팁과 요소의 간격은 5px입니다.
  • 가능하면 툴팁은 요소를 기준으로 중앙에 있도록 합시다.
  • 툴팁은 창 크기보다 커질 수 없습니다. 일반적인 경우라면 툴팁은 요소 위에 있을 텐데, 요소가 창 맨 위에 있어서 툴팁을 보여줄 공간이 없다면 툴팁은 요소 아래에 나타납니다.
  • 툴팁안에 띄울 콘텐츠는 data-tooltip 속성에서 가져옵니다. 속성값은 HTML일 수 있습니다.

원하는 기능을 구현하려면 다음 두 가지 이벤트가 필요합니다.

  • mouseover – 요소 안으로 포인터가 이동할 때 발생하는 이벤트
  • mouseout– 요소 밖으로 포인터가 이동할 때 발생하는 이벤트

이벤트 위임을 사용해서 두 개의 핸들러만으로 원하는 기능을 구현하세요. document에 핸들러를 추가해 data-tooltip 속성이 있는 요소 안이나 밖으로 마우스 포인터가 이동하는 경우를 모두 감지하고 두 핸들러를 통해 툴팁을 보여주거나 감추시면 됩니다.

이렇게 툴팁 기능을 구현해 놓으면 자바스크립트에 익숙하지 않은 사람도 원하는 요소에 쉽게 툴팁을 보여줄 수 있을 겁니다.

P.S. 한 번에 한 개의 툴팁 만 보여줄 수 있습니다.

샌드박스를 열어 정답을 작성해보세요.

튜토리얼 지도