[CS300 #205] DOM 과 이벤트 — 문서를 트리로, 입력을 전파로
컴퓨터공학 300 주제 시리즈의 205번째 글이다. 전체 지도는 여기.
한 줄 요약
DOM 은 HTML 문서를 노드 트리로 표현하고 조작하게 해 주는 표준 API 이고, 이벤트는 그 트리를 따라 캡처 → 대상 → 버블 세 단계로 전달된다.
왜 필요한가
자바스크립트로 화면을 바꾸는 모든 일은 결국 DOM 조작이다. React 나 Vue 를 써도 마지막에는 프레임워크가 DOM API 를 호출한다. 그리고 사용자가 하는 모든 일, 즉 클릭·키 입력·스크롤은 DOM 이벤트로 들어온다.
이벤트 전파를 모르면 이런 일이 생긴다.
- 모달 안의 버튼을 눌렀는데 바깥 클릭 핸들러가 실행되어 모달이 닫힌다.
- 목록 항목 1,000개에 각각 리스너를 달아 메모리를 낭비하고, 새로 추가한 항목에는 리스너가 없다.
focus이벤트를 부모에서 잡으려다 아무 일도 일어나지 않는다.
이 문제들은 전부 “이벤트가 트리 위를 어떻게 이동하는가”로 설명된다.
핵심 개념
DOM 은 노드 트리다
WHATWG DOM 표준은 문서를 Node 의 트리로 정의한다. 주요 노드 종류는 다음과 같다.
| 종류 | 예 | 비고 |
|---|---|---|
| Document | document |
트리의 뿌리 |
| Element | <div>, <button> |
속성을 가진다 |
| Text | 태그 사이 글자 | 공백만 있는 텍스트도 노드다 |
| Comment | <!-- --> |
화면에 안 보이지만 트리에 있다 |
| DocumentFragment | document.createDocumentFragment() |
여러 노드를 모아 한 번에 붙일 때 |
자주 쓰는 API 를 성격별로 묶으면 다음과 같다.
// 찾기
document.querySelector("#menu li.active");
document.querySelectorAll("li"); // 정적 NodeList (그 시점 스냅숏)
document.getElementsByTagName("li"); // 살아 있는 HTMLCollection
// 만들고 붙이기
const li = document.createElement("li");
li.textContent = "새 항목"; // 문자열을 그대로 넣는다 (HTML 해석 안 함)
menu.append(li);
// 고치기
li.classList.add("active");
li.setAttribute("aria-current", "page");
li.remove();
innerHTML 에 사용자 입력을 넣으면 그 문자열이 HTML 로 해석되어 스크립트 주입(XSS)의 통로가 된다. 글자만 넣을 때는 textContent 를 쓴다.
querySelectorAll 은 호출 시점의 결과를 고정한 목록을 돌려주고, getElementsBy* 는 DOM 이 바뀌면 함께 바뀌는 목록을 돌려준다. 반복문 안에서 노드를 지우다 순서가 꼬이는 버그가 여기서 나온다.
이벤트 디스패치의 세 단계
이벤트가 발생하면 브라우저는 먼저 대상에서 뿌리까지의 경로를 계산하고, 그 경로를 따라 리스너를 호출한다.
window ──────────────┐ ▲
document │ ① 캡처 │ ③ 버블
ul#menu │ (위→아래) │ (아래→위)
li ▼ │
button ← ② 대상(target) ─┘
el.addEventListener("click", handler); // 버블 단계
el.addEventListener("click", handler, { capture: true }); // 캡처 단계
el.addEventListener("click", handler, { once: true }); // 한 번만
el.addEventListener("wheel", handler, { passive: true }); // preventDefault 안 함 약속
리스너 안에서 쓰는 이벤트 객체의 핵심 속성은 다음과 같다.
| 속성·메서드 | 의미 |
|---|---|
event.target |
이벤트가 실제로 일어난 가장 안쪽 노드 |
event.currentTarget |
지금 실행 중인 리스너가 붙은 노드 |
event.stopPropagation() |
경로의 다음 노드로 전파 중단 |
event.preventDefault() |
기본 동작 취소 (링크 이동, 폼 제출, 스크롤 등) |
event.bubbles |
버블 단계가 있는 이벤트인가 |
전파 중단과 기본 동작 취소는 별개다. stopPropagation() 을 해도 링크는 이동하고, preventDefault() 를 해도 이벤트는 계속 전파된다.
버블되지 않는 이벤트
모든 이벤트가 버블되지는 않는다. focus, blur 는 버블되지 않고, 대신 버블되는 focusin, focusout 이 있다. mouseenter, mouseleave 도 버블되지 않는다. 부모에서 자식의 포커스를 감지하려면 focusin 을 쓰거나 캡처 단계에서 잡는다.
이벤트 위임
버블 덕분에 공통 조상 하나에 리스너를 달고 event.target 으로 실제 대상을 판별할 수 있다.
document.querySelector("#menu").addEventListener("click", (e) => {
const item = e.target.closest("li");
if (!item || !e.currentTarget.contains(item)) return;
select(item.dataset.id);
});
리스너가 하나라 메모리를 덜 쓰고, 나중에 추가된 항목도 자동으로 처리된다. closest() 를 쓰는 이유는 <li> 안의 <span> 을 클릭하면 target 이 <span> 이 되기 때문이다.
passive 리스너와 스크롤
touchstart, wheel 리스너는 preventDefault() 로 스크롤을 막을 수 있다. 그래서 브라우저는 리스너가 끝날 때까지 스크롤을 시작하지 못하고 기다려야 한다. { passive: true } 는 “막지 않겠다”는 약속이라 브라우저가 기다리지 않고 바로 스크롤한다. 스크롤 성능에 직접 영향을 준다.
직접 해 보기
DOM 표준의 디스패치 순서를 파이썬으로 흉내 낸다. 경로를 먼저 계산하고, 캡처·대상·버블 단계를 차례로 돈다.
class Node:
def __init__(self, name, parent=None):
self.name, self.parent, self.listeners = name, parent, []
def add(self, fn, capture=False):
self.listeners.append((fn, capture))
class Event:
def __init__(self, type_, bubbles=True):
self.type, self.bubbles = type_, bubbles
self.target = self.current = None
self.stopped = False
def stop_propagation(self):
self.stopped = True
def dispatch(target, ev):
path, n = [], target
while n: # 대상에서 루트까지 경로를 미리 계산
path.append(n); n = n.parent
path.reverse() # [window, document, ..., target]
ev.target = target
for node in path[:-1]: # 1) 캡처 단계: 위에서 아래로
if ev.stopped: return
ev.current = node
for fn, cap in node.listeners:
if cap: fn(ev, "capture")
if ev.stopped: return
ev.current = target # 2) 대상 단계
for fn, cap in target.listeners:
fn(ev, "target")
if not ev.bubbles: return
for node in reversed(path[:-1]): # 3) 버블 단계: 아래에서 위로
if ev.stopped: return
ev.current = node
for fn, cap in node.listeners:
if not cap: fn(ev, "bubble")
def log(ev, phase):
print(f"{phase:8} current={ev.current.name:9} target={ev.target.name}")
window = Node("window"); doc = Node("document", window)
ul = Node("ul#menu", doc); li = Node("li", ul); btn = Node("button", li)
for n in (window, doc, ul, li):
n.add(log, capture=True); n.add(log)
btn.add(log)
print("== click (bubbles) =="); dispatch(btn, Event("click"))
print("== focus (bubbles=False) =="); dispatch(btn, Event("focus", bubbles=False))
def stopper(ev, phase):
log(ev, phase); print(" -> stopPropagation()"); ev.stop_propagation()
li.listeners = [(stopper, False)] # li 의 리스너를 '버블에서 중단'으로 교체
print("== li 가 버블 단계에서 전파 중단 =="); dispatch(btn, Event("click"))
실행 결과:
== click (bubbles) ==
capture current=window target=button
capture current=document target=button
capture current=ul#menu target=button
capture current=li target=button
target current=button target=button
bubble current=li target=button
bubble current=ul#menu target=button
bubble current=document target=button
bubble current=window target=button
== focus (bubbles=False) ==
capture current=window target=button
capture current=document target=button
capture current=ul#menu target=button
capture current=li target=button
target current=button target=button
== li 가 버블 단계에서 전파 중단 ==
capture current=window target=button
capture current=document target=button
capture current=ul#menu target=button
target current=button target=button
bubble current=li target=button
-> stopPropagation()
target 은 끝까지 button 이고 current 만 바뀐다. 버블되지 않는 focus 도 캡처 단계는 거친다는 점, li 에서 전파를 멈추자 ul#menu 이상의 버블 리스너가 실행되지 않는다는 점을 확인할 수 있다. 실제 브라우저에서는 같은 구조의 HTML 에 리스너를 달고 콘솔에 e.eventPhase 를 찍어 보면 1(캡처), 2(대상), 3(버블)이 나온다.
현업에서는
- “바깥 클릭 시 닫기”: 모달·드롭다운은 보통
document에 클릭 리스너를 달고,event.target이 모달 안에 있는지contains()로 검사한다. 내부 버튼에서 무심코stopPropagation()을 쓰면 다른 컴포넌트의 바깥 클릭 감지가 깨지므로, 전파 중단은 마지막 수단으로 남겨 둔다. - 큰 표와 목록: 행마다 리스너를 다는 대신
<tbody>에 하나를 달고 위임한다. React 도 내부적으로 루트 컨테이너에 리스너를 달아 위임하는 방식을 쓴다. - XSS 점검: 코드 리뷰에서
innerHTML,outerHTML,insertAdjacentHTML에 외부 값이 들어가는지를 먼저 본다. - 크롤러·E2E 테스트: Playwright 같은 도구로 홈랩 서비스의 관리 화면을 자동 점검할 때도, 클릭이 실제로 어느 요소에 떨어지는지(위에 투명한 요소가 덮고 있지 않은지)가 실패 원인이 되는 경우가 많다. 이벤트 대상 개념을 알면 빨리 찾는다.
확인 문제
event.target과event.currentTarget의 차이는?stopPropagation()과preventDefault()는 각각 무엇을 막는가.- 이벤트 위임의 장점 두 가지와,
closest()를 함께 쓰는 이유를 설명하라. - 부모 요소에서 자식 입력칸의 포커스를 감지하려면 어떻게 하는가.
{ passive: true }가 스크롤 성능을 높이는 이유는?
풀이
target은 이벤트가 실제로 일어난 가장 안쪽 노드,currentTarget은 지금 실행 중인 리스너가 등록된 노드다.stopPropagation()은 경로상 다음 노드로의 전파를,preventDefault()는 브라우저 기본 동작을 막는다. 서로 독립이다.- 리스너 수가 줄어 메모리를 아끼고, 나중에 추가된 자식도 처리된다. 클릭이 자식의 자식에서 일어나면
target이 더 안쪽 노드이므로closest()로 원하는 항목까지 올라간다. - 버블되는
focusin을 쓰거나,focus를 캡처 단계({ capture: true })에서 잡는다. - 리스너가 스크롤을 막지 않는다고 미리 약속하므로 브라우저가 리스너 실행을 기다리지 않고 바로 스크롤을 시작할 수 있다.
더 읽을거리 (References)
- WHATWG, DOM Living Standard: https://dom.spec.whatwg.org/
- MDN, EventTarget: addEventListener() method: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
- MDN, Event bubbling: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Event_bubbling
- MDN, Document Object Model (DOM): https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model