Devin.KR
로그인

자바스크립트 이벤트 - addEventListener, 버블링과 이벤트 위임, preventDefault

개발자 조회 1

이 단원에서 배우는 것

7단원에서 참가자 배열을 화면에 그리는 renderMembers를 만들었다. 하지만 아직 화면은 한 번 그려지고 끝이다. 이번 단원에서는 사용자가 버튼을 누르고 값을 입력했을 때 그 배열을 고치고 다시 그리도록 만든다. 초급 커리큘럼의 마지막 단원이자, 앞의 일곱 단원이 하나로 합쳐지는 지점이다.

  • addEventListener로 이벤트를 등록하고, 필요할 때 정확히 제거한다.
  • 버블링을 이용한 이벤트 위임으로, 나중에 만들어진 요소까지 한 번에 처리한다.
  • 폼 제출과 링크의 기본 동작을 막고 원하는 동작으로 대체한다.

브라우저 전용이다. AbortController를 이용한 리스너 제거는 2021년 이후 브라우저에서 쓸 수 있다.

왜 필요한가

참가자를 추가하는 버튼을 만든다고 하자. HTML 속성에 직접 쓰는 방식이 가장 먼저 눈에 들어온다.

<button onclick="addMember()">추가</button>

동작은 한다. 하지만 세 가지가 걸린다. 첫째, 함수 이름이 전역에 있어야 해서 다른 스크립트와 부딪힌다. 둘째, 한 요소에 처리 함수를 하나밖에 못 단다. 셋째, HTML과 자바스크립트가 섞여서 어느 파일에 무슨 동작이 붙어 있는지 추적이 안 된다.

그리고 더 큰 문제가 있다. 7단원에서 목록을 다시 그릴 때마다 li는 새 요소로 교체된다. 각 줄의 삭제 버튼에 일일이 처리 함수를 붙여 뒀다면, 다시 그리는 순간 전부 사라진다. 이 문제를 구조적으로 푸는 방법이 이벤트 위임이고, 그러려면 이벤트가 어떻게 전달되는지부터 알아야 한다.

문법과 예제

실습용 HTML

<body>
  <h1>스터디 참가자 명단</h1>

  <form id="add-form">
    <input id="name-input" type="text" placeholder="이름">
    <button type="submit">추가</button>
  </form>

  <label>
    <input id="unpaid-only" type="checkbox"> 미납자만 보기
  </label>

  <p id="summary"></p>
  <ul id="member-list"></ul>
</body>

등록과 이벤트 객체

const checkbox = document.querySelector("#unpaid-only");

checkbox.addEventListener("change", (event) => {
  console.log(event.type);            // change
  console.log(event.target.checked);  // true / false
});

처리 함수는 이벤트 객체를 인자로 받는다. 여기서 자주 쓰는 것은 다음 세 가지다.

속성의미
event.target실제로 이벤트가 일어난 요소(눌린 그 버튼)
event.currentTarget지금 처리 함수가 붙어 있는 요소
event.preventDefault()브라우저 기본 동작을 취소

둘이 같은 경우가 많아서 구분이 흐릿해지는데, 위임을 쓰는 순간 완전히 달라진다. 아래에서 다시 본다.

기본 동작 막기 — 폼 제출

const form = document.querySelector("#add-form");
const nameInput = document.querySelector("#name-input");

form.addEventListener("submit", (event) => {
  event.preventDefault();          // 이게 없으면 페이지가 새로고침된다

  const name = nameInput.value.trim();
  if (name === "") return;

  members.push({ id: nextId++, name, sessions: 0, paid: false });
  nameInput.value = "";
  render();
});

폼의 기본 동작은 "값을 서버로 보내고 페이지를 다시 불러오기"다. 화면 안에서 처리할 거라면 그 동작을 막아야 한다. { id: nextId++, name, ... }에서 name만 쓴 것은 단축 속성이라는 문법이다. 키 이름과 변수 이름이 같으면 한 번만 쓴다.

버블링

목록 안의 버튼을 누르면 이벤트는 그 버튼에서 시작해 부모로 거슬러 올라간다. buttonliulbodydocument 순이다. 그래서 ul에 붙인 처리 함수도 안쪽 버튼의 클릭을 받는다.

document.querySelector("#member-list").addEventListener("click", (event) => {
  console.log("target:", event.target.tagName);              // BUTTON
  console.log("currentTarget:", event.currentTarget.tagName); // UL
});

이 성질이 이벤트 위임의 근거다. li가 몇 개든, 나중에 몇 개가 더 생기든 ul 하나에만 처리 함수를 붙이면 된다.

이벤트 위임 — 삭제 버튼 처리

7단원의 createRow에 삭제 버튼을 추가한다.

function createRow(member) {
  const li = document.createElement("li");
  li.dataset.memberId = String(member.id);

  const text = document.createElement("span");
  text.textContent = `${member.name} / 출석 ${member.sessions}회 / ${member.paid ? "납부" : "미납"}`;

  const removeButton = document.createElement("button");
  removeButton.type = "button";
  removeButton.textContent = "삭제";
  removeButton.dataset.action = "remove";

  li.append(text, removeButton);
  return li;
}

버튼마다 처리 함수를 붙이지 않고, 목록 전체에 하나만 붙인다.

const list = document.querySelector("#member-list");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action='remove']");
  if (button === null) return;              // 버튼이 아닌 곳을 눌렀으면 무시

  const li = button.closest("li");
  const id = Number(li.dataset.memberId);   // dataset 은 문자열이므로 변환한다

  members = members.filter((m) => m.id !== id);
  render();
});

closest는 자기 자신부터 시작해 위로 올라가며 선택자에 맞는 가장 가까운 조상을 찾고, 없으면 null을 준다. 버튼 안에 아이콘 같은 요소가 들어 있어서 event.target이 버튼이 아닐 수도 있기 때문에, event.target === button으로 비교하는 대신 closest를 쓴다.

여기서 event.target은 실제로 눌린 요소(button 또는 그 안의 무엇)이고, event.currentTarget은 항상 ul이다. 위임에서는 이 둘을 절대 바꿔 쓰면 안 된다.

전체 코드

앞 단원들에서 만든 것을 합치면 이렇게 된다. study.js에 그대로 넣고 defer로 불러오면 동작한다.

let members = [
  { id: 1, name: "김지훈", sessions: 8, paid: true },
  { id: 2, name: "박서연", sessions: 3, paid: false },
  { id: 3, name: "이도현", sessions: 0, paid: true }
];
let nextId = 4;

const listBox = document.querySelector("#member-list");
const summaryBox = document.querySelector("#summary");
const form = document.querySelector("#add-form");
const nameInput = document.querySelector("#name-input");
const unpaidOnly = document.querySelector("#unpaid-only");

function createRow(member) {
  const li = document.createElement("li");
  li.dataset.memberId = String(member.id);

  const text = document.createElement("span");
  text.textContent = `${member.name} / 출석 ${member.sessions}회 / ${member.paid ? "납부" : "미납"}`;

  const removeButton = document.createElement("button");
  removeButton.type = "button";
  removeButton.textContent = "삭제";
  removeButton.dataset.action = "remove";

  li.append(text, removeButton);
  return li;
}

function render() {
  const visible = unpaidOnly.checked ? members.filter((m) => !m.paid) : members;

  listBox.replaceChildren(...visible.map(createRow));
  summaryBox.textContent = visible.length === 0
    ? "표시할 참가자가 없습니다"
    : `표시 ${visible.length}명 / 전체 ${members.length}명`;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const name = nameInput.value.trim();
  if (name === "") return;

  members.push({ id: nextId++, name, sessions: 0, paid: false });
  nameInput.value = "";
  render();
});

unpaidOnly.addEventListener("change", render);

listBox.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action='remove']");
  if (button === null) return;

  const id = Number(button.closest("li").dataset.memberId);
  members = members.filter((m) => m.id !== id);
  render();
});

render();

구조를 다시 보면 이렇다. 이벤트는 데이터를 고치고, render는 데이터를 화면으로 바꾼다. 이벤트 처리 함수 안에서 DOM을 직접 손대는 코드가 한 줄도 없다는 점이 중요하다. 화면과 데이터가 어긋날 여지가 사라진다.

리스너 제거와 옵션

function onScroll() { console.log("스크롤"); }

window.addEventListener("scroll", onScroll);
window.removeEventListener("scroll", onScroll);   // 같은 함수를 넘겨야 제거된다

// 한 번만 실행하고 자동 해제
button.addEventListener("click", onScroll, { once: true });

// 여러 리스너를 한 번에 해제
function onResize() { console.log("창 크기 변경"); }

const controller = new AbortController();
window.addEventListener("scroll", onScroll, { signal: controller.signal });
window.addEventListener("resize", onResize, { signal: controller.signal });
controller.abort();     // 위 두 개가 동시에 해제된다

실무에서 자주 틀리는 것

1. 함수를 등록하는 대신 호출해 버린다

button.addEventListener("click", render());    // 잘못
button.addEventListener("click", render);      // 올바름

괄호를 붙이면 지금 당장 실행되고, 그 반환값(대개 undefined)이 등록된다. 증상은 "페이지를 열자마자 한 번 실행되고, 정작 클릭하면 아무 일도 안 일어남"이다. 인자를 넘겨야 한다면 화살표 함수로 감싼다.

button.addEventListener("click", () => removeMember(3));

2. 익명 함수로 등록해 놓고 제거하려 한다

window.addEventListener("scroll", () => console.log("스크롤"));
window.removeEventListener("scroll", () => console.log("스크롤"));   // 제거되지 않는다

두 화살표 함수는 생김새만 같을 뿐 서로 다른 값이다. removeEventListener같은 함수인지로 판단하므로 아무 일도 일어나지 않고, 오류도 안 난다. 나중에 제거할 리스너는 이름 있는 함수로 만들거나 { signal }을 쓴다. 화면 전환이 잦은 앱에서 이 실수가 쌓이면 리스너가 계속 누적돼 같은 동작이 여러 번 실행된다.

3. 폼 안의 button 이 페이지를 새로고침한다

<form>
  <button>삭제</button>              <!-- type 이 없으면 submit 이다 -->
  <button type="button">삭제</button> <!-- 이렇게 써야 한다 -->
</form>

form 안의 buttontype을 생략하면 기본값이 submit이다. 삭제 버튼을 눌렀는데 폼이 제출되면서 페이지가 새로고침되고, 방금 고친 데이터가 사라진다. 증상이 "클릭하면 화면이 깜빡이고 원래대로 돌아옴"이라 원인을 엉뚱한 데서 찾기 쉽다. 위 전체 코드에서 removeButton.type = "button"을 지정한 이유다.

4. 다시 그린 뒤 리스너가 사라졌다고 다시 붙인다

// 나쁜 예 — 다시 그릴 때마다 버튼마다 새로 붙인다
function render() {
  listBox.replaceChildren(...members.map(createRow));
  listBox.querySelectorAll("button").forEach((b) => {
    b.addEventListener("click", onRemove);
  });
}

동작은 하지만 줄 수만큼 리스너가 생기고, 다시 그릴 때마다 그 작업을 반복한다. 게다가 render를 두 번 부르는 실수라도 하면 같은 요소에 리스너가 두 번 붙어 삭제가 두 번 실행된다. 위임을 쓰면 부모에 하나만 붙이고 끝난다. 목록처럼 개수가 변하는 요소는 위임이 기본이다.

5. stopPropagation 으로 문제를 덮는다

event.stopPropagation();   // 이 이벤트가 부모로 올라가지 않는다

당장은 원하는 동작이 나오지만, 같은 페이지의 다른 기능이 조용히 죽는다. 바깥을 클릭하면 닫히는 팝업, 통계 수집, 상위에 걸린 다른 위임 처리기가 전부 부모에서 이벤트를 기다리고 있기 때문이다. 게다가 원인이 전혀 다른 파일에 있어서 추적이 오래 걸린다. 대부분의 경우 stopPropagation 대신 상위 처리기 쪽에서 event.target.closest(...)로 조건을 좁히는 것이 옳다. preventDefault는 이것과 전혀 다른 기능이니 헷갈리지 않는다. 하나는 전달을 막고, 하나는 브라우저 기본 동작을 막는다.

이벤트 옵션의 전체 목록은 MDN의 EventTarget.addEventListener 문서에 정리돼 있다.

스스로 확인하기

  1. 다음 코드는 페이지를 열자마자 한 번 실행되고 클릭에는 반응하지 않는다. 이유와 고친 코드를 쓰라.
    button.addEventListener("click", render());
  2. 목록의 각 줄에 "출석 +1" 버튼을 추가하려 한다. createRow에 어떤 요소를 만들어야 하고, 위임 처리기에서는 어떤 조건으로 그 버튼을 구분해야 하는가?
  3. 폼 제출 처리 함수에서 event.preventDefault()를 빼면 어떤 일이 벌어지는가? 그때 members 배열은 어떻게 되는가?

정답

  1. render()는 함수를 호출한 결과를 등록한다. 등록 시점에 즉시 실행되고, 반환값 undefined가 리스너로 등록되므로 클릭해도 아무 일이 없다. button.addEventListener("click", render);처럼 괄호 없이 함수 자체를 넘긴다.
  2. createRow 안에서 type="button"인 버튼을 하나 더 만들고 dataset.action = "increase"를 붙인다. 위임 처리기에서는 event.target.closest("button[data-action='increase']")로 구분한 뒤, 같은 방식으로 lidataset.memberId를 읽어 해당 참가자의 sessions를 1 늘리고 render()를 부른다.
  3. 폼의 기본 동작인 제출이 그대로 실행되어 페이지가 다시 로드된다. 스크립트가 처음부터 다시 실행되므로 members는 코드에 적힌 초기값으로 되돌아가고, 방금 추가한 참가자는 사라진다. 화면이 깜빡이며 원래대로 돌아가는 것처럼 보이는 이유가 이것이다.