동적으로 생성된 DOM 요소에 이벤트를 붙이려고 할 때, 개발자들은 보통 새 요소가 생성될 때마다 이벤트 리스너를 다시 등록해야 한다고 생각한다. 다만 대부분의 개발자들은 이 과정이 얼마나 비효율적이고 성능을 낭비하는지 깨닫지 못한다. 이번에는 Event Delegation(이벤트 위임)이 정확히 뭔지, 왜 필요한지, 그리고 어떻게 구현하는지 실무에서 바로 쓸 수 있는 방식으로 완벽하게 정리해서 소개하겠다.
Event Delegation(이벤트 위임)은 간단히 말해 부모 요소에 이벤트 리스너를 붙여서 자식 요소의 이벤트를 처리하는 기법이다. 자식 요소마다 일일이 리스너를 붙이는 대신, 버블링(bubbling) 특성을 이용해 부모가 모든 자식의 클릭/입력 이벤트를 한 번에 처리한다.
왜 필요한가?
- 성능 향상: 100개의 버튼이 있으면 100개의 리스너를 붙이는 대신 1개만 붙이면 된다.
- 동적 요소 대응: 나중에 새로운 요소가 추가되어도 이벤트가 자동으로 작동한다. 다시 리스너를 등록할 필요가 없다.
- 메모리 절약: 리스너 수가 적으면 메모리 점유율도 낮다.
HTML 구조
<ul id="item-list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
<button id="add-btn">새 항목 추가</button>
✗ 잘못된 방법 (요소마다 리스너를 붙임)
// 현재 있는 요소들에만 리스너가 붙는다
const items = document.querySelectorAll('.item');
items.forEach(item => {
item.addEventListener('click', function() {
console.log(this.textContent + ' 클릭됨');
});
});
// 새로운 항목을 추가해도 이벤트가 작동하지 않는다
const addBtn = document.getElementById('add-btn');
addBtn.addEventListener('click', function() {
const newItem = document.createElement('li');
newItem.className = 'item';
newItem.textContent = 'Item 4';
document.getElementById('item-list').appendChild(newItem);
// Item 4는 클릭해도 이벤트가 작동 안 함!
});
결과: Item 1, 2, 3은 클릭이 되지만, 새로 추가된 Item 4는 클릭이 안 된다.
✓ 올바른 방법 (부모에 리스너를 붙임)
// 부모 요소(ul)에 리스너를 붙인다
const itemList = document.getElementById('item-list');
itemList.addEventListener('click', function(event) {
// 클릭된 요소가 .item 클래스인지 확인
if (event.target.classList.contains('item')) {
console.log(event.target.textContent + ' 클릭됨');
}
});
// 새로운 항목을 추가
const addBtn = document.getElementById('add-btn');
addBtn.addEventListener('click', function() {
const newItem = document.createElement('li');
newItem.className = 'item';
newItem.textContent = 'Item 4';
document.getElementById('item-list').appendChild(newItem);
// Item 4도 자동으로 클릭 이벤트가 작동한다!
});
결과: 기존 항목과 새로 추가된 항목 모두 클릭 이벤트가 정상 작동한다.
jQuery 프로젝트라면 on() 메서드의 두 번째 파라미터에 선택자를 넣으면 자동으로 delegation이 적용된다.
✓ jQuery Event Delegation
// jQuery의 on() 메서드 사용
$('#item-list').on('click', '.item', function() {
console.log($(this).text() + ' 클릭됨');
});
// 새 항목 추가
$('#add-btn').on('click', function() {
$('<li class="item">Item 4</li>')
.appendTo('#item-list');
// Item 4도 자동으로 이벤트가 작동한다
});
HTML
<div id="comments">
<div class="comment">
<span class="comment-text">좋은 글이네요!</span>
<button class="delete-btn">삭제</button>
</div>
<div class="comment">
<span class="comment-text">감사합니다.</span>
<button class="delete-btn">삭제</button>
</div>
</div>
<button id="add-comment">댓글 추가</button>
JavaScript
const commentContainer = document.getElementById('comments');
const addCommentBtn = document.getElementById('add-comment');
let commentCount = 2;
// 부모 요소에 이벤트 위임
commentContainer.addEventListener('click', function(event) {
// 삭제 버튼이 클릭되었는지 확인
if (event.target.classList.contains('delete-btn')) {
// 클릭된 버튼의 부모(comment)를 삭제
event.target.parentElement.remove();
console.log('댓글이 삭제되었습니다.');
}
});
// 댓글 추가 버튼
addCommentBtn.addEventListener('click', function() {
commentCount++;
const newComment = document.createElement('div');
newComment.className = 'comment';
newComment.innerHTML = `
<span class="comment-text">새 댓글 ${commentCount}</span>
<button class="delete-btn">삭제</button>
`;
commentContainer.appendChild(newComment);
// 새로 추가된 댓글의 삭제 버튼도 자동으로 작동한다
});
| 상황 | 설명 |
|---|---|
| 버블링 안 하는 이벤트 | focus, blur, load, unload, scroll 등은 버블링이 안 되므로 delegation 불가. 이 경우 직접 리스너를 붙여야 함. |
| 성능 - 너무 많은 조건 확인 | 매번 클릭할 때마다 event.target을 확인하므로, 불필요하게 복잡한 조건문은 피하자. |
| event.target vs event.currentTarget | event.target은 실제 클릭된 요소, event.currentTarget은 리스너가 붙은 요소(부모). 헷갈리지 말자. |
| stopPropagation() 주의 | 자식 요소에서 event.stopPropagation()을 호출하면 버블링이 멈춰서 부모의 delegation 리스너가 작동 안 할 수 있다. |
✗ 잘못된 예: stopPropagation 오용
// 자식 요소에서 stopPropagation()을 호출
const items = document.querySelectorAll('.item');
items.forEach(item => {
item.addEventListener('click', function(event) {
event.stopPropagation(); // 버블링 중단!
console.log('자식 클릭');
});
});
// 부모의 delegation 리스너
document.getElementById('item-list').addEventListener('click', function(event) {
if (event.target.classList.contains('item')) {
console.log('부모에서 감지'); // 이 코드는 실행 안 됨
}
});
✓ 올바른 예: stopPropagation() 사용 최소화
// 부모에만 리스너를 붙이고, stopPropagation() 필요 없음
document.getElementById('item-list').addEventListener('click', function(event) {
if (event.target.classList.contains('item')) {
console.log('부모에서 감지됨');
// 필요하면 여기서만 stopPropagation() 호출
}
});
1000개의 리스트 항목이 있을 때를 기준으로 생각해보자:
| 방식 | 리스너 개수 | 메모리 사용량 | 동적 요소 처리 |
|---|---|---|---|
| 개별 리스너 | 1000개 | 높음 | 수동 재등록 필요 |
| Event Delegation | 1개 | 극히 낮음 | 자동 처리 |
Event Delegation은 단순한 성능 최적화 기법이 아니라 동적 웹 애플리케이션을 만들 때 필수적인 패턴이다. 개별 요소에 리스너를 붙이면 관리도 복잡해지고, 새 요소가 추가될 때마다 일일이 등록해야 한다. 하지만 부모에 delegation으로 붙이면 모든 자식 요소(현재+미래의 요소)가 자동으로 이벤트를 받게 된다.
특히 댓글, 알림, 쇼핑카트 항목처럼 동적으로 생성되는 요소들을 다룰 때는 필수다. 이 글의 "실전 예제: 동적 댓글 삭제" 부분을 참고해 지금 개발 중인 프로젝트에 적용해보면, 훨씬 간결하고 성능 좋은 코드를 얻을 수 있을 것이다. 오늘부터 querySelector로 일일이 선택해서 리스너를 붙이는 습관은 버리고, 부모의 delegation으로 생각하는 습관을 들이자.