자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는.
DOM 이벤트의 capture/bubble, delegation, default action, React synthetic event 경계를 프론트엔드 코드 리뷰 관점에서 정리합니다.
오늘의 질문: “버튼에 클릭 핸들러를 달았으니 이 이벤트는 버튼에서만 처리된다고 봐도 될까?”
const list = document.querySelector(".todo-list");
list.addEventListener("click", (event) => {
if (event.target.matches("button[data-delete]")) {
deleteTodo(event.target.dataset.id);
}
});삭제 버튼마다 핸들러를 붙인 코드가 아닙니다. 리스트 하나에 핸들러를 붙여 두고, 클릭 이벤트가 위로 올라오는 흐름을 이용합니다. 이것이 event delegation입니다. 새 항목이 나중에 추가되어도 리스트의 핸들러 하나가 계속 처리할 수 있습니다.
브라우저 이벤트는 보통 세 단계를 거칩니다.
| 단계 | 방향 | 코드 리뷰에서 볼 질문 |
|---|---|---|
| capture | 바깥 → target | 부모가 먼저 가로채야 하는 이유가 있는가? |
| target | 실제 이벤트 발생 요소 | event.target과 현재 핸들러 위치가 같은가? |
| bubble | target → 바깥 | delegation이나 부모 공통 처리를 의도했는가? |
기본 addEventListener("click", handler)는 bubble 단계에서 동작합니다. capture 단계에서 듣고 싶다면 세 번째 인자나 options를 써야 합니다.
document.addEventListener("click", logBeforeTarget, { capture: true });
document.addEventListener("click", logAfterTarget);이 차이를 모르면 “왜 부모 핸들러가 먼저 실행되지?” 또는 “왜 modal 바깥 클릭이 버튼 클릭까지 같이 닫아 버리지?” 같은 버그를 잘못 읽습니다.
이벤트 핸들러에서 자주 섞이는 값이 target과 currentTarget입니다.
list.addEventListener("click", (event) => {
console.log(event.target); // 실제 클릭된 가장 안쪽 요소
console.log(event.currentTarget); // 핸들러가 붙어 있는 list
});버튼 안에 아이콘 <span>이 있으면 event.target은 버튼이 아니라 span일 수 있습니다. 그래서 delegation 코드는 대개 closest로 의도를 조금 더 안전하게 표현합니다.
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-delete]");
if (!button || !list.contains(button)) return;
deleteTodo(button.dataset.id);
});리뷰 질문은 간단합니다.
이 코드는 실제 클릭된 노드가 바뀌어도 의도한 요소를 찾을 수 있는가?
event.stopPropagation()은 이벤트가 더 위로 올라가지 못하게 합니다. modal, dropdown, nested clickable card 같은 곳에서 필요할 수 있습니다. 하지만 습관적으로 넣으면 부모의 접근성 처리, analytics, delegation 핸들러를 조용히 끊을 수 있습니다.
button.addEventListener("click", (event) => {
event.stopPropagation();
openMenu();
});리뷰에서는 “정말 전파를 막아야 하는가?”를 봅니다. 단순히 부모 핸들러가 귀찮아서 막는다면 구조를 다시 잡는 편이 낫습니다. 예를 들어 부모가 클릭 위치를 더 명확히 검사하거나, 버튼과 카드의 역할을 분리할 수 있습니다.
preventDefault()는 전파를 막는 함수가 아닙니다. 링크 이동, 폼 제출, 체크박스 토글 같은 브라우저의 기본 동작을 막습니다.
form.addEventListener("submit", async (event) => {
event.preventDefault();
await saveForm(new FormData(form));
});프론트엔드에서 이 둘을 섞으면 버그가 생깁니다.
| 원하는 일 | 맞는 도구 |
|---|---|
| 폼의 새로고침 제출 막기 | preventDefault() |
| 부모 클릭 핸들러로 안 올리기 | stopPropagation() |
| 둘 다 필요한지 판단하기 | 의도를 별도로 설명하기 |
React에서 onClick은 브라우저의 native event를 그대로 노출하기보다 React의 synthetic event 시스템을 거칩니다. 최신 React에서는 예전처럼 event pooling 때문에 값을 비동기에서 못 읽는 문제는 크게 줄었지만, 리뷰 포인트는 여전히 남습니다.
function Card({ onOpen }: { onOpen: () => void }) {
return (
<article onClick={onOpen}>
<button
onClick={(event) => {
event.stopPropagation();
bookmark();
}}
>
저장
</button>
</article>
);
}여기서 버튼 클릭이 카드 열기까지 이어지면 UX가 깨집니다. 반대로 모든 내부 버튼에 stopPropagation을 흩뿌리면 나중에 analytics나 키보드 접근성 흐름이 끊길 수 있습니다. React 코드에서도 결국 질문은 같습니다.
부모 클릭 영역이 너무 넓지 않은가? 버튼/링크/카드의 역할이 겹치지 않는가? 이벤트를 막는 코드가 local fix인지, 구조 문제를 숨기는지 설명되는가?
delegation을 쓴다면 target, closest, contains 검사가 충분한가?
capture가 필요한 코드인지, 기본 bubble 단계로 충분한지 설명되는가?
stopPropagation이 부모의 공통 처리나 analytics를 끊지 않는가?
preventDefault와 stopPropagation을 목적에 맞게 구분했는가?
React에서 clickable card 안에 button/link를 중첩해 역할 충돌을 만들지 않았는가?
키보드 접근성과 기본 브라우저 동작을 불필요하게 막지 않았는가?
이벤트 코드는 “핸들러가 어디 붙었는가”만 보면 부족합니다. 실제 클릭된 노드, 현재 핸들러 위치, 전파 단계, 기본 동작, 프레임워크의 이벤트 레이어까지 이어서 읽어야 합니다.
Post Q&A
자바스크립트 퀴즈북 리마인드 Day 12: 이벤트는 target에서 끝나지 않는다 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
프로토타입 조회, class 문법, 인스턴스 필드와 prototype 메서드 차이를 프론트엔드 코드 리뷰 관점에서 정리합니다.