모달을 닫을 수가 없었던 그날
모달 구현을 맡았을 때였다. "모달 외부 클릭하면 닫히게 해주세요"라는 요구사항이었다. 간단해 보였다. 모달 배경에 클릭 이벤트 달고, 그 안에서 setIsOpen(false) 호출하면 끝 아닌가? 그렇게 생각했다.
function Modal({ children }) {
return (
<div className="overlay" onClick={() => setIsOpen(false)}>
<div className="modal-content">
{children}
</div>
</div>
);
}
문제는 모달 내부의 버튼을 클릭해도 모달이 닫혔다는 점이다. 내가 원한 건 "모달 외부" 클릭인데, 버튼을 눌러도 모달이 사라졌다. 내부를 눌렀는데 외부로 인식되는 이 황당한 상황. 나는 이벤트가 그냥 클릭된 곳에서만 발생하는 줄 알았다. 완전히 틀렸다.
이벤트는 혼자 있지 않더라
브라우저에서 이벤트가 발생하면, 그 이벤트는 세 단계를 거친다는 걸 알게 됐다. 마치 엘리베이터가 최상층에서 출발해서 목적층을 거쳐 다시 위로 올라가는 것처럼.
- 캡처링 단계(Capture Phase):
window부터 시작해서 클릭된 요소까지 내려간다 - 타겟 단계(Target Phase): 실제로 클릭된 요소에 도달한다
- 버블링 단계(Bubble Phase): 다시 부모들을 거슬러 올라간다
나는 이걸 "물방울이 떨어지는 과정"으로 이해했다. 물방울이 나뭇가지(부모)를 타고 내려와서(캡처링) 잎(타겟)에 닿았다가, 잎에서 다시 나뭇가지로 튀어 올라가는(버블링) 모습.
기본적으로 우리가 addEventListener로 이벤트를 등록하면, 버블링 단계에서 실행된다. 그래서 내 모달에서는 이런 일이 벌어졌던 것이다:
- 버튼을 클릭한다
- 버튼의 클릭 이벤트가 실행된다
- 이벤트가 부모인
modal-content로 올라간다 - 다시 부모인
overlay로 올라간다 overlay의onClick이 발동한다- 모달이 닫힌다
결국 내가 버튼을 클릭한 건데, 그 이벤트가 "버블처럼 위로 올라가서" 부모인 overlay까지 도달했고, 그래서 모달이 닫혔던 것이다. 나는 이벤트가 클릭한 곳에서 끝나는 줄 알았는데, 실제로는 부모를 계속 타고 올라가는 구조였다.
event.target vs event.currentTarget의 차이가 전부였다
이 문제를 해결하려고 콘솔에 이것저것 찍어보다가, event.target과 event.currentTarget이 다르다는 걸 발견했다.
- event.target: 실제로 클릭된 요소 (버튼)
- event.currentTarget: 이벤트 리스너가 달린 요소 (overlay)
<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니까, 조건이 거짓이 되어 모달이 안 닫힌다. 완벽했다.
stopPropagation으로 버블링을 끊는 방법
또 다른 해결책도 있었다. 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 체크 방식을 더 선호하게 됐다. 이벤트 흐름 자체는 막지 않으면서도 원하는 동작만 제어할 수 있으니까.
stopImmediatePropagation은 더 강력하다
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는 또 다른 이야기
처음에 나는 preventDefault()와 stopPropagation()을 헷갈렸다. 둘 다 "뭔가를 막는다"는 느낌이 있어서.
근데 이 둘은 완전히 다른 역할을 한다.
- stopPropagation(): 이벤트가 부모로 전파되는 걸 막는다
- preventDefault(): 브라우저의 기본 동작을 막는다
// 폼 제출 막기
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();
});
});
문제점:
- 이벤트 리스너가 100개 생긴다 (메모리 낭비)
- 나중에 새 아이템을 추가하면? 다시 리스너를 달아줘야 한다
- 아이템이 1000개면? 1000개의 리스너
이벤트 위임 방식:
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을 확인해서 "아, 삭제 버튼이 눌렸구나" 판단하고 처리한다.
장점:
- 리스너가 1개다 (메모리 효율)
- 나중에 추가된 아이템도 자동으로 동작한다
- 아이템이 10000개여도 리스너는 1개
실제 프로젝트에서 동적으로 아이템을 추가하는 리스트를 만들 때, 이 패턴 덕분에 코드가 엄청 간결해졌다.
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의 Synthetic Event는 다르게 동작한다
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()은 신중하게 써야 한다- 이벤트 위임 패턴으로 메모리와 코드를 절약할 수 있다
- React의 Synthetic Event는 조금 다르게 동작한다
이 개념들을 이해하고 나니, 프론트엔드 코드가 훨씬 깔끔해졌고, 예상치 못한 버그도 많이 줄었다. 특히 모달이나 드롭다운 같은 UI를 만들 때, 이벤트 흐름을 정확히 이해하는 게 얼마나 중요한지 체감했다.