Study

[JavaScript] 이벤트(event)

[JavaScript] 이벤트(event)

이벤트 리스너(event listener) : 특정 이벤트를 감지하고 처리하는 메커니즘

이벤트 핸들러(event handler) : 이벤트가 발생했을 때 호출되는 실제 함수

AbortController를 통한 이벤트 리스너 제거

AbortController 객체를 사용하면 removeEventListener를 직접 호출하지 않고도 이벤트 리스너를 제거하는 효과를 얻을 수 있다.

const controller = new AbortController();
const button = doument.getElementById('myButton');
function handleClick() {
  console.log('버튼 클릭');
  controller.abort();
}

button.addEventListener('click', handleClick, { signal: controller.signal });

// 여러 개의 이벤트 리스너를 제어해야할 때 유용
buttons.forEach((button) => {
  button.addEventListener('click', handleClick, { signal: controller.signal });
});

브라우저의 기본동작 제어하기: preventDefault

브라우저는 기본 동작을 실행하기 전에 이벤트 리스너를 호출하여 preventDefault가 호출되었는지 확인한다.

특히 scroll이나 touchmove와 같은 이벤트는 매우 짧은 간격으로 반복 → 브라우저가 매번 preventDefault 호출 여부를 확인한 후 스크롤을 처리하면 지연이 발생할 가능성이 높음. 이로 인해 UI 반응이 느려지는 성능문제로 이어질 수 있다.

이를 방지하기 위해 이벤트 리스너를 등록할 때 옵션 객체에서 passive 속성을 true로 설정하면 브라우저가 기본 동작을 차단할 가능성이 없다고 인식하여 즉시 기본동작을 실행

window.addEventListener('touchmove', function (event) {
  console.log('터치 이동 발생');
  event.preventDefault(); // 이 호출은 무시
});

공통 부모 요소에 하나의 이벤트 리스너만 등록하기: 이벤트 위임

이벤트 버블링을 활용하면 이벤트 위임을 통해 더 효율적이고 최적화된 코드를 작성할 수 있다.

// 각 li 요소에 이벤트 리스너를 등록하는 대신 부모 요소인 ul에 이벤트 리스너를 등록하고
// 이벤트 객체의 target 속성 사용하기
<ul id='menu'>
  <li>항목1</li>
  <li>항목2</li>
  <li>항목3</li>
</ul>;

const menu = document.getElementById('menu');
menu.addEventListener('click', function (event) {
  if ((event.target.tagName = 'LI')) {
    console.log(`${event.target.textContent} 클릭`);
  }
});

이벤트 위임 기법의 장점은?

부모 요소 하나에만 등록하여 전체를 관리할 수 있다. 이를 통해 메모리 사용량을 줄이고 성능을 최적화할 수 있으며 유지/보수가 쉬워진다. 특히 자식요소가 동적으로 추가되는 경우에도 부모 요소에 등록된 리스너만으로 이벤트 처리가 가능해진다.

합성이벤트(synthetic event) : 직접 생성한 이벤트

이벤트 생성

Event 생성자를 사용하면 이벤트를 직접 생성 가능

// 생성된 이벤트 객체
const myEvent = new Event('myEvent', {
  bubbles: false, // 이벤트가 버블링되는지 여부
  cancelable: false, // preventDefault로 취소할 수 있는지 여부
  composed: false, // 이벤트 셰도 루트 바깥까지 전달될지 여부
});

// dispatchEvent 메서드를 사용하여 해당 요소에 전달할 수 있다.
const button = document.qetElementById('myButton');
function handleMyEvent(event) {
  console.log(event.type); // myEvent
}
button.addEventListener('myEvent', handleMyEvent);
button.dispatchEvent(myEvent);

Node.js에서 이벤트

EventTarget과 유사한 이벤트 시스템을 Node.js에서도 제공한다. → EventEmitter 클래스

  • 이벤트 리스너 등록 on
  • 이벤트 리스너 해제 off
  • 이벤트 실행 emit
const EventEmitter = require('node:events');
const emitter = new EventEmitter();
function handleMyEvent() {
  emitter.off('myEvent');
  console.log('이벤트 발생');
}

emitter.on('myEvent', handleMyEvent);
emitter.emit('myEvent'); // "이벤트 발생" 출력
emitter.emit('myEvent'); // 이벤트 리스너가 제거되어 실행되지 않음

Node.js는 DOM과 같은 계층 구조가 없기 때문에 이벤트 버블링이나 캡쳐링 같은 개념이 존재하지 않는 차이점이 있다.


자바스크립트 실행환경마다 이벤트를 다루는 방식이 다른 이유가 뭘까?

브라우저는 사용자 인터랙션과 페이지 생명주기를 처리하는게 목적 → DOM 이벤트 모델을 중심으로 설계되어있다. 캡쳐링/버블링 단계가 있고 addEventListener로 이벤트를 등록하는 방식

Node.js는 서버사이드 환경으로 파일시스템, 네트워크 요청, 스트림 같은 I/O 작업을 비동기로 처리하는게 핵심이다.

이벤트 루프도 다르다.

브라우저는 렌더링 파이프라인(repaint, reflow)을 고려해야해서 태스크큐, 마이크로태스크큐 외에도 애니메이션 프레임과 같은 우선순위 체계가 복잡하다.

Node.js 이벤트 루프는 timers → pending callbacks → idle/prepare → poll → check → close callbacks 같은 여러 페이즈로 나뉘어있다.

브라우저 환경에서 이벤트 리스너를 등록할 때 콜백 함수 대신 handleEvent 메서드를 가진 객체를 사용하는 이유와 구체적인 사례

handleEvnet 패턴은 상태를 가진 이벤트 핸들러를 만들때 유용하다.

let clickCount = 0;

function handleClick() {
  clickCount++;
  console.log(`클릭 횟수: ${clickCount}`);
}

button.addEventListener('click', handleClick);

clickCount가 전역에 노출되는 문제가 있다. 여러 버튼이 존재하게 되면 각자 카운트를 관리하는 것 또한 어려워진다.

// 상태를 객체 내부에 캡슐화
const clickCounter = {
	count: 0;
	maxClicks: 5,
	handleEvent(event){
		this.count++;
		console.log(`클릭 횟수: ${clickCount}`);

		if(this.count >= this.maxClicks){
			event.currentTarget.removeEventListener('click', this);
		}
	}
}

button.addEventListener('click', clickCounter);

장점은?

  1. 상태가 객체 내부에 안전하게 캡슐화된다.
  2. this 바인딩이 자동으로 객체를 가리킨다.
  3. 여러 메서드와 상태를 하나로 묶을 수 있다.

사례 1: 디바운스 기능

class DebouncedSearch {
  constructor(delay = 300) {
    this.delay = delay;
    this.timeoutId = null;
    this.searchCount = 0;
  }

  handleEvent(event) {
    // 이전 타이머 취소
    clearTimeout(this.timeoutId);

    // 새 타이머 설정
    this.timeoutId = setTimeout(() => {
      this.performSearch(event.target.value);
    }, this.delay);
  }

  performSearch(query) {
    this.searchCount++;
    console.log(`검색 #${this.searchCount}: ${query}`);
  }
}

const searchHandler = new DebouncedSearch(500);
searchInput.addEventListener('input', searchHandler);

사례 2: 다중 이벤트 관리

class FormValidator {
  constructor(form) {
    this.form = form;
    this.errors = {};
    this.isDirty = false;

    // 여러 이벤트를 한 객체로 관리
    form.addEventListener('input', this);
    form.addEventListener('blur', this, true);
    form.addEventListener('submit', this);
  }

  handleEvent(event) {
    switch (event.type) {
      case 'input':
        this.handleInput(event);
        break;
      case 'blur':
        this.handleBlur(event);
        break;
      case 'submit':
        this.handleSubmit(event);
        break;
    }
  }

  handleInput(event) {
    this.isDirty = true;
    this.validateField(event.target);
  }

  handleBlur(event) {
    if (this.isDirty) {
      this.showErrors(event.target);
    }
  }

  handleSubmit(event) {
    if (!field.value) {
      this.errors[field.name] = '필수 항목입니다';
    } else {
      delete this.errors[field.name];
    }
  }

  showErrors(field) {
    console.log(this.errors);
  }
}

const validator = new FormValidator(document.querySelector('form'));

언제 사용하면 좋을까?

  • 이벤트 핸들러가 내부 상태를 유지해야할 때
  • 여러 이벤트를 하나의 컨테스트로 관리할 때
  • 복잡한 로직을 캡슐화하고 싶을 때

웹에서 이벤트 버블링과 캡쳐링은 왜 두 단계로 나뉘었을까?

1990년대 넷스케이프와 마이크로소프트 IE가 서로 방식을 채택한다.

  • 넷스케이프: 캡쳐링 방식(부모 → 자식)
  • IE: 버블링 방식(자식 → 부모)

W3C가 DOM Level2 Events 표준을 만들 때 둘 다 채택해서 3단계 모델로 통합되었다.

  • 캡쳐링 → 타겟 → 버블링 단계

각각의 용도가 다르다.

캡처링은 먼저 가로채는 역할을 한다. 부모가 자식보다 먼저 이벤트를 처리해야하는 경우 사용한다.

// 전역 단축키 처리
document.addEventListener(
  'keydown',
  (e) => {
    // Ctrl+S를 전역에서 먼저 가로채서 저장 기능 실행
    if (e.ctrlKey && e.key === 's') {
      e.preventDefault();
      e.stopPropagation(); // 자식으로 전파 막기
      saveDoucment();
    }
  },
  true,
); // 캡쳐링 단계에서 실행

// 자식 요소의 핸들러는 실행되지 않음
textarea.addEventListener('keydown', (e) => {
  console.log('입력:', e.key); // Ctrl+S는 여기까지 오지 않음
});

버블링은 자식 이벤트를 부모에서 한 번에 처리하는 경우 사용한다.

// ❌ 비효율적: 각 항목마다 리스너 등록
const items = document.querySelectorAll('.item');
items.forEach((item) => {
  item.addEventListener('click', handleClick); // 1000개면 1000번 등록
});

// ✅ 효율적: 부모 하나만 등록
const list = document.querySelector('.list');
list.addEventListener('click', (e) => {
  // 버블링으로 올라온 이벤트 처리
  if (e.target.classList.contains('item')) {
    handleClick(e);
  }
});

// 동적으로 추가된 항목도 자동으로 작동!
list.innerHTML += '<div class="item">새 항목</div>';

두 단계를 함께 활용하는 경우: 모달 닫기 구현

const modal = document.querySelector('.modal');
const modalContent = document.querySelector('.modal-content');

// 캡쳐링: 모달 외부 클릭 감지
modal.addEvnetListener(
  'click',
  (e) => {
    if (e.target === modal) {
      closeModal();
    }
  },
  true,
);

// 버블링: 내부 클릭은 모달을 닫지 않음
modalContent.addEventListener('click', (e) => {
  e.stopPropagation(); // 부모로 전파 막기
});

자바스크립트의 커스텀 이벤트가 필요한 이유와 적용사례

브라우저는 click, input, submit과 같은 기본 이벤트만 제공한다. 하지만 실제 앱에서는

// ❌ 이런 이벤트는 없다.
button.addEventListener('userLoggedIn', handler);
button.addEventListener('cartUpdated', handler);
button.addEventListener('paymentCompleted', handler);

커스텀 이벤트로 직접 구현이 가능하다.

const event = new CustomEvent('userLoggedIn', {
  detail: { userId: 123, username: 'john' },
});

// 이벤트 발생(디스패치)
document.dispatchEvent(event);

// 이벤트리스너 등록
document.addEventListener('userLoggedIn', (e) => {
  console.log('로그인:', e.detail.username);
});

사례: 전역상태 관리하기

// 스토어 구현
class Store {
	constructor(initialState) {
		this.state = initialState;
		this.element = document.createElement('div');
	}

	setState(updates) {
		const oldState = {...this.state};
		this.state = {...this.state, ...updates};

		// 상태변경 이벤트 발행
		this.element.dispatchEvent(new CustomEvent('state:changed', {
			detail: {
				oldState,
				newState: this.state,
				updates
			}
		});
	}

	subscribe(listener) {
		this.element.addEventListener('state:changed', listener);
	}
}

// 사용
const store = new Store({user: null, cart:[]});

// 컴포넌트1: 헤더
store.subscribe((e) => {
	if(e.detail.updates.user){
		updateUserMenu(e.deatil.newState.user);
	}
});

// 컴포넌트 2: 장바구니
store.subscribe((e) => {
  if (e.detail.updates.cart) {
    updateCartDisplay(e.detail.newState.cart);
  }
});

// 상태 변경
store.setState({ user: { id: 1, name: 'John' } });

사례 2: 로그인 시스템

class AuthService {
  async login(username, password) {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      });

      if (response.ok) {
        const userData = await response.json();

        // 👉 개발자가 "지금 로그인 성공했어!"라고 알림
        document.dispatchEvent(
          new CustomEvent('userLoggedIn', {
            detail: userData,
          }),
        );
      }
    } catch (error) {
      // 👉 개발자가 "로그인 실패했어!"라고 알림
      document.dispatchEvent(
        new CustomEvent('loginFailed', {
          detail: { error: error.message },
        }),
      );
    }
  }
}

// 리스너는 대기 중
document.addEventListener('userLoggedIn', (e) => {
  console.log('환영합니다,', e.detail.username);
  redirectToDashboard();
});

document.addEventListener('loginFailed', (e) => {
  console.log('로그인 실패:', e.detail.error);
  showErrorMessage();
});

// 사용자가 로그인 버튼 클릭
loginButton.addEventListener('click', () => {
  const auth = new AuthService();
  auth.login('john', 'password123');
  // 이때 내부에서 dispatchEvent()가 호출됨
});

currentTarget과 target은 각각 어떤 상황에서 쓰는게 좋을까?

  • event.currentTarget: 이벤트 핸들러가 등록된 요소
  • event.target: 이벤트가 실제로 발생한 요소

currentTarget은 특정 요소의 이벤트 리스너가 실행될 때 해당 요소를 명확히 식별할 때 유용하고, target은 이벤트가 발생한 요소를 세부적으로 구분해야할 때 유용하다.

이벤트 위임을 사용하면 어떤 장점이 있을까?

여러 자식 요소의 이벤트를 부모 요소의 이벤트 핸들러에서 처리하면 메모리 사용량이 줄어들고, 특히 많은 요소가 있을 때 성능 최적화에도 유리하다. 이벤트 핸들러를 한 번만 작성하면 되기 때문에 유지/보수가 쉬워지고 코드가 간결해진다.

HTML 문서의 생명주기와 관련된 주요 이벤트는?

  • DOMContentLoaded: HTML 문서 파싱이 완료되고 DOM 트리가 완성되었을 때 발생
  • load: 문서에 포함된 모든 리소스가 완전히 로드된 후 발생
  • unload: 사용자가 페이지를 떠날 때 발생

사용자가 페이지를 떠나려고 할 때 이동 여부를 확인하기 위해 사용할 수 있는 이벤트 타입은?

사용자가 페이지를 떠나는 것을 감지하려면 beforeunload 또는 visibilitychange 이벤트를 사용할 수 있다.

  • beforeunload는 사용자가 페이지를 닫거나 다른 URL로 이동하려 할 때 실행
  • visibilitychange는 사용자가 다른 탭으로 전환할 때도 감지할 수 있어 보다 유연한 처리가 가능