
DOM 이벤트는 다음 3단계로 전파된다:
Document
↓ (캡처링)
HTML
↓
Body
↓
Div
↓
Button (타겟)
↑
Div
↑ (버블링)
Body
↑
HTML
↑
Document
루트 요소에서 시작해서 이벤트가 발생한 타겟 요소까지 하향식으로 전파되는 방식
// addEventListener의 3번째 매개변수를 true로 설정
document.getElementById('outer').addEventListener('click', () => {
console.log('Outer div 클릭됨 (캡처링)');
}, true); // 캡처링 단계에서 실행
document.getElementById('middle').addEventListener('click', () => {
console.log('Middle div 클릭됨 (캡처링)');
}, true);
document.getElementById('inner').addEventListener('click', () => {
console.log('Button 클릭됨');
});
// 버튼 클릭 시 출력 순서:
// "Outer div 클릭됨 (캡처링)"
// "Middle div 클릭됨 (캡처링)"
// "Button 클릭됨"
이벤트가 발생한 요소에서 시작해서 부모 요소들로 차례로 전파되는 현상
<div id="outer">
<div id="middle">
<button id="inner">클릭</button>
</div>
</div>
document.getElementById('outer').addEventListener('click', () => {
console.log('Outer div 클릭됨');
});
document.getElementById('middle').addEventListener('click', () => {
console.log('Middle div 클릭됨');
});
document.getElementById('inner').addEventListener('click', () => {
console.log('Button 클릭됨');
});
// 버튼 클릭 시 출력 순서:
// "Button 클릭됨"
// "Middle div 클릭됨"
// "Outer div 클릭됨"
// 동적으로 추가되는 버튼들을 처리
document.getElementById('container').addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
console.log(`${event.target.textContent} 버튼이 클릭됨`);
}
});
이벤트의 추가 전파를 중단한다.
document.getElementById('inner').addEventListener('click', (event) => {
console.log('Button 클릭됨');
event.stopPropagation(); // 버블링 중단
});
// 부모 요소의 이벤트 핸들러는 실행되지 않음
같은 요소에 등록된 다른 이벤트 리스너도 함께 중단한다.
document.getElementById('inner').addEventListener('click', (event) => {
console.log('첫 번째 핸들러');
event.stopImmediatePropagation();
});
document.getElementById('inner').addEventListener('click', () => {
console.log('두 번째 핸들러'); // 실행되지 않음
});
<div id="parent">
<a href="https://google.com" id="link">구글로 이동</a>
</div>
// 부모 div의 클릭 이벤트
document.getElementById('parent').addEventListener('click', () => {
console.log('부모 div 클릭됨');
});
// preventDefault만 사용
document.getElementById('link').addEventListener('click', (event) => {
event.preventDefault(); // 페이지 이동만 막음
console.log('링크 클릭됨 - 페이지 이동 차단');
});
// 결과: 링크 클릭 시
// "링크 클릭됨 - 페이지 이동 차단"
// "부모 div 클릭됨" // 여전히 실행됨!
<div class="modal-backdrop" onclick="closeModal()">
<div class="modal-content">
<button onclick="saveData()">저장</button>
</div>
</div>
document.querySelector('.modal-content button').addEventListener('click', (event) => {
event.preventDefault(); // 기본 동작 차단
event.stopPropagation(); // 부모의 closeModal 실행 차단
console.log('저장 실행');
// 모달이 닫히지 않음!
});
동적으로 생성되는 요소들에 대해 효율적으로 이벤트를 처리할 수 있다.
// 할 일 목록 예시
document.getElementById('todo-list').addEventListener('click', (event) => {
// 삭제 버튼 클릭 처리
if (event.target.classList.contains('delete-btn')) {
const todoItem = event.target.closest('.todo-item');
todoItem.remove();
return;
}
// 완료 체크박스 처리
if (event.target.type === 'checkbox') {
const todoItem = event.target.closest('.todo-item');
todoItem.classList.toggle('completed');
return;
}
// 편집 버튼 처리
if (event.target.classList.contains('edit-btn')) {
const todoItem = event.target.closest('.todo-item');
editTodo(todoItem);
}
});
// 새로운 할 일 추가 (이벤트 리스너 별도 등록 불필요)
function addTodo(text) {
const todoList = document.getElementById('todo-list');
todoList.innerHTML += `
<div class="todo-item">
<input type="checkbox">
<span>${text}</span>
<button class="edit-btn">편집</button>
<button class="delete-btn">삭제</button>
</div>
`;
}
// 테이블 전체에 하나의 이벤트 리스너로 모든 행과 셀 처리
document.getElementById('data-table').addEventListener('click', (event) => {
const cell = event.target.closest('td');
const row = event.target.closest('tr');
if (cell && cell.classList.contains('editable')) {
// 편집 가능한 셀 클릭
startEditMode(cell);
} else if (row) {
// 행 클릭 시 선택 상태 토글
row.classList.toggle('selected');
updateSelectionCount();
}
});
// 모든 드롭다운을 하나의 이벤트로 처리
document.body.addEventListener('click', (event) => {
const dropdownToggle = event.target.closest('.dropdown-toggle');
const dropdownMenu = event.target.closest('.dropdown-menu');
if (dropdownToggle) {
// 드롭다운 토글 버튼 클릭
const menu = dropdownToggle.nextElementSibling;
menu.classList.toggle('show');
// 다른 드롭다운들은 닫기
closeOtherDropdowns(menu);
} else if (dropdownMenu) {
// 메뉴 아이템 클릭
const action = event.target.dataset.action;
if (action) {
executeAction(action, event.target);
dropdownMenu.classList.remove('show');
}
} else {
// 외부 클릭 시 모든 드롭다운 닫기
closeAllDropdowns();
}
});
// 카드 컨테이너에서 모든 카드 상호작용 처리
document.getElementById('card-container').addEventListener('click', (event) => {
const card = event.target.closest('.card');
if (!card) return;
// 좋아요 버튼
if (event.target.classList.contains('like-btn')) {
toggleLike(card.dataset.id);
event.target.classList.toggle('liked');
}
// 공유 버튼
else if (event.target.classList.contains('share-btn')) {
shareCard(card.dataset.id);
}
// 더보기 버튼
else if (event.target.classList.contains('more-btn')) {
showCardOptions(card);
}
// 카드 본체 클릭 (상세보기)
else if (!event.target.closest('.card-actions')) {
showCardDetail(card.dataset.id);
}
});
// 전체 폼에서 모든 입력 필드의 이벤트를 처리
document.getElementById('signup-form').addEventListener('input', (event) => {
const field = event.target;
if (field.type === 'email') {
validateEmail(field);
} else if (field.type === 'password') {
validatePassword(field);
} else if (field.name === 'confirmPassword') {
validatePasswordConfirm(field);
} else if (field.type === 'tel') {
validatePhone(field);
}
// 전체 폼 유효성 업데이트
updateFormValidation();
});
// 실시간 에러 메시지 표시
function validateEmail(field) {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(field.value);
toggleError(field, isValid, '올바른 이메일 형식이 아닙니다.');
}
// 이미지 갤러리에서 모든 이미지와 컨트롤 처리
document.getElementById('image-gallery').addEventListener('click', (event) => {
const img = event.target.closest('img');
const thumbnail = event.target.closest('.thumbnail');
if (thumbnail) {
// 썸네일 클릭 시 큰 이미지로 전환
const fullSizeUrl = thumbnail.dataset.fullsize;
showFullSizeImage(fullSizeUrl);
} else if (event.target.classList.contains('download-btn')) {
// 다운로드 버튼
const imgUrl = event.target.closest('.image-item').querySelector('img').src;
downloadImage(imgUrl);
} else if (event.target.classList.contains('favorite-btn')) {
// 즐겨찾기 버튼
const imageId = event.target.closest('.image-item').dataset.id;
toggleFavorite(imageId);
}
});
// 로딩 중일 때 모든 하위 요소들의 이벤트를 원천 차단
function disableAllInteractions() {
document.getElementById('loading-overlay').addEventListener('click', (event) => {
console.log('로딩 중입니다...');
event.preventDefault(); // 기본 동작 차단
event.stopPropagation(); // 하위 요소들의 이벤트 차단
}, true); // 캡처링 단계에서 먼저 실행
}
function blockUnauthenticatedActions() {
if (!isUserLoggedIn()) {
document.getElementById('main-content').addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
showLoginModal();
console.log('로그인이 필요합니다');
}, true); // 캡처링으로 먼저 가로챔
}
}
document.addEventListener('keydown', (event) => {
// Ctrl+S 저장 기능
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
event.stopPropagation();
console.log('문서 저장됨');
saveDocument();
return;
}
// ESC로 모달 닫기
if (event.key === 'Escape') {
closeAllModals();
event.stopPropagation();
}
}, true); // 캡처링 단계에서 실행
function EventExample() {
const handleClickCapture = (event) => {
console.log('캡처링 단계에서 실행');
};
const handleClick = (event) => {
console.log('버블링 단계에서 실행');
};
return (
<div
onClickCapture={handleClickCapture} // 캡처링
onClick={handleClick} // 버블링 (기본)
>
<button onClick={() => console.log('버튼 클릭')}>
클릭
</button>
</div>
);
}
function AdminPanel({ userRole, children }) {
const handleCapturingClick = (event) => {
if (userRole !== 'admin') {
event.preventDefault();
event.stopPropagation();
alert('관리자 권한이 필요합니다');
}
};
return (
<div
className="admin-panel"
onClickCapture={handleCapturingClick}
>
{children}
</div>
);
}
function App() {
const [isLoading, setIsLoading] = useState(false);
const handleLoadingCapture = (event) => {
if (isLoading) {
event.preventDefault();
event.stopPropagation();
console.log('로딩 중입니다...');
}
};
return (
<div
className="app"
onClickCapture={handleLoadingCapture}
>
<button onClick={() => console.log('버튼 클릭')}>
로딩 중엔 작동 안함
</button>
</div>
);
}
function App() {
const handleKeyDownCapture = (event) => {
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
event.stopPropagation();
saveDocument();
return;
}
};
return (
<div onKeyDownCapture={handleKeyDownCapture}>
<input
type="text"
onKeyDown={() => console.log('입력 필드 키')}
placeholder="Ctrl+S 눌러도 저장됨"
/>
</div>
);
}
<div
onClickCapture={handleClickCapture}
onMouseDownCapture={handleMouseDownCapture}
onKeyDownCapture={handleKeyDownCapture}
onSubmitCapture={handleSubmitCapture}
onFocusCapture={handleFocusCapture}
onBlurCapture={handleBlurCapture}
// ... 등등
>
// 댓글 시스템
function CommentList({ comments }) {
const handleCommentAction = (event) => {
const commentId = event.target.closest('.comment').dataset.id;
if (event.target.classList.contains('like-btn')) {
likeComment(commentId);
} else if (event.target.classList.contains('reply-btn')) {
showReplyForm(commentId);
} else if (event.target.classList.contains('delete-btn')) {
deleteComment(commentId);
} else if (event.target.classList.contains('edit-btn')) {
startEditComment(commentId);
}
};
return (
<div className="comment-list" onClick={handleCommentAction}>
{comments.map(comment => (
<div key={comment.id} className="comment" data-id={comment.id}>
<div className="comment-content">{comment.text}</div>
<div className="comment-actions">
<button className="like-btn">좋아요</button>
<button className="reply-btn">답글</button>
<button className="edit-btn">수정</button>
<button className="delete-btn">삭제</button>
</div>
</div>
))}
</div>
);
}
3번째 인자의 boolean 값이 DOM 엔진에게 "언제 이 리스너를 실행할지"를 알려준다:
true (캡처링): "루트에서 타겟으로 가는 길에 실행해줘"false (버블링): "타겟에서 루트로 가는 길에 실행해줘"<div id="outer"> <!-- 최상위 -->
<div id="inner"> <!-- 중간 -->
<button id="target">클릭</button> <!-- 타겟 -->
</div>
</div>
// 리스너 선언 순서를 뒤섞어도
document.getElementById('target').addEventListener('click', () => {
console.log('3. Target');
}, false);
document.getElementById('outer').addEventListener('click', () => {
console.log('1. Outer');
}, false);
document.getElementById('inner').addEventListener('click', () => {
console.log('2. Inner');
}, false);
// 실행은 DOM 구조 순서로: Target → Inner → Outer
function dispatchEvent(target, event) {
// 1. 캡처링 경로 계산
const capturingPath = getPathFromRoot(target);
// 2. 캡처링 단계 실행
for (const element of capturingPath) {
const capturingListeners = getListeners(element, true);
capturingListeners.forEach(listener => listener(event));
if (event.isPropagationStopped()) return;
}
// 3. 타겟 단계 실행
const targetListeners = getListeners(target);
targetListeners.forEach(listener => listener(event));
// 4. 버블링 단계 실행
const bubblingPath = capturingPath.reverse();
for (const element of bubblingPath) {
const bubblingListeners = getListeners(element, false);
bubblingListeners.forEach(listener => listener(event));
if (event.isPropagationStopped()) return;
}
}
DOM 구조가 이벤트 전파 순서를 결정하고, 3번째 인자가 언제 실행할지를 결정한다