
이벤트 버블링과 캡처링
버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

모달 구현을 맡았을 때였다. "모달 외부 클릭하면 닫히게 해주세요"라는 요구사항이었다. 간단해 보였다. 모달 배경에 클릭 이벤트 달고, 그 안에서 setIsOpen(false) 호출하면 끝 아닌가? 그렇게 생각했다.
function Modal({ children }) {
return (
<div className="overlay" onClick={() => setIsOpen(false)}>
<div className="modal-content">
{children}
</div>
</div>
);
}
문제는 모달 내부의 버튼을 클릭해도 모달이 닫혔다는 점이다. 내가 원한 건 "모달 외부" 클릭인데, 버튼을 눌러도 모달이 사라졌다. 내부를 눌렀는데 외부로 인식되는 이 황당한 상황. 나는 이벤트가 그냥 클릭된 곳에서만 발생하는 줄 알았다. 완전히 틀렸다.
브라우저에서 이벤트가 발생하면, 그 이벤트는 세 단계를 거친다는 걸 알게 됐다. 마치 엘리베이터가 최상층에서 출발해서 목적층을 거쳐 다시 위로 올라가는 것처럼.
window부터 시작해서 클릭된 요소까지 내려간다나는 이걸 "물방울이 떨어지는 과정"으로 이해했다. 물방울이 나뭇가지(부모)를 타고 내려와서(캡처링) 잎(타겟)에 닿았다가, 잎에서 다시 나뭇가지로 튀어 올라가는(버블링) 모습.
기본적으로 우리가 addEventListener로 이벤트를 등록하면, 버블링 단계에서 실행된다. 그래서 내 모달에서는 이런 일이 벌어졌던 것이다:
modal-content로 올라간다overlay로 올라간다overlay의 onClick이 발동한다결국 내가 버튼을 클릭한 건데, 그 이벤트가 "버블처럼 위로 올라가서" 부모인 overlay까지 도달했고, 그래서 모달이 닫혔던 것이다. 나는 이벤트가 클릭한 곳에서 끝나는 줄 알았는데, 실제로는 부모를 계속 타고 올라가는 구조였다.
이 문제를 해결하려고 콘솔에 이것저것 찍어보다가, event.target과 event.currentTarget이 다르다는 걸 발견했다.
<div className="overlay" onClick={(e) => {
console.log('target:', e.target); // <button>
console.log('currentTarget:', e.currentTarget); // <div class="overlay">
}}>
<div className="modal-content">
<button>내부 버튼</button>
</div>
</div>
내가 버튼을 클릭하면:
e.target은 <button>이다e.currentTarget은 <div className="overlay">다이 차이를 이해하고 나서, 모달 문제를 해결할 수 있었다.
function Modal({ children, onClose }) {
return (
<div
className="overlay"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="modal-content">
{children}
</div>
</div>
);
}
"클릭된 요소가 overlay 자기 자신일 때만" 모달을 닫는다. 버튼을 클릭하면 e.target은 버튼이고 e.currentTarget은 overlay니까, 조건이 거짓이 되어 모달이 안 닫힌다. 완벽했다.
또 다른 해결책도 있었다. e.stopPropagation()을 사용하는 것. 이건 "여기서 이벤트 전파를 멈춰라"는 명령이다.
function Modal({ children, onClose }) {
return (
<div className="overlay" onClick={onClose}>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
modal-content를 클릭하면, 그 즉시 stopPropagation()이 발동해서 이벤트가 부모인 overlay로 올라가지 않는다. 버블링을 중간에 끊는 거다.
근데 나는 이 방법을 쓰지 않았다. 나중에 알게 된 건데, stopPropagation()을 남발하면 예상치 못한 부작용이 생길 수 있다. 예를 들어 Google Tag Manager 같은 분석 도구는 보통 document 레벨에 이벤트를 달아놓고 모든 클릭을 추적하는데, 중간에 stopPropagation()을 해버리면 이벤트가 document까지 올라가지 못해서 분석 데이터가 안 잡힌다.
그래서 나는 e.target === e.currentTarget 체크 방식을 더 선호하게 됐다. 이벤트 흐름 자체는 막지 않으면서도 원하는 동작만 제어할 수 있으니까.
stopPropagation()의 더 강한 형제가 있다. stopImmediatePropagation().
stopPropagation()은 부모로 가는 버블링만 막는다. 같은 요소에 달린 다른 리스너들은 여전히 실행된다.
button.addEventListener('click', (e) => {
console.log('리스너 1');
e.stopPropagation();
});
button.addEventListener('click', (e) => {
console.log('리스너 2'); // 이건 실행됨
});
출력:
리스너 1
리스너 2
하지만 stopImmediatePropagation()을 쓰면:
button.addEventListener('click', (e) => {
console.log('리스너 1');
e.stopImmediatePropagation();
});
button.addEventListener('click', (e) => {
console.log('리스너 2'); // 실행 안 됨
});
출력:
리스너 1
같은 요소에 달린 다른 리스너들도 싹 다 막아버린다. 더 강력한 만큼, 더 조심해서 써야 한다.
처음에 나는 preventDefault()와 stopPropagation()을 헷갈렸다. 둘 다 "뭔가를 막는다"는 느낌이 있어서.
근데 이 둘은 완전히 다른 역할을 한다.
// 폼 제출 막기
form.addEventListener('submit', (e) => {
e.preventDefault(); // 페이지 새로고침 안 됨
// AJAX로 제출 처리
});
// 링크 이동 막기
link.addEventListener('click', (e) => {
e.preventDefault(); // 페이지 이동 안 됨
// 커스텀 동작
});
// 우클릭 메뉴 막기
document.addEventListener('contextmenu', (e) => {
e.preventDefault(); // 기본 컨텍스트 메뉴 안 뜸
});
나는 이걸 "이벤트 자체를 막는 게 아니라, 브라우저가 하려던 일을 막는 것"으로 받아들였다. 이벤트는 여전히 발생하고, 버블링도 일어나는데, 다만 브라우저가 "아 링크니까 페이지 이동해야지" 하는 자동 동작만 안 하게 만드는 거.
대부분의 경우 버블링 단계만 쓰면 된다. 하지만 가끔 캡처링이 필요할 때가 있다.
addEventListener의 세 번째 인자에 true나 { capture: true }를 넣으면 캡처링 단계에서 실행된다.
element.addEventListener('click', handler, true);
// 또는
element.addEventListener('click', handler, { capture: true });
내가 캡처링을 써본 경우는 "모든 클릭을 가로채서 로깅"하는 기능을 만들 때였다. 버블링 단계에서는 이미 자식 요소들이 stopPropagation()을 해버렸을 수도 있는데, 캡처링 단계에서 잡으면 그런 방해를 받지 않고 모든 이벤트를 기록할 수 있었다.
document.addEventListener('click', (e) => {
console.log('클릭 발생:', e.target);
// 로깅 서버로 전송
}, true); // 캡처링 단계에서 실행
버블링을 이해하고 나서, "이벤트 위임(Event Delegation)"이라는 패턴이 눈에 들어왔다. 이건 내가 만나본 프론트엔드 패턴 중에 가장 강력한 것 중 하나였다.
할 일 목록을 만든다고 생각해보자. 아이템이 100개 있고, 각각 삭제 버튼이 있다.
나쁜 방식:const items = document.querySelectorAll('.todo-item');
items.forEach(item => {
const deleteBtn = item.querySelector('.delete-btn');
deleteBtn.addEventListener('click', () => {
item.remove();
});
});
문제점:
const todoList = document.querySelector('.todo-list');
todoList.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
e.target.closest('.todo-item').remove();
}
});
부모인 todo-list에 리스너 하나만 단다. 버블링 덕분에 어떤 삭제 버튼을 눌러도 이벤트가 부모까지 올라온다. 거기서 e.target을 확인해서 "아, 삭제 버튼이 눌렸구나" 판단하고 처리한다.
장점:
실제 프로젝트에서 동적으로 아이템을 추가하는 리스트를 만들 때, 이 패턴 덕분에 코드가 엄청 간결해졌다.
function TodoApp() {
const [todos, setTodos] = useState([]);
const handleListClick = (e) => {
// 삭제 버튼 클릭
if (e.target.classList.contains('delete-btn')) {
const id = e.target.closest('.todo-item').dataset.id;
setTodos(todos.filter(todo => todo.id !== id));
}
// 완료 체크박스 클릭
if (e.target.type === 'checkbox') {
const id = e.target.closest('.todo-item').dataset.id;
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}
// 편집 버튼 클릭
if (e.target.classList.contains('edit-btn')) {
const id = e.target.closest('.todo-item').dataset.id;
// 편집 모드 진입
}
};
return (
<ul className="todo-list" onClick={handleListClick}>
{todos.map(todo => (
<li key={todo.id} className="todo-item" data-id={todo.id}>
<input type="checkbox" checked={todo.completed} />
<span>{todo.text}</span>
<button className="edit-btn">편집</button>
<button className="delete-btn">삭제</button>
</li>
))}
</ul>
);
}
하나의 리스너로 삭제, 완료, 편집을 전부 처리한다. 새 todo를 추가해도 자동으로 동작한다. 마법 같았다.
React를 쓰면서 혼란스러웠던 게, 가끔 네이티브 DOM과 동작이 다를 때가 있었다는 점이다. 알고 보니 React는 Synthetic Event라는 걸 사용한다.
React 17 이전에는:
document 레벨에서 하나로 모았다onClick을 요소에 달아도, 실제로는 document에 달린다e.stopPropagation()을 해도, 네이티브 DOM 이벤트는 이미 document까지 올라간 상태였다React 17 이후로는:
<div id="root">)그래서 React에서 모달을 만들 때, useEffect에서 네이티브 이벤트를 따로 다는 경우가 있다:
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
ESC 키로 모달을 닫는 기능은 React의 이벤트 시스템이 아니라 네이티브 document.addEventListener를 사용했다. 이렇게 하면 포커스가 어디 있든 상관없이 확실하게 동작한다.
이벤트 문제를 디버깅할 때 내가 자주 쓰는 방법:
element.addEventListener('click', (e) => {
console.log('=== 이벤트 정보 ===');
console.log('target:', e.target); // 실제 클릭된 요소
console.log('currentTarget:', e.currentTarget); // 리스너가 달린 요소
console.log('eventPhase:', e.eventPhase); // 1: 캡처, 2: 타겟, 3: 버블
console.log('bubbles:', e.bubbles); // 버블링 되는 이벤트인가?
console.log('defaultPrevented:', e.defaultPrevented); // preventDefault 됐나?
// 이벤트 경로 출력 (Chrome)
if (e.composedPath) {
console.log('경로:', e.composedPath());
}
});
e.composedPath()가 특히 유용했다. 이벤트가 어떤 경로로 전파되는지 배열로 보여준다.
// [button, div.modal-content, div.overlay, body, html, document, window]
이걸 보면 "아, 여기서 stopPropagation 하면 저기까지는 안 가겠구나" 하는 게 한눈에 들어온다.
회사에서 만든 대시보드에 큰 테이블이 있었다. 수백 개의 행이 있고, 각 행을 클릭하면 상세 페이지로 가야 했다. 근데 각 행에는 버튼들도 있었다 (편집, 삭제, 공유).
function DataTable({ rows }) {
const handleTableClick = (e) => {
// 버튼 클릭은 무시
if (e.target.tagName === 'BUTTON') {
return;
}
// 체크박스 클릭은 무시
if (e.target.type === 'checkbox') {
return;
}
// 행 클릭 처리
const row = e.target.closest('tr');
if (row && row.dataset.id) {
navigate(`/detail/${row.dataset.id}`);
}
};
return (
<table onClick={handleTableClick}>
<tbody>
{rows.map(row => (
<tr key={row.id} data-id={row.id}>
<td><input type="checkbox" /></td>
<td>{row.name}</td>
<td>{row.email}</td>
<td>
<button onClick={(e) => {
e.stopPropagation();
handleEdit(row.id);
}}>편집</button>
<button onClick={(e) => {
e.stopPropagation();
handleDelete(row.id);
}}>삭제</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
버튼에는 stopPropagation()을 써서 행 클릭으로 전파되지 않게 했다. 테이블 레벨에서는 "버튼이나 체크박스가 아닌" 클릭만 행 클릭으로 처리했다.
이벤트 버블링은 처음엔 "왜 이렇게 복잡하게 만들었지?"라는 생각이 들었다. 그냥 클릭된 곳에서만 이벤트가 발생하면 더 간단하지 않나?
근데 쓰다 보니, 이 메커니즘이 없었으면 이벤트 위임 같은 강력한 패턴을 쓸 수 없었을 거다. 동적으로 추가되는 요소들을 처리하려면 부모에서 이벤트를 받아야 하는데, 버블링이 없으면 불가능했을 것이다.
결국 이벤트 버블링은:
e.target과 e.currentTarget을 구분해야 한다stopPropagation()은 신중하게 써야 한다이 개념들을 이해하고 나니, 프론트엔드 코드가 훨씬 깔끔해졌고, 예상치 못한 버그도 많이 줄었다. 특히 모달이나 드롭다운 같은 UI를 만들 때, 이벤트 흐름을 정확히 이해하는 게 얼마나 중요한지 체감했다.