자바스크립트 실전 #2 이벤트 핸들링과 위임

#1 DOM 조작 기본 에서 엘리먼트를 다루는 도구를 봤다면, 이번엔 그것들로 사용자 인터랙션을 처리하는 단계로 나아갑니다.

이벤트 등록 — addEventListener #

기본 이벤트 등록
const btn = document.querySelector('#submit');

btn.addEventListener('click', (e) => {
  console.log('클릭됨');
});

addEventListener('타입', 핸들러) — 이게 모던 자바스크립트의 표준입니다.

옛 코드에는 btn.onclick = () => {...}같이 직접 할당하는 방식도 보이는데, 한 엘리먼트에 한 핸들러만 등록할 수 있어 한계가 있습니다. addEventListener는 같은 이벤트에 여러 핸들러를 등록할 수 있습니다.

자주 쓰는 이벤트 타입 #

자주 쓰는 이벤트
click          마우스 클릭
input          입력 필드 값 변경 (실시간)
change         값 변경 (포커스 잃을 때)
submit         폼 제출
keydown/keyup  키 입력
focus/blur     포커스 받기/잃기
mouseenter     마우스 진입
mouseleave     마우스 떠남
scroll         스크롤
load           로딩 완료
DOMContentLoaded  HTML 파싱 완료

이벤트 객체 — e #

핸들러가 받는 인자는 이벤트 객체. 자주 쓰는 속성들.

이벤트 객체
btn.addEventListener('click', (e) => {
  e.target;           // 이벤트가 시작된 엘리먼트
  e.currentTarget;    // 핸들러가 등록된 엘리먼트
  e.type;             // 'click'

  e.preventDefault();    // 기본 동작 막기
  e.stopPropagation();   // 버블링 막기
});

e.target vs e.currentTarget #

가장 헷갈리는 부분입니다.

HTML
<button class="card">
  <span>클릭하세요</span>
</button>
target vs currentTarget
btn.addEventListener('click', (e) => {
  console.log(e.target);         // <span> (실제 클릭된 엘리먼트)
  console.log(e.currentTarget);  // <button> (핸들러 등록된 엘리먼트)
});
  • target — 이벤트가 발생한 엘리먼트 (가장 안쪽 클릭 대상)
  • currentTarget — 핸들러가 등록된 엘리먼트

이벤트 위임 패턴에서 둘의 차이가 중요합니다. 잠시 뒤에 살펴보겠습니다.

이벤트의 흐름 — 캡처링과 버블링 #

이벤트가 발생하면 두 단계로 전파됩니다.

이벤트 전파 흐름
1. 캡처링 (capturing) — document → 이벤트 발생 위치까지 위→아래
2. 타깃 (target) — 발생 위치 도착
3. 버블링 (bubbling) — 발생 위치 → document까지 아래→위

기본은 버블링 단계에서 핸들러가 실행됩니다.

중첩된 구조
<div id="outer">
  <div id="inner">
    <button id="btn">클릭</button>
  </div>
</div>
버블링 확인
document.querySelector('#outer').addEventListener('click', () => console.log('outer'));
document.querySelector('#inner').addEventListener('click', () => console.log('inner'));
document.querySelector('#btn').addEventListener('click', () => console.log('button'));

// 버튼 클릭 시 출력:
// button
// inner
// outer

이벤트가 버튼 → inner → outer로 위로 흘러올라가며 각 핸들러가 차례로 실행됩니다. 이게 버블링입니다.

캡처링으로 듣기 #

캡처 단계
document.querySelector('#outer').addEventListener(
  'click',
  () => console.log('outer (캡처)'),
  { capture: true }
);

세 번째 인자에 { capture: true }를 주면 캡처 단계에서 핸들러가 실행됩니다. 거의 안 쓰지만, “위쪽이 먼저 처리해야 하는 경우"가 있을 때 가끔 유용합니다.

stopPropagationpreventDefault #

stopPropagation — 전파 멈추기 #

버블링 막기
button.addEventListener('click', (e) => {
  e.stopPropagation();   // 더 위로 안 올라감
});

위 핸들러에서 stopPropagation을 부르면 — outer/inner 핸들러는 호출되지 않습니다.

preventDefault — 기본 동작 막기 #

기본 동작 막기
form.addEventListener('submit', (e) => {
  e.preventDefault();   // 폼이 페이지를 새로고침하지 않음
  // ... 직접 처리
});

link.addEventListener('click', (e) => {
  e.preventDefault();   // 링크가 이동하지 않음
});

브라우저 기본 동작을 막습니다. 폼 제출, 링크 이동, 우클릭 메뉴 등 자주 만나는 경우.

두 메서드는 목적이 다릅니다. stopPropagation은 자바스크립트 핸들러 전파를, preventDefault는 브라우저 기본 동작을. 헷갈리지 않게 따로 두어 사용합니다.

이벤트 위임 — 효율적인 패턴 #

리스트 안에 100개 항목이 있고 각각 클릭 핸들러가 필요하다면:

안 좋은 패턴 — 항목마다 핸들러 #

비효율적
document.querySelectorAll('.item').forEach((item) => {
  item.addEventListener('click', (e) => {
    console.log('클릭:', item.dataset.id);
  });
});

100개 핸들러가 메모리에 등록됩니다. 리스트에 동적으로 항목이 추가되면 새 항목엔 핸들러가 없습니다.

좋은 패턴 — 부모에 한 핸들러 #

이벤트 위임
const list = document.querySelector('.list');

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item || !list.contains(item)) return;

  console.log('클릭:', item.dataset.id);
});

핸들러는 하나만. 클릭이 일어나면 버블링으로 부모까지 올라오고, 거기서 closest로 어떤 항목이 클릭됐는지 확인합니다.

위임의 이점 #

  1. 메모리 절약 — 핸들러가 하나
  2. 동적 항목 자동 처리 — 나중에 추가된 항목도 같은 핸들러로 동작
  3. 간결함 — 리스트가 변할 때마다 핸들러 등록/해제 안 해도 됨

리스트, 테이블, 카드 그리드 같은 경우에 거의 표준 패턴입니다.

핸들러 제거 — removeEventListener #

제거
function onClick() {
  console.log('클릭');
}

btn.addEventListener('click', onClick);

// 나중에
btn.removeEventListener('click', onClick);

핸들러 함수의 참조가 같아야 제거됩니다. 화살표 함수를 즉석에서 등록하면 제거할 수 없습니다.

제거 못 함
btn.addEventListener('click', () => console.log('클릭'));
btn.removeEventListener('click', () => console.log('클릭'));   // ✗ 다른 함수

AbortController — 모던 핸들러 정리 #

중급 #6 fetch API 에서 본 AbortController가 이벤트 정리에도 쓰입니다.

AbortController 로 핸들러 정리
const controller = new AbortController();

btn.addEventListener('click', onClick, { signal: controller.signal });
input.addEventListener('input', onInput, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });

// 한 번에 모두 제거
controller.abort();

여러 핸들러를 한 번에 정리할 수 있습니다. 컴포넌트 cleanup에 매우 적합합니다.

한 번만 실행 — { once: true } #

once 옵션
btn.addEventListener('click', () => {
  console.log('처음이자 마지막');
}, { once: true });

자기 자신을 자동으로 제거합니다. 일회성 핸들러에 매우 깔끔.

Passive 이벤트 — 스크롤 성능 #

scroll, touchmove 같은 이벤트는 passive로 등록하는 게 권장됩니다.

passive 옵션
window.addEventListener('scroll', onScroll, { passive: true });

passive 핸들러는 preventDefault를 못 부른다는 약속입니다. 그 약속이 있으면 브라우저가 스크롤을 더 빠르게 처리할 수 있습니다.

폼/입력 자주 쓰는 패턴 #

input vs change #

실시간 vs 변경 완료
input.addEventListener('input', (e) => {
  // 매 키 입력마다
  console.log(e.target.value);
});

input.addEventListener('change', (e) => {
  // 입력이 끝나고 포커스 잃을 때 (또는 select 변경 시)
  console.log(e.target.value);
});

검색 자동완성처럼 실시간 처리는 input. 검증/저장 같은 무거운 작업은 change 또는 디바운스된 input.

키보드 이벤트 #

key 검사
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') submit();
  if (e.key === 'Escape') close();
  if (e.ctrlKey && e.key === 's') {
    e.preventDefault();
    save();
  }
});

e.key가 모던 표준. 옛날의 e.keyCode는 deprecated.

커스텀 이벤트 #

자기만의 이벤트를 만들고 dispatch 할 수 있습니다.

커스텀 이벤트
const event = new CustomEvent('user-login', {
  detail: { id: 'u1', name: '커티스' },
});

document.dispatchEvent(event);

// 다른 곳에서
document.addEventListener('user-login', (e) => {
  console.log(e.detail);   // { id: 'u1', name: '커티스' }
});

큰 앱에서 모듈 사이 통신에 가끔 활용합니다. 다만 너무 많이 쓰면 흐름 추적이 어려워지니 신중하게.

마무리 #

이번 글에서 정리한 내용:

  • addEventListener가 모던 표준
  • e.target (실제 발생) vs e.currentTarget (핸들러 등록 위치)
  • 이벤트 흐름: 캡처링 → 타깃 → 버블링 (기본은 버블링)
  • stopPropagation은 전파, preventDefault는 기본 동작
  • 이벤트 위임 — 부모에 한 번 등록, closest로 위치 확인
  • removeEventListener는 같은 함수 참조 필요
  • AbortController로 여러 핸들러 한 번에 정리
  • { once: true }, { passive: true } 옵션
  • input은 실시간, change는 입력 완료 시점
  • e.key가 키보드 이벤트 모던 표준

다음 글(#3 fetch와 비동기 UI)에서는 fetch로 데이터를 받아 DOM에 반영하는 패턴 — 로딩/에러 상태 표시, 디바운스, AbortController까지 묶어서 다룹니다.

X