[모던JS: 브라우저] 브라우저 이벤트 (1)

KG·2021년 6월 15일
0

모던JS

목록 보기
31/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

브라우저 이벤트

자바스크립트를 설명하는 수 많은 설명 중에는 이벤트 기반(Event Driven) 언어라고 표현되는 경우를 종종 볼 수 있다. 이벤트는 무언가 일어났다는 신호이다. 자바스크립트는 엔진을 통해 번역되고, 내부적으로는 싱글 스레드를 기반으로 한 이벤트 드리븐 형식으로 처리된다. 자바스크립트는 브라우저와 밀접한 연관을 맺고 있고, 이와 같은 흐름은 브라우저 역시 상당 부분 유사하다. 즉 브라우저 역시 이벤트를 활용하여 상호 통신 및 자바스크립트와 연계가 가능하다.

모든 DOM노드는 이러한 이벤트 신호를 만들어 낼 수 있다. 물론 이벤트는 DOM에만 한정되는 개념은 아니다. 이벤트의 종류와 속성은 매우 다양한데, 브라우저에서 자주 사용되는 DOM 이벤트를 몇몇 살펴보며 다음과 같다.

  • 마우스 이벤트

    • click
    • contextMenu
    • mouseover, mouseout
    • ...
  • 폼(form) 요소 이벤트

    • submit
    • focus
  • 키보드 이벤트

    • keydown, keyup
  • 문서 이벤트

    • DOMContentLoaded
  • CSS 이벤트

    • transitionend

이 외에도 다양한 이벤트가 있는데, 각 이벤트와 그에 대한 속성에 대한 자세한 사항은 차근차근 살펴보도록 하자.

1) 이벤트 핸들러 (Event Handler)

이벤트가 발생했을 때 이에 대해 반응하기 위한 장치가 필요하다. 이처럼 이벤트 발생 시 실행되는 함수를 보통 이벤트 핸들러(Event Handler)라고 표현한다. 핸들러는 사용자 행동에 어떤 반응을 출력할 지 자바스크립트 코드로 구현할 수 있다. 핸들러는 여러 가지 방법으로 할당이 가능하다.

HTML속성

가장 간단하게 핸들러를 등록하는 방법은 HTML 태그의 속성으로 할당하는 방법이다. 아래와 같이 on<event> 속성을 키워드로 HTML 태그에 이벤트를 등록할 수 있다.

<input value="클릭" onclick="alert('click')" />

해당 <input> 태그를 클릭하면 onclick 속성에 할당한 코드가 실행된다. 지금은 코드가 짧기에 문자열 형태로 바로 할당하고 있지만, 만약 작동 로직이 길어지게 된다면 위와 같은 방식은 매우 불편할 것이다. 때문에 이런 경우에는 별도로 함수를 만들고 함수 자체를 속성에 할당할 수 있다.

<script>
  function countRabbits() {
    for(let i=1; i<=3; i++) {
      alert(`토끼 ${i}마리`);
    }
  }
</script>
  
<input value="토끼카운트" onclick="countRabbits()" />

이때 주의깊게 살펴보아야 할 점은 HTML 속성이 대소문자를 구분하지 않는다는 점이다. 따라서 위와 같이 onclick 속성은 ONCLICK이나 onClick 등으로 표기하더라도 모두 동일하게 동작한다. 그러나 이후 살펴볼 DOM 객체의 프로퍼티 값은 대게 고정된 형태로 접근해야 한다.

또 하나 유의할 점은 HTML 속성에 이와 같이 함수 형태로 할당하는 경우에는 꼭 괄호와 함께 등록해야 한다는 점이다. 보통 자바스크립트에서 괄호의 역할은 호출을 하는 의미이지만 HTML 속성에서는 호출하는 형태로 함수를 핸들러로 등록해야 한다. 이 부분도 곧이어 자세히 살펴보도록 하자.

React의 경우에는 JSX라는 별도의 템플릿 문법을 사용하기 때문에 이는 HTML과 정확하게 일치하지 않는 문법이 있다. 가장 큰 차이점은 다음과 같다.

  • HTML과 달리 항상 onClick 속성으로 이벤트 할당
  • 문자열이 아닌 함수로 이벤트 핸들러 등록
// HTML
<button onclick="activateLasers()">
  Activate Lasers
</button>
// React jsx template
<button onClick={activateLasers}>
  Activate Lasers
</button>

이 외에도 차이점이 있지만 해당 챕터에서 그 차이점들을 살펴보도록 하자. 자세한 사항은 리액트 공식문서를 참고하는 것도 좋다.

DOM 프로퍼티

DOM 프로퍼티를 통해서도 이벤트를 등록할 수 있다. HTML 속성과 동일하게 on<event> 프로퍼티에 값을 할당하는 방식으로 등록이 가능하다.

<input id="elem" type="button" value="click" />
<script>
  elem.onclick = function () {
    alert('hello world!');
  }
</script>

핸들러를 HTML 속성을 사용해 할당할 때, 브라우저는 속성값을 이용해서 새로운 함수를 생성한다. 그리고 생성된 함수를 DOM 프로퍼티에 할당하게 된다. 반면 위와 같이 직접 DOM 프로퍼티에 이벤트를 할당하는 방법은 그 이전 과정을 생략하고 바로 이벤트를 등록하는 것과 같다. 때문에 위에서 살펴본 HTML 속성을 이용한 예시와 지금의 예시는 동일하게 동작한다.

그러나 DOM 프로퍼티인 onclick은 오직 하나만 존재하기 때문에 복수의 이벤트 핸들러를 할당할 수가 없다. 아래 예시와 같이 핸들러를 하나 더 추가하는 경우엔, 기존에 HTML 속성으로 할당된 이벤트 핸들러는 덮어씌워져 사라지게 된다.

<input value="button" id="elem" onclick="alert('prev')" />

<script>
  // 기존 prev를 출력하는 핸들러를 덮어 씌웠기 때문에
  // 이 시점 이후로는 next만 출력하는 핸들러만 존재
  elem.onclick = function () {
    alert('next');
  }
</script>
                                         

등록한 핸들러를 제거하고 싶다면 onclick 프로퍼티에 단순히 null을 할당하면 된다.

2) this로 요소 접근

핸들러 내부에 쓰인 this의 값은 별도로 컨텍스트를 지정하지 않는다면 핸들러가 할당되어 있는 요소(= HTML 태그)가 된다. 아래 예시의 경우 thisbutton이 되기 때문에 버튼 안의 콘텐츠가 출력된다.

<button onclick="alert(this.innerHTML)">click</button>

3) 자주 하는 실수

위에서 잠깐 언급했던 사항을 다시 살펴보자. HTML 속성으로 이벤트를 등록할 때와 DOM 프로퍼티로 등록할 때엔 미세한 차이가 존재한다. 다음과 같은 함수가 있다고 가정하자.

function sayThanks() {
  console.log('thank you!');
}

이때 DOM 프로퍼티를 통해 선언된 함수를 이벤트 핸들러로 할당하는 경우에는 함수 자체를 할당해야 한다.

// 잘못된 방법
button.onclick = sayThanks();

// 올바른 방법
button.onclick = sayThanks;

만약 첫 번째 처럼 괄호와 함께 할당하면, 자바스크립트는 해당 함수를 호출하고 나서의 결과값(= undefined)을 할당하게 될 것이다.

그렇지만 HTML 속성에서는 이와 반대다. 속성값으로 이벤트를 등록할 때는 괄호를 함께 붙여주어야 한다. 브라우저는 앞서 말했듯 속성값을 먼저 읽고, 이 값을 함수 본문으로 하는 핸들러 함수를 만들기 때문에 이러한 차이가 존재한다. 따라서 다음과 같이 속성값으로 이벤트를 할당하면, 브라우저는 다시 onclick 프로퍼티에 새로운 함수를 할당하게 된다.

// 먼저 HTML 속성값으로 선언된 함수를 등록 (괄호와 함께 호출 형식)
<input type="button" id="button" onclick="sayThanks()">
// 그리고 브라우저는 내부적으로 속성값을 먼저 읽고
// 새로운 함수를 프로퍼티에 할당: 읽어온 값을 그대로 할당
button.onclick = function () {
  sayThanks();
}

또 하나의 주의점은 앞서 살펴본 DOM 조작 메서드 중 하나인 속성값을 설정하는 setAttribute를 통해서는 핸들러를 할당할 수 없다. 속성은 항상 문자열로 인식되기 때문에, 함수 형태로 전달하는 값이 그저 문자열 데이터로 인식되어 전달되기 때문이다.

// function() ... 부분은 그냥 문자열로 인식
document.body.setAttribute('onclick', function() { alert(1); });

또한 DOM 프로퍼티는 HTML 속성과 달리 대소문자 구분에 엄격하다. 이는 객체의 속성이 이미 선언되어 지정되어 있기 때문이다. 반면 HTML은 파서가 자체적으로 어느 정도 오류 수정을 해주기 때문에 대소문자 구분이 엄격하지 않다.

elem.onclick = ... (O)
elem.onClick = ... (X)
elem.ONCLICK = ... (X)

<input onclick = ... /> (O)
<input onClick = ... /> (O)
<input ONCLICK = ... /> (O)

4) addEventListener

HTML 속성과 DOM 프로퍼티를 이용한 이벤트 핸들러 할당 방식에는 한계점이 존재한다. 바로 하나의 이벤트에 복수개의 핸들러를 등록할 수 없다는 점이다. 그러나 개발을 하다보면 종종 한 개에 이벤트에 대해서 여러 개의 반응으로 대응해야 할 때가 있다. 한 가지 핸들러에 모든 반응을 구현할 수 있겠지만 의존성과 모듈화 문제로 보통 각각의 반응에 대한 이벤트 핸들러를 따로따로 구현한다. 이 경우 기존에 살펴본 방식으로 따로 구현된 모든 핸들러를 하나에 이벤트에 등록이 불가하다.

웹 표준에 관여하는 개발자들은 이러한 문제를 해결하기 위해 addEventListenerremoveEventListener라는 특별한 메서드를 이용해 핸들러를 관리하자는 대안을 제시했다. 이를 통해 우리는 하나의 이벤트에 대해 여러 핸들러를 등록할 수 있다. 문법은 다음과 같다.

elem.addEventListener(event, handler, [options]);
  • event : 이벤트의 이름 (eg. click)
  • handler : 핸들러 함수
  • options : 아래 프로퍼티를 갖는 객체
    • once : true이면 이벤트가 트리거 될 때 리스너가 자동 삭제된다
    • capture : 어느 단계에서 이벤트를 다뤄야 할 지 알려주는 프로퍼티로 이벤트 버블링/캡처와 관련된 프로퍼티이다. 호환성 유지를 위해 options를 객체가 아닌 단순 true/false로 할당 가능한데, 이는 { capture: true/false }와 동일하다
    • passive : true이면 리스너에서 지정한 함수가 preventDefault()를 호출하지 않는다

위에서 자세히 소개되지 않는 프로퍼티는 다음 챕터에서 살펴보도록 하자.

핸들러 삭제는 명시적으로 removeEventListener 메서드를 통해 가능하다. 문법 역시 addEventListener와 동일하다. 그러나 이때 삭제는 항상 동일한 함수만 할 수 있다는 점에 유의해야 한다.

핸들러를 삭제하기 위해서는 핸들러 할당 시 사용한 함수를 그대로 전달해야 한다. 따라서 익명 함수를 사용해서 이벤트를 할당하는 방식에서는 removeEventListener를 통해 동일하게 익명함수를 전달하더라도 정상적으로 삭제할 수 없다.

elem.addEventListener('click', () => alert(1));
elem.removeEventListener('click', () => alert(1));

함수의 생김새는 동일하지만 호출 시점에서 매번 새로운 함수를 생성하기 때문이다. 따라서 별도로 함수로 선언한 기명 함수를 통해서 등록한다면 제거가 가능하다.

function handler() {
  alert(1);
}

elem.addEventListener('click', handler);
elem.removeEventListener('click', handler);

addEventListener 메서드를 연달아 사용하면 동일 이벤트에 대해 여러 개의 이벤트 핸들러를 등록할 수 있다. 이 같은 장점이 있기 때문에 보통 이벤트를 등록하는 경우addEventListener를 사용한다.

function handler1() {
  console.log('handler1');
}

function handler2() {
  console.log('handler2');
}

elem.onclick = () => console.log('hello');
elem.addEventListener('click', handler1);
elem.addEventListener('click', handler2);

만약 하나의 핸들러만 사용하는 것이 확실하게 보장된다면 HTML 속성 등을 이용해 할당하더라도 문제 없다. 하지만 어떤 이벤트의 경우에는 항상 addEventListener로만 등록해야 정상적으로 동작하는 경우도 있다. 대표적으로 DOMContentLoaded가 대표적인 예시이다.

5) 이벤트 객체

이벤트가 발생했을 때 이를 제대로 다루려면 어떤 일이 일어났는지 상세히 알아야 한다. 예를 들어 click 이벤트가 발생했을 때 당시의 마우스 포인터의 좌표 등에 대한 정보가 필요할 수 있다.

따라서 이벤트가 발생하면 브라우저는 동시에 이벤트 객체(Event Object)를 생성한다. 해당 객체는 각 이벤트에 대한 상세한 정보를 가지고 있고, 핸들러를 통해 인수 형태로 전달된다. 아래는 이벤트 객체로부터 포인터 좌표를 얻는 예시이다.

<input type="button" value="클릭해 주세요." id="elem" />

<script>
  elem.onclick = function(event) {
    console.log(event.type + ": 이벤트 타입");
    console.log("타겟 : ", event.currentTarget);
    console.log("좌표 : ", event.clientX, event.clientY);
  }
</script>

이벤트 객체에서 지원하는 프로퍼티는 공통적인 것도 있으나 어떤 이벤트냐에 따라 모두 다르다. 예를 들어 좌표 정보는 포인터 관련 이벤트에서 지원되지만, 다른 이벤트에서는 지원되지 않는 프로퍼티이다.

  • event.type : 이벤트 타입 정보, 위에서는 click
  • event.currentTarget : 이벤트를 처리하는 요소. 화살표 함수를 사용해 핸들러를 만들거나 다른 곳에 바인딩하지 않은 경우엔 this가 가리키는 값과 동일. 만약 컨텍스트를 따로 지정한 경우엔 해당 프로퍼티를 이용해 현재 이벤트가 처리되고 있는 요소 정보 접근 가능

이러한 이벤트 객체는 HTML 속성 안에서도 접근이 가능하다. 이는 앞서 설명한 바와 같이 브라우저가 속성을 먼저 읽고, 이에 대한 함수를 생성하기 때문이다.

<input type="button" onclick="alert(event.type)" />

6) 객체형태의 핸들러와 handleEvent

addEventListener를 사용하면 함수뿐만 아니라 객체를 이벤트 핸들러로 할당할 수 있다. 이 경우엔 해당 이벤트가 발생했을 때, 객체 내에 구현되어 있는 handleEvent 메서드가 호출된다. 따라서 handleEvent 메서드가 따로 구현되어 있지 않는다면 아무런 반응도 발생하지 않는다.

let obj = {
  handleEvent (event) {
    console.log(event.type, event.currentTarget);
  }
};

elem.addEventListener('click', obj);

이는 ES6에서 도입된 클래스를 이용하더라도 동일한 동작을 보장한다.

class Menu {
  handleEvent (event) {
    switch(event.type) {
      case 'mousedown' :
        elem.innerHTML = '마우스 버튼 눌름';
        break;
      case 'mouseup' :
        elem.innerHTML = '마우스 버튼 뗌';
        break;
    }
  }
}

let menu = new Menu();
elem.addEventListener('mousedown', menu);
elem.addEventListener('mouseup', menu);

위 클래스를 사용한 예시에서는 하나의 객체(클래스)에서 두 개의 이벤트를 처리하고 있는 것을 볼 수 있다. 주의할 점은 객체는 두 이벤트를 처리하고 있지만 addEventListener를 통해 두 가지 이벤트를 모두 등록해주어야 한다는 점이다.

handlEvent 메서드 내에서 모든 이벤트를 처리하지 않고, 이벤트 관련 메서드를 호출하는 방식으로 바꾸어 구현할 수도 있다.

class Menu {
  handleEvent (event) {
    let method = 'on' + event.type[0].toUpperCase() + event.type.slice(1);
    this[method](event);
  }
  
  onMousedown() {
    elem.innerHTML = '마우스 버튼 눌름';
  }
  
  onMouseup() {
    elem.innerHTML = '마우스 버튼 뗌';
  }
}

let menu = new Menu();
elem.addEventListener('mousedown', menu);
elem.addEventListener('mouseup', menu);

위와 같이 이벤트 핸들러를 분리시키면 코드 변경이 원활하다.

이벤트 버블링과 캡처링

1) 버블링 (Bubbling)

버블링의 원리는 매우 간단하다. 한 요소에 이벤트가 발생하면, 이 요소에 할당된 이벤트 핸들러가 동작할 것이다. 이는 앞서서 살펴본 것과 완벽하게 동일하다.

그러나 그후 이벤트는 버블링 단계에 돌입하게 되는데, 할당된 요소의 핸들러의 동작이 끝나면 이어서 부모 요소의 핸들러가 동작한다. 그리고 이는 계속 이어져서 가장 최상단의 조상 요소를 만날때까지 이 과정을 반복하게 되며, 각 요소에 할당된 핸들러가 동작하는 것을 버블링이라고 한다. 마치 수면 아래서 발생한 거품이 연거푸 수면 위로 올라오는 현상과 유사하다고 하여 이런 이름이 붙게 되었다.

예를 들어 다음처럼 3개의 요소가 FORM > DIV > P 형태로 중첩된 구조를 가지고 있고, 각각의 요소는 하나씩 핸들러가 할당되어 있다고 가정해보자.

<form onclick="alert('form')"> FORM
  <div onclick="alert('div')">DIV
    <p onclick="alert('p')">P</p>
  </div>
</form>

이때 가장 안쪽의 p 태그를 클릭하면 순서대로 다음의 과정이 일어난다.

  1. <p> 태그에 할당된 onclick 핸들러가 동작
  2. 바깥의 <div>에 할당된 핸들러가 동작
  3. 그 바깥의 <form>에 할당된 핸들러가 동작
  4. document 객체를 만날 때까지, 각 요소에 할당된 onclick 핸들러 동작

이러한 동작 방식으로 인해 <p> 요소를 클릭하더라도 그 상위에 있는 부모 요소들의 이벤트 핸들러가 모두 동작하는 것이다.

거의 모든 이벤트는 별도의 설정을 하지 않는다면 버블링이 일어난다. 그러나 focus와 같이 버블링 되지 않는 이벤트도 존재한다.

2) event.target

부모 요소의 핸들러는 이벤트가 정확히 어디에서 시작되었는지 등에 대한 상세 정보를 얻을 수 있다. 이벤트가 발생하면 브라우저는 이벤트 객체를 생성한다고 했었는데, 해당 이벤트 객체는 이벤트가 발생한 가장 안쪽 요소의 정보를 가지고 있다. 보통 이러한 요소를 타겟(target) 요소라고 불리는데, event.target 프로퍼티를 통해 접근할 수 있다.

앞서서 이와 유사하게 생긴 event.currentTarget 프로퍼티를 보았는데 이는 현재 요소를 가리키는 this와 동일하다고 했다. event.targetthis(= event.currentTarget)는 다음과 같은 차이가 있다.

  • event.target 은 실제 이벤트가 시작된 타겟 요소이다. 버블링이 진행되더라도 이는 변하지 않는다.
  • this는 현재 이벤트가 발생하고 있는 요소로, 현재 실행 중인 핸들러가 할당된 요소를 참조한다.

위의 예시에서 <form> 태그를 살펴보자. 핸들러는 form.onclick 하나밖에 없지만, 이 핸들러에서 폼 내부에 있는 모든 요소에서 발생하는 클릭 이벤트를 버블링으로 인해 감지할 수 있다. 이때 form.onclick 핸들러 내의 thisevent.target은 다음과 같다.

  • this(event.currentTarget) : <form> 요소에 있는 핸들러가 동작했기 때문에 <form> 요소와 동일
  • event.target : 폼 내부에서 실제 클릭한 요소

3) 버블링 중단하기

이벤트 버블링은 타겟 이벤트에서 시작해 <html> 요소를 거쳐 document 객체를 만날 때까지 각 노드에서 모두 발생한다. 몇몇 이벤트는 window 객체까지 거슬러 올라가기도 한다. 이 역시 거쳐가는 요소에 할당된 모든 핸들러가 호출된다.

그런데 핸들러에게 이벤트를 완전히 처리하게 하고 나서 버블링을 중단하도록 설정할 수 있다. 이벤트 객체에는 stopPropagation() 메서드를 지원하는데, 해당 메서드를 통해 상위로 전파되는 버블링을 막을 수 있다. 아래 예시에서는 <button>을 클릭하더라도 <body> 요소에 할당된 이벤트 핸들러가 동작하지 않는다.

<body onclick="alert('도달하지 못함')">
  <button onclick="event.stopPropagation()">클릭</button>
</body>

그러나 꼭 필요한 경우가 아닌 이상 버블링을 막지 않는 것을 추천한다. 버블링을 통해 굉장히 유용한 처리를 다양하게 할 수 있기 때문이다. 만약 버블링을 막으려고 한다면 아키텍처를 잘 고려해서 진짜 막아야 하는 상황인지 아닌지를 잘 판단하고 막아야 할 것 이다.

버블링을 막음으로 인해 발생할 수 있는 문제를 살펴보자.

  1. 중첩 메뉴를 하나 만들고, 각 서브메뉴에 해당하는 요소에서 클릭 이벤트를 처리하도록 설정. 그리고 이때 상위 메뉴의 클릭 이벤트 핸들러는 동작하지 않도록 버블링 차단
  2. 사람들이 페이지에서 어디를 클릭했는지 등 행동 패턴을 분석하기 위해 window 내에서 발생하는 클릭 이벤트 전부 감지하는 이벤트 핸들러 등록
  3. 하지만 stopPropagation 메서드로 버블링이 차단된 서브 메뉴에서는 해당 이벤트가 발생하더라도 window에 등록된 이벤트 핸들러가 이를 정상적으로 감지할 수 없음

즉 버블링을 차단하는 경우엔 추후 확장 가능성에 굉장한 악영향을 끼치게 된다. 따라서 신중한 판단이 필요하다. 버블링을 막아야 해결되는 문제로 판단되는 경우 역시 대부분 커스텀 이벤트 등을 사용하여 해결이 가능한 경우가 많다. 커스텀 이벤트는 다음 챕터에서 살펴보도록 하자.

한 요소의 특정 이벤트를 처리하는 핸들러가 여러개인 상황이면, 핸들러 중 하나가 버블링을 멈추더라도 나머지 핸들러는 계속 동작한다. 이들은 상위에 등록된 핸들러가 아닌 같은 레벨이 존재하는 핸들러기 때문이다. 만약 버블링을 멈춤과 동시에 요소에 존재하는 다른 핸들러의 모든 동작 역시 중단시키려면 event.stopImmediatePropagation()을 사용할 수 있다.

4) 캡처링 (Capturing)

이벤트에는 버블링이라고 불리는 전파 흐름 외에 캡처링이라고 불리는 전파 흐름 역시 존재한다. 실제 코드에서는 버블링 만큼 자주 쓰이지 않아 접할 기회가 흔치 않지만, 종종 유용한 경우가 있기 때문에 알아두는 것이 좋다.

표준 DOM 이벤트에서 정의한 이벤트 흐름은 총 3가지로 구분할 수 있다.

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

이 3가지 흐름을 그림으로 나타내면 다음과 같다.

즉 캡처링은 앞서 설명한 버블링과 정반대되는 방향을 가지고 있다고 볼 수 있다. 보통 캡처링 단계를 이용해야 하는 경우는 잘 없기 때문에 관련 코드를 발견하기 쉽지 않을 수 있다.

앞서 이벤트를 등록하는 방법에서 살펴본 방식 대부분은 캡처링에 대해 전혀 알 수 없다. 문법적으로도 캡처링을 감지하는 값이 falsedefault 인 형태로 설정되어 있는 것이다. 만약 이벤트 핸들러가 캡처링 단계를 감지하도록 해주려면, addEventListener 메서드에서 세 번째 인수인 options 객체를 전달해주어야 한다. 하위 호환성을 위해 두 가지 방식이 가능하다.

// 정석
elem.addEventListener(..., { capture: true });

// 하위 호환성을 위해 아직 남아있는 문법
elem.addEventListener(..., true);

capture 프로퍼티는 두 가지 값을 가질 수 있다. true라면 핸들러가 캡처링 단계에서 동작하고, false라면 버블링 단계에서 동작한다. 그리고 앞서 언급한 바와 같이 default 값은 false이다.

공식적으로는 총 3개의 이벤트 흐름이 있지만, 이벤트가 실제 타겟 요소에 전달되는 단계인 타겟 객체는 별도로 처리되지는 않는다. 캡처링 또는 버블링 단계에서 타겟에 이르렀을때 처리된다고 볼 수 있다.

<form>FORM
  <div>DIV
    <p>P</p>
  </div>
</form>

<script>
  for(let elem of document.querySelectorAll('*')) {
    elem.addEventListener("click", e => alert(`캡쳐링: ${elem.tagName}`), true);
    elem.addEventListener("click", e => alert(`버블링: ${elem.tagName}`));
  }
</script>

위 예시를 통해 요소를 클릭했을 때 발생하는 캡처링과 버블링 이벤트 흐름을 살펴볼 수 있다. 만약 가장 내부에 있는 <p> 요소를 클릭했다면 다음과 같은 순서로 이벤트가 전달된다.

  1. HTML > BODY > FORM > DIV (캡처링 단계)
  2. P (타겟 단계, 두번 호출됨: 캡처링/버블링 두 개의 리스너를 설정했으므로)
  3. DIV > FORM > BODY > HTML (버블링 단계)

앞서 핸들러를 제거할 땐 removeEventListener 메서드를 사용한다고 했다. 그리고 핸들러는 동일한 핸들러를 전달해야 정상적으로 제거할 수 있다고 했는데, 이때 추가로 options에 대한 설정이 있었다면 이 역시 동일하게 전달해주어야 완벽히 제거할 수 있다. 예를 들어 addEventListener(..., true)를 통해 이벤트를 등록했다면, removeEventListener(..., true)로 이벤트를 제거해야 한다.

이러한 이벤트 흐름을 이용하면 이벤트 위임(delegation)으로 보다 효과적으로 핸들러를 통제할 수 있다. 이에 대해서 살펴보도록 하자.

이벤트 위임 (Event Delegation)

캡처링과 버블링을 활용하면 강력한 이벤트 핸들링 패턴인 이벤트 위임을 구현할 수 있다. 이벤트 위임은 비슷한 방식으로 여러 요소를 다뤄야 할 때 사용하는 방식인데, 요소마다 일일이 핸들러를 할당하는 것이 아닌, 요소의 공통 조상에 단 하나의 핸들러를 등록하고 여러 요소를 처리하는 방식을 말한다.

1) event.target

앞서 버블링을 통해 어떤 요소에서 이벤트가 발생하더라도 이 흐름은 상위 부모 요소로 계속 전파되는 것을 살펴보았다. 따라서 공통 조상에서 하나의 이벤트 핸들러가 각 요소를 구분하여 적절한 동작을 처리하도록 설정해줄 수 있다. 이때 필요한 것이 event.target이다. 해당 프로퍼티를 이용하면 실제 어디에서 이벤트가 발생했는지 파악할 수 있다.

여러 개의 칸이 있는 테이블을 생각해보자. 칸이 9개이든 99개이든 상관없이 어떠한 <td> 태그를 클릭하더라도 해당 칸을 강조할 수 있는 클릭 이벤트 핸들러를 구현해보자. 가장 먼저 할 수 있는 생각은 개개의 <td> 태그에 하나씩 이벤트 핸들러를 등록하는 방법이 있을 수 있다. 물론 해당 태그가 많지 않다면 썩 나쁜 방법이 아니지만, 만약 엄청나게 많은 수의 태그가 있다면 일일이 이벤트를 지정하는데 엄청난 작업 시간이 소요될 것이다.

따라서 각 <td> 마다 onclick 핸들러를 할당하는 대신, 모든 이벤트를 잡아내는 핸들러를 공통 조상 요소인 <table>에 할당하자. <table>에 할당된 이벤트 핸들러는 내부적으로 event.target을 이용해 어떤 요소가 클릭되었는지 감지하고, 해당 칸을 강조하도록 설정할 수 있다.

let selectedTd;

table.onclick = function(event) {
  let target = event.target;
  
  if (target.tagName !== 'TD') return;
  
  highlight(target);
};

function highlight (td) {
  if (selectedTd) {
    selectedTd.classList.remove('highlight');
  }
  selectedTd = td;
  selectedTd.classList.add('highlight');
}

이렇게 공통 조상 요소인 <table>에서 이벤트를 한 번에 처리하게 된다면 테이블 내 칸의 개수는 더 이상 고민거리가 아니다. 강조 기능을 그대로 유지하면서 얼마든지 몇 개의 <td>를 추가할 수 있다.

하지만 이러한 이벤트 위임 방식으로 핸들러를 등록할 때는 세밀한 요소 컨트롤이 필요할 수 있다. 현재 구현한 방식으로는 클릭 이벤트가 <td> 요소가 아니라 <td> 내부에서도 동작할 수 있다.

<td>
  <strong>some text...</strong>
</td>

이런 경우 유저는 칸을 선택했지만 내부적으로 <strong> 태그가 선택될 수 있고, 따라서 조건문에 의해 이벤트 핸들러가 동작하지 않을 수 있다. 이러한 경우를 모두 고려해 세밀한 컨트롤이 필요하다. 위 코드에서 event.target을 이용해 클릭 이벤트가 <td> 안쪽에서 일어났는지 아닌지를 추가로 알아낼 수 있도록 해주자.

table.onclick = function (event) {
  let td = event.target.closest('td');
  
  if (!td) return;
  
  if (!table.contains(td)) return;
  
  highlight(td);
};
  1. closest() 메서드를 이용해 현재 event.target 요소의 상위 요소 중 td와 가장 가까운 조상 요소를 반환한다.
  2. 만약 td를 찾지 못했다면 클릭 영역은 <td> 요소 외부에 있다. 따라서 바로 핸들러를 중지시킨다.
  3. 중첩 테이블에 있는 경우 event.target은 현재 테이블 바깥에 있는 <td>가 될 수도 있다. 따라서 현재 테이블에 td가 포함되어 있는지 추가로 검사한다.
  4. 모든 검사를 통과했다면 선택된 td를 강조 처리한다.

2) 이벤트 위임 활용하기

이벤트 위임은 매우 강력한 패턴이다. 다른 식으로 이벤트 위임을 활용한 사례를 살펴보자.

저장/로드/검색 등의 버튼이 있는 메뉴를 구현해야 한다고 가정해보자. 각 버튼의 기능과 관련된 메서드 save/load/search가 구현된 객체도 이미 구현이 되어있다. 이때 버튼과 메서드를 연결시키는 좋은 방법은 무엇일까?

가장 단순하게는 각 버튼에 일일이 독립된 핸들러를 등록할 수 있을 것이다. 그러나 이보다 더 우아한 해결책이 있다. 메뉴 전체에 핸들러를 하나 추가해주고, 각 버튼의 data-action 커스텀 속성에 호출할 메서드를 할당해주자.

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

핸들러에서는 해당 속성값을 읽고 적절한 메서드를 실행하도록 지정해주자.

class Menu {
  constructor(elem) {
    this._elem = elem;
    elem.onclick = this.onClick.bind(this);
  }
  
  save() {
    console.log('save');
  }
  
  load() {
    console.log('load');
  }
  
  search() {
    console.log('search');
  }
  
  onClick(event) {
    let action = event.target.dataset.action;
    if (action) {
      this[action]();
    }
  };
}
<div id='menu'>
  <button data-action='save'>save</button>
  <button data-action='load'>load</button>
  <button data-action='search'>search</button>
</div>

이때 constructor 에서 this를 바인딩하고 있음에 주의하자. 이를 생략하면 추후 어떤 요소에서 이벤트를 호출할 때 this 정보는 Menu 객체가 아닌 DOM 요소(elem)을 참조하게 된다. 때문에 this[action]에서 원하는 동작을 참조할 수 없다.

이처럼 이벤트 위임을 이용하면 버튼마다 일일이 핸들러를 할당하는 코드를 작성할 필요가 없다. 또한 언제든지 버튼을 추가하고 제거하기 더 확장성 좋은 구조를 가지게 된다. 또한 메모리 관점에서도 많은 핸들러가 할당되지 않기 때문에 절약이 가능하고, 사용하지 않는 핸들러라면 제거가 간편하다는 장점이 있다.

물론 이벤트 위임은 만능이 아니다. 이벤트 위임의 단점으로는 이벤트가 반드시 버블링 되어야 한다는 점이다. 하지만 몇몇 이벤트는 기본적으로 버블링이 되지 않는다. 그리고 낮은 레벨에 할당한 핸들러에는 stopPropagation()을 사용할 수 없다. 또한 컨테이너 수준에 할당된 핸들러가 응답할 필요가 있는 이벤트이든 아니든 상관없이 모든 하위 컨테이너에서 발생하는 이벤트에 응당해야 하기 때문에 상대적으로 CPU 부하가 발생할 우려가 있다. 하지만 모던 브라우저에서 이러한 부하는 대부분 무시할만한 수준이다.

3) 행동 패턴

이벤트 위임은 요소에 선언전 방식으로 행동을 추가할 때 사용할 수 있다. 이땐 특별한 속성과 클래스를 사용한다.

행동 패턴은 두 부분으로 구성되는데, 이는 다음과 같다.

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

행동 패턴을 통해 카운터를 구현해보자. 버튼을 클릭하면 숫자가 증가하는 행동을 부여해주는 속성 data-counter를 선언한다.

첫 번째 counter: <input type='button' value='1' data-counter />
두 번째 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 속성이 있다면 해당 요소는 카운터로 동작하게 될 것이다. (물론 적절한 태그 요소와 관련 속성이 필요하다) 따라서 data-counter 속성만으로 카운터 버튼을 손쉽게 생성할 수 있다. 이처럼 이벤트 위임을 사용해 새로운 행동을 선언해주는 속성을 추가해서 HTML을 확장하는 패턴이 행동 패턴이다.

문서 레벨 핸들러를 만들 땐 항상 addEventListener를 사용해야 한다. document 객체에 핸들러를 할당할 때 onclick을 사용하는 것은 권장되지 않는다. 이는 기존에 핸들러가 있을 때 이를 덮어씌어 버리기 때문에 충돌 가능성이 매우 높다. 코드 전역에서 document 객체에 이벤트를 등록할 수 있기 때문에 addEventListener를 사용하는 것이 좋다.

이번엔 행동 패턴을 사용해 토글러를 구현해보자. 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>

별도의 자바스크립트로 코드를 구현하지 않아도 요소에 토글 기능을 이벤트 핸들러를 통해 구현할 수 있다. 우리가 필요한 것은 단순히 HTML 커스텀 속성 data-toggle-id가 될 것이다.

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

React에서는 addEventListener를 이용해 이벤트를 잘 할당하지 않는다. 앞서 살펴보았듯이 JSX 템플릿 문법 내에서 요소에 onClick 속성으로 직접 함수를 담는다. 이때도 마찬가지로 버블링이 디폴트로 발생한다. 만약 캡처링을 사용하고자 한다면 onClickCapture 속성을 사용할 수 있다. 물론 addEventListener를 통해 할당 가능한 이벤트는 리액트에서도 해당 메서드를 이용해서 할당할 수 있다.

React에서 이벤트 위임은 자체적으로 이루어진다. 16버전에서는 document 최상단에 이벤트를 처리하도록 설정했고, 17버전에 들어와서는 리액트의 가상 DOM 트리가 렌더링 되는 루트 DOM 컨테이너에 이벤트를 연결한다 (아래 그림 참고). 따라서 JSX 문법 내 속성값으로 이벤트를 등록한다면 리액트에서 알아서 이벤트 위임처리를 해준다고 볼 수 있다. 이보다 낮은 수준에서 위임 방식을 통해 세분화를 하고 싶다면 해당 레벨에서 이벤트 위임을 구현할 수는 있다.

References

  1. https://ko.javascript.info/events
  2. https://ko.reactjs.org/docs/handling-events.html
  3. https://xn--xy1bk56a.run/react-master/lecture/r-version-17.html#%E1%84%89%E1%85%A2%E1%84%85%E1%85%A9%E1%84%8B%E1%85%AE%E1%86%AB-%E1%84%80%E1%85%A5%E1%86%AB-%E1%84%8B%E1%85%A5%E1%86%B9%E1%84%8B%E1%85%B3%E1%86%B7
profile
개발잘하고싶다

0개의 댓글