목록을 화면에 뿌리는 코드를 짜다 보면 누구나 한 번은 이 벽에 부딪힌다. 항목이 100개인데 각각 클릭 핸들러를 달아야 하나. 항목이 나중에 더 추가되면 그건 또 어떻게 하나. 나는 처음에 반복문을 돌면서 100개 요소에 핸들러를 하나씩 다 붙였다. 그러다 서버에서 항목이 새로 오면 그건 핸들러가 없어서 클릭이 안 먹혔고, 새로 온 것들에도 또 핸들러를 붙이는 코드를 덧대다 보니 금세 엉망이 됐다. 이 문제를 깔끔하게 푸는 게 이벤트 위임이고, 그걸 이해하려면 먼저 버블링을 알아야 한다.


이번 편은 이벤트가 화면에서 어떻게 퍼지는지, 그 성질을 이용해 핸들러를 어떻게 하나로 줄이는지를 다룬다. 앞 편과 마찬가지로 여기 코드는 브라우저에서 도는 것이라 콘솔에서 결과를 찍을 수 없다. 결과는 실제로 눌렀을 때 무슨 일이 벌어지는지로 설명한다.


버블링은 이벤트가 위로 타고 오르는 성질이다. 목록 항목 안의 글자를 클릭했다고 하자. 그 클릭은 그 글자에서 끝나지 않는다. 클릭된 요소에서 시작해 그것을 감싼 부모로, 다시 그 부모의 부모로, 화면 꼭대기까지 같은 클릭 이벤트가 차례로 전달된다. 물에 잠긴 곳에서 공기 방울이 수면으로 떠오르듯 이벤트가 바깥으로 올라온다고 해서 버블링(bubbling, 거품 오름)이라 부른다.


<ul id="list">
<li>사과</li>
<li>바나나</li>
</ul>


이 구조에서 "사과" 글자를 누르면, 클릭 이벤트는 li를 거쳐 ul로, 다시 그 위로 계속 올라간다. 그러니 ul에만 핸들러를 하나 달아둬도, 그 안 어느 항목을 눌렀든 이벤트가 ul까지 올라오면서 그 핸들러가 불린다. 이게 위임의 씨앗이다.


이벤트 위임은 부모 하나에만 핸들러를 단다. 항목마다 핸들러를 다는 대신, 항목들을 감싼 부모에 핸들러를 딱 하나 단다. 클릭이 부모로 올라오면, 진짜 어디서 시작됐는지를 e.target으로 알아내 처리한다. 이걸 이벤트 위임(event delegation, 이벤트 넘겨 맡김)이라 한다.


const list = document.querySelector("#list");
list.addEventListener("click", function(e) {
if (e.target.tagName === "LI") {
console.log("고른 항목: " + e.target.textContent);
}
});


"바나나"를 누르면 "고른 항목: 바나나"가 찍힌다. 핸들러는 ul에 하나뿐인데 어느 항목을 눌러도 다 잡힌다. 항목이 1000개라도 핸들러는 여전히 하나다. 게다가 나중에 li를 새로 추가해도 그건 ul 안에 들어가는 순간 이미 그 핸들러의 관할이라, 따로 핸들러를 달 필요가 없다. 앞서 내가 새 항목마다 핸들러를 덧대던 삽질이, 이 방식으로 통째로 사라졌다.


target과 currentTarget은 다르다. 여기서 초보가 꼭 한 번 미끄러진다. e.target은 이벤트가 처음 시작된 요소, 즉 실제로 클릭된 것이다. e.currentTarget은 지금 이 핸들러가 붙어 있는 요소다. 위임에서 이 둘은 거의 항상 다르다.


list.addEventListener("click", function(e) {
console.log(e.target.tagName);
console.log(e.currentTarget.tagName);
});


li 하나를 누르면 e.target.tagName은 "LI"(눌린 항목)를, e.currentTarget.tagName은 "UL"(핸들러가 달린 부모)을 찍는다. 나는 이 둘을 같은 걸로 착각하고 e.currentTarget으로 눌린 항목을 알아내려다, 항상 부모만 잡혀서 한참 헤맸다. 진짜 눌린 건 target, 핸들러 붙은 곳은 currentTarget. 이 구분을 붙잡아야 위임이 손에 잡힌다.


closest로 안쪽 요소 클릭까지 잡는다. 방금 코드엔 구멍이 있다. li 안에 아이콘이나 span 같은 게 들어 있으면, 그 안쪽을 눌렀을 때 e.targetli가 아니라 그 span이 된다. 그러면 tagName === "LI" 조건에 안 걸려 처리가 빠진다. 이럴 때 closest를 쓴다. 눌린 지점에서 위로 올라가며 조건에 맞는 가장 가까운 조상을 찾아준다.


list.addEventListener("click", function(e) {
const item = e.target.closest("li");
if (item) {
console.log("고른 항목: " + item.textContent);
}
});


li 안의 어느 자식을 누르든 closest("li")가 그 항목 li를 찾아 올려준다. 만약 목록 밖 엉뚱한 곳을 눌러 맞는 li가 없으면 null을 주니, if (item)으로 걸러 안전하게 넘긴다. 실무 위임 코드는 거의 다 이 closest 형태로 짠다. 항목 안에 뭐가 들었든 안 깨지기 때문이다.


stopPropagation은 올라가는 걸 멈춘다. 가끔은 이벤트가 위로 퍼지는 걸 막아야 한다. 예를 들어 큰 패널을 클릭하면 패널이 닫히게 해놨는데, 그 안의 버튼을 누를 때는 패널이 닫히면 안 된다고 하자. 버튼 핸들러에서 e.stopPropagation을 부르면 그 지점에서 버블링이 멈춰서 부모의 닫기 핸들러까지 올라가지 않는다.


const inner = document.querySelector("#save");
inner.addEventListener("click", function(e) {
e.stopPropagation();
console.log("저장만 하고 패널은 그대로");
});


버튼을 누르면 "저장만 하고 패널은 그대로"가 찍히고, 클릭 이벤트가 부모로 안 올라가서 패널 닫기 핸들러는 불리지 않는다. 다만 stopPropagation은 남용하면 위험하다. 이벤트가 위로 안 올라간다는 건, 부모에 위임으로 걸어둔 다른 핸들러들도 이 클릭을 영영 못 본다는 뜻이다. 나는 이걸 여기저기 뿌려놨다가, 멀쩡히 짜둔 위임 핸들러가 특정 항목에서만 조용히 안 먹혀서 원인을 찾느라 반나절을 날렸다. 진짜 퍼짐을 막아야 할 때만 쓰는 게 맞다.


내려가는 단계도 있다. 사실 이벤트는 위로 올라오기 전에, 꼭대기에서 클릭된 요소까지 먼저 내려온다. 이 내려오는 단계를 캡처링(capturing, 붙잡아 내려옴)이라 한다. 평소엔 신경 쓸 일이 거의 없고 우리가 다루는 건 올라오는 버블링 단계다. addEventListener의 세 번째 인자로 true를 주면 내려오는 단계에서 잡겠다는 뜻인데, 특수한 경우가 아니면 기본값(버블링)으로 두면 된다. 이런 게 있다는 것만 알아두면 나중에 캡처링 코드를 만나도 당황하지 않는다.


정리하면 이벤트는 클릭된 요소에서 시작해 부모들을 타고 위로 올라온다. 이 버블링 덕에 부모 하나에만 핸들러를 달고 e.targetclosest로 진짜 눌린 항목을 알아내는 위임이 가능하다. 핸들러 수가 확 줄고 나중에 추가된 항목도 저절로 잡힌다. currentTargettarget을 구분하고, 퍼짐을 막을 땐 stopPropagation을 아껴 쓰면, 목록을 다루는 코드가 훨씬 단단해진다.