본문 바로가기

웹 개발/프론트엔드 - JavaScript

[JavaScript] 한 달 동안 자바스크립트 박살내기 (14) - 브라우저 이벤트

「한 달 동안 자바스크립트 박살내기」는, ChatGPT쨩이 선생님이 되어, 매일 수업해주는 느낌으로 진행합니다.

GPT쨩의 말은 이렇게 작성합니다.

 

 

 

오늘 공부 내용 및 목표

 

오늘 학습 목표

어제는 state → render() → DOM 흐름을 만들었지. 오늘은 그 흐름의 시작점이 되는 브라우저 이벤트(event) 를 다룬다. 버튼 클릭처럼 사용자의 행동이 어디서 시작되고, DOM 트리를 따라 어떻게 전달되며, 어떤 요소가 실제 출발점인지 구분할 수 있어야 해.

오늘 핵심은 API 이름을 외우는 게 아니라 이벤트가 DOM 트리를 따라 이동하는 흐름을 머릿속으로 그리는 거야. 이걸 잡아두면 오래된 jQuery 코드의 이벤트 처리나 Backbone View의 events도 훨씬 읽기 쉬워진다.

그렇다고 한다. 오늘도 가보자잇!

 

오늘의 위치

단계: 브라우저 DOM / 이벤트
주제: bubbling, capturing, target / currentTarget, preventDefault, stopPropagation, event delegation
필수 예상시간: 약 90분

 

 

사전 체크 1 - event listener

브라우저에서 event(이벤트) 는 클릭, 입력, 제출처럼 브라우저가 감지한 사건이야.
const button = document.querySelector("#attack");  
  
button.addEventListener("click", function () {  
 console.log("attack");  
});

대표적으로 버튼의 click 이벤트가 있다. 유저가 버튼을 클릭하면, 브라우저가 그 사실을 감지한다.

 

addEventListener()는 button에서 click 이벤트가 발생했을 때 실행할 callback을 등록한다.

이벤트가 발생하면 실행할 함수를 등록해준다.

예를 들어, 버튼이 클릭되면 addCount()가 실행되도록 해서, 카운트가 올라가게 한다든지.

 

 

사전 체크 2 - event object

이벤트 callback은 브라우저가 넘겨주는 객체를 받을 수 있어.
button.addEventListener("click", function (event) {  
 console.log(event.type); // "click"  
});

이벤트 콜백 함수에, 브라우저가 감지한 이벤트 객체를 넘기는 것이 가능하다.

이를 통해, 이벤트의 종류나, 이벤트가 발생한 요소에 대해 접근해서 처리할 수 있다.

 

 

 

어제 내용 복습

 

복습 1

다음에서 state.hp를 50으로 바꾸기만 하면 화면의 <span id="hp">도 자동으로 50이 될까?
const state = { hp: 100 };  
state.hp = 50;

안된다. 상태가 바뀌었으면, 그걸 가지고 갱신도 해주어야 한다.

정답. 보충하자면, state와 DOM은 별개이기 때문에 render()와 같은 반영 과정이 필요하다.

 

 

복습 2

document.querySelector("#missing")이 요소를 찾지 못하면 무엇을 반환할까?

undefinned가 반환된다.

오답. null이 반환된다.

 

 

복습 3

await는 JavaScript 전체 실행을 멈추는가, 아니면 현재 async 함수의 나머지 진행을 미루는가?

현재 async 함수의 나머지 진행만 미룬다.

정답.

 

 

복습 4

closure는 바깥 변수의 값을 복사해 저장하는가, 아니면 그 변수에 대한 접근을 유지하는가?

후자.

정답.

 

 

 

핵심 수업

 

event propagation - 이벤트가 DOM 트리를 따라 전달되는 과정

HTML 요소는 부모/자식 관계의 트리를 이룬다.
<div id="battle">  
 <button id="attack">공격</button>  
</div>
사용자가 button을 클릭했을 때 이벤트는 버튼 하나에서만 끝나는 게 아니라 DOM 트리를 따라 이동한다. 이 전체 과정을 event propagation(이벤트 전파) 이라고 해.

이 전파에는 크게 capturing과 bubbling 단계가 있다.

HTML요소가 부모-자식 관계를 가지기 때문에, 어떤 요소에 이벤트가 발생해도 그 부모 혹은 자식 요소에 까지 영향을 끼칠 수 있다는 말인 것 같다.

 

 

이벤트의 전파 순서 - capturing과 bubbling

<div id="battle">  
 <button id="attack">공격</button>  
</div>

이 예제에서, 공격 버튼이 눌렸을 때 이벤트 전파 과정은 다음과 같다.

 

1. document
↓
2. battle
↓
3. attack(button)
↓
4. battle
↓
5. document

바깥쪽 요소로 들어가서 (1~3), 이벤트가 발생한 대상까지 들어간 다음 (3), 다시 바깥쪽으로 나온다. (3~5)

이 때, 1~3의 페이즈를 capturing phase, 3을 target phase, 3~5의 페이즈를 bubbling phase라고 한다.

 

const battle = document.querySelector("#battle");  
const attack = document.querySelector("#attack");  
  
battle.addEventListener("click", function () {  
 console.log("battle");  
});  
  
attack.addEventListener("click", function () {  
 console.log("attack");  
});

기본 addEventListener은, 보통 bubbling 단계에서 callback을 실행한다고 한다.

그래서, 이 예제같은 경우엔 attack, battle 순으로 출력된다.

 

하지만, addEventListener에 인수를 추가해 capturing 단계에서 callback을 실행하도록 하는 것도 가능하다.

battle.addEventListener("click", function () {  
 console.log("battle capture");  
}, true);

battle.addEventListener("click", function () {  
 console.log("battle");  
});  
  
attack.addEventListener("click", function () {  
 console.log("attack");  
});

이렇게 하면, battle capture, attack, battle 순으로 출력된다.

 

 

event.target - 이벤트가 실제로 시작된 요소

event.target은 사용자가 실제로 클릭한 가장 안쪽 요소라고 생각하면 돼.
<button id="attack">  
 <span>공격</span>  
</button>
button.addEventListener("click", function (event) {  
 console.log(event.target);  
});

이 경우, 만약 유저가 span 부분을 클릭한다면 event.target은 span이다. 

즉, listener를 어디에 등록했는지와 실제 시작점은 다를 수가 있다.

listener가 어디에 등록됐는지를 알고싶다면, 후술할 event.currentTarget을 쓰면 된다.

 

 

event.currentTarget - 현재 listener가 실행되고 있는 요소

event.currentTarget은 지금 실행 중인 listener가 등록된 요소야.
const battle = document.querySelector("#battle");  
  
battle.addEventListener("click", function (event) {  
 console.log(event.target);  
 console.log(event.currentTarget);  
});

event.target은 event가 실제로 시작된 가장 안쪽 요소를 의미하고,

event.currentTarget은 실행중인 listener가 어떤 요소의 것인지를 출력한다.

앞선 예제의 경우면, battle쪽이 출력된다.

 

 

preventDefault() - 브라우저의 기본 동작을 막는 메소드

브라우저에는 이벤트와 함께 기본적으로 수행하는 행동이 있다.
예를 들어, 링크 클릭의 경우엔 기본 동작이 해당 주소로 이동하는 것인데, preventDefault()는 기본 행동을 막는다.
<div id="outer">
  <a id="link" href="https://example.com">이동</a>
</div>
const link = document.querySelector("#link");  
  
link.addEventListener("click", function (event) {  
 event.preventDefault();  
 console.log("이동하지 않음");  
});

이렇게 되면, a태그의 링크 이동을 하지 않게 된다.

 

중요한 점:
preventDefault() ≠ 이벤트 전파 중단
이벤트는 여전히 부모 쪽으로 bubbling할 수 있어.
const outer = document.querySelector("#outer");
const link = document.querySelector("#link");

link.addEventListener("click", (event) => {
  event.preventDefault();
  console.log("link click");
});

outer.addEventListener("click", () => {
  console.log("outer click");
});

이러면, link click과 outer click이 모두 출력된다.

preventDefault()가 link의 클릭 이벤트 콜백 안에서 호출되지만, 이벤트의 전파를 막지는 못한다.

이벤트의 전파를 막기 위해선, 후술할 stopPropagation()을 사용한다.

 

 

event.stopPropagation() - 이벤트가 더 이상 전파되지 않게 하는 메소드

attack.addEventListener("click", function (event) {  
 event.stopPropagation();  
 console.log("attack");  
});

이 경우, click 이벤트가 bubbling phase에서 부모로 전파되는걸 막을 수 있다.

브라우저 기본 행동을 막는 preventDefault()와 헷갈리지 않게 주의.

 

 

event delegation - 부모 하나가 자식들의 이벤트를 대신 처리하는 패턴

버튼이 100개 있다고 해보자.
<ul id="skills">  
 <li><button data-skill="attack">Attack</button></li>  
 <li><button data-skill="heal">Heal</button></li>  
 <li><button data-skill="guard">Guard</button></li>  
 ...
</ul>
각 버튼마다 listener를 하나씩 붙일 수도 있지만, bubbling을 이용하면 부모 하나에서 처리할 수 있어.
const skills = document.querySelector("#skills");  
  
skills.addEventListener("click", function (event) {  
 if (event.target.tagName !== "BUTTON") {  
   return;  
 }  
  
 console.log(event.target.dataset.skill);  
});

dataset은, 요소의 data-* 속성을 읽기 위한 프로퍼티이다.

위 예제의 경우, data-skill 속성으로 지정되어있어 dataset.skill로 접근하면 된다.

그래서 출력은 attack, heal, guard, ...이 되는데, 중요한건 부모 요소에만 addEventListener를 했는데도 그 자식 요소 전부에 적용되었다는 것이다.

이렇게 이벤트를 자식 요소에 위임해주면 자식마다 listener를 반복해서 등록해줄 필요가 없고, 나중에 동적으로 추가된 자식도 같은 부모 listener에서 처리하기 쉽다.

 

 

 

코드 보고 동작 예측하기

 

예측 1

<div id="parent">  
 <button id="child">click</button>  
</div>
parent.addEventListener("click", function () {  
 console.log("parent");  
});  
  
child.addEventListener("click", function () {  
 console.log("child");  
});
버튼을 클릭하면 기본 설정에서 출력 순서는?

일반적인 addEventListener는 bubbling 페이즈에서 callback이 실행되기 때문에, child, parent순으로 출력된다.

정답.

 

 

예측 2

parent.addEventListener("click", function (event) {  
 console.log(event.target.id);  
 console.log(event.currentTarget.id);  
});
#child 버튼을 클릭했다면 두 줄은 무엇을 출력할까?

각각 child, parent를 출력한다.

정답.

 

 

예측 3

child.addEventListener("click", function (event) {  
 event.preventDefault();  
});
이 코드만으로 bubbling도 멈출까?

멈추지 않는다. preventDefault()는 브라우저 기본 동작을 멈추지, 이벤트 전파를 막지는 앞기 때문이다.

정답.

 

 

 

직접 코딩

 

전투 UI 확장하기

<h2 id="name"></h2>  
<p id="hp"></p>  
  
<div id="actions">  
 <button data-action="damage">20 데미지</button>  
 <button data-action="heal">10 회복</button>  
 <button data-action="reset">리셋</button>  
</div>
const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100  
};
#actions 하나에만 click listener를 등록해서 세 버튼을 처리해봐.

아래와 같이 작성해주었다.

 

const battleState = {  
 name: "Gran",  
 hp: 100,  
 maxHp: 100  
};  

const actions = document.querySelector("#actions");
const nameUI = document.querySelector("#name");
const hpUI = document.querySelector("#hp");

const render = () => {
  nameUI.textContent = battleState.name;
  hpUI.textContent = battleState.hp;
}

render();

actions.addEventListener("click", (e) => {
  if(!(e.target instanceof HTMLElement)){
    return;
  }

  switch(e.target.dataset.action){
    case "damage":
      battleState.hp = Math.max(battleState.hp - 20, 0);
      break;
    case "heal":
      battleState.hp = Math.min(battleState.hp + 10, battleState.maxHp);
      break;
    case "reset":
      battleState.hp = battleState.maxHp;
      break;
  }

  render();
})

 

 

코드 읽기 / 디버깅 드릴 1

<div id="panel">  
 <button id="attack">Attack</button>  
</div>
panel.addEventListener("click", function (event) {  
 console.log(event.currentTarget.id);  
});
버튼을 클릭했는데 개발자는 attack이 출력될 거라고 예상했다. 실제로는 panel이 출력된다. 왜일까?

currentTarget은 addEventListener가 달려있는 요소를 출력하는데, addEventListener가 panel에 달려있기 때문이다.

 

 

코드 읽기 / 디버깅 드릴 2

다음 코드에서 부모 listener가 실행되지 않는다. 원인을 설명해봐.
child.addEventListener("click", function (event) {  
 event.stopPropagation();  
});  
  
parent.addEventListener("click", function () {  
 console.log("parent");  
});

child의 이벤트 콜백에 stopPropagation을 호출하고 있기 때문. 이 때문에 이벤트 전파가 일어나지 않는다.

 

 

 

종료 테스트

 

1번 문제

bubbling과 capturing의 방향을 각각 한 줄로 설명해봐.

둘다 이벤트 전파의 과정인데, capturing은 부모요소에서 자식요소쪽으로 진행되는 것, 그 이후에 일어나는 bubblig은 반대로 자식요소에서 부모요소쪽으로 진행되는 것.

정답.

 

 

2번 문제

event.target과 event.currentTarget의 차이는?

event.target은 이벤트가 발생한, 유저가 조작한 그 대상.

event.currentTarget은 addEventListener가 붙어있는 요소.

정답.

 

 

3번 문제

preventDefault()와 stopPropagation()의 차이는?

preventDefault는 브라우저 기본 동작을 막는 것이고, stopPropagation은 이벤트의 전파를 막는다.

정답.

 

 

4번 문제

다음 구조에서 button을 클릭했다.

document
└─ div#battle
    └─ div#actions
         └─ button#attack

#battle, #actions, #attack 세 요소에 모두 기본 click listener가 있다면, bubbling 단계의 실행 순서를 적어봐.

attack, actions, battle순이다.

정답.

 

 

 

감상

 

밀린 것들을 처리할 수가 없다!

눈 앞이 캄캄해졌다!