DOM 조작과 이벤트 처리 기초
DOM 요소를 선택해 내용·속성·스타일·구조를 바꾸고 이벤트 리스너로 클릭과 입력에 반응하는 UI를 만듭니다.
지금까지 자바스크립트의 문법과 데이터, 흐름 제어를 익혔습니다. 이제 이 지식으로 웹 페이지와 직접 주고받는 단계로 넘어갑니다. 이를 가능하게 하는 것이 DOM(Document Object Model) 조작과 이벤트 처리입니다. 이 글에서는 자바스크립트로 페이지의 요소를 찾아 내용, 속성, 스타일, 구조를 바꾸고, 사용자의 클릭과 입력에 반응하게 만드는 기본 흐름을 익힙니다.
DOM 선택부터 상태 갱신까지 — 상태 추적
등록→대기→이벤트→DOM 갱신 반복과 소유자 종료 시 별도 해제 경로가 있다. 등록 자원/현재 DOM 값/소유자 상태를 추적하면 매 갱신 후 해제한다는 오해를 줄인다.
DOM UPDATE · RESOURCE LIFETIME
DOM 갱신: 반복 처리와 등록 자원의 정리
사용자 이벤트를 받을 때마다 현재 값을 읽고 필요한 DOM을 갱신한다. 정리는 매 갱신의 뒤가 아니라, 등록한 자원이 더 이상 필요 없거나 교체될 때 소유자가 수행한다.
- 선택·등록
대상이 존재하는지 확인하고 필요한 범위에 리스너를 등록한다.
- 사용자 이벤트·핸들러
click/input 이벤트에서 현재 값과 전달된 데이터를 읽고 다음 값을 계산한다.
- 필요한 DOM 갱신
textContent·class·attribute와 필요한 노드만 바꾸고 다음 이벤트를 기다린다.
- 등록 자원 종료·교체
소유자가 리스너를 제거하거나 연결한 signal을 abort한다. 타이머·observer는 각각의 정리 API를 사용한다.
DOM 연결 여부만으로 정리 책임이 끝나지는 않는다
노드를 제거해도 다른 참조나 등록 자원이 남을 수 있다. AbortSignal은 그 signal을 연결한 리스너를 해제하며, 일반 타이머나 observer까지 자동 정리하지 않는다. 텍스트는 textContent로 처리하고 신뢰하지 않는 입력을 innerHTML에 바로 넣지 않는다.
사용자 이벤트의 일반적인 처리 흐름을 그렸다. dispatchEvent로 만든 이벤트는 동기 호출일 수 있고, 한 번 처리하는 리스너는 once 옵션을 사용할 수 있다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | 선택·등록: 대상이 존재하는지 확인하고 필요한 범위에 리스너를 등록한다. |
| 원도표 조건·주석 | 사용자 이벤트·핸들러: click/input 이벤트에서 현재 값과 전달된 데이터를 읽고 다음 값을 계산한다. |
| 원도표 조건·주석 | 필요한 DOM 갱신: textContent·class·attribute와 필요한 노드만 바꾸고 다음 이벤트를 기다린다. |
| 원도표 조건·주석 | 등록 자원 종료·교체: 소유자가 리스너를 제거하거나 연결한 signal을 abort한다. 타이머·observer는 각각의 정리 API를 사용한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| listener 등록 | listener: 연결됨 / DOM: 이전 표시 | callback을 등록하고 현재 함수가 반환한다 | 등록 자체가 사용자 사건 실행은 아니다 |
| 사건 처리 | callback: 실행 중 / DOM: 새 결과 | 후속 event callback이 현재 사건을 읽어 DOM을 바꾼다 | 콜백이 실행될 때만 UI 동작이 발생한다 |
| 소유자 종료 | listener: 해제 / 후속 사건: 이 handler 없음 | 더 이상 필요하지 않을 때 listener를 해제한다 | 매 사건마다가 아니라 연결 소유권 종료 시 정리한다 |
DOM 선택부터 상태 갱신까지 — 상태 추적
등록→대기→이벤트→DOM 갱신 반복과 소유자 종료 시 별도 해제 경로가 있다. 등록 자원/현재 DOM 값/소유자 상태를 추적하면 매 갱신 후 해제한다는 오해를 줄인다.
DOM UPDATE · RESOURCE LIFETIME
DOM 갱신: 반복 처리와 등록 자원의 정리
사용자 이벤트를 받을 때마다 현재 값을 읽고 필요한 DOM을 갱신한다. 정리는 매 갱신의 뒤가 아니라, 등록한 자원이 더 이상 필요 없거나 교체될 때 소유자가 수행한다.
- 선택·등록
대상이 존재하는지 확인하고 필요한 범위에 리스너를 등록한다.
- 사용자 이벤트·핸들러
click/input 이벤트에서 현재 값과 전달된 데이터를 읽고 다음 값을 계산한다.
- 필요한 DOM 갱신
textContent·class·attribute와 필요한 노드만 바꾸고 다음 이벤트를 기다린다.
- 등록 자원 종료·교체
소유자가 리스너를 제거하거나 연결한 signal을 abort한다. 타이머·observer는 각각의 정리 API를 사용한다.
DOM 연결 여부만으로 정리 책임이 끝나지는 않는다
노드를 제거해도 다른 참조나 등록 자원이 남을 수 있다. AbortSignal은 그 signal을 연결한 리스너를 해제하며, 일반 타이머나 observer까지 자동 정리하지 않는다. 텍스트는 textContent로 처리하고 신뢰하지 않는 입력을 innerHTML에 바로 넣지 않는다.
사용자 이벤트의 일반적인 처리 흐름을 그렸다. dispatchEvent로 만든 이벤트는 동기 호출일 수 있고, 한 번 처리하는 리스너는 once 옵션을 사용할 수 있다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | 선택·등록: 대상이 존재하는지 확인하고 필요한 범위에 리스너를 등록한다. |
| 원도표 조건·주석 | 사용자 이벤트·핸들러: click/input 이벤트에서 현재 값과 전달된 데이터를 읽고 다음 값을 계산한다. |
| 원도표 조건·주석 | 필요한 DOM 갱신: textContent·class·attribute와 필요한 노드만 바꾸고 다음 이벤트를 기다린다. |
| 원도표 조건·주석 | 등록 자원 종료·교체: 소유자가 리스너를 제거하거나 연결한 signal을 abort한다. 타이머·observer는 각각의 정리 API를 사용한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| listener 등록 | listener: 연결됨 / DOM: 이전 표시 | callback을 등록하고 현재 함수가 반환한다 | 등록 자체가 사용자 사건 실행은 아니다 |
| 사건 처리 | callback: 실행 중 / DOM: 새 결과 | 후속 event callback이 현재 사건을 읽어 DOM을 바꾼다 | 콜백이 실행될 때만 UI 동작이 발생한다 |
| 소유자 종료 | listener: 해제 / 후속 사건: 이 handler 없음 | 더 이상 필요하지 않을 때 listener를 해제한다 | 매 사건마다가 아니라 연결 소유권 종료 시 정리한다 |
위 그림은 DOM을 다루는 코드의 흐름을 보여 줍니다. 먼저 다룰 요소를 찾아 그것이 실제로 있는지 확인하고 이벤트 리스너를 등록한 뒤, 사용자의 클릭이나 입력을 기다립니다. 이벤트가 일어나면 핸들러가 현재 값을 읽어 다음 상태를 계산하고, textContent나 클래스처럼 필요한 부분만 바꾼 뒤 다시 이벤트를 기다립니다. 이 반복은 이벤트가 올 때마다 일어납니다.
그림의 아래쪽 경로는 정리입니다. 정리는 화면을 바꿀 때마다 하는 일이 아니라, 그 기능이 더 필요 없어지거나 다른 것으로 바뀔 때 리스너를 제거하거나 연결한 signal을 중단(abort)하는 일입니다. 그림 아래의 설명처럼 요소를 DOM에서 지워도 다른 참조나 등록한 자원이 남을 수 있고, 신뢰할 수 없는 입력을 innerHTML에 바로 넣지 않고 textContent로 다룬다는 점도 함께 기억해 둡니다.
DOM 조작
브라우저는 HTML 문서를 읽으면 그 구조를 객체로 만들어 메모리에 둡니다. 이것이 DOM입니다. DOM은 HTML의 각 요소를 노드(node)라는 객체로 나타내고, 이 노드들을 부모와 자식의 트리(tree) 구조로 연결합니다. 자바스크립트는 DOM을 통해 요소의 내용, 스타일, 속성을 읽고 바꾸며, 새 요소를 추가하거나 기존 요소를 지울 수 있습니다.
요소 찾기
요소를 다루려면 먼저 자바스크립트에서 그 요소를 찾아야 합니다. 아래 예제의 HTML과 자바스크립트는 짝으로 실행합니다. 자바스크립트가 실행될 때 찾을 요소가 이미 만들어져 있어야 하므로, <script>는 그 요소보다 뒤에 두거나 외부 파일을 <script src="script.js" defer></script>로 연결합니다. 찾는 요소가 없으면 결과가 null이고, 그대로 textContent 같은 속성을 쓰면 오류가 납니다.
document.getElementById('id')는 id 속성 값으로 요소를 찾습니다. id는 문서에서 하나뿐이어야 합니다.
<h1 id="mainTitle">안녕하세요, DOM 조작!</h1>// script.js (HTML 파일에 연결된 자바스크립트 파일)
const titleElement = document.getElementById('mainTitle');
console.log(titleElement); // <h1 id="mainTitle">...</h1> 요소 객체가 출력됩니다.인수에는 # 없이 id 값만 적습니다. 찾는 id가 문서에 없으면 getElementById()는 오류 대신 null을 돌려주고, 오류는 그 값의 속성을 읽으려는 다음 줄에서 납니다.
document.querySelector('선택자')는 CSS 선택자로 요소를 찾아, 조건에 맞는 첫 번째 요소를 돌려줍니다. 인수에 #, . 같은 기호를 붙인 CSS 선택자를 그대로 쓴다는 점이 getElementById()와 다릅니다. 다음 코드의 네 줄은 각각 태그, 클래스, id, 자손 선택자로 요소를 찾습니다.
<p class="greeting">반갑습니다.</p>
<div id="container">
<span class="text">첫 번째 텍스트</span>
<span class="text">두 번째 텍스트</span>
</div>const firstParagraph = document.querySelector('p'); // 첫 번째 <p> 태그
const greetingText = document.querySelector('.greeting'); // class가 greeting인 첫 번째 요소
const containerDiv = document.querySelector('#container'); // id가 container인 요소
const firstSpan = document.querySelector('#container .text'); // container 내부의 첫 번째 class="text" span
console.log(firstParagraph);
console.log(greetingText);
console.log(containerDiv);
console.log(firstSpan);document.querySelectorAll('선택자')는 조건에 맞는 모든 요소를 NodeList로 돌려줍니다. NodeList는 배열과 비슷해 forEach()와 length를 쓸 수 있지만, 배열의 모든 메서드가 있지는 않습니다.
const allSpansInContainer = document.querySelectorAll('#container .text');
console.log(allSpansInContainer); // NodeList(2) [span.text, span.text]
allSpansInContainer.forEach(span => {
console.log(span.textContent);
});
// 결과:
// 첫 번째 텍스트
// 두 번째 텍스트이 NodeList는 찾은 순간의 결과로 고정된 정적인 목록입니다. 두 span을 찾은 뒤 세 번째 span을 추가해도 기존 목록의 길이는 2이며, 다시 찾아야 길이가 3인 새 목록을 얻습니다. 다만 목록 안의 요소 객체를 바꾸면 실제 DOM의 요소가 바뀝니다.
내용 바꾸기
element.textContent는 요소 안의 텍스트를 읽거나 바꾸며, 넣은 문자열은 태그가 들어 있어도 글자 그대로 표시됩니다. element.innerHTML은 요소 안의 HTML을 읽거나 바꾸며, 넣은 문자열을 브라우저가 HTML로 해석해 요소를 만듭니다.
<p id="myParagraph">원래 텍스트</p>
<div id="myDiv"></div>const paragraph = document.getElementById('myParagraph');
const myDiv = document.getElementById('myDiv');
// textContent 변경
paragraph.textContent = "새로운 텍스트입니다!";
console.log(paragraph.textContent); // 결과: 새로운 텍스트입니다!
// innerHTML 변경 (HTML 태그 포함)
myDiv.innerHTML = "<h2>새로운 제목</h2><p>이것은 <strong>HTML</strong>입니다.</p>";innerHTML은 편리하지만, 사용자가 입력한 값을 그대로 넣으면 그 안의 스크립트가 실행되는 XSS(cross-site scripting) 공격의 통로가 될 수 있습니다. 글자만 바꿀 때는 textContent를 씁니다. textContent에 값을 넣으면 요소 안의 기존 자식 요소까지 모두 그 텍스트로 바뀌며, 입력에 태그처럼 보이는 글자가 있어도 HTML로 실행되지 않습니다. XSS는 13장의 웹 보안 기초에서 자세히 다룹니다.
속성 바꾸기
src, href, alt 같은 HTML 속성은 getAttribute('속성')으로 읽고, setAttribute('속성', '값')으로 바꾸며, removeAttribute('속성')으로 지웁니다. 다음 코드는 이미지와 링크의 속성을 읽고 바꿉니다. setAttribute()로 src를 바꾸면 브라우저가 새 경로의 이미지를 불러와 화면의 그림이 바뀝니다.
<img id="myImage" src="image1.jpg" alt="첫 번째 이미지">
<a id="myLink" href="https://www.google.com">Google로 이동</a>const image = document.getElementById('myImage');
const link = document.getElementById('myLink');
// src 속성 값 가져오기
console.log(image.getAttribute('src')); // 결과: image1.jpg
// src 속성 값 변경
image.setAttribute('src', 'image2.png');
image.setAttribute('alt', '두 번째 이미지');
// href 속성 값 변경
link.setAttribute('href', 'https://www.naver.com');
link.textContent = "Naver로 이동"; // 링크 텍스트도 변경
// class 속성 추가/제거 (class는 특별히 classList 메서드를 주로 사용)
// image.removeAttribute('alt'); // alt 속성 제거요소의 class는 classList로 다루면 편합니다. add()는 클래스를 더하고, remove()는 빼고, toggle()은 있으면 빼고 없으면 더하며, contains()는 그 클래스가 있는지 불리언으로 알려 줍니다.
<button id="myButton" class="btn">클릭하세요</button>/* style.css */
.btn {
padding: 10px 20px;
background-color: blue;
color: white;
}
.active {
background-color: green;
font-weight: bold;
}const button = document.getElementById('myButton');
button.classList.add('active'); // 'active' 클래스 추가
console.log(button.className); // 결과: "btn active"
button.classList.remove('btn'); // 'btn' 클래스 제거
console.log(button.className); // 결과: "active"
button.classList.toggle('active'); // 'active' 클래스 제거
console.log(button.className); // 결과: ""
button.classList.toggle('active'); // 'active' 클래스 다시 추가
console.log(button.classList.contains('active')); // 결과: trueclassName은 class 속성 값 전체를 공백으로 이은 문자열이므로, 클래스를 모두 빼면 빈 문자열이 됩니다. 버튼의 모양은 그 순간 붙어 있는 클래스에 따라 바뀝니다. 처음에는 .btn의 파란 배경이고, active가 더해지면 같은 명시도에서 나중에 쓴 .active의 초록 배경이 이깁니다.
스타일 바꾸기
element.style.속성으로 요소의 스타일을 직접 바꿀 수 있습니다. CSS에서 하이픈으로 이어 쓰는 속성 이름은 자바스크립트에서 카멜 표기법으로 바꿔, background-color는 backgroundColor로 씁니다.
<p id="styledParagraph">이 문단은 스타일이 바뀔 것입니다.</p>const styledParagraph = document.getElementById('styledParagraph');
styledParagraph.style.color = 'red'; // 글자색 변경
styledParagraph.style.fontSize = '20px'; // 글자 크기 변경
styledParagraph.style.backgroundColor = 'lightgray'; // 배경색 변경 (CSS: background-color)element.style은 이렇게 요소에 직접 넣은 인라인 스타일만 다루며, CSS 파일에서 계산된 최종 스타일을 읽는 속성이 아닙니다. 여러 속성을 함께 바꾸는 상태라면, CSS에 그 상태의 규칙을 만들어 두고 자바스크립트로는 클래스만 붙이고 떼는 편이 관리하기 쉽습니다. 클래스를 붙인다고 CSS 규칙이 생기는 것은 아니므로, 규칙은 CSS에 따로 써야 합니다.
요소 만들기와 지우기
document.createElement('태그')는 새 요소를 만들고, 부모.appendChild(자식)은 그 요소를 부모의 마지막 자식으로 넣으며, 부모.removeChild(자식)은 자식 요소를 지웁니다. createElement()로 만든 요소는 아직 문서에 들어가지 않은 상태라서, appendChild()로 넣기 전에는 화면에 보이지 않습니다. 다음 코드는 새 항목을 만들어 목록 끝에 넣은 뒤, 곧바로 마지막 항목을 지워 원래 목록으로 되돌립니다.
<ul id="myList">
<li>기존 아이템 1</li>
<li>기존 아이템 2</li>
</ul>const myList = document.getElementById('myList');
// 새로운 li 요소 생성
const newItem = document.createElement('li');
newItem.textContent = "새로운 아이템 3";
newItem.style.color = "purple"; // 생성된 요소에 스타일 적용
// ul에 li 추가
myList.appendChild(newItem);
// 결과: <ul>...<li>새로운 아이템 3</li></ul>
// 마지막 아이템 제거
const lastChild = myList.lastElementChild; // 마지막 자식 요소 선택
if (lastChild) {
myList.removeChild(lastChild);
}
// 결과: <ul><li>기존 아이템 1</li><li>기존 아이템 2</li></ul> (새로운 아이템 3은 사라짐)이 코드를 한 번에 실행하면 새 항목을 넣자마자 다시 지우므로, 마지막에는 원래의 두 항목만 남습니다. 추가된 모습을 보려면 appendChild()까지만 먼저 실행해 확인한 뒤 지우는 부분을 실행합니다.
이벤트 처리
이벤트(event)는 웹 페이지에서 일어나는 일입니다. 사용자가 버튼을 클릭하거나, 키를 누르거나, 마우스를 움직이거나, 페이지를 다 불러오는 것이 모두 이벤트입니다. 이벤트 처리(event handling)는 이벤트가 일어났을 때 정해 둔 코드를 실행하게 하는 방식입니다. 아래 예제들은 같은 버튼에 리스너가 계속 쌓일 수 있으므로, 예제마다 페이지를 새로 열고 실행합니다.
리스너 등록
요소에 이벤트를 처리할 함수를 연결할 때는 addEventListener()를 씁니다. 리스너(listener)는 이벤트가 일어나기를 기다렸다가 실행되는 함수라는 뜻이며, 한 요소에 여러 리스너를 붙일 수도 있습니다. 기본 형식은 다음과 같습니다.
// element.addEventListener('이벤트_타입', 함수);첫 번째 인수는 'click', 'input', 'keydown', 'submit', 'load' 같은 이벤트의 종류를 나타내는 문자열이고, 두 번째 인수는 이벤트가 일어났을 때 실행할 함수인 이벤트 핸들러(event handler)입니다. 두 번째 인수에는 함수 자체를 넘깁니다. handleClick()처럼 괄호를 붙여 넘기면 등록하는 순간 함수가 한 번 실행되고 그 반환값이 넘어가므로, 정작 이벤트가 일어날 때는 아무 일도 하지 않습니다. 다음 예는 버튼과 입력란에 네 종류의 이벤트를 연결합니다.
<button id="clickMeBtn">여기를 클릭하세요</button>
<input type="text" id="myInput" placeholder="여기에 입력하세요">const clickButton = document.getElementById('clickMeBtn');
const myInput = document.getElementById('myInput');
// 1. 클릭 이벤트 (click)
clickButton.addEventListener('click', function() {
alert("버튼이 클릭되었습니다!");
});
// 2. 입력 이벤트 (input)
myInput.addEventListener('input', function() {
console.log("현재 입력 값:", myInput.value);
});
// 3. 마우스 오버 이벤트 (mouseover)
clickButton.addEventListener('mouseover', function() {
this.style.backgroundColor = 'lightgreen'; // 일반 함수 리스너의 this는 보통 currentTarget과 같습니다.
});
// 4. 마우스 아웃 이벤트 (mouseout)
clickButton.addEventListener('mouseout', function() {
this.style.backgroundColor = ''; // 원래대로 (또는 특정 색상)
});버튼을 누르면 안내창이 뜨고, 입력란에 글자를 칠 때마다 현재 값이 Console에 출력되며, 버튼에 마우스를 올리면 배경이 연두색이 되었다가 내리면 원래대로 돌아옵니다. 일반 함수로 만든 리스너 안의 this는 리스너를 등록한 요소를 가리킵니다.
이벤트 객체
이벤트 핸들러는 일어난 이벤트의 정보를 담은 이벤트 객체를 첫 번째 인수로 받으며, 이름은 보통 e나 event로 씁니다. 다음 코드의 첫 리스너는 입력란에서 누른 키의 정보를 e.key와 e.code로 읽고, 두 번째 리스너는 e.preventDefault()로 링크를 눌러도 페이지가 이동하지 않게 합니다. 두 예는 앞에서 만든 myInput 입력란과 myLink 링크가 문서에 있어야 동작합니다.
myInput.addEventListener('keydown', function(e) {
console.log("눌린 키:", e.key); // "a", "Enter" 같은 키의 값
console.log("키 위치:", e.code); // "KeyA", "Enter" 같은 물리적인 키 이름
// 한글처럼 조합 중인 글자를 확정하는 Enter는 건너뜁니다.
if (e.key === 'Enter' && !e.isComposing) {
alert("Enter 키를 누르셨군요!");
}
});
// 링크 기본 동작 방지 예시
const preventLink = document.getElementById('myLink');
preventLink.addEventListener('click', function(e) {
e.preventDefault(); // 링크의 기본 동작(페이지 이동)을 막습니다.
console.log("링크 클릭! 하지만 페이지 이동은 막았습니다.");
});한글을 입력할 때는 글자를 조합하는 동안에도 keydown이 일어나므로, 조합을 확정하려고 누른 Enter를 입력 완료로 잘못 처리할 수 있습니다. 그래서 e.isComposing이 true일 때는 건너뜁니다. 예전에 쓰던 e.keyCode는 사용 중단된 속성이므로, e.key와 e.code를 씁니다.
이벤트 객체의 주요 속성과 메서드는 다음과 같습니다.
e.target: 이벤트가 실제로 시작된 요소입니다.e.currentTarget: 지금 실행 중인 리스너가 등록된 요소입니다. 뒤에서 볼 이벤트 위임에서는target과 다를 수 있습니다.e.preventDefault(): 링크를 누르면 이동하고 폼을 제출하면 페이지를 다시 여는 것 같은 기본 동작을 막습니다. 취소할 수 있는 이벤트에서만 효과가 있고,passive: true로 등록한 리스너에서는 쓸 수 없습니다.e.stopPropagation(): 이벤트가 다른 요소로 전파되는 것을 멈춥니다. 같은 요소에 등록된 뒤쪽 리스너까지 멈추려면stopImmediatePropagation()을 씁니다.
더 깊이: passive 옵션은 왜 생겼을까
터치로 화면을 밀거나 마우스 휠을 굴리면, 브라우저는 페이지를 스크롤하기 전에 touchstart, touchmove, wheel 리스너가 preventDefault()로 스크롤을 막을지부터 알아야 합니다. 그래서 이런 리스너가 하나라도 있으면 리스너의 실행이 끝날 때까지 스크롤을 시작하지 못하고, 리스너가 무거우면 스크롤이 끊겨 보입니다. addEventListener('touchmove', 함수, { passive: true })처럼 passive를 켜는 것은 이 리스너가 기본 동작을 취소하지 않겠다고 미리 알리는 것이며, 등록된 리스너가 모두 이렇다면 브라우저는 리스너를 기다리지 않고 스크롤을 시작할 수 있습니다.
DOM 표준은 여기에 기본값 규칙도 더했습니다. 이벤트 종류가 touchstart, touchmove, wheel, mousewheel 가운데 하나이고 리스너를 window, document, document.documentElement, document.body 가운데 하나에 등록했다면, passive를 적지 않아도 true로 다룹니다. 페이지 전체에 건 리스너 하나 때문에 모든 스크롤이 기다리는 일을 기본으로 피하게 한 것입니다.
그래서 이 경우에 스크롤을 정말 막으려고 preventDefault()를 호출해도 아무 일도 일어나지 않습니다. 직접 만든 드래그나 확대 기능처럼 기본 스크롤을 막아야 한다면 { passive: false }를 명시하고, 가능하면 리스너를 페이지 전체가 아니라 그 기능이 필요한 요소에 등록합니다. 규칙은 DOM 표준의 기본 passive 값 항목에 있습니다.
이벤트 전파와 위임
이벤트 전파와 위임의 인과 경로 — 상태 추적
같은 click이 Window→ul→span을 거쳐 캡처/타깃 단계에 도달한 뒤 ul로 버블하며 target/currentTarget과 위임 처리 지점이 다르다. 현재 전파 단계와 처리 여부가 실제로 바뀐다.
EVENT PROPAGATION · DELEGATION
이벤트 위임: 시작점과 리스너 등록점은 다르다
button 내부 span을 클릭해도 조상 ul의 리스너에서 처리할 수 있다. target은 시작점, currentTarget은 현재 실행 중인 리스너가 등록된 요소다.
- Capture · 바깥에서 target으로
Window와 조상을 거쳐 클릭된 span으로 내려간다. 중간 조상은 그림에서 생략했다.
- Target 단계
target의 capture 리스너와 비캡처 리스너가 target 단계에서 실행된다.
- Bubble · 조상의 리스너
버블링 click이 ul에 도달하면 등록한 비캡처 위임 리스너가 실행된다.
- 처리 뒤에도 전파 가능
대상 판별과 처리만으로 전파가 끝나지 않는다. 중단하지 않으면 바깥 조상으로 계속 간다.
target · 클릭 시작점
이 예시에서는 button 내부 span이다. Element인 target에서 closest("[data-action]")로 실제 처리 버튼을 찾는다.
currentTarget · 현재 리스너의 등록점
위임 리스너가 실행되는 동안 ul이다. 찾은 버튼이 없거나 currentTarget.contains(버튼)이 false이면 처리하지 않는다.
기본 동작 취소와 전파 중단은 다른 제어다
preventDefault는 취소 가능한 이벤트의 기본 동작을 막고 전파를 멈추지는 않는다. passive 리스너에서는 기본 동작을 취소할 수 없다. stopPropagation은 다른 노드로의 전파를, stopImmediatePropagation은 현재 노드의 뒤쪽 리스너까지 멈춘다.
그림은 전파를 중단하지 않은 일반 DOM의 bubbling click 예시다. Shadow DOM의 retargeting과 이벤트별 기본 동작 시점은 별도 규칙이다. 리스너 정리에는 동일한 함수 참조와 capture 값 또는 연결한 AbortSignal을 사용한다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | Capture · 바깥에서 target으로: Window와 조상을 거쳐 클릭된 span으로 내려간다. 중간 조상은 그림에서 생략했다. |
| 원도표 조건·주석 | Target 단계: target의 capture 리스너와 비캡처 리스너가 target 단계에서 실행된다. |
| 원도표 조건·주석 | Bubble · 조상의 리스너: 버블링 click이 ul에 도달하면 등록한 비캡처 위임 리스너가 실행된다. |
| 원도표 조건·주석 | 처리 뒤에도 전파 가능: 대상 판별과 처리만으로 전파가 끝나지 않는다. 중단하지 않으면 바깥 조상으로 계속 간다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| capture | target: span 유지 / currentTarget: 각 listener 위치 | window부터 ul을 지나 span target으로 내려간다 | target과 현재 handler 위치는 다르다 |
| target 및 bubble | target: span 유지 / currentTarget: ul 등 현재 listener | span target 뒤 ul로 올라가는 handler를 실행한다 | 위임 listener의 currentTarget은 현재 등록 위치이고 target은 최초 span으로 유지된다 |
| 위임 대상 검사 | 검사: closest + contains / 동작: 허용된 대상만 | closest 결과가 지정 container 안인지 확인한다 | 바깥 조상 매칭을 같은 목록 항목으로 처리하지 않는다 |
이벤트 전파와 위임의 인과 경로 — 상태 추적
같은 click이 Window→ul→span을 거쳐 캡처/타깃 단계에 도달한 뒤 ul로 버블하며 target/currentTarget과 위임 처리 지점이 다르다. 현재 전파 단계와 처리 여부가 실제로 바뀐다.
EVENT PROPAGATION · DELEGATION
이벤트 위임: 시작점과 리스너 등록점은 다르다
button 내부 span을 클릭해도 조상 ul의 리스너에서 처리할 수 있다. target은 시작점, currentTarget은 현재 실행 중인 리스너가 등록된 요소다.
- Capture · 바깥에서 target으로
Window와 조상을 거쳐 클릭된 span으로 내려간다. 중간 조상은 그림에서 생략했다.
- Target 단계
target의 capture 리스너와 비캡처 리스너가 target 단계에서 실행된다.
- Bubble · 조상의 리스너
버블링 click이 ul에 도달하면 등록한 비캡처 위임 리스너가 실행된다.
- 처리 뒤에도 전파 가능
대상 판별과 처리만으로 전파가 끝나지 않는다. 중단하지 않으면 바깥 조상으로 계속 간다.
target · 클릭 시작점
이 예시에서는 button 내부 span이다. Element인 target에서 closest("[data-action]")로 실제 처리 버튼을 찾는다.
currentTarget · 현재 리스너의 등록점
위임 리스너가 실행되는 동안 ul이다. 찾은 버튼이 없거나 currentTarget.contains(버튼)이 false이면 처리하지 않는다.
기본 동작 취소와 전파 중단은 다른 제어다
preventDefault는 취소 가능한 이벤트의 기본 동작을 막고 전파를 멈추지는 않는다. passive 리스너에서는 기본 동작을 취소할 수 없다. stopPropagation은 다른 노드로의 전파를, stopImmediatePropagation은 현재 노드의 뒤쪽 리스너까지 멈춘다.
그림은 전파를 중단하지 않은 일반 DOM의 bubbling click 예시다. Shadow DOM의 retargeting과 이벤트별 기본 동작 시점은 별도 규칙이다. 리스너 정리에는 동일한 함수 참조와 capture 값 또는 연결한 AbortSignal을 사용한다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | Capture · 바깥에서 target으로: Window와 조상을 거쳐 클릭된 span으로 내려간다. 중간 조상은 그림에서 생략했다. |
| 원도표 조건·주석 | Target 단계: target의 capture 리스너와 비캡처 리스너가 target 단계에서 실행된다. |
| 원도표 조건·주석 | Bubble · 조상의 리스너: 버블링 click이 ul에 도달하면 등록한 비캡처 위임 리스너가 실행된다. |
| 원도표 조건·주석 | 처리 뒤에도 전파 가능: 대상 판별과 처리만으로 전파가 끝나지 않는다. 중단하지 않으면 바깥 조상으로 계속 간다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| capture | target: span 유지 / currentTarget: 각 listener 위치 | window부터 ul을 지나 span target으로 내려간다 | target과 현재 handler 위치는 다르다 |
| target 및 bubble | target: span 유지 / currentTarget: ul 등 현재 listener | span target 뒤 ul로 올라가는 handler를 실행한다 | 위임 listener의 currentTarget은 현재 등록 위치이고 target은 최초 span으로 유지된다 |
| 위임 대상 검사 | 검사: closest + contains / 동작: 허용된 대상만 | closest 결과가 지정 container 안인지 확인한다 | 바깥 조상 매칭을 같은 목록 항목으로 처리하지 않는다 |
위 그림은 버튼 안의 span을 클릭했을 때 click 이벤트가 전해지는 경로를 보여 줍니다. 이벤트는 Window에서 조상 요소를 거쳐 클릭된 span까지 내려가는 캡처(capture) 단계, span에서 실행되는 타깃(target) 단계, 다시 조상 쪽으로 올라가는 버블(bubble) 단계를 차례로 거칩니다. 버블 단계에서 ul에 도달하면 그곳에 등록한 위임 리스너가 실행되어, closest()로 실제 처리할 버튼을 찾고 그 버튼이 ul 안에 있는지 확인한 뒤 처리합니다.
그림 아래의 설명처럼 target은 클릭이 시작된 span이고, currentTarget은 지금 리스너가 등록된 ul입니다. 위임 리스너가 일을 처리해도 전파를 멈추지 않으면 이벤트는 바깥 조상으로 계속 올라갑니다. 또 preventDefault()는 기본 동작을 막을 뿐 전파를 멈추지는 않으며, 전파를 멈추는 것은 stopPropagation()입니다.
부모 요소 하나에 리스너를 등록해 그 안의 여러 자식에서 일어난 이벤트를 처리하는 방식을 이벤트 위임(event delegation)이라고 합니다. 자식마다 리스너를 붙이지 않아도 되고, 나중에 추가된 자식도 같은 리스너로 처리됩니다.
<ul id="todoList">
<li>우유 사기 <button type="button" data-action="done"><span>완료</span></button></li>
<li>책 반납 <button type="button" data-action="done"><span>완료</span></button></li>
</ul>const todoList = document.getElementById('todoList');
todoList.addEventListener('click', (e) => {
if (!(e.target instanceof Element)) return;
// 클릭이 시작된 곳에서 가장 가까운 처리 버튼을 찾습니다.
const button = e.target.closest('[data-action="done"]');
if (!button || !e.currentTarget.contains(button)) return;
button.closest('li').classList.toggle('done');
});버튼 안의 “완료” 글자를 클릭하면 e.target은 span이지만, closest()가 가장 가까운 조상 버튼을 찾아 주므로 버튼을 클릭한 것과 같게 처리됩니다. 일치하는 조상이 없으면 closest()는 null을 돌려주므로 확인한 뒤에 씁니다. e.currentTarget.contains(button)은 찾은 버튼이 위임 영역인 ul 안에 있는지 확인해, 영역 바깥의 조상을 잘못 고르지 않게 합니다.
대부분의 화면 코드는 버블 단계의 리스너를 쓰지만, 바깥을 클릭했는지 감지하는 것처럼 먼저 확인해야 하는 작업은 addEventListener('click', 함수, { capture: true })로 캡처 단계에 등록할 수 있습니다. 모든 이벤트가 버블링되는 것은 아니며, focus처럼 버블링되지 않는 이벤트도 있습니다.
리스너 제거
removeEventListener()로 등록했던 리스너를 지울 수 있습니다. 더 필요 없는 리스너가 계속 실행되거나 메모리에 남는 것을 막을 때 씁니다. 이때는 addEventListener()에 넘겼던 것과 같은 함수를 넘겨야 하며, 캡처 단계로 등록했다면 capture 값도 같게 맞춥니다.
const onceButton = document.getElementById('clickMeBtn');
function clickHandler() {
alert("한 번만 실행됩니다!");
onceButton.removeEventListener('click', clickHandler); // 함수 실행 후 리스너 제거
}
onceButton.addEventListener('click', clickHandler);한 번만 실행할 리스너라면 직접 지우는 대신 { once: true } 옵션을 줍니다. 다음 코드는 앞 예제의 onceButton을 이어서 씁니다.
onceButton.addEventListener('click', () => {
alert("한 번만 실행됩니다!");
}, { once: true });여러 리스너를 한꺼번에 지울 때는 AbortController의 signal을 함께 넘기는 방법도 있습니다. 다음 코드의 handleResize는 미리 정의한 함수입니다.
function handleResize() {
console.log(window.innerWidth);
}const controller = new AbortController();
window.addEventListener('resize', handleResize, {
signal: controller.signal,
});
controller.abort(); // signal을 공유한 리스너 해제이 코드는 등록하자마자 controller.abort()를 실행하므로, 그 뒤 창의 크기를 바꿔도 리스너가 실행되지 않습니다. 동작을 보려면 등록까지만 먼저 실행해 창 크기를 바꿔 본 뒤, 마지막 줄을 실행합니다. once는 그 리스너 하나의 수명만 정하며 같은 버튼의 다른 리스너까지 지우지는 않습니다.
자주 쓰는 이벤트
- 마우스:
click,dblclick,mousedown,mouseup,mouseover,mouseout,mousemove - 키보드:
keydown,keyup - 폼:
submit,focus,blur,change,input - 한글 같은 조합 입력:
compositionstart,compositionupdate,compositionend - 문서와 창:
DOMContentLoaded(DOM 트리 완성),load(이미지까지 모든 자원 완료),resize,scroll
실습: 클릭 횟수 세기
다음 전체 파일을 counter.html로 저장해 브라우저로 엽니다. 외부 파일이 필요하지 않습니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>클릭 횟수</title>
</head>
<body>
<p id="countResult" aria-live="polite">0회</p>
<button id="increaseCount" type="button">횟수 늘리기</button>
<button id="resetCount" type="button">초기화</button>
<script>
const result = document.getElementById("countResult");
const increase = document.getElementById("increaseCount");
const reset = document.getElementById("resetCount");
let count = 0;
function render() {
result.textContent = `${count}회`;
}
increase.addEventListener("click", () => {
count += 1;
render();
});
reset.addEventListener("click", () => {
count = 0;
render();
});
</script>
</body>
</html>증가 버튼을 두 번 누르면 “2회”, 초기화 버튼을 누르면 “0회”가 보입니다. 키보드로 버튼에 초점을 옮긴 뒤 Enter나 Space를 눌러도 같게 동작하며, 페이지를 다시 열면 0부터 시작합니다. 스크립트는 두 버튼보다 뒤에 있으므로 버튼을 찾을 때 이미 만들어져 있습니다.
이 코드는 그림의 흐름을 그대로 따릅니다. 이벤트를 받을 때마다 변수 count를 바꾸고, render()로 화면의 텍스트만 다시 그립니다. render()만 호출하면 횟수가 늘지 않고, 변수만 바꾸면 화면이 저절로 바뀌지 않으므로, 상태를 바꾸는 일과 화면을 그리는 일을 둘 다 해야 합니다. 결과 문단의 aria-live="polite"는 내용이 바뀌면 스크린 리더가 새 내용을 읽어 주게 합니다.
정리
- DOM은 HTML을 노드의 트리로 나타낸 객체이며,
getElementById(),querySelector(),querySelectorAll()로 요소를 찾고, 없으면null이 됩니다. textContent는 글자를,innerHTML은 HTML을 넣으며, 사용자 입력은textContent로 다뤄 XSS를 피합니다.- 속성은
getAttribute()와setAttribute()로, 클래스는classList로, 인라인 스타일은style로 바꾸며, 여러 스타일은 클래스로 묶는 편이 좋습니다. addEventListener()로 리스너를 등록하고, 이벤트 객체의target,currentTarget,preventDefault(),stopPropagation()을 씁니다.- 이벤트는 캡처, 타깃, 버블 단계를 거치며, 부모에 리스너 하나를 두고
closest()로 대상을 찾는 이벤트 위임을 쓸 수 있습니다. - 리스너는 같은 함수로
removeEventListener()하거나,once나AbortController의signal로 정리합니다.
연습 문제
1. 세 번까지만 늘리기
counter.html을 고쳐 최대 3회까지만 늘어나게 해 보세요. 3회가 되면 증가 버튼을 비활성화하고, 초기화하면 다시 쓸 수 있어야 합니다. 버튼의 상태를 바꾸는 코드는 어디에 두면 두 리스너가 같은 규칙을 쓸 수 있을까요?
해설 보기
render() 안에 increase.disabled = count >= 3;을 더합니다. 두 리스너가 모두 상태를 바꾼 뒤 render()를 호출하므로, 버튼의 상태도 같은 규칙으로 함께 바뀝니다. 처음 화면도 규칙에 맞추려면 리스너를 등록한 뒤 render()를 한 번 호출합니다.
3회가 되면 버튼이 비활성화되어 클릭해도 증가하지 않고, 초기화 리스너가 count를 0으로 바꾼 뒤 render()를 호출하면 버튼이 다시 활성화됩니다. 이 처리는 사용자의 실제 버튼 조작을 막는 것이며, 코드로 직접 만든 이벤트까지 막는 입력 검증은 아닙니다.
2. 고정된 목록
#container 안에 span.text 두 개가 있습니다. const spans = document.querySelectorAll('#container .text');로 찾은 뒤, 새 span.text를 만들어 #container에 추가했습니다. spans.length와 document.querySelectorAll('#container .text').length는 각각 얼마일까요?
해설 보기
spans.length는 2, 다시 찾은 목록의 길이는 3입니다. querySelectorAll()이 돌려주는 NodeList는 찾은 순간의 결과로 고정된 정적인 목록이라, 나중에 추가한 요소가 들어가지 않습니다.
새 요소까지 다루려면 추가한 뒤에 다시 찾습니다. 이벤트 위임을 쓰면 부모의 리스너 하나가 나중에 추가된 자식의 이벤트도 처리하므로, 이런 경우에 다시 등록할 필요가 없습니다.
3. target과 currentTarget
이벤트 위임 예제에서 첫 번째 항목의 “완료” 글자를 클릭했습니다. ul의 리스너 안에서 e.target, e.currentTarget, button은 각각 어떤 요소일까요? 항목의 “우유 사기” 글자를 클릭하면 어떻게 될까요?
해설 보기
e.target은 클릭이 시작된 span, e.currentTarget은 리스너가 등록된 ul, button은 closest()가 찾은 첫 번째 항목의 버튼입니다. 그래서 첫 번째 li에 done 클래스가 붙습니다.
“우유 사기” 글자를 클릭하면 e.target이 li이고, li와 그 조상에는 data-action="done"인 요소가 없어 closest()가 null을 돌려줍니다. 리스너는 아무것도 하지 않고 끝나므로, 버튼이 아닌 곳의 클릭은 무시됩니다.
querySelectorAll()의 결과에 대한 규칙은 MDN의 querySelectorAll 설명에서 확인할 수 있습니다.
이것으로 5장을 마칩니다. 5장에서는 변수와 데이터 타입, 연산자, 조건문과 반복문, 함수와 스코프, 객체와 배열, DOM 조작과 이벤트 처리로 자바스크립트의 기초를 다졌습니다. 다음 장의 클로저와 실행 컨텍스트부터는 클로저와 this, 프로토타입처럼 자바스크립트가 동작하는 원리를 더 깊이 살펴봅니다.