LeChuck

브라우저 이벤트

·18 min to read

이벤트가 발생했을 때 브라우저에 의해서 호출될 함수를 이벤트 핸들러event Handler라 하고, 이벤트가 발생했을 때 브라우저에게 이벤트 핸들러의 호출을 위임하는 것을 이벤트 핸들러 등록이라 한다.

브라우저는 사용자가 버튼을 클릭하는 행위를 감지하여 버튼 이벤트를 발생시킬 수 있다. 그리고 특정 버튼 요소에서 클릭 이벤트가 발생하면 특정 함수(이벤트 핸들러)를 호출하도록 브라우저에게 위임(이벤트 핸들러 등록)할 수 있다. 즉, 함수를 언제 호출할지 알 수 없으므로 브라우저에게 함수 호출을 위임하는 것이다.

<script>
	const btn = document.querySelector('button');
    // btn onclick 프로퍼티에 함수를 등록 (이벤트 핸들러 등록)
    btn.onclick = () => { alert('button clicked'); };
</script>

이벤트 타입

마우스, 키보드, 포커스, 값 변경, 리소스 등 여러 종류의 이벤트 타입이 있다.

MDN 이벤트 참조

이벤트 핸들러 등록하기

1. 어트리뷰트 방식

HTML 요소의 어트리뷰트 중에는 이벤트에 대응하는 이벤트 핸들러 어트리뷰트가 있다. 이벤트 핸들러 어트리뷰트의 이름은 onclick과 같이 on 접두사 뒤에 이벤트 타입명이 붙는 형식이다. 이벤트 핸들러 어트리뷰트 값으로 함수 호출문과 같은 _문statement_을 할당하면 이벤트 핸들러가 등록된다. 함수 참조가 아닌, 함수 호출문을 이벤트 핸들러 어트리뷰트의 값으로 전달해야 한다는 사실을 유념하라.

함수 참조는 onclick="sayHi"와 같은 형식이고, 함수 호출문은 onclick="sayHi()와 같은 형태여서 인수를 전달할 수 있다.

<button onclick="sayHi('Lee')">Click me!</button>
 
<script>
    function sayHi(name)
    	console.log(`Hi! ${name}.`);
</script>

이벤트 핸들러 어트리뷰트 방식은 HTML과 자바스크립트를 혼용해서 사용하는 방식이다. 따라서 일반적으로는 이벤트 핸들러 어트리뷰트 방식을 지양해서 HTML과 자바스크립트 코드를 따로 분리하는 게 맞다. 하지만 Angular/React/Svelte/Vue.js와 같은 컴포넌트Component 기반의 개발 프레임워크에서는 이벤트 핸들러 어트리뷰트 방식으로 이벤트를 처리하고있다. CBD(Component Based Development)에서는 HTML, CSS, 자바스크립트를 관심사가 다른 개별적인 요소가 아닌, 뷰를 구성하기 위한 하나의 구성 요소로 보기 때문이다.

2. 프로퍼티 방식

window 객체와 Document/HTMLElement 타입의 DOM 노드 객체는 이벤트에 대응하는 이벤트 핸들러 프로퍼티를 갖는다. 이벤트 핸들러 프로퍼티에 함수를 바인딩하면 이벤트 핸들러가 등록된다.

<button>Click me!</button>
    
<script>
    const btn = document.querySelector('button');
    
    // btn의 이벤트 핸들러 프로퍼티 onclick에 이벤트 핸들러를 바인딩
    btn.onclick = function () {
    	console.log('button clicked');
    };
</script>

이벤트 핸들러를 등록하기 위해서는 이벤트를 발생시킬 객체인 이벤트 타깃event target과 이벤트의 종류를 나타내는 문자열인 이벤트 타입event type, 그리고 이벤트 핸들러를 지정해야 한다.

대부분의 경우 이벤트 핸들러는 이벤트를 발생시킬 이벤트 타깃에 바인딩 된다. 하지만 꼭 그래야만 하는 것은 아니다. 이벤트 핸들러는 이벤트 타깃 또는 전파된 이벤트를 캐치할 DOM 노드 객체에 바인딩하면 된다.

<script>
	const btn = document.querySelector('button');
    
    // 첫 번째로 바인딩된 이벤트 핸들러는 
    // 두 번째 바인딩된 이벤트 핸들러에 의해 재할당되며, 실행되지 않는다.
    btn.onclick = function () {
    	console.log('Button clicked 1');
    };
    
    bnt.onclick = function () {
    	console.log('Button clicked 2');
    };
    
</script>

이벤트 핸들러 프로퍼티 방식은 하나의 이벤트에 하나의 이벤트 핸들만을 바인딩 할 수 있다는 단점을 지닌다.

3. addEventListener 메서드방식

앞서 소개된 이벤트 핸들러 어트리뷰트/프로퍼티 방식이 DOM Level 0 단계에 속했다면, EventTarget.prototype.addEventListener 메서드는 DOM Level 2에 도입된 방식이다.

addEventTarget 메서드는 총 세 개의 매개변수가 자리할 수 있다. 첫 번째 매개변수는 이벤트의 종류를 나타내는 문자열인 이벤트 타입(click 등)이다. 두 번째 매개변수로는 이벤트 핸들러(함수)를 전달한다. 마지막 매개변수에는 이벤트를 캐치할 이벤트 전파 단계(캡처링 또는 버블링)를 지정한다. 생략하거나 false를 전달하면 <span style="color:#ffdce0;">버블링 단계</span>에서 이벤트를 캐치하고, true를 전달하면 <span style="color:#ffdce0;">캡처링 단계</span>에서 이벤트를 캐치한다(후술).

addEventListener 메서드 방식을 이용하면 동일 HTML 요소에 여러개의 이벤트 핸들러를 등록할 수가 있다. 아래의 코드를 참고하자. 하나의 btn에 여러개의 이벤트 핸들러를 등록한 모습이다. 이때 이벤트 핸들러는 등록된 순서대로 호출된다.

<script>
    // btn 이벤트 요소에 여러 개의 이벤트 핸들러를 등록한 모습
    const btn = document.querySelector('button');
    
    btn.addEventListener('click', function () {
    	console.log('[1]button click');
    };
    
    btn.addEventListener('click', function () {
    	console.log('[2]button click');
    };
</script>

이벤트 핸들러 제거하기

EventTarget.prototype.removeEventListener 메서드를 사용해서 addEventListener 메서드로 등록한 이벤트 핸들러를 제거할 수 있다. 단, addEventListenr 메서드에 전달한 인수와 removeEventListener 메서드에 전달한 인수가 동일해야만 삭제할 수 있다.

<script>
    const handleClick = () => console.log('btn click');
 
    btn.addEventListenr('click', handleClick);
    
    // 이벤트 핸들러 제거하기
    btn.removeEventListener('click', handleClick, true); // 전달한 인수가 다르다. 실패
    btn.removeEventListener('click', handleClick); // 성공
</script>
 

또한 addEventListener로 이벤트 핸들러를 등록할 때 무명 함수를 사용했다면 이벤트 핸들러를 참조할 수가 없으므로 이벤트 핸들러를 제거하지 못한다. 그래서 가급적이면 이벤트 핸들러의 참조를 변수나 자료구조에 저장하여 사용하는 게 좋다.

마지막으로, 이벤트 핸들러 프로퍼티 방식으로 등록한 이벤트 핸들러는 removeEventListner 메서드로 제거할 수 없다. 이벤트 핸들러 프로퍼티에 null을 할당하는 방식으로 제거해야 한다.

<script>
    const handleClick = () => console.log('button click');
    
    // 이벤트 핸들러 프로퍼티 방식으로 이벤트 핸들러를 등록하기
    btn.onclick = handleClick;
    
    // 이벤트 핸들러 제거가 안 된다.
    btn.removeEventListener('click', handleClick);
    
    btn.onclick = null; // 이벤트 핸들러 제거 성공
</script>

이벤트 객체

이벤트가 발생하면 이벤트 객체가 생성된다. 이벤트 객체는 이벤트 핸들러의 첫 번째 인수로 전달되어서는 이벤트에 대한 정보를 제공하는 역할을 맡게된다.

<script>
    const msg = document.querySelector('.message');
    
    function showCoords(e)
    	msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
    
    document.onclick = showCoords;
</script>

위 코드를 살펴보면, click 이벤트에 의해 생성된 이벤트 객체가 showCoords 이벤트 핸들러의 첫 번째 인수(e)로 전달되어 이벤트의 좌표 정보를 제공하고 있다는 사실을 알 수 있다. 이처럼 이벤트 객체를 이용하고 싶은 경우에는 이벤트 핸들러를 정의할 때 매개변수를 명시적으로 선언해주어야 한다. 그러면 이벤트 발생 시 이벤트 객체가 이벤트 핸들러로 전달 될 것이다.

상속 구조와 공통 프로퍼티

이벤트 객체는 아래와 같은 상속 구조를 갖는다. 모든 이벤트 객체의 공통 프로퍼티가 정의되어 있는 Event 인터페이스를 중심으로, FocusEvent, MouseEvent, KeyboardEvent와 같이 고유한 프로퍼티가 정의되어 있는 인터페이스가 상속되는 형태를 띈다. Event 인터페이스는 DOM 내에서 발생한 이벤트에 의해 생성되는 이벤트 객체를 나타낸다.

Event 인터페이스, 즉 Event.prototype에 정의되어 있는 공통 프로퍼티는 모든 이벤트 객체에서 사용할 수 있다. 이벤트 객체의 공통 프로퍼티는 다음과 같다.

공통 프로퍼티설명타입
type이벤트 타입string
target이벤트가 발생하는 DOM 요소DOM 요소 노드
currentTarget이벤트 핸들러가 바인딩된 DOM 요소 </br> (이벤트 핸들러가 부착되어 있는 요소)DOM 요소 노드
eventPhase(이벤트 전파 단계) </br> 0: 이벤트 없음, 1: 캡처링 단계, 2: 버블링 단계number
bubbles이벤트를 버블링으로 전파하는지 여부boolean
cancelablepreventDefault 메서드를 호출하여 이벤트의 기본 동작을 취소할 수 있는지 여부boolean
defaultPreventedpreventDefault 메서드를 호출하여 이벤트를 취소했는지 여부boolean
timeStamp이벤트가 발생한 시각 (1970/01/01/00:00:00부터 경과한 밀리초)number

target/currentTarget의 차이점?

target과 currentTarget이 헷갈릴 수 있기 때문에 아래와 같은 코드를 준비했다. 마우스 클릭 이벤트 발동 시 화면에 좌표값을 출력하고 e.targete.currentTarget을 콘솔에 차례대로 출력하는 코드다.

<p>클릭하세요. 클릭한 곳의 좌표가 표시됩니다.</p>
<em class="message"></em>
 
<script>
    const msg = document.querySelector('.message');
    
    function showCoords(e) {
    	msg.textContent = `clientX: ${e.clientX}, clientY: ${e.clientY}`;
        console.log(e.target);
        console.log(e.currentTarget);
    }
    
    // 함수 참조가 아닌 함수 호출문 showCoords() 형식은 동작하지 않는 이유??
    document.onclick = showCoords;
</script>

처음 <p>...좌표가 표시됩니다.</p> 부분을 클릭하면 클릭한 p태그가 e.target의 값으로서 콘솔에 출력되는 것을 볼 수 있다. 두 번째로 브라우저의 빈 화면인 document를 클릭했을 때도 마찬가지로 클릭한 document가 출력되는 것을 볼 수 있다. 이처럼 e.target은 이벤트가 발생한 DOM 요소를 가리키며, 클릭 위치에 따라 유동적인 면모를 보이고 있다.

반면 e.currentTarget은 고정된 형태다. 어디를 클릭하든 #document를 출력하고 있다. 이는 이벤트 핸들러 함수 showCoords를 document.onclick = showCoords와 같이 document에 프로퍼티 형식으로 등록했기 때문이다. currentTarget은 이벤트 핸들러가 바인딩된 DOM 요소를 출력한다. 그리고 showCoords 이벤트 핸들러가 document에 바인딩되어 있기에, currentTarget은 항상 document를 가리킬 것이다.

만약 <input type="checkbox">의 onchange 이벤트처럼 이벤트 발생 위치가 체크박스에 한정된 경우라면, e.target 그리고 e.currentTarget이 동일하게 체크박스를 가리킬 것이다. 왜냐하면 체크박스의 onchange 이벤트 핸들러가 바인딩된 위치가 체크박스이고, onchange 이벤트가 발생한 곳 또한 체크박스이기 때문이다. checkbox.onchange = e => msg.textContent = e.target.checked ? 'on' : 'off';

이벤트 전파

이벤트 전파(Event Propagation)란 DOM 트리 상에 존재하는 DOM 요소 노드에서 발생한 이벤트가 DOM 트리에 전파되는 것을 말한다.

<ul id="fruits">
    <li id="apple">Apple</li>
    <li id="banana">Banana</li>
    <li id="orange">Orange</li>
</ul>

id가 banana인 <li> 태그를 클릭해서 클릭 이벤트가 발생한 상황을 가정해보자. 이때 생성된 이벤트 객체는 이벤트를 발생시킨 DOM 요소인 이벤트 타깃을 중심으로 DOM 트리를 통해 전파된다. <span style="color:#ffdce0">캡처링 단계</span>는 이벤트가 상위 요소에서 하위 요소 방향으로 전파되는 것을 뜻하고, <span style="color:#ffdce0">타깃 단계</span>는 이벤트가 이벤트 타깃에 도달된 것을 의미하며 <span style="color:#ffdce0;">버블링 단계</span>는 이벤트가 하위 요소에서 상위 요소 방향으로 전파되는 것을 말한다.

다시 id가 banana인 <li> 태그를 클릭한 상황으로 돌아가보자. ul 요소에 이벤트 핸들러를 바인딩한 상황이다. li 요소를 클릭하면 클릭 이벤트 객체가 생성될 것이고 클릭된 li 요소가 이벤트 타깃이 된다. 이때 이벤트 객체는 캡처링에 의해 window에서 시작해서 이벤트 타깃 방향으로 전파된다. 캡처링에 이어서 이벤트 객체는 이벤트 타깃에 도달할 것이고, 이후 이벤트 객체는 버블링에 의해 window 방향으로 되돌아간다.

이벤트 핸들러를 어트리뷰트/프로퍼티 방식으로 등록한 경우에는 타깃 단계와 버블링 단계의 이벤트만 캐치할 수 있다. 반면 addEventListener 메서드 방식으로 등록한 이벤트 핸들러는 캡처링 단계까지도 캐치할 수 있다. addEventListener 메서드의 세 번째 인자로 true를 전달하면 캡처링 단계를 캐치한다.

   <ul id="fruits">
      <li id="apple">Apple</li>
      <li id="banana">Banana</li>
      <li id="orange">Orange</li>
    </ul>
 
  <script>
    const fruits = document.getElementById('fruits');
    const banana = document.getElementById('banana');
 
    // fruits 요소의 하위 요소인 li 요소를 클릭한 경우 캡처링 단계의 이벤트를 캐치한다.
    fruits.addEventListener('click', e => {
      console.log(`이벤트 단계: ${e.eventPhase}`); // 1: 캡처링 단계
      console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
      console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
    }, true);
 
    banana.addEventListener('click', e => {
      console.log(`이벤트 단계: ${e.eventPhase}`); // 2: 타깃 단계
      console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
      console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLLIElement]
    });
 
    // addEventListener의 세 번째 인자로 true를 전달하지 않았으므로, 캡처링 단계는 캐치하지 못한 모습.
    fruits.addEventListener('click', e => {
      console.log(`이벤트 단계: ${e.eventPhase}`); // 3: 버블링 단계
      console.log(`이벤트 타깃: ${e.target}`); // [object HTMLLIElement]
      console.log(`커런트 타깃: ${e.currentTarget}`); // [object HTMLUListElement]
    })
  </script>

이처럼 이벤트는 이벤트를 발생시킨 이벤트 타깃 단계에서는 물론이고 상위 DOM 요소에서도 캐치할 수 있다. 즉, <span style="color:#ffdce0">Event Path</span>(이벤트가 통과하는 DOM 트리 상의 경로)에 위치한 모든 DOM 요소에서 캐치할 수 있다.

대부분의 이벤트는 캡처링과 버블링을 통해 전파되지만, 아래의 이벤트는 버블링을 통해 전파되지 않는 성질을 지닌다. 이벤트 객체의 공통 프로퍼티인 event.bubbles의 값이 false다. 하지만 아래 이벤트를 상위 요소인 캡처링 단계에서 꼭 캐치해야할 경우는 잘 없다. 이벤트 타깃에서 캐치해도 무방할 것.

  • 포커스 이벤트 : focus/blur

  • 리소스 이벤트: load/unload/abort/error

  • 마우스 이벤트: mouseenter/mouseleave

캡처링 단계에서 이벤트를 캐치해서 처리하는 경우는 거의 없다고 한다.

이벤트 위임

<span style="color:#ffdce0">이벤트 위임(event delegation)</span>은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 (비용이 많이 소요되는) 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법을 말한다. 이벤트는 꼭 이벤트 타깃이 아닌 상위 DOM 요소에서도 캐치할 수 있으므로, 이벤트 위임을 통해 상위 DOM 요소에 이벤트 핸들러를 등록함으로써 여러 개의 하위 DOM 요소에 일일히 이벤트 핸들러를 등록하는 수고를 덜어내는 방식이다.

  <nav>
    <ul id="fruits">
      <li id="apple" class="active">Apple</li>
      <li id="banana">Banana</li>
      <li id="orange">Orange</li>
    </ul>
  </nav>
 
  <div>선택된 네비게이션 아이템: <em class="msg">apple</em> </div>
 
  <script>
    const fruits = document.querySelector('#fruits');
    const msg = document.querySelector('.msg');
 
    // 사용자 클릭에 의해 선택된 네비게이션 아이템(li 요소)에 active 클래스를 추가하고
    // 그 외의 모든 네비게이션 아이템의 active 클래스를 제거한다
    function activate( {target} ){
      // 이벤트 위임시에는 이벤트 타깃이 아닌 상위 DOM 요소에서 이벤트를 캐치하기 떄문에,
      // 조건문을 통해서 내가 원하는 타깃에서 캡처링 혹은 버블링된(전파된) 이벤트가 맞는지 확인해주는 작업이 필요하다.
      // Element.prototype.matches 메서드는 target이 #fruits > li 조건을 충족하면 true를 반환한다.
      if(!target.matches('#fruits > li')) return;
 
      [...fruits.children].forEach(fruit => {
          fruit.classList.toggle('active', fruit === target);
          msg.textContent = target.id;
      })
    }
 
    // 상위 DOM 요소에서 이벤트를 캐치하는, 이벤트 위임
    fruits.onclick = activate;
 
    // 이벤트 위임을 활용하지 않는 경우, 아래와 같이 하위 DOM 요소에 일일히 이벤트 핸들러를 바인딩해주어야 한다.
    // document.getElementById('apple').onclick = activate;
    // document.getElementById('banana').onclick = activate;
    // document.getElementById('orange').onclick = activate;
  </script>

일반적으로는 이벤트 객체의 target 프로퍼티와 currentTarget 프로퍼티는 동일한 DOM 요소를 가리킨다. 하지만 이벤트 위임을 통해 상위 DOM 요소에 이벤트를 바인딩한 경우 target과 currentTarget 프로퍼티가 서로 다른 DOM 요소를 가리킬 수 있다. (target은 이벤트 타깃을 가리키고, currentTarget은 이벤트 핸들러가 바인딩된 요소를 가리킴)

DOM 요소의 기본 동작의 조작

DOM 요소의 기본 동작 중단

DOM 요소는 저마다의 기본 동작이 있다. <a> 요소를 클릭하면 href 어트리뷰트에 지정된 링크로 이동하고, <checkbox> 또는 <radio> 요소를 클릭하면 체크 또는 해지되는 식이다.

이벤트 객체의 preventDefault 메서드는 이러한 DOM 요소의 기본 동작을 중단시킨다.

<script>
    documnet.querySelector('a').onclick = e => {
    	e.preventDefault();
    };
</script>

'wheel' 이벤트와 같이 passive한 이벤트 리스너의 경우 preventDefault()가 적용되지 않는다. 하지만 addEventListener() 메서드의 optional 인자로 {passive:false}를 전달하면 preventDefault가 적용된다. 권장하지는 않음.

이벤트 전파 방지

이벤트 객체의 stopPropagation 메서드는 이벤트 전파(버블링) 를 중지시킨다.

  <div class="container">
    <button class="btn1">Button 1</button>
    <button class="btn2">Button 2</button>
    <button class="btn3">Button 3</button>
  </div>
 
  <script>
    // 이벤트 위임. 클릭된 하위 버튼 요소의 color를 변경한다.
    document.querySelector('.container').onclick = ({target}) => {
      if(!target.matches('.container > button')) return;
      target.style.color = 'red';
    }
 
    // .btn2 요소는 이벤트를 전파하지 않으므로 상위 요소에서 이벤트를 캐치할 수 없다.
    document.querySelector('.btn2').onclick = e => {
      e.stopPropagation(); // 이벤트 전파 중단
    }
  </script>

이처럼 stopPropagation 메서드는 하위 DOM 요소의 이벤트를 개별적으로 처리하기 위해 이벤트의 전파를 중단시키는 용도로 활용된다.

하지만 stopPropagation은 코드의 규모가 커지거나 협업시 예상치 못한 문제를 발생시킬 여지가 있으므로 지양하는 게 좋다. 이벤트 타깃이 아닌, event path에 있는 상위 DOM 요소에서 이벤트 처리를 하는 게 싫다면은 상위 DOM 요소에 if(event.target !== event.currentTarget) return;과 같은 조건문을 사용하면 된다.

이벤트 핸들러 내부의 this

어트리뷰트 방식으로 지정한 이벤트 핸들러의 this는 전역 객체 window를 가리킨다. (일반 함수로서 호출되었기 때문)

프로퍼티 방식과 addEventListener 메서드 방식으로 지정한 이벤트 핸들러의 this는 이벤트를 바인딩한 DOM 요소를 가리킨다. 즉, 이러한 경우에 이벤트 핸들러 내부의 this는 이벤트 객체의 currentTarget 프로퍼티와 같다.

화살표 함수로 정의한 이벤트 핸들러 내부의 this는 상위 스코프의 this를 가리킨다.

  <button class="btn">0</button>
 
  <script>
 
    const btn = document.querySelector('.btn');
 
    // addEventListener
    btn.addEventListener('click', function (e) {
      console.log(this); // .btn
    })
 
    // addEventListener + 화살표 함수
    btn.addEventListener('click', e => {
      console.log(this); // Window
    })
 
  </script>

이벤트 핸들러에 인수 전달하기

이벤트 핸들러 어트리뷰트 방식은 함수 호출문을 사용할 수 있기 때문에 함수 호출시에 인수를 전달하면 됐지만, 이벤트 핸들러 프로퍼티 방식과 addEventListener 메서드 방식의 경우에는 인수를 전달할 수 없었다. 이벤트 핸들러를 브라우저가 호출해서 함수 호출문이 아닌 함수 자체를 등록해야 하기 때문이다. 그러나 아래와 같이 이벤트 핸들러 내부에서 함수를 호출하는 방식으로 인수를 전달할 수 있다.

  <label>User name <input type="text"></label>
  <em class="message"></em>
  
  <script>
    const MIN_USER_NAME_LENGTH = 5;
    const input = document.querySelector('input[type=text]');
    const msg = document.querySelector('.message');
 
    const checkUserNameLength = min => {
      msg.textContent
        = input.value.length < min ? `이름은 ${min}자 이상 입력해 주세요` : '';
    };
 
    input.addEventListener('blur', () => {
      checkUserNameLength(MIN_USER_NAME_LENGTH);
    })
    
  </script>

Reference

<모던 자바스크립트 Deep Dive> 이웅모

프론트엔드 필수 브라우저 101 (드림코딩)