포스트

JavaScript 이벤트 위임(Event Delegation) 완벽 가이드 - 버블링, 캡처링, 그리고 성능 최적화

JavaScript 이벤트 버블링과 캡처링의 동작 원리를 시각적으로 이해하고, 이벤트 위임(Event Delegation) 패턴으로 메모리 효율적인 DOM 이벤트 처리를 구현하는 방법을 코드 예제와 함께 알아봅니다. 프론트엔드 면접 필수 개념.

JavaScript 이벤트 위임(Event Delegation) 완벽 가이드 - 버블링, 캡처링, 그리고 성능 최적화

개요

이벤트 위임(Event Delegation)은 JavaScript에서 이벤트를 효율적으로 처리하기 위한 핵심 패턴입니다. 이 패턴을 이해하려면 먼저 DOM 이벤트가 어떻게 전파되는지 - 버블링(Bubbling)캡처링(Capturing) - 알아야 합니다.

이 글에서는 DOM 이벤트 흐름의 동작 원리를 시각적으로 설명하고, 이벤트 위임 패턴을 활용하여 메모리 효율적이고 동적 요소에도 대응 가능한 이벤트 처리 방법을 다룹니다. 프론트엔드 면접에서 자주 나오는 주제이므로 정확하게 이해해두시기 바랍니다.

학습 목표

  • DOM 이벤트 흐름(캡처링 -> 타겟 -> 버블링) 이해
  • 이벤트 버블링과 캡처링의 차이점 파악
  • event.targetevent.currentTarget의 차이 이해
  • 이벤트 위임 패턴 구현 방법 습득
  • React의 합성 이벤트(SyntheticEvent)와 비교
  • 실무 적용 Best Practices 학습

사전 지식

  • JavaScript 함수 기초
  • DOM 조작 기본 개념
  • HTML 요소 구조

관련 포스트


DOM 이벤트 흐름 개요

브라우저에서 이벤트가 발생하면, 해당 이벤트는 3가지 단계를 거쳐 전파됩니다:

단계이름전파 방향
1캡처링 단계 (Capturing Phase)windowdocumenthtmlbody → … → 타겟의 부모
2타겟 단계 (Target Phase)이벤트가 실제로 발생한 요소
3버블링 단계 (Bubbling Phase)타겟의 부모 → … → bodyhtmldocumentwindow

시각적 예시

다음과 같은 HTML 구조가 있다고 가정해봅시다:

1
2
3
4
5
<div id="outer">
  <div id="inner">
    <button id="button">클릭</button>
  </div>
</div>

버튼을 클릭하면 이벤트는 다음 순서로 전파됩니다:

순서단계요소설명
캡처링#outer최상위에서 캡처링 시작
캡처링#inner중간 요소 캡처링
타겟#button타겟 요소의 캡처링 핸들러 실행
타겟#button타겟 요소의 버블링 핸들러 실행
버블링#inner상위로 버블링 시작
버블링#outer최상위까지 버블링

전파 순서: ① → ② → ③④ → ⑤ → ⑥

대부분의 이벤트 핸들러는 기본적으로 버블링 단계에서 실행됩니다. 캡처링 단계에서 이벤트를 잡으려면 명시적으로 설정해야 합니다.


이벤트 버블링(Event Bubbling)

버블링이란?

이벤트 버블링은 특정 요소에서 이벤트가 발생했을 때, 해당 이벤트가 상위 요소들로 전파되는 현상입니다. 마치 물속에서 거품(bubble)이 위로 올라가는 것처럼, 이벤트가 DOM 트리를 따라 위로 올라갑니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// HTML 구조
// <div id="grandparent">
//   <div id="parent">
//     <button id="child">클릭</button>
//   </div>
// </div>

const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');

grandparent.addEventListener('click', () => {
  console.log('grandparent 클릭!');
});

parent.addEventListener('click', () => {
  console.log('parent 클릭!');
});

child.addEventListener('click', () => {
  console.log('child 클릭!');
});

// 버튼(child)을 클릭하면:
// "child 클릭!"
// "parent 클릭!"
// "grandparent 클릭!"
// (버블링으로 인해 상위 요소의 핸들러도 실행됨)

event.stopPropagation()

버블링을 중단하고 싶을 때는 event.stopPropagation() 메서드를 사용합니다:

1
2
3
4
5
6
7
8
child.addEventListener('click', (event) => {
  console.log('child 클릭!');
  event.stopPropagation(); // 버블링 중단
});

// 이제 버튼을 클릭하면:
// "child 클릭!" 만 출력됨
// parent와 grandparent의 핸들러는 실행되지 않음

stopPropagation()은 신중하게 사용해야 합니다. 무분별하게 사용하면 상위 요소에서 이벤트를 감지할 수 없어 디버깅이 어려워지고, 분석 도구나 로깅 시스템에 영향을 줄 수 있습니다.

event.stopImmediatePropagation()

같은 요소에 여러 이벤트 핸들러가 등록되어 있을 때, 현재 핸들러 이후의 모든 핸들러 실행을 막으려면 stopImmediatePropagation()을 사용합니다:

1
2
3
4
5
6
7
8
9
10
child.addEventListener('click', (event) => {
  console.log('첫 번째 핸들러');
  event.stopImmediatePropagation();
});

child.addEventListener('click', () => {
  console.log('두 번째 핸들러'); // 실행되지 않음
});

// 출력: "첫 번째 핸들러" 만 출력됨

버블링이 발생하지 않는 이벤트

모든 이벤트가 버블링되는 것은 아닙니다. 다음 이벤트들은 버블링되지 않습니다:

이벤트설명
focus / blur포커스 관련 이벤트
mouseenter / mouseleave마우스 진입/이탈 이벤트
load / unload로드 관련 이벤트
scroll스크롤 이벤트 (일부 상황)

focus/blur 대신 버블링되는 focusin/focusout을, mouseenter/mouseleave 대신 mouseover/mouseout을 사용하면 이벤트 위임이 가능합니다.


이벤트 캡처링(Event Capturing)

캡처링이란?

이벤트 캡처링은 버블링의 반대 방향입니다. 이벤트가 최상위 요소(window)에서 시작하여 타겟 요소까지 내려가는 단계입니다. 캡처링은 버블링보다 먼저 발생합니다.

addEventListener의 세 번째 인자

캡처링 단계에서 이벤트를 처리하려면 addEventListener의 세 번째 인자를 설정해야 합니다:

1
2
3
4
// 세 번째 인자: capture 옵션
element.addEventListener('click', handler, true);
// 또는 옵션 객체로 전달
element.addEventListener('click', handler, { capture: true });

캡처링 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const grandparent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// 캡처링 단계에서 실행
grandparent.addEventListener('click', () => {
  console.log('grandparent 캡처링');
}, true);

parent.addEventListener('click', () => {
  console.log('parent 캡처링');
}, true);

child.addEventListener('click', () => {
  console.log('child 캡처링');
}, true);

// 버블링 단계에서 실행 (기본값)
grandparent.addEventListener('click', () => {
  console.log('grandparent 버블링');
});

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

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

// child 버튼 클릭 시 출력 순서:
// "grandparent 캡처링"
// "parent 캡처링"
// "child 캡처링"
// "child 버블링"
// "parent 버블링"
// "grandparent 버블링"

캡처링 사용 시나리오

캡처링은 버블링보다 덜 사용되지만, 다음과 같은 상황에서 유용합니다:

1. 이벤트를 하위 요소보다 먼저 처리해야 할 때

1
2
3
4
5
6
7
// 모달 외부 클릭 감지 (이벤트가 모달에 도달하기 전에 처리)
document.addEventListener('click', (event) => {
  const modal = document.getElementById('modal');
  if (!modal.contains(event.target)) {
    closeModal();
  }
}, true); // 캡처링 단계에서 처리

2. 이벤트를 가로채서 조건부로 전파를 막을 때

1
2
3
4
5
6
7
// 특정 조건에서 모든 클릭 이벤트 차단
document.addEventListener('click', (event) => {
  if (isEditMode && !event.target.classList.contains('editable')) {
    event.stopPropagation();
    console.log('편집 모드에서는 editable 요소만 클릭 가능합니다.');
  }
}, true);

3. 포커스 이벤트 위임 구현

1
2
3
4
5
6
7
8
// focus는 버블링되지 않으므로 캡처링 사용
form.addEventListener('focus', (event) => {
  event.target.classList.add('focused');
}, true);

form.addEventListener('blur', (event) => {
  event.target.classList.remove('focused');
}, true);

event.target vs event.currentTarget

이벤트 위임을 이해하는 데 가장 중요한 개념 중 하나입니다.

핵심 차이점

속성설명변경 여부
event.target이벤트가 실제로 발생한 요소이벤트 전파 중 변하지 않음
event.currentTarget이벤트 핸들러가 등록된 요소이벤트 전파 단계에 따라 변함

시각적 설명

1
2
3
4
5
<ul id="list">           <!-- event.currentTarget: 핸들러가 등록된 요소 -->
  <li>
    <button>클릭</button> <!-- event.target: 실제 클릭된 요소 -->
  </li>
</ul>
속성가리키는 요소설명
event.currentTarget<ul id="list">핸들러가 등록된 요소
event.target<button>실제 클릭이 발생한 요소

실제 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const list = document.getElementById('list');

list.addEventListener('click', function(event) {
  console.log('event.target:', event.target);          // 클릭된 실제 요소
  console.log('event.currentTarget:', event.currentTarget); // ul#list
  console.log('this:', this);  // event.currentTarget과 동일

  // event.target과 event.currentTarget이 같은지 확인
  console.log('같은 요소?:', event.target === event.currentTarget);
});

// <li> 클릭 시:
// event.target: <li>...</li>
// event.currentTarget: <ul id="list">...</ul>
// 같은 요소?: false

// <ul> 직접 클릭 시 (li 바깥 영역):
// event.target: <ul id="list">...</ul>
// event.currentTarget: <ul id="list">...</ul>
// 같은 요소?: true

면접에서 자주 나오는 질문입니다: “event.targetevent.currentTarget의 차이점을 설명해주세요.” 핸들러가 등록된 요소와 실제 이벤트가 발생한 요소의 차이를 명확히 이해해야 합니다.

화살표 함수에서의 this

주의할 점은, 화살표 함수에서 thisevent.currentTarget을 가리키지 않습니다:

1
2
3
4
5
6
7
8
9
// 일반 함수
list.addEventListener('click', function(event) {
  console.log(this === event.currentTarget); // true
});

// 화살표 함수
list.addEventListener('click', (event) => {
  console.log(this === event.currentTarget); // false (this는 상위 스코프의 this)
});

화살표 함수와 일반 함수의 this 바인딩 차이에 대해 더 자세히 알아보려면 JavaScript this 바인딩 완벽 가이드를 참고하세요.


이벤트 위임(Event Delegation) 패턴

이벤트 위임이란?

이벤트 위임은 여러 요소에 각각 이벤트 핸들러를 등록하는 대신, 공통 상위 요소에 하나의 핸들러만 등록하여 이벤트를 처리하는 패턴입니다. 이벤트 버블링을 활용합니다.

이벤트 위임 vs 개별 등록 비교

구분개별 등록 방식이벤트 위임 방식
핸들러 위치<li>에 핸들러 등록<ul>에 핸들러 1개만 등록
핸들러 수N개1개
메모리 사용많음적음
동적 요소별도 처리 필요자동 처리

왜 이벤트 위임을 사용하는가?

1. 메모리 효율성

1
2
3
4
5
6
7
8
9
// 비효율적: 각 항목마다 핸들러 등록
const items = document.querySelectorAll('.item');
items.forEach(item => {
  item.addEventListener('click', handleClick); // N개의 핸들러
});

// 효율적: 부모에 하나의 핸들러만 등록
const container = document.getElementById('container');
container.addEventListener('click', handleClick); // 1개의 핸들러

1000개의 리스트 항목이 있다면, 개별 등록 방식은 1000개의 이벤트 핸들러가 메모리에 존재합니다. 이벤트 위임을 사용하면 단 1개의 핸들러만 필요합니다.

이벤트 위임의 메모리 효율성과 관련하여 JavaScript의 메모리 관리 원리를 이해하면 더 효과적입니다. JavaScript 메모리 관리와 가비지 컬렉션에서 자세히 알아보세요.

2. 동적으로 추가되는 요소 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 문제: 나중에 추가된 요소에는 핸들러가 없음
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// 나중에 추가되는 요소
const newItem = document.createElement('li');
newItem.className = 'item';
container.appendChild(newItem);
// 클릭해도 handleClick이 실행되지 않음!

// 해결: 이벤트 위임 사용
container.addEventListener('click', (event) => {
  if (event.target.classList.contains('item')) {
    handleClick(event);
  }
});
// 동적으로 추가된 요소도 자동으로 처리됨!

3. 코드 간결성

핸들러 등록/해제 로직이 단순해지고, 유지보수가 쉬워집니다.

기본 구현 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const todoList = document.getElementById('todo-list');

todoList.addEventListener('click', (event) => {
  const target = event.target;

  // 클릭된 요소가 삭제 버튼인지 확인
  if (target.classList.contains('delete-btn')) {
    const todoItem = target.closest('.todo-item');
    todoItem.remove();
    return;
  }

  // 클릭된 요소가 완료 체크박스인지 확인
  if (target.classList.contains('complete-checkbox')) {
    const todoItem = target.closest('.todo-item');
    todoItem.classList.toggle('completed');
    return;
  }

  // 클릭된 요소가 todo-item 자체인지 확인
  if (target.classList.contains('todo-item')) {
    openEditModal(target);
  }
});

closest() 메서드 활용

closest() 메서드는 현재 요소에서 시작하여 조건에 맞는 가장 가까운 조상 요소를 찾습니다. 이벤트 위임에서 매우 유용합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// HTML 구조
// <ul id="list">
//   <li class="item" data-id="1">
//     <span class="text">항목 1</span>
//     <button class="delete">삭제</button>
//   </li>
// </ul>

list.addEventListener('click', (event) => {
  // 삭제 버튼이 아니라 span이나 li를 클릭해도
  // 가장 가까운 .item을 찾을 수 있음
  const item = event.target.closest('.item');

  if (!item) return; // .item 외부 클릭 시 무시

  const itemId = item.dataset.id;
  console.log(`항목 ${itemId} 클릭됨`);

  // 삭제 버튼 클릭 확인
  if (event.target.closest('.delete')) {
    deleteItem(itemId);
  }
});

실전 예제: 동적 테이블

1
2
3
4
5
6
7
8
9
10
11
12
<table id="user-table">
  <thead>
    <tr>
      <th>이름</th>
      <th>이메일</th>
      <th>액션</th>
    </tr>
  </thead>
  <tbody>
    <!-- 동적으로 행이 추가됨 -->
  </tbody>
</table>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const table = document.getElementById('user-table');
const tbody = table.querySelector('tbody');

// 이벤트 위임으로 모든 행의 이벤트 처리
table.addEventListener('click', (event) => {
  const target = event.target;
  const row = target.closest('tr');

  if (!row || row.parentElement.tagName === 'THEAD') return;

  const userId = row.dataset.userId;

  // 편집 버튼
  if (target.closest('.edit-btn')) {
    editUser(userId);
    return;
  }

  // 삭제 버튼
  if (target.closest('.delete-btn')) {
    if (confirm('정말 삭제하시겠습니까?')) {
      row.remove();
      deleteUserFromServer(userId);
    }
    return;
  }

  // 행 클릭 (상세 보기)
  if (target.tagName === 'TD') {
    showUserDetails(userId);
  }
});

// 새 사용자 추가 (이벤트 핸들러 등록 불필요!)
function addUser(user) {
  const row = document.createElement('tr');
  row.dataset.userId = user.id;
  row.innerHTML = `
    <td>${user.name}</td>
    <td>${user.email}</td>
    <td>
      <button class="edit-btn">편집</button>
      <button class="delete-btn">삭제</button>
    </td>
  `;
  tbody.appendChild(row);
}

실전 예제: 탭 컴포넌트

1
2
3
4
5
6
7
8
9
10
11
12
<div class="tabs" id="tabs">
  <div class="tab-list" role="tablist">
    <button class="tab" data-tab="tab1" aria-selected="true">탭 1</button>
    <button class="tab" data-tab="tab2">탭 2</button>
    <button class="tab" data-tab="tab3">탭 3</button>
  </div>
  <div class="tab-panels">
    <div id="tab1" class="tab-panel active">탭 1 내용</div>
    <div id="tab2" class="tab-panel">탭 2 내용</div>
    <div id="tab3" class="tab-panel">탭 3 내용</div>
  </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const tabs = document.getElementById('tabs');

tabs.addEventListener('click', (event) => {
  const tab = event.target.closest('.tab');

  if (!tab) return;

  const tabId = tab.dataset.tab;

  // 모든 탭 비활성화
  tabs.querySelectorAll('.tab').forEach(t => {
    t.setAttribute('aria-selected', 'false');
  });

  // 모든 패널 숨김
  tabs.querySelectorAll('.tab-panel').forEach(panel => {
    panel.classList.remove('active');
  });

  // 클릭된 탭 활성화
  tab.setAttribute('aria-selected', 'true');
  document.getElementById(tabId).classList.add('active');
});

React의 합성 이벤트(SyntheticEvent)

React는 브라우저의 네이티브 이벤트를 래핑한 합성 이벤트(SyntheticEvent) 시스템을 사용합니다.

React 17 이전 vs 이후

React 이벤트 위임 방식 변화

구분React 16 이하React 17 이상
이벤트 위임 위치document<div id="root"> (루트 컨테이너)
장점-여러 React 버전 공존 가능, 다른 라이브러리와 호환성 개선
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- React 16 이하: document에 이벤트 위임 -->
<html> <!-- ← 이벤트 핸들러가 여기에 등록 -->
  <body>
    <div id="root">
      <App />
    </div>
  </body>
</html>

<!-- React 17 이상: root 컨테이너에 이벤트 위임 -->
<html>
  <body>
    <div id="root"> <!-- ← 이벤트 핸들러가 여기에 등록 -->
      <App />
    </div>
  </body>
</html>

React 17부터 document 대신 React 앱의 루트 컨테이너에 이벤트를 위임합니다. 이로 인해 여러 React 버전의 공존과 다른 라이브러리와의 호환성이 개선되었습니다.

React에서의 이벤트 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '할 일 1', completed: false },
    { id: 2, text: '할 일 2', completed: false },
  ]);

  // React에서도 이벤트 위임 패턴 활용 가능
  const handleClick = (event: React.MouseEvent<HTMLUListElement>) => {
    const target = event.target as HTMLElement;
    const todoItem = target.closest('[data-todo-id]') as HTMLElement;

    if (!todoItem) return;

    const todoId = Number(todoItem.dataset.todoId);

    // 삭제 버튼
    if (target.closest('.delete-btn')) {
      setTodos(prev => prev.filter(todo => todo.id !== todoId));
      return;
    }

    // 완료 토글
    if (target.closest('.toggle-btn')) {
      setTodos(prev => prev.map(todo =>
        todo.id === todoId
          ? { ...todo, completed: !todo.completed }
          : todo
      ));
    }
  };

  return (
    <ul onClick={handleClick}>
      {todos.map(todo => (
        <li key={todo.id} data-todo-id={todo.id}>
          <span className={todo.completed ? 'completed' : ''}>
            {todo.text}
          </span>
          <button className="toggle-btn">완료</button>
          <button className="delete-btn">삭제</button>
        </li>
      ))}
    </ul>
  );
}

React에서 네이티브 이벤트 접근

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Component() {
  const handleClick = (event: React.MouseEvent) => {
    // React 합성 이벤트
    console.log('합성 이벤트:', event);

    // 네이티브 DOM 이벤트에 접근
    console.log('네이티브 이벤트:', event.nativeEvent);

    // event.target과 event.currentTarget은 동일하게 동작
    console.log('target:', event.target);
    console.log('currentTarget:', event.currentTarget);
  };

  return <button onClick={handleClick}>클릭</button>;
}

React에서 stopPropagation 주의사항

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Parent() {
  // document에 네이티브 이벤트 등록
  useEffect(() => {
    const handleDocumentClick = () => {
      console.log('document 클릭');
    };
    document.addEventListener('click', handleDocumentClick);
    return () => document.removeEventListener('click', handleDocumentClick);
  }, []);

  return (
    <div onClick={() => console.log('Parent 클릭')}>
      <Child />
    </div>
  );
}

function Child() {
  const handleClick = (event: React.MouseEvent) => {
    event.stopPropagation();
    // React 17+에서는 document까지 전파되지 않음
    // React 16에서는 document까지 이미 전파된 후였음
    console.log('Child 클릭');
  };

  return <button onClick={handleClick}>클릭</button>;
}

React 권장 패턴

React에서는 일반적으로 개별 핸들러를 사용하는 것이 더 선언적이고 명확합니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function TodoList() {
  const [todos, setTodos] = useState([...]);

  const handleToggle = (id: number) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const handleDelete = (id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={() => handleToggle(todo.id)}
          onDelete={() => handleDelete(todo.id)}
        />
      ))}
    </ul>
  );
}

function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li>
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={onToggle}>완료</button>
      <button onClick={onDelete}>삭제</button>
    </li>
  );
}

React에서는 Virtual DOM과 효율적인 렌더링 시스템 덕분에, 개별 핸들러 방식도 성능에 큰 영향을 주지 않습니다. 수백 개 이상의 요소가 있는 경우에만 이벤트 위임을 고려하세요. 더 자세한 React 성능 최적화 기법은 React Custom Hooks 패턴React Hooks 성능 최적화에서 확인할 수 있습니다.


이벤트 위임 주의사항과 한계

1. 버블링되지 않는 이벤트

focus, blur, mouseenter, mouseleave 등은 버블링되지 않아 이벤트 위임이 직접적으로 작동하지 않습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 작동하지 않음
container.addEventListener('focus', (event) => {
  console.log('포커스됨:', event.target);
});

// 해결 1: 캡처링 단계에서 처리
container.addEventListener('focus', (event) => {
  console.log('포커스됨:', event.target);
}, true); // 캡처링 사용

// 해결 2: 버블링되는 대체 이벤트 사용
container.addEventListener('focusin', (event) => {
  console.log('포커스됨:', event.target);
});

2. stopPropagation으로 인한 문제

하위 요소에서 stopPropagation()을 호출하면 이벤트 위임이 작동하지 않습니다:

1
2
3
4
5
6
7
8
9
10
// 상위 컴포넌트의 이벤트 위임
container.addEventListener('click', (event) => {
  console.log('컨테이너에서 처리'); // 실행되지 않음
});

// 하위 컴포넌트에서 전파 중단
button.addEventListener('click', (event) => {
  event.stopPropagation();
  console.log('버튼에서 처리');
});

3. 복잡한 타겟 판별

중첩된 요소가 많을 경우 올바른 타겟을 찾기 어려울 수 있습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 복잡한 구조
// <div class="card">
//   <div class="card-header">
//     <img class="avatar" />
//     <span class="name">...</span>
//   </div>
//   <div class="card-body">...</div>
// </div>

container.addEventListener('click', (event) => {
  const target = event.target;

  // 어떤 요소가 클릭되었는지에 따라 다른 처리 필요
  // img, span, div 등 여러 요소가 클릭될 수 있음

  // closest()를 사용하여 해결
  const card = target.closest('.card');
  if (!card) return;

  const header = target.closest('.card-header');
  if (header) {
    // 헤더 클릭 처리
    return;
  }

  // 카드 본문 클릭 처리
});

4. 이벤트 객체의 속성 변화

이벤트 위임 시 event.currentTarget이 예상과 다를 수 있습니다:

1
2
3
4
5
6
7
8
container.addEventListener('click', function(event) {
  // this와 event.currentTarget은 container
  // event.target은 실제 클릭된 요소

  console.log(this);              // container
  console.log(event.currentTarget); // container
  console.log(event.target);        // 실제 클릭된 하위 요소
});

실무 적용 Best Practices

1. 데이터 속성(data-*) 활용

1
2
3
4
<ul id="product-list">
  <li data-product-id="123" data-action="view">상품 보기</li>
  <li data-product-id="456" data-action="edit">상품 편집</li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
productList.addEventListener('click', (event) => {
  const item = event.target.closest('[data-product-id]');
  if (!item) return;

  const productId = item.dataset.productId;
  const action = item.dataset.action;

  switch (action) {
    case 'view':
      viewProduct(productId);
      break;
    case 'edit':
      editProduct(productId);
      break;
  }
});

2. 이벤트 위임 헬퍼 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function delegate(parent, selector, eventType, handler) {
  parent.addEventListener(eventType, (event) => {
    const targetElement = event.target.closest(selector);

    if (targetElement && parent.contains(targetElement)) {
      handler.call(targetElement, event);
    }
  });
}

// 사용 예시
delegate(
  document.getElementById('list'),
  '.item',
  'click',
  function(event) {
    // this는 .item 요소
    console.log('클릭된 항목:', this);
  }
);

3. 조건부 이벤트 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const handlers = {
  'delete-btn': (event, element) => {
    const item = element.closest('.item');
    if (confirm('삭제하시겠습니까?')) {
      item.remove();
    }
  },
  'edit-btn': (event, element) => {
    const item = element.closest('.item');
    openEditModal(item.dataset.id);
  },
  'toggle-btn': (event, element) => {
    const item = element.closest('.item');
    item.classList.toggle('completed');
  }
};

container.addEventListener('click', (event) => {
  const target = event.target;

  for (const [className, handler] of Object.entries(handlers)) {
    if (target.closest(`.${className}`)) {
      handler(event, target.closest(`.${className}`));
      return;
    }
  }
});

4. 성능을 위한 이벤트 위임 전략

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 나쁜 예: 너무 높은 레벨에 위임
document.body.addEventListener('click', (event) => {
  // 모든 클릭에 대해 처리해야 함
});

// 좋은 예: 적절한 레벨에 위임
const appRoot = document.getElementById('app');
appRoot.addEventListener('click', (event) => {
  // 앱 내부 클릭만 처리
});

// 더 좋은 예: 기능 단위로 분리
const sidebar = document.getElementById('sidebar');
const content = document.getElementById('content');

sidebar.addEventListener('click', handleSidebarClick);
content.addEventListener('click', handleContentClick);

5. TypeScript에서의 이벤트 위임

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
interface DelegateOptions<T extends Element = Element> {
  parent: Element;
  selector: string;
  eventType: keyof HTMLElementEventMap;
  handler: (event: Event, element: T) => void;
}

function createDelegate<T extends Element = Element>(
  options: DelegateOptions<T>
): () => void {
  const { parent, selector, eventType, handler } = options;

  const listener = (event: Event) => {
    const target = event.target as Element;
    const delegateTarget = target.closest(selector) as T | null;

    if (delegateTarget && parent.contains(delegateTarget)) {
      handler(event, delegateTarget);
    }
  };

  parent.addEventListener(eventType, listener);

  // cleanup 함수 반환
  return () => parent.removeEventListener(eventType, listener);
}

// 사용 예시
const cleanup = createDelegate<HTMLButtonElement>({
  parent: document.getElementById('toolbar')!,
  selector: 'button',
  eventType: 'click',
  handler: (event, button) => {
    console.log('클릭된 버튼:', button.textContent);
  }
});

// 컴포넌트 언마운트 시
cleanup();

면접 대비 핵심 포인트

자주 나오는 질문

Q1. 이벤트 버블링과 캡처링의 차이점은?

버블링은 이벤트가 타겟 요소에서 시작하여 상위 요소로 전파되는 것이고, 캡처링은 최상위 요소에서 시작하여 타겟 요소로 내려가는 것입니다. 이벤트는 캡처링 -> 타겟 -> 버블링 순서로 전파되며, 기본적으로 이벤트 핸들러는 버블링 단계에서 실행됩니다.

Q2. event.target과 event.currentTarget의 차이점은?

event.target은 이벤트가 실제로 발생한 요소이고, event.currentTarget은 이벤트 핸들러가 등록된 요소입니다. 이벤트 위임에서 이 차이가 중요합니다.

Q3. 이벤트 위임이란 무엇이고, 왜 사용하나요?

이벤트 위임은 여러 자식 요소에 개별 핸들러를 등록하는 대신 공통 부모 요소에 하나의 핸들러를 등록하는 패턴입니다. 메모리 효율성, 동적 요소 처리, 코드 간결성 등의 장점이 있습니다.

Q4. stopPropagation()과 stopImmediatePropagation()의 차이는?

stopPropagation()은 이벤트의 상위 요소로의 전파를 막고, stopImmediatePropagation()은 같은 요소에 등록된 다른 핸들러의 실행까지 막습니다.

Q5. React에서 이벤트 처리 방식은 Vanilla JS와 어떻게 다른가요?

React는 합성 이벤트(SyntheticEvent)를 사용하며, 내부적으로 이벤트 위임을 사용합니다. React 17부터는 document 대신 root 컨테이너에 이벤트를 위임합니다.


마무리

핵심 정리

개념설명
이벤트 흐름캡처링 -> 타겟 -> 버블링 순서로 전파
버블링타겟에서 상위 요소로 이벤트 전파
캡처링상위 요소에서 타겟으로 이벤트 전파
event.target이벤트가 발생한 실제 요소
event.currentTarget핸들러가 등록된 요소
이벤트 위임부모 요소에 핸들러를 등록하여 자식 이벤트 처리

언제 이벤트 위임을 사용해야 하나?

  • 동적으로 추가되는 요소가 많을 때
  • 비슷한 동작을 하는 여러 요소가 있을 때
  • 메모리 사용을 최적화하고 싶을 때
  • 이벤트 핸들러 관리를 단순화하고 싶을 때

주의사항 요약

  • stopPropagation() 남용 주의
  • 버블링되지 않는 이벤트(focus, blur 등) 확인
  • 적절한 레벨의 부모 요소 선택
  • closest() 메서드를 활용한 정확한 타겟 판별

이벤트 버블링, 캡처링, 그리고 이벤트 위임은 DOM 이벤트 처리의 핵심 개념입니다. 이를 올바르게 이해하고 활용하면 더 효율적이고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

함께 읽으면 좋은 글

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.