특정 사건을 이벤트라고 합니다. 브라우저는 처리해야할 특정 사건이 생기면 이벤트를 발생시킵니다.
예를 들어, 클릭, 키보드 입력, 마우스 이동 등이 일어나면 브라우저는 이를 감지하여 특정한 타입의 이벤트를 발생시킵니다.
이때, 이벤트가 발생되었을 때 호출될 함수를 이벤트 핸들러라고 하고,
브라우저에 이벤트 핸들러의 실행을 위임하는 것을 이벤트 핸들러 등록이라고 합니다.
Node.js 환경에서는 이벤트가 존재하지 않고, 오직 브라우저 환경에서만 이벤트가 존재하므로
이벤트는 자바스크립트의 범용적 개념이라고 보기는 어렵습니다. 하지만 웹 개발자라면 반드시 알아야하는 개념이죠.
(브라우저를 사용하니까요)
오늘은 웹 개발에서 빠질 수 없는 이벤트에 대해 알아보는 시간을 갖겠습니다.

이벤트 드리븐 프로그래밍 (Event Driven Programming)
Driven이라는 것은 "중심"이 된다라는 것의 의미입니다. 예를 들어 TDD의 경우 테스트 주도 개발이라고 해석하고, 테스트 코드 작성을 우선하여 코드의 안정성과 신뢰를 높이는 것을 목표로 하는 개발 방식입니다.
이벤트 드리븐 프로그래밍은, 프로그램의 흐름을 이벤트를 중심으로 제어하는 프로그래밍 방식을 의미합니다.
특정 버튼 요소에서 클릭 이벤트가 발생하면 특정 함수(이벤트 핸들러)를 호출하도록 브라우저에게 위임(이벤트 핸들러 등록)을 해둡니다.
이는 사실 이벤트 핸들러가 언제 호출될지 모르니 명시적으로 호출할 수 없어서 브라우저에게 위임해두는 겁니다.
이벤트 드리븐 프로그래밍 덕분에, 사용자와 애플리케이션은 상호작용(interaction)을 할 수 있게 됩니다.
이벤트 타입
이벤트 타입은 이벤트의 종류를 나타내는 문자열입니다.
약 200가지가 존재하는데, https://developer.mozilla.org/en-US/docs/Web/Events 를 참고하면 됩니다.
click, input, change, DOMContetnLoaded, load, keydown과 같은 다양한 이벤트 타입이 존재합니다.
그 중에서 input vs change 이벤트와 DOMContentLoaded vs load 이벤트만 설명하고 넘어가겠습니다.
1. input vs change (리액트에서는 onChange를 쓰던데?)
input은 input(text, checkbox, radio), selected, textarea 요소의 값이 입력되었을 때마다,
change는 해당 요소의 값이 변경되었을 때(즉, 포커스를 잃어서 종료되었다고 판단되었을 때) 발생합니다.
그러나 React에 익숙하신 분들은 아래와 같은 코드를 자주 볼겁니다.
<input
value={someState}
onChange={(e) => setSomeState(e.target.value)}
/>
onInput을 사용하는게 아니라, onChange를 이용하는거죠. 그 이유가 뭘까요?
그건 리액트가 Controlled Component 패턴을 이용하여 더욱 일관되도록 동작하게 만들기 위해서입니다.
Controlled Component : 폼 요소의 상태(value)가 React의 state에 의해 완전히 제어되는 컴포넌트
보통 입력값을 input에서만 사용하는게 아니라, 여러곳에서 사용하곤 합니다. 그래서 이 입력값 자체를 state로 정의해두고 매번 리렌더링을 발생시키면서 값을 업데이트하는 중앙화된 관리 방식이 채택되어 있습니다. 따라서 input보다는 change에 가까운 동작이므로 onChange를 주로 사용했던 겁니다.
2. DOMContentLoaded vs load
DOMContentLoaded 이벤트는 HTML 문서의 로드와 파싱이 완료되어 DOM 생성이 완료되었을 때 발생하고,
load 이벤트는 DOMContentLoaded 이벤트가 발생한 이후, 모든 리소스(이미지,폰트 등)의 로딩이 완료되었을 때 발생합니다.
따라서 load는 보통 window 객체에서 발생하는 이벤트입니다.
<!DOCTYPE html>
<html>
<head>
<title>Load Event Example</title>
</head>
<body>
<h1>Hello!</h1>
<script>
window.addEventListener('load', () => {
console.log('🌐 모든 리소스 로딩 완료!');
});
</script>
</body>
</html>
물론, 특정 리소스의 로딩이 완료된 이후에 실행되도록 만들 수도 있습니다.
<img id="myImage" src="image.jpg" alt="Sample" />
<script>
const img = document.getElementById('myImage');
img.addEventListener('load', () => {
console.log('🖼️ 이미지 로딩 완료!');
});
</script>
이벤트 핸들러 등록 방식 (3가지)
이벤트가 발생했을 때 이벤트 핸들러의 호출을 브라우저에 위임하는 것을 이벤트 핸들러 등록이라고 했습니다.
이벤트핸들러는 특정 요소에서 이벤트를 감지하면 호출되는 함수이므로, 요소에 등록해둔다고 생각하면 됩니다.
참고로 어트리뷰트방식이나 프로퍼티 방식은 이벤트 핸들러를 오직 1개만 등록할 수 있습니다.
또한 on (접두사) + 이벤트타입 으로 구성된 어트리뷰트 혹은 프로퍼티에 직접 이벤트 핸들러를 할당합니다.
addEventListener 방식은 여러개의 이벤트 핸들러를 등록할 수 있고, 이벤트 발생 시 등록된 순으로 실행됩니다.
(단, 동일한 이벤트에 동일한 참조의 이벤트 핸들러를 등록해두면 하나의 핸들러만 등록됩니다.)
1. 이벤트 핸들러 어트리뷰트 방식 (DOM Level 0) - 레거시 (비추천)
어트리뷰트(attribute)는 HTML의 속성을 의미합니다.
<button onclick="alert('clicked!')">Click Me</button>
여기서는 함수의 참조를 넘기는 것이 아니라, 함수의 실행문을 넘겨야합니다. (표현식이 아니라 문!)
그 이유는 이벤트 핸들러 어트리뷰트값은 암묵적으로 생성되는 이벤트 핸들러의 함수 몸체를 의미하기 때문입니다.
function onClick(event){
alert("clicked!");
}
이처럼 동작하는 이유는 이벤트 핸들러에 인수를 전달하기 위함입니다. 만약 함수의 참조를 전달한다면 인수를 전달하기 어려우니까요.
HTML과 자바스크립트는 관심사가 다르기 때문에 분리하는 것이 좋기 때문에 사용을 추천하지는 않습니다.
그러나 Component Based Development (앵귤러, 리액트, 뷰 등)에서는 JS를 HTML, CSS와 마찬가지로 뷰를 구성하기 위한 요소로 보기 때문에 관심사가 다르다고 생각하지 않아 해당 방식을 사용하곤 합니다.
2. 이벤트 핸들러 프로퍼티 방식 (DOM Level 0)
프로퍼티(property)는 객체의 key값을 의미합니다. 더욱 정확히는 DOM의 속성을 의미합니다.
<button id="btn">Click</button>
<script>
const btn = document.getElementById('btn');
btn.onclick = function (e) {
alert('clicked!');
};
</script>
어트리뷰트 방식과는 다르게, 이벤트 타깃의 프로퍼티에 함수의 참조를 넘겨줍니다.
3. addEventListener 메서드 방식 (DOM Level 2)
EventTarget.addEventListener('eventType', eventHandler [, useCapture]) 방식으로 사용합니다.
<button id="btn">Click</button>
<script>
const btn = document.getElementById('btn');
btn.addEventListener('click', () => {
alert('clicked!');
});
</script>
이벤트 타입, 이벤트 핸들러, 캡쳐링 사용 여부를 지정할 수 있습니다.
참고로 세번째 인자인 useCapture의 default 값은 false입니다. (이로 인해 타깃 - 버블링 단계의 이벤트에만 반응합니다)
addEventListener 로 등록한 이벤트 제거
EventTarget.prototype.removeEventListener 메서드를 사용하면 됩니다.
<button id="btn">Click</button>
<script>
const btn = document.getElementById('btn');
btn.addEventListener('click', () => {
alert('clicked!');
});
//반드시 addEventListener에 전달한 인수와 동일해야합니다. - 캡쳐링 여부까지!
btn.removeEventListener('click', () => {
alert('clicked!');
});
</script>
이벤트 객체
이벤트가 발생하면, 이벤트에 대한 다양한 정보를 담고 있는 이벤트 객체가 동적으로 생성됩니다.
그리고 이렇게 생성된 이벤트 객체는, 이벤트 핸들러의 첫 번째 인수로 전달됩니다.
통상적으로는 이 인수를 사용하기 위해, e 라고 정의해두곤 합니다.
그러나 이벤트 핸들러 어트리뷰트 방식으로 등록된 경우, 반드시 event라는 명을 사용해야합니다.
암묵적으로 생성되는 이벤트 핸들러가 이벤트 객체를 event라는 이름의 인수로 받아와서 사용하기 때문이에요.
//onclick = "showCoord(event)" 와 같은 방식으로 등록해야만 한다.
//그래야 아래처럼 암묵적으로 이벤트 핸들러가 생성되고, 이벤트 객체가 잘 전달된다.
function onclick(event){
showCoord(event);
}
이벤트 객체 상속 구조
이벤트 객체는 생성자 함수에 의해 생성되고, 당연히 프로토타입 체인의 일원이 됩니다.
모든 이벤트의 부모가 되는 프로토타입은 Event.prototype 입니다. (당연이 이 프로토타입은 Object.prototype을 상속 받습니다)
따라서 Event 인터페이스는 DOM 내에서 발생한 이벤트에 의해 생성되는 이벤트 객체를 나타냅니다.
Event 인터페이스에는 모든 이벤트 객체의 공통 프로퍼티가 정의되어 있고 MouseEvent, KeyboardEvent 등 하위 인터페이스에는 이벤트 타입에 따라 고유한 프로티가 정의되어 있습니다.
공통 프로퍼티에는 다음과 같은 프로퍼티가 존재합니다.
| type | 이벤트의 타입 |
| target | 이벤트를 발생시킨 DOM 요소 |
| currentTarget | 이벤트 핸들러를 실행하는(바인딩된) DOM 요소 |
| eventPhase | 이벤트 전파 단계 (0: 이벤트 없음,1:캡쳐링,2:타깃,3: 버블링) |
| bubbles | 버블링으로 이벤트를 전파하는지 여부 |
| cancelable | (preventDefault로) 기본동작을 취소할 수 있는지 여부 |
| defaultPrevented | preventDefault 메서드를 호출하여 이벤트를 취소했는지 여부 |
| isTrusted | 사용자의 행위에 의해 발생한 이벤트인지 여부 (click 메서드나 dispatchEvent로 인위적 발생시엔 false) |
| timeStamp | 이벤트가 발생한 시각 |
이벤트 전파 (event propagation) - 3단계
브라우저는 사용자의 이벤트를 감지하여 이벤트 객체를 생성하고, 이를 DOM 트리에 전파합니다.
이벤트 전파 과정을 거치면서, DOM 요소 중 전파된 이벤트 객체를 catch하는 이벤트 핸들러가 붙어 있을 경우 이벤트 핸들러를 매크로 태스크 큐에 등록해두었다가 콜스택이 비면 순차적으로 자바스크립트 엔진에서 실행됩니다.
따라서 기본적으로 이벤트 핸들러의 실행은 비동기적입니다.
다만 인위적으로 click메서드나 dispatchEvent를 통해 인위적으로 이벤트를 발생시키기 때문에 동기적으로 이벤트 핸들러를 실행합니다.
이벤트 전파 과정은 3단계로 나눌 수 있습니다.

1. 캡쳐링 단계
HTML부터 이벤트 객체를 전파합니다.
2. 타깃 단계
이벤트를 발생시킨 target에 닿는 시점입니다.
3. 버블링 단계
target부터 역으로 상위 요소로 이벤트를 전파하는 단계입니다.
기본적으로 이벤트 핸들러는 타깃 - 버블링 단계에서 잡아서 실행한다!
addEventListener를 통해 이벤트 핸들러를 등록할 때, 세번째 인수의 기본값은 false 였습니다.
즉 기본적으로는 캡쳐링이 아닌 버블링 단계에서 이벤트를 감지하여 이벤트핸들러를 실행합니다.
또한, 위의 3단계를 잘 이해했다면 이벤트는 절대 target의 하위 요소가 인지할 수 없음을 알 수 있습니다.
(dispatchEvent를 통해 의도적으로 통신하지 않는 한)
이벤트의 전파 중, 버블링은 이루어지지 않을 수도 있지만 캡쳐링은 반드시 이뤄지는 이유는 뭘까요?
이는 브라우저가 이벤트를 감지하고, 이벤트 객체를 생성하여 DOM 요소에 전파하기 때문입니다.
target 요소에 의해 이벤트가 발생해도, 브라우저는 당장 어떤 요소에 의해 이벤트가 발생했는지는 알 수 없습니다.
따라서 최상위요소부터 순차적으로 탐색하며, target 요소를 찾아나섭니다. (이게 캡쳐링 단계입니다.)
target 요소를 찾으면, 더 이상 탐색할 필요는 없으니까 탐색을 거기서 멈추고 순차 탐색 loop를 멈춰도 되지만,
일반 사용자의 관점에서는 버블링의 과정이 더욱 자연스럽게 느껴지므로 이벤트 버블링도 "굳이" 해준 것입니다.
(직관적으로는 이벤트를 발생시킨 요소부터 이벤트가 전파되는게 자연스럽잖아요)
따라서 캡쳐링은 필수, 버블링은 옵셔널이 될 수 있었습니다.
이벤트 위임 (event delegation)
이벤트 처리를 상위 요소에게 맡겨버리는 방식입니다. (공통 부모 요소에 한 번만 이벤트 핸들러를 등록해서 처리)
많은 DOM 요소에 이벤트 핸들러를 등록하면 성능 저하의 원인이 되고, 유지 보수에도 좋지 않기 때문입니다.
(메모리 사용, 이벤트 처리 비용에서 비효율적)
as-is (위임 전)
<ul id="menu">
<li>Home</li>
<li>About</li>
<li>Contact</li>
</ul>
<script>
const items = document.querySelectorAll('#menu li');
items.forEach(item => {
item.addEventListener('click', () => {
alert(item.textContent);
});
});
</script>
to-be (위임 후)
<ul id="menu">
<li>Home</li>
<li>About</li>
<li>Contact</li>
</ul>
<script>
const $menu = document.getElementById('menu');
$menu.addEventListener('click', (e) => {
if (!e.target.matches('#menu > li')) return;
alert(e.target.textContent);
});
</script>
이렇게 된다면 당연히 target과 currentTarget은 달라질 겁니다. (부모 요소가 currentTarget이 될 거고, 자식 요소가 target)
DOM 요소의 기본 동작 조작
1. preventDefault
DOM 요소의 기본 동작을 중지 시킵니다.
예를 들어, a 요소를 클릭하면 href 어트리뷰트에 지정된 링크로 이동하고, checkbox를 클릭하면 체크 또는 해제되는데 이를 막습니다.
2. stopPropagation
해당 currentTarget에 닿는 순간, 더이상 이벤트 전파를 하지 않도록 막습니다.
캡처링시 이벤트 핸들러가 실행되도록 만들었다면, 캡처링 단계에서도 중지할 수 있게 됩니다.
이벤트 핸들러에 인수를 전달하고 싶다면? - 2가지 방식
살짝 비껴서 생각해야합니다.
1. 이벤트 핸들러 내부에서 함수를 호출하면서 인수를 전달하거나
<button id="btn1">Say Hello</button>
<script>
const $btn1 = document.getElementById('btn1');
function greet(name) {
alert(`Hello, ${name}!`);
}
$btn1.addEventListener('click', () => {
greet('Alice'); // ← 여기서 인수 전달
});
</script>
2. 이벤트 핸드러를 반환하는 함수를 호출하면서 인수를 전달하거나 - 커링(curring) 혹은 클로저(closure) 활용
<button id="btn2">Greet</button>
<script>
const $btn2 = document.getElementById('btn2');
function createGreetHandler(name) {
return function (e) {
alert(`Hi, ${name}! You clicked: ${e.target.id}`);
};
}
$btn2.addEventListener('click', createGreetHandler('Bob'));
</script>
커스텀 이벤트
사용자의 행동에 의해서가 아닌, 개발자의 의도에 의해 생성된 이벤트를 커스텀 이벤트라고 합니다.
커스텀 이벤트 객체는 버블링되지 않고, preventDefault로 취소할 수도 없습니다. 또한 isTrusted 프로퍼티의 값도 false 입니다.
커스텀 이벤트를 만드는 방법
1. 커스텀 이벤트는 기존 이벤트 타입을 사용할 수도 있고,
<button id="btn">Click me</button>
<script>
const $btn = document.getElementById('btn');
// 핸들러 등록 - 반드시 dispatch가 되기 전에 등록되어야함. dispatchEvent는 동기적으로 실행되므로!
$btn.addEventListener('click', () => {
console.log('Button was clicked (even programmatically)');
});
// 기존 이벤트 타입으로 커스텀 이벤트 디스패치
const clickEvent = new Event('click');
$btn.dispatchEvent(clickEvent); // ← 수동 발생
</script>
2. 임의의 문자열을 사용하여 아예 새로운 이벤트 타입을 지정할 수도 있습니다. (보통 CustomEvent 이벤트 생성자 함수를 사용합니다.)
<div id="box">Box</div>
<script>
const $box = document.getElementById('box');
// 새 커스텀 이벤트 생성
const customEvt = new CustomEvent('custom-hello', {
detail: { user: 'Alice' }
});
// 핸들러 등록
$box.addEventListener('custom-hello', (e) => {
console.log('Custom event received:', e.detail.user);
});
// 이벤트 디스패치
$box.dispatchEvent(customEvt);
</script>
커스텀 이벤트 디스패치
커스텀이벤트는 반드시 DOM요소가 상속받은 dispatchEvent 메서드를 통해 이벤트를 발생시켜야만 합니다.
그래야 커스텀 이벤트로 서로 소통할 수 있어요. (기본적으로 발생하는 이벤트가 아니기 때문)
그렇다면 도대체 언제 커스텀 이벤트를 디스패치하는 방식을 사용하는걸까요?
1. UI 테스트 코드를 짜거나, 시뮬레이션을 하려고 할 때 - 이벤트를 가짜로 만들어 테스트
2. 직접적인 의존 없이 소통하고 싶을 때
임의로 만든 이벤트 타입의 커스텀 이벤트를 위한 이벤트 핸들러 등록
반드시 addEventListener 메서드를 통해 이벤트 핸들러를 등록해야합니다.
임의로 만든 이벤트 타입에 대응하는 프로퍼티나 어트리뷰트는 존재하지 않기 때문입니다.
지금까지 이벤트에 대해 필요한 부분들은 거의 다 챙겨보았습니다.
이벤트 핸들러 내부의 this에 대해서는 아직 다루지 않았는데, 추후 필요하면 찾아보기 바랍니다.
긴 글 읽어주셔서 감사합니다!
참고 서적 ) 모던 자바스크립트 Deep Dive (저자-이웅모)
'Develop > Javascript' 카테고리의 다른 글
| AJAX - 네트워크 요청을 비동기적으로 처리하기 위한 (0) | 2025.11.15 |
|---|---|
| ESM과 CJS 그리고 모듈 객체 (0) | 2025.09.22 |
| 클래스 (Class) (0) | 2025.04.13 |
| 프로토타입 (ProtoType) (0) | 2025.02.11 |
| 클로저 (Closure) (0) | 2025.02.06 |