이벤트 캡처링과 버블링

contability·2025년 9월 1일

목차

  1. 이벤트 전파의 3단계
  2. 이벤트 캡처링
  3. 이벤트 버블링
  4. 이벤트 전파 제어
  5. preventDefault vs stopPropagation
  6. 캡처링의 실제 활용 사례
  7. 버블링의 실제 활용 사례
  8. React에서의 이벤트 처리
  9. DOM 엔진의 이벤트 처리 방식

이벤트 전파의 3단계

DOM 이벤트는 다음 3단계로 전파된다:

  1. 캡처링 단계(Capturing Phase): 루트에서 타겟 요소까지 하향식으로 전파
  2. 타겟 단계(Target Phase): 실제 이벤트가 발생한 요소에서 처리
  3. 버블링 단계(Bubbling Phase): 타겟 요소에서 루트까지 상향식으로 전파
Document
    ↓ (캡처링)
   HTML
    ↓
   Body
    ↓
   Div
    ↓
 Button (타겟)
    ↑
   Div
    ↑ (버블링)
   Body
    ↑
   HTML
    ↑
Document

이벤트 캡처링 (Event Capturing)

루트 요소에서 시작해서 이벤트가 발생한 타겟 요소까지 하향식으로 전파되는 방식

기본 사용법

// 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 클릭됨"

이벤트 버블링 (Event Bubbling)

이벤트가 발생한 요소에서 시작해서 부모 요소들로 차례로 전파되는 현상

기본 예시

<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} 버튼이 클릭됨`);
  }
});

이벤트 전파 제어

stopPropagation()

이벤트의 추가 전파를 중단한다.

document.getElementById('inner').addEventListener('click', (event) => {
  console.log('Button 클릭됨');
  event.stopPropagation(); // 버블링 중단
});
// 부모 요소의 이벤트 핸들러는 실행되지 않음

stopImmediatePropagation()

같은 요소에 등록된 다른 이벤트 리스너도 함께 중단한다.

document.getElementById('inner').addEventListener('click', (event) => {
  console.log('첫 번째 핸들러');
  event.stopImmediatePropagation();
});

document.getElementById('inner').addEventListener('click', () => {
  console.log('두 번째 핸들러'); // 실행되지 않음
});

preventDefault vs stopPropagation

핵심 차이점

  • preventDefault(): 브라우저의 기본 동작만 막는다 (링크 이동, 폼 제출 등)
  • stopPropagation(): 이벤트 전파를 막는다 (다른 이벤트 핸들러 실행 차단)

실제 차이 확인

<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('저장 실행');
  // 모달이 닫히지 않음!
});

버블링의 실제 활용 사례

1. 이벤트 위임 (Event Delegation)

동적으로 생성되는 요소들에 대해 효율적으로 이벤트를 처리할 수 있다.

// 할 일 목록 예시
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>
  `;
}

2. 테이블 행/셀 이벤트 처리

// 테이블 전체에 하나의 이벤트 리스너로 모든 행과 셀 처리
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();
  }
});

3. 드롭다운 메뉴 시스템

// 모든 드롭다운을 하나의 이벤트로 처리
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();
  }
});

4. 카드/패널 시스템

// 카드 컨테이너에서 모든 카드 상호작용 처리
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);
  }
});

5. 폼 유효성 검사

// 전체 폼에서 모든 입력 필드의 이벤트를 처리
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, '올바른 이메일 형식이 아닙니다.');
}

6. 갤러리/이미지 뷰어

// 이미지 갤러리에서 모든 이미지와 컨트롤 처리
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);
  }
});

버블링의 장점

  1. 성능 최적화: 하나의 이벤트 리스너로 여러 요소 처리
  2. 동적 요소 지원: 나중에 추가된 요소도 자동으로 이벤트 처리
  3. 메모리 효율성: 각 요소마다 리스너를 등록할 필요 없음
  4. 코드 간소화: 유사한 이벤트 로직을 한 곳에서 관리

캡처링의 실제 활용 사례

1. 전체 영역 비활성화

// 로딩 중일 때 모든 하위 요소들의 이벤트를 원천 차단
function disableAllInteractions() {
  document.getElementById('loading-overlay').addEventListener('click', (event) => {
    console.log('로딩 중입니다...');
    event.preventDefault();    // 기본 동작 차단
    event.stopPropagation();   // 하위 요소들의 이벤트 차단
  }, true); // 캡처링 단계에서 먼저 실행
}

2. 권한 기반 UI 차단

function blockUnauthenticatedActions() {
  if (!isUserLoggedIn()) {
    document.getElementById('main-content').addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      
      showLoginModal();
      console.log('로그인이 필요합니다');
    }, true); // 캡처링으로 먼저 가로챔
  }
}

3. 전역 키보드 단축키 처리

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); // 캡처링 단계에서 실행

React에서의 이벤트 처리

이벤트 사용법

function EventExample() {
  const handleClickCapture = (event) => {
    console.log('캡처링 단계에서 실행');
  };
  
  const handleClick = (event) => {
    console.log('버블링 단계에서 실행');
  };
  
  return (
    <div 
      onClickCapture={handleClickCapture}  // 캡처링
      onClick={handleClick}                // 버블링 (기본)
    >
      <button onClick={() => console.log('버튼 클릭')}>
        클릭
      </button>
    </div>
  );
}

React 캡처링 실제 활용

1. 전역 권한 체크

function AdminPanel({ userRole, children }) {
  const handleCapturingClick = (event) => {
    if (userRole !== 'admin') {
      event.preventDefault();
      event.stopPropagation();
      alert('관리자 권한이 필요합니다');
    }
  };
  
  return (
    <div 
      className="admin-panel"
      onClickCapture={handleCapturingClick}
    >
      {children}
    </div>
  );
}

2. 로딩 상태 차단

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>
  );
}

3. 키보드 단축키 우선 처리

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>
  );
}

React 캡처링 이벤트 종류

<div
  onClickCapture={handleClickCapture}
  onMouseDownCapture={handleMouseDownCapture}
  onKeyDownCapture={handleKeyDownCapture}
  onSubmitCapture={handleSubmitCapture}
  onFocusCapture={handleFocusCapture}
  onBlurCapture={handleBlurCapture}
  // ... 등등
>

React 버블링 실제 활용

// 댓글 시스템
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>
  );
}

DOM 엔진의 이벤트 처리 방식

핵심 원리

3번째 인자의 boolean 값이 DOM 엔진에게 "언제 이 리스너를 실행할지"를 알려준다:

  • true (캡처링): "루트에서 타겟으로 가는 길에 실행해줘"
  • false (버블링): "타겟에서 루트로 가는 길에 실행해줘"

DOM 구조가 순서를 결정한다

<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;
  }
}

정리

언제 무엇을 사용할까?

버블링 활용

  • 이벤트 위임 (동적 요소 처리)
  • 하위에서 상위로 정보 전달
  • 일반적인 UI 상호작용

캡처링 활용

  • 우선순위가 높은 이벤트 처리 (전역 단축키, 권한 체크)
  • 하위 요소들의 이벤트를 제어하고 싶을 때
  • 전역 상태 변경이 필요한 경우 (모달 닫기, 로딩 차단)

핵심 개념

  • preventDefault(): "브라우저야, 너의 기본 동작 하지마"
  • stopPropagation(): "다른 이벤트 핸들러들아, 너희는 실행되지마"
  • 버블링: "내가 처리했으니 위로 올라가지마"
  • 캡처링: "내가 먼저 처리할게, 아래로 내려가지마"

DOM 구조가 이벤트 전파 순서를 결정하고, 3번째 인자가 언제 실행할지를 결정한다

0개의 댓글