「한 달 동안 자바스크립트 박살내기」는, 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순이다.
정답.
감상
밀린 것들을 처리할 수가 없다!
눈 앞이 캄캄해졌다!
'웹 개발 > 프론트엔드 - JavaScript' 카테고리의 다른 글
| [JavaScript] 한 달 동안 자바스크립트 박살내기 (15) - HTTP/AJAX/브라우저 데이터 (0) | 2026.10.04 |
|---|---|
| [JavaScript] 한 달 동안 자바스크립트 박살내기 (13) - DOM과 상태 (0) | 2026.09.14 |
| [JavaScript] 한 달 동안 자바스크립트 박살내기 (12) - Promise / async-await / fetch (0) | 2026.09.14 |
| [JavaScript] 한 달 동안 자바스크립트 박살내기 (11) - event loop (0) | 2026.09.13 |
| [JavaScript] 한 달 동안 자바스크립트 박살내기 (10) - 모듈/오류처리, JS핵심 체크포인트 (0) | 2026.09.13 |