| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 리눅스
- 자바의 정석
- 코딩테스트
- 둘만의 암호 자바
- DP
- 분할정복
- Comparator
- Ubuntu서버
- 프로그래머스Lv2
- 백트래킹
- 티스토리챌린지
- java
- DFS
- spring security 설정
- hackerrank
- 스프링부트 배포
- spring security
- 혼공얄코
- 동적계획법
- BFS
- 이분탐색
- 자바의정석
- 완전탐색
- 서버초기설정
- 프로그래머스
- 오블완
- 알고리즘
- greedy
- 자바
- 그리디
- Today
- Total
쉽게 쉽게
[JavaScript] 이벤트 버블링과 캡처링, 그리고 이벤트 위임 본문
- 클릭은 한 곳에서 끝나지 않는다 — 이벤트는 캡처링 → 타깃 → 버블링 3단계로 흐르며, 자식에서 발생한 이벤트가 부모까지 전달된다
- addEventListener의 세 번째 인자 — 생략하면 버블링 단계에서 실행되고,
true를 주면 캡처링 단계에서 먼저 실행된다 - stopPropagation과 preventDefault는 다른 일을 한다 — 전자는 전파를 막고, 후자는 브라우저 기본 동작을 막는다
- 이벤트 위임 — 자식마다 리스너를 다는 대신 부모에 하나만 달면, 나중에 추가된 요소까지 자동으로 처리된다
1 왜 이벤트 흐름을 알아야 할까
게시판 목록을 만들다 보면 이런 화면을 자주 만듭니다.
행 전체를 클릭하면 상세 페이지로 이동하고, 행 안에 있는 삭제 버튼을 누르면 삭제되는 구조입니다.
<tr class="row">
<td>홍길동</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
그런데 막상 삭제 버튼을 누르면 삭제가 되면서 상세 페이지까지 같이 열립니다.
버튼에만 클릭 이벤트를 걸었는데도 행(tr)에 걸어둔 이벤트가 함께 실행되는 것입니다.
이유는 간단합니다. 클릭이 발생한 요소 하나에서만 이벤트가 처리되는 것이 아니기 때문입니다.
브라우저는 클릭이 일어나면 그 사실을 조상 요소들에게도 순서대로 알려주는데, 이 과정을 이벤트 전파(Event Propagation)라고 합니다.
2 이벤트 전파의 3단계
이벤트는 발생 즉시 해당 요소에서 처리되는 것이 아니라, 최상위(window)에서 출발해 내려갔다가 다시 올라오는 왕복 경로를 거칩니다. 이 경로는 3단계로 나뉩니다.

| 단계 | 영문 | 방향 | 설명 |
|---|---|---|---|
| 1. 캡처링 | Capturing | window → 대상 | 최상위에서 클릭된 요소까지 내려가는 단계 |
| 2. 타깃 | Target | 대상 | 실제 클릭된 요소에 도달한 순간 |
| 3. 버블링 | Bubbling | 대상 → window | 클릭된 요소에서 최상위까지 올라가는 단계 |
버블링(bubbling)이라는 이름은 물속에서 기포가 위로 떠오르는 모습에서 왔습니다.
자식 → 부모 → 조상 순으로 이벤트가 떠오른다고 이해하면 됩니다.
직접 확인해보기
3중으로 감싼 구조를 만들고 각 요소에 클릭 이벤트를 걸어봅니다. 실행 순서를 눈으로 확인하는 것이 가장 빠른 이해 방법입니다.
<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', function(){
console.log('3. 할아버지 div');
});
parent.addEventListener('click', function(){
console.log('2. 부모 div');
});
child.addEventListener('click', function(){
console.log('1. 버튼');
});
/* 버튼을 클릭했을 때 콘솔 출력
1. 버튼
2. 부모 div
3. 할아버지 div ← 안쪽에서 바깥쪽으로 올라간다 */
버튼 하나만 눌렀는데 세 개의 함수가 모두 실행되었습니다. 1번 섹션에서 겪은 문제의 원인이 바로 이것입니다. addEventListener는 기본적으로 버블링 단계에서 동작하기 때문에, 안쪽 요소부터 바깥쪽 순서로 실행됩니다.
3 addEventListener의 세 번째 인자
그렇다면 캡처링 단계는 언제 쓸까요? addEventListener의 세 번째 인자로 어느 단계에서 실행할지 선택할 수 있습니다.
요소.addEventListener(이벤트명, 실행할함수, useCapture);
// useCapture 생략 또는 false → 버블링 단계에서 실행 (기본값)
parent.addEventListener('click', fn);
parent.addEventListener('click', fn, false);
// true → 캡처링 단계에서 실행
parent.addEventListener('click', fn, true);
앞의 예제에서 할아버지 div만 true로 바꾸면 출력 순서가 달라집니다.
grandparent.addEventListener('click', function(){
console.log('할아버지 div');
}, true); // ← 캡처링
/* 출력 결과
할아버지 div ← 내려오는 길에 먼저 실행
버튼
부모 div ← 올라가는 길에 실행 */
할아버지 div가 가장 먼저 실행되었습니다. 캡처링은 위에서 아래로 내려오는 단계이므로, 클릭된 버튼보다 먼저 신호를 받는 것입니다.
options 객체로 쓰는 방법
최신 문법에서는 true/false 대신 객체를 넘길 수 있습니다. 옵션을 여러 개 조합할 수 있어 실무에서는 이쪽을 더 많이 씁니다.
el.addEventListener('click', fn, {
capture: true, // 캡처링 단계에서 실행
once: true // 딱 한 번만 실행되고 자동 해제
});
/* passive 옵션은 스크롤을 유발하는 이벤트에서만 의미가 있다
(touchstart, touchmove, wheel 등 — click에 붙이면 효과 없음) */
el.addEventListener('touchmove', fn, {
passive: true // preventDefault를 쓰지 않겠다는 약속 → 스크롤 성능 향상
});
또 하나, 버블링되지 않는 이벤트를 상위에서 감지할 때도 캡처링이 답이 됩니다. 이 경우는 6번 섹션에서 다룹니다.
4 stopPropagation과 preventDefault
이제 1번에서 만난 문제를 해결해봅니다.
삭제 버튼을 눌렀을 때 행 클릭 이벤트가 실행되지 않게 하려면 이벤트가 위로 올라가는 것을 막아야 합니다.
이때 쓰는 것이 stopPropagation()입니다.
deleteBtn.addEventListener('click', function(e){
e.stopPropagation(); // 여기서 전파를 끊는다
deleteUser();
});
// 이제 삭제 버튼을 눌러도 tr의 클릭 이벤트는 실행되지 않는다
tr)가 { capture: true }로 리스너를 걸어뒀다면, 그 리스너는 버튼보다 이미 먼저 실행된 뒤입니다. 내려가는 길에 끝난 일이라 버튼에서 전파를 끊어도 되돌릴 수 없습니다.이름이 비슷해서 자주 혼동되는 preventDefault()는 완전히 다른 일을 합니다.
이쪽은 전파와 무관하게 브라우저가 원래 하려던 동작을 취소합니다.
| 메서드 | 막는 것 | 대표 사용 예 |
|---|---|---|
| stopPropagation() | 부모로의 이벤트 전파 | 버튼 클릭 시 부모 행 이벤트 차단 |
| preventDefault() | 브라우저 기본 동작 | a 태그 이동 취소, form 새로고침 방지 |
| stopImmediatePropagation() | 전파 + 같은 요소의 다른 리스너 | 같은 요소에 리스너가 여러 개일 때 |
// a 태그를 눌러도 페이지가 이동하지 않는다
link.addEventListener('click', function(e){
e.preventDefault();
openModal();
});
// 폼 제출 시 페이지 새로고침을 막고 ajax로 처리
form.addEventListener('submit', function(e){
e.preventDefault();
sendAjax();
});
document에 걸어뒀다면, 중간에서 전파를 끊은 순간 그 팝업은 영영 닫히지 않습니다. 원인을 찾기도 어려운 종류의 버그라서, 꼭 필요한 자리에서만 쓰는 것이 좋습니다. 대안으로는 전파를 막는 대신 부모 쪽에서 걸러내는 방법이 있습니다. 이것이 다음에 볼 이벤트 위임과 자연스럽게 이어집니다.
5 이벤트 위임(Event Delegation)
버블링은 골칫거리로만 보이지만, 오히려 적극적으로 활용하는 기법이 있습니다. 바로 이벤트 위임입니다.
먼저, 무엇이 문제인가
목록 100건에 삭제 버튼이 하나씩 있다고 해봅시다. 보통은 이렇게 작성합니다.
document.querySelectorAll('.btn-delete').forEach(function(btn){
btn.addEventListener('click', onDelete);
});
// 리스너가 100개 등록된다
이 방식에는 두 가지 문제가 있습니다.
- 등록 비용 — 리스너 등록을 요소 수만큼 반복해야 하고, 콜백을 인라인 함수로 작성하면 함수 객체도 그만큼 만들어집니다. (위 예제처럼
onDelete하나를 재사용하면 함수 자체는 1개입니다.) - 동적 요소 누락 — 훨씬 결정적인 문제입니다. ajax로 목록을 다시 그리거나 행을 추가하면, 새로 생긴 버튼에는 리스너가 없습니다. 코드는 그대로인데 갑자기 클릭이 안 되는 현상이 여기서 나옵니다.
부모 하나에만 걸기
이벤트 위임은 자식들에게 일일이 리스너를 다는 대신, 부모 하나에만 걸고 버블링으로 올라온 이벤트를 부모가 대신 처리하는 방식입니다. "누가 클릭됐는지"는 이벤트 객체가 알려주므로 구분이 가능합니다.
const list = document.querySelector('#userList');
list.addEventListener('click', function(e){
// 클릭 지점에서 가장 가까운 .btn-delete를 찾는다
const btn = e.target.closest('.btn-delete');
// 삭제 버튼이 아니거나, 컨테이너 밖의 요소라면 종료
if(!btn || !list.contains(btn)) return;
const row = btn.closest('tr');
deleteUser(row.dataset.id);
});
/* 리스너는 단 1개.
나중에 ajax로 행을 100개 더 추가해도 코드 수정이 필요 없다. */
closest()는 조건에 맞는 요소를 찾을 때까지 컨테이너를 넘어서도 계속 위로 올라갑니다.
그래서 list.contains(btn)으로 "정말 이 목록 안의 버튼인지" 한 번 더 확인해두면 안전합니다.
target과 currentTarget의 차이
위임을 쓸 때 반드시 구분해야 하는 두 값입니다. 이걸 헷갈리면 위임 코드가 엉뚱하게 동작합니다.
| 속성 | 의미 | 위 예제에서의 값 |
|---|---|---|
| e.target | 실제로 클릭된 요소 | 삭제 버튼 (또는 그 안의 아이콘) |
| e.currentTarget | 리스너를 등록한 요소 | #userList |
<button class="btn-delete"><i class="icon"></i>삭제</button>—아이콘을 클릭하면 e.target은 버튼이 아니라 i 태그가 됩니다. 그래서
e.target.classList.contains('btn-delete')처럼 검사하면 어떤 날은 되고 어떤 날은 안 되는 버그가 생깁니다. closest()는 자기 자신부터 부모 방향으로 올라가며 조건에 맞는 요소를 찾아주므로 이 문제를 깔끔하게 해결합니다.jQuery에서의 이벤트 위임
jQuery를 쓰고 있다면 이미 위임 문법이 준비되어 있습니다.
on()의 두 번째 인자에 선택자를 넣으면 위임 방식으로 동작합니다.
// ❌ 직접 바인딩 — 지금 존재하는 요소에만 적용된다
$('.btn-delete').on('click', function(){
deleteUser();
});
// ✅ 위임 — 나중에 추가된 요소도 자동으로 동작한다
$('#userList').on('click', '.btn-delete', function(){
var id = $(this).closest('tr').data('id');
deleteUser(id);
});
ajax로 목록을 다시 그린 뒤 클릭이 안 먹는 문제를 겪어본 적이 있다면, 원인이 대부분 첫 번째 방식입니다.
예전에 쓰던 live()가 사라지고 on()의 위임 문법으로 통합되었습니다.
closest()와 NodeList.forEach()는 IE11에서 동작하지 않습니다. polyfill을 추가하거나, 위와 같이 jQuery의 on() 위임 문법을 사용하세요.6 실전에서 주의할 점
버블링되지 않는 이벤트가 있다
모든 이벤트가 위로 올라가는 것은 아닙니다. 위임이 안 먹는다면 이 목록을 먼저 확인해보세요.
| 버블링 안 됨 | 대응 방법 | 비고 |
|---|---|---|
focus / blur |
focusin / focusout |
발생 시점 다름 |
mouseenter / mouseleave |
mouseover / mouseout |
자식 진입 시에도 발생 |
scroll (요소) |
캡처 단계로 감지 | 버블링 위임만 불가 |
focusin은 버블링이 된다는 점 외에 발생 시점도 다릅니다.
focusin은 포커스가 들어가기 직전에, focus는 들어간 직후에 발생합니다.
평소엔 티가 나지 않지만 포커스 이동 순서를 직접 제어할 때 차이가 드러납니다.
요소의 scroll은 버블링되지 않지만, 캡처 단계는 여전히 그 요소를 지나갑니다.
3번에서 배운 세 번째 인자를 true로 주면 상위에서도 잡을 수 있습니다.
// 요소의 scroll은 버블링되지 않아 일반 위임으로는 잡히지 않는다
// 캡처 단계는 내려가는 길이므로 상위에서도 감지할 수 있다
document.addEventListener('scroll', function(e){
console.log(e.target); // 실제로 스크롤된 요소
}, true);
위임은 가까운 부모에 걸자
document에 위임을 걸면 페이지의 모든 클릭이 그 함수를 거쳐갑니다.
동작은 하지만 불필요한 검사가 늘어나고, 다른 위임 코드와 순서가 얽히기 쉽습니다.
해당 요소들을 감싸는 가장 가까운 컨테이너에 거는 것이 좋습니다.
// △ 동작은 하지만 범위가 너무 넓다
document.addEventListener('click', handler);
// ○ 목록을 감싸는 컨테이너에 건다
document.querySelector('#userList').addEventListener('click', handler);
innerHTML로 통째로 갈아끼우는 구조라면, 컨테이너까지 새로 만들어지면서 리스너도 함께 사라집니다. 이럴 때는 교체되지 않는 바깥쪽 요소에 위임을 걸어야 합니다.정리 — 언제 무엇을 쓸까
| 상황 | 해결 방법 |
|---|---|
| 자식 클릭 시 부모 이벤트가 같이 실행됨 | e.stopPropagation() |
| a 태그 이동 / form 새로고침을 막고 싶음 | e.preventDefault() |
| ajax로 추가한 요소에 클릭이 안 먹음 | 이벤트 위임 |
| 목록 항목마다 같은 리스너를 반복 등록 중 | 이벤트 위임 |
| 자식보다 무조건 먼저 이벤트를 가로채야 함 | { capture: true } |
| 버블링되지 않는 이벤트를 상위에서 감지 | { capture: true } |
정리하면, 이벤트가 왜 부모까지 올라가는지를 이해하는 순간 두 가지가 함께 풀립니다.
하나는 "누르지도 않은 이벤트가 실행되는" 버그의 원인이고, 다른 하나는 그 성질을 역이용하는 이벤트 위임입니다.
둘 다 같은 원리를 앞뒤로 뒤집어 본 것뿐입니다.
| 잘못된 내용이 있다면 지적부탁드립니다. 방문해주셔서 감사합니다. |

'Javascript' 카테고리의 다른 글
| [JavaScript] 우편번호 구현 (5) | 2024.10.21 |
|---|---|
| [JavaScrpit] 특수문자 치환 (0) | 2024.05.31 |
| [Javascript] 이미지 업로드 (1) | 2024.05.21 |
| [JavaScript] 쿠키(Cookie) 사용법 (0) | 2024.05.13 |
| [Javascript] 공지 팝업창 쿠키 설정 (3) | 2024.05.05 |
