DOM과 이벤트가 전달되는 과정
브라우저는 HTML 문서를 DOM이라는 트리 구조로 다룹니다. 클릭이나 입력이 발생하면 이벤트를 통해 등록된 함수를 호출합니다. 버튼의 클릭은 부모 요소에 등록한 리스너에도 전달될 수 있습니다. 모달, 할 일 목록을 예로, 클릭이 전달되고 처리되는 과정을 살펴봅니다.
일반 DOM의 click 이벤트를 다룹니다. 비동기 실행 순서와 React의 이벤트 처리는 별도 주제입니다.
1. DOM
DOM은 문서를 노드의 부모·자식 관계로 표현한 트리입니다. 노드에는 태그를 나타내는 Element와 글자를 담는 Text 등이 있습니다. 아래 그림에서 배경은 설정 창을, 설정 창은 저장 버튼을 감쌉니다. 상자가 안에 들어 있는 모습으로 부모·자식 관계를 표현했습니다.
모달의 포함 관계
배경 안에 설정 창, 그 안에 저장 버튼이 있습니다.
- 모달 배경 — 설정 창의 부모
- 설정 창 — 저장 버튼의 부모
- 저장 버튼 — 가장 안쪽 요소
이 부모·자식 구조를 따라 클릭 이벤트가 전달됩니다.
2. 이벤트와 리스너
이벤트는 클릭이나 입력처럼 발생한 일을 나타냅니다. 리스너는 그 이벤트를 받으면 실행할 함수입니다.
saveButton.addEventListener("click", handleSave);
addEventListener()로 함수를 등록하면, 이후 버튼이 click을 받을 때 브라우저가 handleSave를 호출합니다. 버튼처럼 리스너를 등록할 수 있는 대상은 EventTarget의 기능을 갖습니다. 버튼과 배경에 각각 리스너를 등록했다면, 한 번의 클릭으로 둘 다 호출될 수 있습니다.
3. 이벤트 전파
이벤트 전파는 경로에 있는 리스너들이 같은 이벤트를 받는 과정입니다. click은 Capture → Target → Bubble 순서로 처리됩니다.
저장 버튼 click의 전달 순서
각 위치에 해당 리스너를 등록한 조건입니다. 설정 창 위의 document와 window는 생략했습니다.
- Capture: 모달 배경 → 설정 창
- Target: 저장 버튼에서 처리
- Bubble: 설정 창 → 모달 배경
그림은 왼쪽부터 조상의 캡처 → 버튼의 대상 단계 → 조상의 버블 순서입니다. 더 위에 있는 document와 window는 생략했습니다.
조상 리스너에 { capture: true }를 지정하면 Capture 단계에서, 생략하면 click을 Bubble 단계에서 받습니다. 배경의 캡처 리스너가 먼저 실행돼도 클릭 대상은 저장 버튼으로 유지됩니다. click은 버블링하지만 focus는 버블링하지 않습니다.
4. 이벤트 객체
이벤트 객체는 대상과 전파 단계 등 발생한 이벤트의 정보를 담습니다. 리스너의 매개변수로 받아 확인할 수 있습니다. target은 이벤트 대상, currentTarget은 현재 리스너가 등록된 대상입니다. 저장 버튼을 누른 경우, 아래 표에서 달라지는 것은 리스너 위치와 단계입니다.
| 리스너가 실행되는 위치 | target | currentTarget | eventPhase |
|---|---|---|---|
| 배경의 캡처 리스너 | 저장 버튼 | 모달 배경 | 1 · Capture |
| 버튼의 리스너 | 저장 버튼 | 저장 버튼 | 2 · Target |
| 배경의 버블 리스너 | 저장 버튼 | 모달 배경 | 3 · Bubble |
eventPhase는 현재 단계를 나타냅니다. 대상 요소에서는 capture 옵션으로 등록한 리스너도 단계 값이 2입니다.
모달에서 두 값 비교하기
① 기존 처리에서 저장을 누르면, 버튼이 저장하고 배경이 창을 닫습니다. 이어 ② 배경만 닫기로 바꿔 같은 버튼을 눌러 봅니다.
모달에서 저장과 닫기 비교
배경에서 닫는 조건만 바꿔, 같은 저장 버튼 클릭을 비교합니다.
- 조건 없이 닫기: 저장 + 창 닫힘
- 배경 직접 클릭만 닫기: 저장 + 창 유지
- 버튼에서 전파 중단: 저장 + 배경 bubble 미실행
두 번째 경우에는 배경이 클릭을 받은 뒤, 두 값이 같을 때만 닫습니다.
backdrop.addEventListener("click", (event) => {
if (event.target !== event.currentTarget) return;
closeSettings();
});
버튼을 누르면 두 값이 달라 창이 유지됩니다. 배경의 빈 부분을 직접 누르면 두 값이 같아서 창을 닫습니다.
5. 이벤트 제어와 위임
이벤트 제어는 전파나 브라우저 기본 동작을 조절하는 일입니다. 이벤트 위임은 부모 리스너에서 자식의 이벤트를 처리하는 방식입니다.
전파 중단과 기본 동작 취소
앞 실습의 ③ 버튼에서 전파 중단은 stopPropagation()으로 버블 전파를 멈춥니다. 배경의 캡처 리스너는 그전에 실행된 상태입니다.
| 방법 | 바뀌는 동작 |
|---|---|
| target과 currentTarget 비교 | 이벤트를 받은 뒤 처리할 조건을 선택합니다. |
| stopPropagation() | 이후 다른 요소로 이어지는 전파를 멈춥니다. |
| preventDefault() | 링크 이동·폼 제출 같은 취소 가능한 기본 동작을 취소합니다. |
preventDefault()를 호출해도 전파는 계속됩니다. 배경 리스너의 closeSettings()도 그대로 실행되므로, 모달을 닫을 조건은 따로 둡니다. 같은 요소에 등록된 뒤쪽 리스너까지 멈추려면 stopImmediatePropagation()을 사용합니다.
목록에서 이벤트 위임하기
아래 목록에서 새 항목을 추가한 뒤 삭제해 봅니다. 새 버튼에도 목록에 등록된 리스너 하나가 삭제를 처리합니다.
할 일 목록에서 삭제 처리
목록 리스너 하나가 삭제 버튼의 클릭을 받습니다. 이벤트 전달과 버튼 탐색은 별도 단계입니다.
- 버블링: 클릭이 목록에 도착
- closest: 클릭한 곳에서 삭제 버튼 찾기
- data-remove: 처리할 항목의 표시
첫 그림은 클릭이 목록으로 전달되는 경로입니다. 삭제 글자를 누르면 target은 버튼 안의 span이 됩니다. 두 번째 그림은 리스너 안에서 처리할 버튼을 찾는 과정입니다. closest()로 span을 감싼 삭제 버튼을 찾습니다.
list.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest("button[data-remove]");
if (!button || !list.contains(button)) return;
button.closest("li")?.remove();
});
버블링은 이벤트를 전달하고, closest()는 처리할 요소를 찾습니다. 목록이 중첩된 경우에는 어느 목록의 항목인지 확인하는 조건도 필요합니다.
6. 리스너 수명
리스너 수명은 등록한 함수가 이벤트를 받을 수 있는 기간입니다. 아래 코드는 기능을 끌 때 AbortController로 리스너를 해제합니다.
const controller = new AbortController();
button.addEventListener("click", handleClick, {
signal: controller.signal,
});
// 기능을 끌 때
controller.abort();
한 번만 받을 이벤트라면 { once: true }를 사용할 수 있습니다. 직접 removeEventListener()로 해제할 때는 이벤트 종류, 등록했던 함수 참조, capture 값을 맞춥니다.
passive는 기본 동작을 취소하지 않겠다는 등록 옵션입니다. { passive: true }인 리스너에서는 preventDefault()가 반영되지 않습니다.
정리
DOM은 요소의 부모·자식 구조를 만들고, 이벤트는 그 경로에 등록된 리스너들에게 전달됩니다. 한 번의 클릭으로 여러 함수가 실행될 수 있는 이유도 여기에 있습니다.
이때 target으로 이벤트 대상을, currentTarget으로 현재 리스너 위치를 확인합니다. eventPhase를 함께 보면 캡처·대상·버블 중 어느 단계에서 처리하고 있는지도 알 수 있습니다.
모달과 목록은 같은 전파를 서로 다르게 활용한 사례입니다.
- 모달: 배경도 클릭을 받되, target과 currentTarget이 같을 때만 닫습니다.
- 목록: 자식의 클릭을 부모에서 받고, closest()로 삭제할 버튼을 찾습니다.
전파 자체를 멈출 때는 stopPropagation(), 링크 이동 같은 기본 동작을 취소할 때는 preventDefault()를 사용합니다. 리스너가 필요 없어지면 등록할 때 연결한 signal이나 같은 함수 참조로 해제합니다.
어디서 발생한 이벤트인지, 지금 어느 리스너가 받고 있는지부터 확인하면 처리 조건을 바꿀지, 전파를 멈출지 판단할 수 있습니다.