테마
04. 이벤트 플로우와 위임 패턴
이벤트는 타깃에서 갑자기 튀어나오는 것이 아니라 DOM 트리를 따라 이동한다. 이 흐름을 이해해야
왜 여기서 실행됐는가와왜 막혔는가를 설명할 수 있다.
학습 목표
- 캡처, 타깃, 버블 단계의 차이를 설명할 수 있다.
event.target,event.currentTarget,event.eventPhase를 구분할 수 있다.stopPropagation,stopImmediatePropagation,preventDefault의 역할을 분리해서 이해한다.closest()기반 이벤트 위임과CustomEvent사용 맥락을 설명할 수 있다.
1. 이벤트는 어떻게 이동할까?
단계로 보기
DOM 이벤트는 내려갔다가 타깃을 찍고 다시 올라간다
1 / 3캡처 단계
이벤트는 Window에서 시작해 Document, Parent를 거쳐 실제 타깃 쪽으로 내려간다.
이 흐름을 세 단계로 나누면 다음과 같다.
- 캡처 단계: 위에서 아래로 내려온다
- 타깃 단계: 실제 이벤트가 발생한 요소에 도달한다
- 버블 단계: 다시 아래에서 위로 올라간다
대부분의 앱 코드는 기본적으로 버블 단계 리스너를 사용한다.
스스로 확인
이벤트 위임에서 부모 리스너가 실제로 클릭된 자식을 찾을 때 어떤 값을 기준으로 삼아야 할까?
2. target과 currentTarget을 헷갈리지 말자
이벤트 위임을 처음 배울 때 가장 많이 헷갈리는 부분이다.
| 프로퍼티 | 의미 |
|---|---|
event.target | 실제로 이벤트가 시작된 가장 안쪽 요소 |
event.currentTarget | 현재 리스너가 붙어 있는 요소 |
예를 들어 ul에 클릭 리스너를 붙이고 내부 button을 눌렀다면:
target은buttoncurrentTarget은ul
이 차이를 알아야 부모 하나에서 자식 여러 개의 동작을 분기할 수 있다.
3. 이벤트 위임은 왜 중요한가?
여러 버튼이나 리스트 아이템에 각각 리스너를 붙이는 대신, 공통 부모 하나에 붙이고 target을 해석하는 방식이 이벤트 위임이다.
장점은 다음과 같다.
- 동적으로 추가된 자식도 별도 등록 없이 처리 가능
- 리스너 수가 줄어든다
- 구조가 바뀌어도 부모 중심으로 제어하기 쉽다
현대적인 패턴은 matches()보다 closest()를 자주 쓴다.
js
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-action]')
if (!button || !list.contains(button)) return
const action = button.dataset.action
if (action === 'delete') removeItem(button)
})4. 기본 동작 방지와 전파 중단은 다르다
원문도 이 부분을 중요하게 다루는데, 지금도 정확히 구분해야 한다.
| 메서드 | 막는 것 |
|---|---|
preventDefault() | 브라우저 기본 동작 |
stopPropagation() | 상위/하위 단계로의 이벤트 전파 |
stopImmediatePropagation() | 같은 요소의 다른 리스너 실행까지 포함한 즉시 중단 |
예를 들면:
- 링크 이동을 막고 싶다 ->
preventDefault() - 부모 카드 클릭 핸들러까지 올라가지 않게 하고 싶다 ->
stopPropagation() - 같은 요소에 붙은 다른 클릭 리스너도 멈추고 싶다 ->
stopImmediatePropagation()
이 셋을 한 덩어리로 외우면 디버깅이 꼬인다.
5. 이벤트 위임 실전에서 자주 보는 패턴
메뉴, 리스트, 테이블 액션
- 버튼마다 리스너를 붙이지 않고 부모에 하나만 등록
data-action과dataset으로 분기
토글 UI
- 카드 전체는 열고 닫기
- 내부 닫기 버튼은 상위 클릭까지 전파되지 않게 제어
동적 렌더링 목록
- 서버 응답 후 아이템이 계속 늘어남
- 새 노드마다 리스너 등록하는 비용을 줄임
6. 캡처 단계는 언제 쓸까?
평소에는 버블 단계로 충분하지만, 아래 경우는 캡처를 고려할 수 있다.
- 상위 컨테이너가 먼저 로깅이나 추적을 해야 할 때
- 특정 이벤트를 아래 요소보다 먼저 가로채야 할 때
- Shadow DOM 경계나 라이브러리 내부 리스너보다 먼저 반응해야 할 때
다만 무조건 캡처를 쓰면 디버깅 난이도만 올라갈 수 있으니, 이유가 분명한 경우에만 쓰는 편이 좋다.
7. CustomEvent와 dispatchEvent()
DOM 이벤트 모델은 브라우저 기본 이벤트만 위한 것이 아니다.
컴포넌트끼리 의미 있는 사건을 전달할 때도 쓸 수 있다.
js
card.dispatchEvent(new CustomEvent('item:remove', {
bubbles: true,
detail: { id: 42 },
}))이 패턴이 유용한 상황:
- 커스텀 엘리먼트가 외부에 상태 변화를 알릴 때
- 특정 UI 단위가 "무슨 일이 일어났는지"를 의미 중심으로 알릴 때
이때 detail에는 필요한 최소 데이터만 넣는 편이 좋다.
8. Shadow DOM과 이벤트 경계
Web Components 장에서 더 자세히 다루지만, 여기서 핵심만 먼저 잡자.
- 어떤 이벤트는 Shadow DOM 경계를 넘어 전파된다
- 이때
event.composed와event.composedPath()가 중요하다 click같은 이벤트는 경계를 넘어오는 경우가 많다
즉, 이벤트 위임을 Shadow DOM 바깥에서 할 때는 단순한 target만으로 충분하지 않을 수 있다.
9. 흔한 안티패턴
| 안티패턴 | 문제점 | 더 나은 방식 |
|---|---|---|
| 자식마다 리스너 남발 | 동적 UI에서 추적이 어려움 | 부모 하나 + 위임 |
preventDefault()와 전파 중단을 혼용 | 버그 원인 파악이 어려움 | 목적별로 분리 |
event.target만 믿고 로직 실행 | 내부 아이콘 클릭 같은 경우 오작동 | closest()로 의도한 요소를 찾기 |
| 불필요한 캡처 사용 | 디버깅 난이도 상승 | 기본은 버블, 이유 있을 때만 캡처 |
10. PR 리뷰 체크리스트
- 이벤트가 어떤 단계에서 실행되는지 설명 가능한가
target과currentTarget을 혼동하고 있지 않은가- 전파 중단과 기본 동작 방지가 목적에 맞게 쓰였는가
- 여러 자식 요소를 처리할 때 이벤트 위임으로 단순화할 수 있는가
- 커스텀 이벤트 이름과
detail구조가 의미 중심으로 설계되었는가
핵심 정리
- 이벤트는 캡처, 타깃, 버블 흐름을 따라 움직인다
- 이벤트 위임은 동적 UI를 단순하게 관리하는 핵심 패턴이다
preventDefault()와 전파 중단은 서로 다른 도구이며, Shadow DOM까지 가면composedPath()이해가 추가로 필요하다