본문으로 건너뛰기
Advertisement

이벤트 시스템

JavaScript의 이벤트 시스템은 사용자 상호작용과 브라우저 이벤트를 처리하는 핵심 메커니즘입니다. 버블링, 캡처링, 이벤트 위임을 이해하면 효율적인 이벤트 처리가 가능합니다.


이벤트 리스너 기본

// addEventListener 기본 형태
element.addEventListener(eventType, handler, options);

const button = document.querySelector('#btn');

// 기본 사용
button.addEventListener('click', function(event) {
console.log('클릭됨', event.target);
});

// 화살표 함수
button.addEventListener('click', (event) => {
console.log(event.type); // 'click'
console.log(event.target); // 이벤트가 발생한 요소
console.log(event.currentTarget); // 핸들러가 등록된 요소
console.log(event.timeStamp); // 발생 시각 (ms)
});

// 이벤트 리스너 제거 (동일한 함수 참조 필요)
function handleClick(e) {
console.log('클릭');
}

button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick); // 정확히 같은 함수 참조

// removeEventListener는 화살표 함수로 등록한 경우 제거 불가
// (매번 새로운 함수가 생성되므로)

addEventListener 옵션

once 옵션: 한 번만 실행

// once: 이벤트가 한 번 발생하면 자동으로 리스너 제거
button.addEventListener('click', (e) => {
console.log('한 번만 실행됩니다');
}, { once: true });

// 실전: 초기화 로직
document.addEventListener('DOMContentLoaded', initializeApp, { once: true });

// 실전: 애니메이션 완료 후 클래스 제거
element.addEventListener('animationend', (e) => {
e.target.classList.remove('animating');
}, { once: true });

passive 옵션: 스크롤 성능 최적화

// passive: true로 설정 시 preventDefault()를 호출하지 않겠다고 브라우저에 알림
// 브라우저가 스크롤을 지연 없이 처리 가능 → 성능 향상

// 모바일에서 스크롤 성능 개선
document.addEventListener('touchstart', handler, { passive: true });
document.addEventListener('touchmove', handler, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });

// passive: true일 때 preventDefault() 호출하면 경고 발생
document.addEventListener('touchmove', (e) => {
e.preventDefault(); // 경고! passive: true로 등록되면 무시됨
}, { passive: false }); // 기본 동작 방지가 필요하면 passive: false

// 일반적인 권장 사항:
// - 스크롤 이벤트: passive: true (기본 동작 방지 불필요)
// - 슬라이더, 드래그: passive: false (기본 스크롤 방지 필요)

capture 옵션: 캡처링 단계 처리

// capture: true → 캡처링 단계에서 실행
// capture: false (기본값) → 버블링 단계에서 실행

document.addEventListener('click', (e) => {
console.log('캡처링: document');
}, { capture: true });

document.body.addEventListener('click', (e) => {
console.log('캡처링: body');
}, true); // 세 번째 인수로도 전달 가능

button.addEventListener('click', (e) => {
console.log('버블링: button');
});

// 클릭 시 출력 순서:
// 캡처링: document
// 캡처링: body
// 버블링: button

이벤트 버블링과 캡처링

이벤트는 발생 후 DOM 트리를 따라 전파됩니다.

전파 단계:
1. 캡처링(Capturing): document → 이벤트 발생 요소
2. 타겟(Target): 이벤트 발생 요소 자체
3. 버블링(Bubbling): 이벤트 발생 요소 → document
// 이벤트 버블링 예시
// HTML: <div id="outer"><div id="inner"><button>클릭</button></div></div>

document.querySelector('#outer').addEventListener('click', (e) => {
console.log('outer 클릭됨');
});

document.querySelector('#inner').addEventListener('click', (e) => {
console.log('inner 클릭됨');
});

document.querySelector('button').addEventListener('click', (e) => {
console.log('button 클릭됨');
console.log('target:', e.target); // button (실제 클릭된 요소)
console.log('currentTarget:', e.currentTarget); // button (핸들러 등록 요소)
});

// 버튼 클릭 시 출력:
// button 클릭됨 (타겟 단계)
// inner 클릭됨 (버블링)
// outer 클릭됨 (버블링)

stopPropagation vs stopImmediatePropagation

const button = document.querySelector('button');

// stopPropagation: 이벤트 전파(버블링/캡처링) 중단
button.addEventListener('click', (e) => {
e.stopPropagation();
console.log('더 이상 부모로 전파되지 않음');
});

// 하지만 같은 요소의 다른 핸들러는 계속 실행됨
button.addEventListener('click', (e) => {
console.log('이 핸들러는 여전히 실행됨');
});

// stopImmediatePropagation: 전파 중단 + 같은 요소의 나머지 핸들러도 중단
button.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('첫 번째 핸들러');
});

button.addEventListener('click', (e) => {
console.log('이 핸들러는 실행되지 않음!');
});

// stopPropagation: 부모로 전파만 막음
// stopImmediatePropagation: 부모 전파 + 같은 요소 나머지 핸들러도 막음

preventDefault

// 기본 동작 방지
const link = document.querySelector('a');
link.addEventListener('click', (e) => {
e.preventDefault(); // 링크 이동 방지
console.log('링크 클릭됨, 이동 방지');
});

// 폼 제출 방지
const form = document.querySelector('form');
form.addEventListener('submit', (e) => {
e.preventDefault(); // 기본 폼 제출 방지

const formData = new FormData(form);
submitFormAsync(Object.fromEntries(formData));
});

// 키보드 단축키
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault(); // 브라우저 저장 방지
saveDocument();
}
});

// 마우스 오른쪽 버튼 컨텍스트 메뉴 커스터마이징
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
showCustomContextMenu(e.clientX, e.clientY);
});

이벤트 위임 패턴

이벤트 위임은 부모 요소에 이벤트 리스너를 등록하고, 버블링을 이용해 자식 요소의 이벤트를 처리하는 패턴입니다.

// 나쁜 방법: 각 항목마다 이벤트 리스너 등록
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleItemClick); // N개의 리스너
});

// 좋은 방법: 이벤트 위임 - 부모에 하나만 등록
const list = document.querySelector('#item-list');
list.addEventListener('click', (e) => {
const item = e.target.closest('.item'); // 클릭된 .item 찾기
if (!item) return; // .item이 아니면 무시

handleItemClick(item);
});

동적 요소 처리

이벤트 위임의 가장 큰 장점은 동적으로 추가된 요소도 자동으로 처리된다는 점입니다.

const list = document.querySelector('#todo-list');

// 이벤트 위임으로 동적 항목 처리
list.addEventListener('click', (e) => {
// 체크박스 클릭
if (e.target.matches('input[type="checkbox"]')) {
const item = e.target.closest('.todo-item');
item.classList.toggle('completed', e.target.checked);
}

// 삭제 버튼 클릭
if (e.target.matches('.delete-btn') || e.target.closest('.delete-btn')) {
const item = e.target.closest('.todo-item');
const id = item.dataset.id;
deleteTodo(id);
item.remove();
}

// 편집 버튼 클릭
if (e.target.matches('[data-action="edit"]')) {
const id = e.target.closest('[data-id]').dataset.id;
startEditing(id);
}
});

// 나중에 동적으로 추가된 항목도 동작함
function addTodo(text) {
const li = document.createElement('li');
li.className = 'todo-item';
li.dataset.id = generateId();
li.innerHTML = `
<input type="checkbox">
<span>${text}</span>
<button class="delete-btn" data-action="delete">삭제</button>
<button data-action="edit">편집</button>
`;
list.appendChild(li);
}

closest를 활용한 정교한 위임

// 중첩된 구조에서도 안전하게 처리
document.querySelector('.product-grid').addEventListener('click', (e) => {
// 클릭한 요소에서 가장 가까운 .product-card 탐색
const card = e.target.closest('.product-card');
if (!card) return;

// 어떤 동작인지 파악
const action = e.target.closest('[data-action]')?.dataset.action;

switch (action) {
case 'add-to-cart': {
const productId = card.dataset.productId;
addToCart(productId);
showToast('장바구니에 추가되었습니다');
break;
}
case 'toggle-wishlist': {
const productId = card.dataset.productId;
toggleWishlist(productId);
e.target.closest('[data-action]').classList.toggle('active');
break;
}
default: {
// 카드 자체 클릭 → 상세 페이지 이동
const productId = card.dataset.productId;
navigateToProduct(productId);
}
}
});

CustomEvent로 커스텀 이벤트 발행/구독

CustomEvent를 사용하면 컴포넌트 간 결합도를 낮출 수 있습니다.

// 커스텀 이벤트 생성
const event = new CustomEvent('user-logged-in', {
detail: {
userId: 123,
username: 'Alice',
role: 'admin'
},
bubbles: true, // 버블링 활성화
cancelable: true // preventDefault 가능
});

// 이벤트 발행
document.dispatchEvent(event);

// 특정 요소에서 발행
const loginButton = document.querySelector('#login-btn');
loginButton.dispatchEvent(new CustomEvent('login-attempt', {
detail: { username: 'Alice' },
bubbles: true
}));

// 구독
document.addEventListener('user-logged-in', (e) => {
const { userId, username, role } = e.detail;
console.log(`${username}님이 로그인했습니다 (역할: ${role})`);
updateNavigation(role);
});

// 실전: 이벤트 버스 패턴
class EventBus {
#listeners = new Map();

on(eventName, handler) {
if (!this.#listeners.has(eventName)) {
this.#listeners.set(eventName, new Set());
}
this.#listeners.get(eventName).add(handler);

// 구독 해제 함수 반환
return () => this.off(eventName, handler);
}

off(eventName, handler) {
this.#listeners.get(eventName)?.delete(handler);
}

emit(eventName, data) {
this.#listeners.get(eventName)?.forEach(handler => handler(data));
}

once(eventName, handler) {
const unsubscribe = this.on(eventName, (data) => {
handler(data);
unsubscribe();
});
return unsubscribe;
}
}

const bus = new EventBus();

// 사용
const unsubscribe = bus.on('cart:updated', ({ items, total }) => {
updateCartUI(items, total);
});

bus.emit('cart:updated', { items: [...], total: 15000 });

// 필요 없을 때 구독 해제
unsubscribe();

키보드 이벤트

// keydown: 키를 누를 때 (반복 발생)
// keyup: 키를 뗄 때
// keypress: 문자 키 입력 시 (deprecated)

document.addEventListener('keydown', (e) => {
console.log(e.key); // 'a', 'Enter', 'ArrowUp', ' ' 등
console.log(e.code); // 'KeyA', 'Enter', 'ArrowUp', 'Space' (물리 키)
console.log(e.keyCode); // deprecated, 사용 금지
console.log(e.ctrlKey); // Ctrl 키 눌림 여부
console.log(e.shiftKey); // Shift 키 눌림 여부
console.log(e.altKey); // Alt 키 눌림 여부
console.log(e.metaKey); // Mac Command / Windows 키
console.log(e.repeat); // 키 반복 여부
});

// 실전: 단축키 시스템
const shortcuts = new Map([
['ctrl+s', saveDocument],
['ctrl+z', undo],
['ctrl+shift+z', redo],
['Escape', closeModal],
['ctrl+/', toggleComment],
]);

document.addEventListener('keydown', (e) => {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
parts.push(e.key.toLowerCase());

const shortcut = parts.join('+');
const action = shortcuts.get(shortcut);

if (action) {
e.preventDefault();
action(e);
}
});

// input 이벤트 처리
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
if (query.length >= 2) {
performSearch(query);
}
});

// IME 조합 중인지 확인 (한국어 입력 등)
searchInput.addEventListener('compositionstart', () => { isComposing = true; });
searchInput.addEventListener('compositionend', () => { isComposing = false; });
searchInput.addEventListener('input', (e) => {
if (!isComposing) {
handleSearch(e.target.value);
}
});

마우스 이벤트

const canvas = document.querySelector('canvas');

// 마우스 이벤트 종류
canvas.addEventListener('mousedown', (e) => {
console.log(e.button); // 0: 왼쪽, 1: 가운데, 2: 오른쪽
console.log(e.clientX, e.clientY); // 뷰포트 기준 좌표
console.log(e.pageX, e.pageY); // 페이지 기준 좌표
console.log(e.offsetX, e.offsetY); // 요소 기준 좌표
console.log(e.buttons); // 눌린 버튼들 (비트 마스크)
});

// 드래그 구현
let isDragging = false;
let startX, startY;

canvas.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
});

document.addEventListener('mousemove', (e) => {
if (!isDragging) return;

const dx = e.clientX - startX;
const dy = e.clientY - startY;
moveElement(dx, dy);

startX = e.clientX;
startY = e.clientY;
});

document.addEventListener('mouseup', () => {
isDragging = false;
});

// 마우스 엔터/리브 (버블링 없음)
// vs. mouseover/mouseout (버블링 있음)
const menu = document.querySelector('.dropdown');

menu.addEventListener('mouseenter', () => menu.classList.add('open'));
menu.addEventListener('mouseleave', () => menu.classList.remove('open'));

폼 이벤트

const form = document.querySelector('#signup-form');

// submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form));
await submitSignup(data);
});

// change: 값이 변경되고 포커스가 떠날 때
document.querySelector('#country').addEventListener('change', (e) => {
updateCityOptions(e.target.value);
});

// input: 값이 변경될 때마다 (실시간)
document.querySelector('#username').addEventListener('input', (e) => {
validateUsername(e.target.value);
});

// focus / blur
document.querySelector('#email').addEventListener('focus', (e) => {
e.target.parentElement.classList.add('focused');
});

document.querySelector('#email').addEventListener('blur', (e) => {
e.target.parentElement.classList.remove('focused');
validateEmail(e.target.value);
});

// focusin / focusout (버블링 있음 - 위임에 유용)
form.addEventListener('focusin', (e) => {
if (e.target.matches('input, select, textarea')) {
e.target.parentElement.classList.add('focused');
}
});

form.addEventListener('focusout', (e) => {
if (e.target.matches('input, select, textarea')) {
e.target.parentElement.classList.remove('focused');
}
});

실전 예제: 모달 시스템

class Modal {
#element;
#focusableElements;
#previousFocus;

constructor(id) {
this.#element = document.getElementById(id);
this.#bindEvents();
}

#bindEvents() {
// 닫기 버튼
this.#element.addEventListener('click', (e) => {
if (e.target.matches('[data-action="close"]') ||
e.target === this.#element) {
this.close();
}
});

// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
});

// 포커스 트랩 (접근성)
this.#element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
this.#trapFocus(e);
}
});
}

open() {
this.#previousFocus = document.activeElement;
this.#element.classList.add('visible');
this.#element.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
this.isOpen = true;

// 첫 번째 포커스 가능 요소로 포커스
const firstFocusable = this.#element.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();

this.#element.dispatchEvent(new CustomEvent('modal:opened', { bubbles: true }));
}

close() {
this.#element.classList.remove('visible');
this.#element.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
this.isOpen = false;
this.#previousFocus?.focus();

this.#element.dispatchEvent(new CustomEvent('modal:closed', { bubbles: true }));
}

#trapFocus(e) {
const focusableEls = this.#element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableEls[0];
const lastFocusable = focusableEls[focusableEls.length - 1];

if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
lastFocusable.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusable) {
firstFocusable.focus();
e.preventDefault();
}
}
}
}

고수 팁

1. 이벤트 핸들러 메모리 누수 방지

class Component {
#handlers = new Map();

addEvent(element, type, handler) {
const bound = handler.bind(this);
this.#handlers.set(`${type}`, { element, type, handler: bound });
element.addEventListener(type, bound);
}

destroy() {
// 컴포넌트 제거 시 모든 이벤트 제거
this.#handlers.forEach(({ element, type, handler }) => {
element.removeEventListener(type, handler);
});
this.#handlers.clear();
}
}

2. AbortController로 이벤트 일괄 제거

// AbortController로 여러 이벤트를 한번에 제거
const controller = new AbortController();
const { signal } = controller;

element.addEventListener('click', handleClick, { signal });
element.addEventListener('mouseover', handleHover, { signal });
document.addEventListener('keydown', handleKey, { signal });

// 모든 이벤트를 한번에 제거
controller.abort();

3. 이벤트 디바운스와 스로틀

// 디바운스: 마지막 호출 후 delay ms 뒤에 실행
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}

// 스로틀: delay ms마다 최대 한 번 실행
function throttle(fn, delay) {
let lastTime = 0;
return (...args) => {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
fn(...args);
}
};
}

// 검색 입력 디바운스 (300ms)
const handleSearch = debounce((value) => {
fetchSearchResults(value);
}, 300);

searchInput.addEventListener('input', (e) => handleSearch(e.target.value));

// 스크롤 스로틀 (100ms)
const handleScroll = throttle(() => {
updateScrollIndicator();
}, 100);

window.addEventListener('scroll', handleScroll, { passive: true });
Advertisement