웹 개발을 하다 보면 사용자 인터랙션을 처리하기 위해 수많은 이벤트 리스너를 등록하게 됩니다. element.addEventListener('click', handler)
와 같은 코드는 매우 익숙하죠. 클릭, 마우스 움직임, 키보드 입력 등 다양한 상황에 대응해야 합니다. 하지만 무심코 남발된 이벤트 리스너는 웹 페이지 성능에 악영향을 줄 수 있다는 사실, 알고 계셨나요? 특히 동적으로 많은 요소가 생겼다 사라지는 복잡한 웹 애플리케이션이라면 더욱 그렇습니다.
오늘은 웹 페이지 성능 문제 중 하나인 "지나치게 많은 이벤트 리스너" 문제를 해결할 수 있는 강력한 기법, 바로 이벤트 위임(Event Delegation) 패턴에 대해 쉽고 자세하게 알아보겠습니다.
다음과 같은 코드가 있다고 가정해 봅시다.
// 특정 페이지의 addWindowEvents() 메소드 예시
addWindowEvents() {
this.dropObservable$ = fromEvent<DragEvent>(window, 'drop');
this.pasteObservable$ = fromEvent<ClipboardEvent>(window, 'paste');
this.copyObservable$ = fromEvent<Event>(window, 'copy');
// ... 수많은 window 이벤트들 ...
// 각 Observable에 대한 구독이 별도로 처리됨
this.subscriptions.push(this.dropObservable$.subscribe(this.dropHandler.bind(this)));
this.subscriptions.push(this.pasteObservable$.subscribe(this.pasteHandler.bind(this)));
// ... 더 많은 구독들 ...
}
위 코드는 window
객체에 직접 수많은 이벤트 리스너를 등록하고, 각 이벤트마다 개별적인 구독(subscription)을 생성하고 있습니다. 만약 여기서 다루는 item
들이 수십, 수백 개가 되고 각 item
마다 클릭 이벤트를 따로 등록한다고 상상해 보세요.
// 기존 방식: 여러 개별 요소에 이벤트 리스너 연결
const items = document.querySelectorAll('.item'); // 페이지 로드 시점에 있는 item들
items.forEach(item => {
item.addEventListener('click', this.handleItemClick);
});
이런 방식은 다음과 같은 문제점을 야기할 수 있습니다:
.item
요소가 추가될 때, 위 코드(items.forEach(...)
)는 이미 실행된 후이므로 이 새 요소에는 이벤트 리스너가 등록되지 않습니다. 따라서 새롭게 추가된 아이템은 클릭해도 handleItemClick
함수가 실행되지 않는 문제가 발생합니다. 이를 해결하려면 요소를 추가할 때마다 수동으로 리스너를 다시 달아줘야 하는 번거로움이 생깁니다. (참고: 캡틴판교님의 블로그 - 이벤트 위임 예시 중 동적 요소 문제)이벤트 위임 패턴은 이러한 문제점들을 해결하는 우아한 방법입니다. 핵심 아이디어는 개별 요소에 이벤트 리스너를 다는 대신, 그들의 공통된 상위 요소(부모 또는 조상 요소)에 단 하나의 리스너를 다는 것입니다.
이벤트 위임 패턴을 적용한 예시를 살펴봅시다.
// 개선: 상위 컨테이너에 하나의 이벤트 리스너 사용
const container = document.querySelector('#item-container'); // 아이템들을 감싸는 부모 요소
container.addEventListener('click', (e) => {
// e.target은 실제 클릭된 요소를 가리킵니다.
// e.currentTarget은 이벤트 리스너가 등록된 요소(여기서는 container)를 가리킵니다.
const item = e.target.closest('.item'); // 클릭된 요소 또는 가장 가까운 '.item' 부모를 찾습니다.
if (item) {
// '.item' 클래스를 가진 요소에서 이벤트가 발생한 경우에만 처리
// this.handleItemClick(e, item); // 실제 실행할 함수 호출
console.log(item.textContent + ' 클릭됨! (이벤트 위임)');
}
});
어떻게 작동하는 걸까요?
이벤트 위임은 이벤트 버블링(Event Bubbling)이라는 자바스크립트 이벤트 모델의 특징을 활용합니다. 이벤트 버블링이란, 특정 요소에서 이벤트가 발생하면 해당 이벤트가 상위 요소로 계속 전달되는 현상을 말합니다.
이벤트 버블링 심층 탐구:
HTML DOM(문서 객체 모델)에서 어떤 요소에 이벤트(예: 'click', 'mouseover', 'keydown' 등)가 발생하면, 그 이벤트는 해당 요소에서 시작하여 DOM 트리를 따라 부모 요소로, 그리고 그 부모의 부모 요소로 계속해서 올라가며 전파됩니다. 마치 물속의 거품이 수면으로 올라오는 모습과 비슷하다고 해서 "버블링"이라는 이름이 붙었습니다. 이 전파는 문서의 최상위 객체인 window
객체에 도달할 때까지 이어집니다.
예를 들어, 다음과 같은 HTML 구조를 생각해 봅시다:
<form id="grandparent">FORM
<div id="parent">DIV
<p id="child">클릭하세요!</p>
</div>
</form>
이제 각 요소(grandparent
, parent
, child
)에 다음과 같이 클릭 이벤트 리스너를 등록했다고 가정해 보겠습니다.
const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');
function logEventDetails(event) {
console.log(
`${event.currentTarget.id} 클릭됨. ` +
`currentTarget: ${event.currentTarget.tagName}#${event.currentTarget.id}, ` +
`target: ${event.target.tagName}#${event.target.id}`
);
}
grandparent.addEventListener('click', logEventDetails);
parent.addEventListener('click', logEventDetails);
child.addEventListener('click', logEventDetails);
만약 사용자가 p#child
요소를 클릭하면, 이벤트 버블링으로 인해 콘솔에는 다음과 같은 순서로 로그가 찍힐 것입니다:
child 클릭됨. currentTarget: P#child, target: P#child
parent 클릭됨. currentTarget: DIV#parent, target: P#child
grandparent 클릭됨. currentTarget: FORM#grandparent, target: P#child
여기서 event.target
은 항상 이벤트가 최초 발생한 요소(p#child
)를 가리키는 반면, event.currentTarget
은 현재 실행 중인 이벤트 핸들러가 연결된 요소(각각 child
, parent
, grandparent
)를 가리킵니다. 이벤트 위임에서는 event.target
을 사용하여 실제로 어떤 자식 요소에서 이벤트가 시작되었는지 판단합니다.
이벤트 전파의 전체 흐름 (캡처링 - 타깃 - 버블링):
사실 이벤트가 발생하면 브라우저는 다음의 3단계를 거쳐 이벤트를 전파합니다. (참고: 인파님의 블로그 글)
window
또는 document
)에서 시작하여 실제 이벤트가 발생한 타깃 요소까지 DOM 트리를 따라 내려옵니다. 이 단계에서 이벤트 리스너를 등록할 수도 있지만 (addEventListener
의 세 번째 인자를 true
로 설정), 일반적으로는 버블링 단계를 더 많이 활용합니다.이벤트 위임은 주로 이 세 번째 단계인 버블링 단계를 활용하여 상위 요소에서 이벤트를 처리합니다.
이벤트 전파, 왜 필요할까요? (참고: 인파님의 블로그 글)
이벤트 위임은 바로 이 버블링 현상을 이용하여, 개별 자식 요소 대신 공통 상위 요소에 리스너를 두고 event.target
으로 실제 이벤트 발생 지점을 확인하여 동작합니다.
container
내부의 특정 item
을 클릭합니다.item
에서 발생하고, 마치 물거품처럼 부모 요소로 전파됩니다. (item
-> ... -> container
)container
요소에 도달하면, 우리가 container
에 등록해 둔 단 하나의 click
이벤트 리스너가 이 이벤트를 감지합니다.event.target
(이벤트가 최초 발생한 요소, 즉 item
)이나 event.target.closest('.item')
(이벤트 발생 요소부터 시작해 .item
선택자와 일치하는 가장 가까운 조상 요소를 반환)을 사용하여 실제로 어떤 하위 요소에서 이벤트가 시작되었는지 확인할 수 있습니다..item
에서 발생한 것이 맞다면 (또는 .item
의 자식 요소에서 발생하여 .item
을 closest
로 찾을 수 있다면), 해당 item
에 대한 원하는 작업을 수행합니다.item
이 있어도 리스너는 여전히 하나뿐입니다!container
내부에 새로운 .item
요소가 나중에 자바스크립트로 추가되거나 기존 .item
이 삭제되어도, 이벤트 리스너를 추가하거나 제거할 필요가 없습니다. container
에 이미 등록된 리스너가 모든 것을 알아서 처리해 주니까요! 이는 앞에서 언급한 "동적 요소 관리의 어려움" 문제를 깔끔하게 해결합니다.ul > li
), 테이블(table > tr > td
), 그리드 아이템 등에 유용합니다.event.stopPropagation()
때로는 이벤트가 상위 요소로 전파되는 것을 원치 않을 수 있습니다. 예를 들어, 특정 버튼을 클릭했을 때 그 버튼의 부모 요소에 있는 클릭 이벤트는 실행되지 않도록 하고 싶을 수 있습니다. 이때 event.stopPropagation()
메소드를 사용할 수 있습니다. (참고: 캡틴판교님의 블로그 - event.stopPropagation())
childElement.addEventListener('click', function(event) {
console.log('자식 요소 클릭됨! 여기서 전파 중단!');
event.stopPropagation(); // 이벤트가 부모로 버블링되는 것을 막습니다.
});
parentElement.addEventListener('click', function(event) {
// childElement에서 stopPropagation()이 호출되면 이 핸들러는 실행되지 않습니다.
console.log('부모 요소 클릭됨 (자식에서 중단 안했다면)');
});
event.stopPropagation()
을 호출하면 해당 이벤트는 현재 요소의 이벤트 핸들러 실행을 마친 후 더 이상 상위(버블링 단계) 또는 하위(캡처링 단계, 만약 캡처링 리스너에서 호출했다면)로 전파되지 않습니다.
주의사항: event.stopPropagation()
은 매우 유용하지만, 남용하면 이벤트 흐름을 예측하기 어렵게 만들거나 상위 요소에서 기대하는 동작을 막을 수 있으므로 신중하게 사용해야 합니다. 예를 들어, 전체 페이지에 적용된 공통적인 분석 로깅이나 UI 동작이 특정 요소에서는 막힐 수 있습니다.
이벤트 위임 패턴은 웹 성능을 개선하고 코드를 더 효율적으로 관리할 수 있게 해주는 강력한 도구입니다. 처음에는 event.target
을 확인하는 로직이 조금 어색하게 느껴질 수 있지만, 한번 익숙해지면 그 편리함과 강력함에 매료될 것입니다.
애플리케이션 전반에 걸쳐 과도하게 등록된 이벤트 리스너가 있다면 이벤트 위임 패턴을 적극적으로 검토해 보세요. 작은 변화가 웹 애플리케이션의 전체적인 성능과 사용자 경험에 큰 차이를 만들 수 있습니다.
다음번에는 또 다른 성능 개선 팁으로 찾아뵙겠습니다!