HTML로 화면을 만들고 CSS로 꾸미고 나면, 다음 질문은 항상 같다. "버튼을 눌렀을 때 이 글자를 바꾸려면 어떻게 하지?"
HTML은 정적인 문서고, 그 문서를 실행 중에 바꾸려면 JavaScript가 문서에 손을 댈 수 있어야 한다. 그 통로가 DOM이고, 사용자의 동작을 감지하는 방식이 Event다. 이 둘을 알면 정적인 페이지가 처음으로 반응하기 시작한다.
이 글은 그 두 축을 정리한 것이다. 요소를 어떻게 골라내고, 무엇을 바꿀 수 있으며, 클릭 한 번이 실제로 어떤 경로로 전파되는지까지 — 나중에 "핸들러가 왜 두 번 실행되지" 같은 상황을 만났을 때 돌아올 지점을 중심으로 묶었다.
이 글은 [JavaScript 기초 문법 정리]에서 이어진다. 화살표 함수와
const가 낯설다면 그쪽을 먼저 보는 편이 좋다.
📖 이 글에서 다루는 내용
- DOM — 브라우저가 HTML을 객체로 바꾸는 과정
- 요소 선택하기 —
querySelector계열 - 요소 조작하기 — 속성, 콘텐츠, 요소, 스타일
- Event와
addEventListener - 이벤트 전파 — 버블링
target과currentTarget- 이벤트 위임 — 버블링을 도구로 쓰기
- 기본 동작 취소 —
preventDefault - 자주 걸려 넘어지는 지점들
- 핵심 요약
1. DOM — 브라우저가 HTML을 객체로 바꾸는 과정
JavaScript는 원래 HTML 파일을 읽을 줄 모른다. HTML은 그냥 텍스트일 뿐이다. 그래서 브라우저가 중간에서 일을 한다. HTML 문자열을 해석해서 JavaScript가 다룰 수 있는 객체 트리로 바꿔주는 것, 이게 DOM이다.

DOM (The Document Object Model)
웹 페이지(Document)를 구조화된 객체로 제공하여, 프로그래밍 언어가 페이지 구조에 접근할 수 있는 방법을 제공하는 것. 문서의 구조·스타일·내용을 변경할 수 있게 한다.
여기서 중요한 건 "객체로 제공한다" 는 부분이다. 파싱이 끝나면 <h1> 태그는 더 이상 문자열이 아니라 textContent, classList, style 같은 속성과 메서드를 가진 객체가 된다. 이 객체들과 그 조작 방법을 묶어 DOM API라고 부른다.
DOM Tree와 document
브라우저는 HTML 문서를 해석해 DOM tree라는 객체 트리로 구조화한다. 요소, 텍스트, 속성이 각각 하나의 노드가 되고, 객체 간에는 상속 구조가 존재한다.
그 트리의 진입점이 document 객체다. 웹 페이지 자체를 나타내는 객체이고, DOM을 다루는 모든 코드는 여기서 출발한다.
console.log(document.title) // 현재 문서의 제목
document.title = '제목 변경' // 브라우저 탭의 제목이 바로 바뀐다
Node와 Element
DOM을 읽다 보면 이 두 단어가 계속 섞여 나온다. 관계는 포함이다.

| 용어 | 설명 |
|---|---|
| Node | DOM의 기본 구성 단위. Document Node(문서 전체), Element Node(HTML 요소), Text Node(텍스트), Attribute Node(속성)를 모두 포함한다 |
| Element | HTML 요소를 나타내는 특별한 유형의 Node. Node의 기능에 더해 className, innerHTML, id 같은 요소 전용 기능을 갖는다 |
➡️ 모든 Element는 Node지만, 모든 Node가 Element인 것은 아니다.
메서드 이름에 Node가 들어가면 텍스트 노드까지 다룰 수 있고, Element가 들어가면 태그만 다룬다고 생각하면 대체로 맞다.
파싱과 렌더링 순서
브라우저가 문자열(HTML)을 해석해 DOM Tree를 만드는 과정을 파싱(Parsing) 이라고 한다. 이후 DOM → Style → Layout → Paint 순으로 화면이 그려진다.
이 순서를 알아둬야 할 실용적인 이유가 있다. 스크립트가 파싱보다 먼저 실행되면 조작할 요소가 아직 존재하지 않는다. 9번 항목에서 다시 다룬다.
2. 요소 선택하기 — querySelector 계열
DOM 조작은 언제나 두 단계로 나뉜다. 이 틀을 잡아두면 어떤 작업이든 헤매지 않는다.

- 조작하려는 요소를 선택한다
- 선택된 요소의 콘텐츠나 속성을 조작한다
두 개의 메서드
document.querySelector(selector) // 조건을 만족하는 첫 번째 요소 하나
document.querySelectorAll(selector) // 조건을 만족하는 모든 요소 (NodeList)
querySelector()— 제공한 CSS 선택자를 만족하는 첫 번째 element 객체를 반환한다. 없으면null.querySelectorAll()— 만족하는 모든 요소를 NodeList로 반환한다. 없으면 빈 NodeList.
CSS에서 쓰던 선택자를 그대로 넣을 수 있다는 게 핵심이다. 별도의 문법을 새로 외울 필요가 없다.
console.log(document.querySelector('.title')) // 첫 번째 .title 요소
console.log(document.querySelectorAll('.content')) // NodeList(3)
console.log(document.querySelectorAll('ul > li')) // NodeList(2)
⚠️
querySelector는 못 찾으면null을 준다null에 대고.textContent를 읽으면Cannot read properties of null에러가 난다. 초보자가 가장 많이 만나는 에러이며, 원인은 대개 셋 중 하나다 — 오타, 클래스 앞의.을 빠뜨림, 스크립트가 요소보다 먼저 실행됨.
💡 구형 메서드도 있다
getElementById(),getElementsByClassName()도 여전히 동작하고getElementById가 미세하게 더 빠르다. 다만 CSS 선택자를 그대로 쓸 수 있는querySelector계열이 사실상 표준이 됐다.
NodeList의 성질
querySelectorAll()이 반환하는 NodeList는 정적(static) 이다. 선택한 이후에 DOM이 바뀌어도 이미 담긴 목록은 변하지 않는다.
const items = document.querySelectorAll('li') // 이 시점에 3개
// ... 나중에 li를 하나 더 추가해도
console.log(items.length) // 여전히 3
또한 배열처럼 보이지만 진짜 배열이 아니다. forEach와 for...of는 되지만 map, filter는 없다. 필요하면 스프레드로 배열로 바꾼다.
items.forEach(item => console.log(item)) // 가능
const arr = [...items].map(item => item.textContent) // map은 변환 후에
📌 원본 노트의 "NodeList는 정적"이라는 표현을 좁혀 적었다
정적인 것은querySelectorAll이 반환한 NodeList다. 같은 NodeList 타입이라도element.childNodes가 반환하는 것은 live여서 DOM 변경이 실시간으로 반영된다.getElementsByClassName이 반환하는 HTMLCollection도 live다. "무엇이 반환했느냐"에 따라 갈린다.
3. 요소 조작하기 — 속성, 콘텐츠, 요소, 스타일
선택했다면 이제 바꿀 차례다. 조작 대상은 네 갈래로 나뉜다.

① 클래스 조작 — classList
element.classList는 클래스 목록을 DOMTokenList(유사 배열) 형태로 반환한다.
| 메서드 | 동작 |
|---|---|
classList.add('red') |
클래스 추가 |
classList.remove('red') |
클래스 제거 |
classList.toggle('red') |
있으면 제거하고 false 반환, 없으면 추가하고 true 반환 |
✅ 스타일 변경은 되도록
classList로style속성을 직접 만지는 것보다 CSS에 정의된 클래스를 붙였다 떼는 편이 낫다. 스타일 정의가 CSS 파일 한 곳에 모이고, 여러 속성을 한 번에 바꿀 수 있다.
② 일반 속성 조작
| 메서드 | 동작 |
|---|---|
getAttribute(name) |
속성 값 조회 |
setAttribute(name, value) |
속성 값 설정 (있으면 갱신, 없으면 추가) |
removeAttribute(name) |
속성 제거 |
const aTag = document.querySelector('a')
console.log(aTag.getAttribute('href'))
aTag.setAttribute('href', 'https://www.naver.com/')
aTag.removeAttribute('href')
③ 콘텐츠 조작 — textContent
요소 안의 텍스트를 읽고 쓴다.
const h1Tag = document.querySelector('.heading')
console.log(h1Tag.textContent) // 'DOM 조작'
h1Tag.textContent = '내용 수정'
⚠️
innerHTML대신textContent를 쓴다innerHTML은 넣은 문자열을 HTML로 해석해서 렌더링한다. 사용자가 입력한 값을 그대로innerHTML에 넣으면<script>나<img onerror=...>같은 코드가 실제로 실행될 수 있다. 이것이 XSS(Cross-Site Scripting) 공격이다.
텍스트만 다룰 때는 항상textContent를 쓴다. 채팅 메시지나 댓글처럼 사용자 입력을 화면에 다시 그리는 기능에서는 특히 지켜야 한다.
④ 요소 생성과 삽입
| 메서드 | 동작 |
|---|---|
document.createElement(tagName) |
태그 이름으로 HTML 요소를 생성해 반환 |
Node.appendChild(node) |
부모의 자식 목록 마지막에 삽입하고, 추가된 Node를 반환 |
Node.removeChild(node) |
자식 Node를 제거하고, 제거된 Node를 반환 |

// 1. 요소를 만든다 — 이 시점에는 아직 화면에 없다
const h1Tag = document.createElement('h1')
// 2. 내용을 채운다
h1Tag.textContent = '제목'
// 3. 트리에 붙인다 — 이때 화면에 나타난다
const divTag = document.querySelector('div')
divTag.appendChild(h1Tag)
// 제거
divTag.removeChild(h1Tag)
createElement만 하고 화면에 안 나온다고 헤매는 경우가 많다. 만드는 것과 붙이는 것은 별개의 단계다.
⑤ 스타일 조작
const pTag = document.querySelector('p')
pTag.style.color = 'crimson'
pTag.style.fontSize = '2rem' // CSS의 font-size → camelCase
pTag.style.border = '1px solid black'
CSS 속성명이 font-size처럼 하이픈을 포함한다면, JavaScript에서는 camelCase로 바꿔 쓴다. background-color는 backgroundColor, border-radius는 borderRadius가 된다.
💡 DOM 속성이 궁금할 때
개발자도구 → Elements → Properties 탭에서 선택한 요소가 가진 모든 DOM 속성을 확인할 수 있다. 외우는 것보다 여기서 찾는 게 빠르다.
4. Event와 addEventListener
DOM 조작을 배우면 "언제 조작할 것인가"가 다음 문제가 된다. 그 시점을 정해주는 게 이벤트다.
- event — 무언가 일어났다는 신호. 모든 DOM 요소는 event를 만들어 낸다.
- event object — 이벤트가 발생했을 때 생성되는 객체. 무슨 일이 어디서 일어났는지의 정보가 담긴다.
- event handler — DOM 요소가 받은 event를 '처리'하는 함수.
addEventListener()
EventTarget.addEventListener(type, handler)
읽는 법은 이렇다 — "대상(EventTarget)에 특정 이벤트(type)가 발생하면, 지정한 할 일(handler)을 실행한다."
| 인자 | 설명 |
|---|---|
type |
수신할 이벤트 이름을 문자열로 작성 ('click', 'input', 'submit' 등) |
handler |
이벤트 발생 시 호출될 콜백 함수. 유일한 매개변수로 event 객체를 자동으로 받는다. 반환값은 사용되지 않는다 |
const btn = document.querySelector('#btn')
const detectClick = function (event) {
console.log(event) // PointerEvent {...}
console.log(event.currentTarget) // <button id="btn">
event.currentTarget.textContent = 'SSAFY'
console.log(this) // <button id="btn"> (currentTarget과 동일)
}
btn.addEventListener('click', detectClick)
핸들러를 function 키워드로 등록하면 내부의 this는 핸들러가 붙은 요소를 가리킨다. event.currentTarget과 같은 값이다.
⚠️ 핸들러는 호출하지 말고 넘긴다
addEventListener('click', detectClick())처럼 괄호를 붙이면 지금 당장 실행해서 그 반환값을 등록하게 된다. 괄호 없이detectClick만 넘겨야 한다. 클릭도 안 했는데 코드가 실행되는 현상의 대부분이 이 오타다.
실습 패턴 세 가지
// 1. 클릭 카운터
let counterNumber = 0
const btn = document.querySelector('#btn')
const clickHandler = function () {
counterNumber += 1
const spanTag = document.querySelector('#counter')
spanTag.textContent = counterNumber
}
btn.addEventListener('click', clickHandler)
// 2. input 실시간 반영
const inputTag = document.querySelector('#text-input')
const pTag = document.querySelector('p')
const inputHandler = function (event) {
pTag.textContent = event.currentTarget.value
}
inputTag.addEventListener('input', inputHandler)
// 3. 클래스 토글
btn.addEventListener('click', function () {
pTag.classList.toggle('blue')
})
세 번째 패턴이 특히 자주 쓰인다. 상태 변화는 클래스로 표현하고, 보이는 방식은 CSS가 결정한다는 분리 구조 때문이다.
5. 이벤트 전파 — 버블링
여기서부터가 이벤트의 진짜 핵심이다. 이벤트는 발생한 요소에서 끝나지 않는다.
한 요소에서 이벤트가 발생하면 그 요소의 핸들러가 동작하고, 이어서 부모 요소의 핸들러가 동작한다. 최상단(document)에 도달할 때까지 이 과정이 반복된다. 이것을 버블링(Bubbling) 이라고 한다. 물속에서 거품이 위로 올라가는 모습에서 온 이름이다.

form > div > p 구조에서 가장 안쪽 p를 클릭하면, 세 요소에 각각 핸들러가 붙어 있을 때 p → div → form 순서로 세 개가 모두 실행된다.
<form>
<div>
<p>여기를 클릭</p>
</div>
</form>
"핸들러를 하나만 붙였는데 왜 여러 번 실행되지"라고 느껴지는 상황의 정체가 이것이다. 실제로는 서로 다른 요소의 핸들러들이 순서대로 실행되고 있는 것이다.
사실은 3단계다
전파는 버블링 하나가 아니라 세 단계로 이루어진다.
| 단계 | 방향 | 설명 |
|---|---|---|
| ① 캡처링(Capturing) | document → 대상 |
위에서 아래로 내려온다 |
| ② 타깃(Target) | 대상 | 실제 이벤트가 발생한 요소 |
| ③ 버블링(Bubbling) | 대상 → document |
아래에서 위로 올라간다 |
addEventListener는 기본적으로 버블링 단계에서 동작한다. 캡처링 단계를 잡고 싶으면 세 번째 인자로 true를 넘긴다.
element.addEventListener('click', handler, true) // 캡처링 단계에서 실행
실무에서는 거의 버블링만 쓰지만, 3단계 구조를 알아야 다음 두 항목이 이해된다.
6. target과 currentTarget
버블링 때문에 핸들러 안에서는 "어디에 붙었는가" 와 "어디서 발생했는가" 가 달라질 수 있다. 그래서 event 객체는 둘을 따로 준다.

| 속성 | 의미 |
|---|---|
event.currentTarget |
핸들러가 연결된 요소. this와 같다. 전파 중에도 항상 핸들러의 주인을 가리킨다 |
event.target |
실제 이벤트가 발생한 요소. 클릭이 시작된 가장 안쪽 요소다 |
// outer에만 핸들러를 붙이고, 그 안의 inner를 클릭하면
outer.addEventListener('click', function (event) {
console.log(event.currentTarget) // <div class="outer"> ← 핸들러의 주인
console.log(event.target) // <div class="inner"> ← 실제 클릭된 곳
})
핸들러를 붙인 요소를 직접 클릭하면 둘은 같은 값이 된다. 달라지는 순간은 자식 요소를 클릭했을 때다.
⚠️
console.log(event)를 펼쳐보면currentTarget이null이다currentTarget은 이벤트가 처리되는 동안에만 유효한 값이다. 콘솔에 찍힌 객체를 나중에 마우스로 펼쳐볼 때는 이미 처리가 끝나null로 비워져 있다. 버그가 아니다.
값을 확인하려면console.log(event.currentTarget)처럼 속성을 직접 출력해야 한다.
7. 이벤트 위임 — 버블링을 도구로 쓰기
버블링은 피해야 할 부작용이 아니라, 알고 나면 강력한 도구다.
목록에 버튼이 100개 있다고 하자. 각각에 addEventListener를 붙이면 핸들러가 100개다. 대신 공통 조상 하나에만 붙이고, event.target으로 실제 클릭된 요소를 알아내면 된다. 이 패턴을 이벤트 위임(Event Delegation) 이라고 한다.

const divTag = document.querySelector('div')
divTag.addEventListener('click', function (event) {
console.log(event.target) // 실제로 클릭된 button
})
조금 더 실전에 가깝게 쓰면, 클릭된 것이 원하는 요소인지 확인하는 단계를 넣는다.
listTag.addEventListener('click', function (event) {
// 삭제 버튼이 아니면 무시한다
if (!event.target.matches('.delete-btn')) return
const item = event.target.closest('li')
item.remove()
})
✅ 동적으로 추가되는 요소에도 자동으로 적용된다
이게 이벤트 위임의 가장 큰 장점이다. 각 요소에 핸들러를 붙이는 방식은 나중에 추가된 요소에는 핸들러가 없다. 새 항목을 만들 때마다 다시 붙여야 한다.
부모에 하나만 붙여두면 목록이 나중에 늘어나든 줄어들든 코드를 고칠 필요가 없다. 검색 결과 목록, 댓글, 장바구니처럼 내용이 계속 바뀌는 영역에서는 사실상 기본 선택지다.
8. 기본 동작 취소 — preventDefault
브라우저에는 이벤트마다 정해진 기본 동작이 있다. 링크를 클릭하면 이동하고, 폼을 제출하면 페이지가 새로고침된다. event.preventDefault() 는 그 기본 동작을 실행하지 않도록 막는다.
// 1. 복사 방지
h1Tag.addEventListener('copy', function (event) {
event.preventDefault()
alert('복사할 수 없습니다.')
})
// 2. form 제출 시 페이지 새로고침 취소
const formTag = document.querySelector('#my-form')
const handleSubmit = function (event) {
event.preventDefault() // action 값으로 요청을 보내는 기본 동작을 취소
// 여기서부터 JavaScript로 직접 처리한다
}
formTag.addEventListener('submit', handleSubmit)
💡
preventDefault는 SPA에서 사실상 필수 첫 줄이다
폼 데이터를fetch나axios로 서버에 보내는 구조에서는, 브라우저의 기본 제출 동작이 실행되면 요청을 보내기도 전에 페이지가 새로고침되어 버린다. 그래서 submit 핸들러는 거의 항상event.preventDefault()로 시작한다. 검색 폼, 로그인 폼, 게시글 작성 폼 모두 같은 구조다.
preventDefault와 stopPropagation은 다르다
이름이 비슷해 섞어 쓰기 쉬운데, 막는 대상이 전혀 다르다.
| 메서드 | 막는 것 |
|---|---|
event.preventDefault() |
브라우저의 기본 동작 (링크 이동, 폼 제출, 체크박스 체크 등) |
event.stopPropagation() |
이벤트 전파(버블링) — 부모 요소의 핸들러가 실행되지 않는다 |
서로 독립적인 기능이므로 필요하면 둘 다 호출한다. 다만 stopPropagation은 부모의 이벤트 위임까지 끊어버리므로, 쓰기 전에 정말 전파를 막아야 하는지 한 번 생각해 보는 게 좋다.
9. 자주 걸려 넘어지는 지점들
① 핸들러에 화살표 함수를 쓰면 this가 달라진다

functionButton.addEventListener('click', function () {
console.log(this) // <button> — 핸들러가 붙은 요소
})
arrowButton.addEventListener('click', () => {
console.log(this) // window — 화살표 함수는 자신의 this가 없다
})
화살표 함수는 자신만의 this를 만들지 않고 상위 스코프의 this를 그대로 쓴다. 그래서 핸들러 안에서 this로 요소를 참조하려던 코드가 엉뚱한 값을 잡는다.
✅ 해결은 둘 중 하나
① 핸들러에서this를 쓸 거라면function키워드로 작성한다
② 화살표 함수를 쓸 거라면this대신event.currentTarget을 쓴다
event.currentTarget은 함수 형태와 무관하게 항상 동작하므로, 아예 이쪽으로 통일하는 편이 헷갈리지 않는다.
② 이벤트를 제거하려면 같은 함수 참조가 필요하다
btn.addEventListener('click', clickHandler)
btn.removeEventListener('click', clickHandler) // 제거된다
등록할 때와 정확히 같은 함수 참조를 넘겨야 제거된다. 익명 함수로 등록하면 그 참조를 다시 만들 방법이 없어 영영 제거할 수 없다.
btn.addEventListener('click', function () { ... })
btn.removeEventListener('click', function () { ... }) // 제거되지 않는다 (다른 함수다)
나중에 뗄 가능성이 있는 핸들러는 처음부터 변수에 담아두는 습관이 필요하다.
③ 스크립트가 DOM보다 먼저 실행되면 null이 나온다
<head>에서 스크립트를 로드하면 아직 <body>가 파싱되기 전이라 querySelector가 null을 반환한다. 해결책은 세 가지다.
// 방법 1. DOMContentLoaded 이후에 실행
document.addEventListener('DOMContentLoaded', function () {
const btn = document.querySelector('#btn')
// ...
})
<!-- 방법 2. body 끝에 script를 배치한다 -->
<body>
<button id="btn">클릭</button>
<script src="main.js"></script>
</body>
<!-- 방법 3. defer 속성을 쓴다 (head에 둬도 파싱 완료 후 실행) -->
<head>
<script defer src="main.js"></script>
</head>
defer는 head에 두면서도 파싱 완료를 기다리므로, 스크립트를 미리 내려받기 시작한다는 점에서 방법 2보다 유리하다.
✅ 핵심 요약
| 주제 | 꼭 기억할 것 |
|---|---|
| DOM | 브라우저가 HTML을 파싱해 만든 객체 트리. document가 진입점이다 |
| Node vs Element | 모든 Element는 Node지만, 모든 Node가 Element는 아니다 |
| 조작 2단계 | ① 요소를 선택하고 ② 콘텐츠나 속성을 바꾼다 |
| 선택 | querySelector(첫 하나, 없으면 null) / querySelectorAll(전부, 정적 NodeList) |
| NodeList | querySelectorAll 결과는 정적. 배열이 아니라 map을 쓰려면 [...nodeList] |
| 콘텐츠 | textContent를 기본으로. innerHTML은 XSS 위험이 있다 |
| 요소 생성 | createElement로 만들고 appendChild로 붙여야 화면에 나온다 |
| 핸들러 등록 | addEventListener(type, handler) — 함수는 괄호 없이 넘긴다 |
| 버블링 | 이벤트는 대상에서 document까지 거슬러 올라간다 |
| target vs currentTarget | target은 실제 발생 지점, currentTarget은 핸들러가 붙은 요소(= this) |
| 이벤트 위임 | 부모에 하나만 붙이고 event.target으로 구분. 동적 요소에도 적용된다 |
| preventDefault | 브라우저 기본 동작 취소. stopPropagation(전파 중단)과는 다르다 |
| 화살표 함수 | 자신의 this가 없다. 핸들러에서는 event.currentTarget을 쓰는 게 안전하다 |
| 실행 시점 | script는 body 끝 또는 defer. 아니면 querySelector가 null이다 |
🔗 참고 자료
'알면좋은것들' 카테고리의 다른 글
| Vue 기초 (2) | 2026.07.27 |
|---|---|
| AJAX (3) | 2026.07.27 |
| JavaScript 기초 (2) | 2026.07.25 |
| HTML & CSS 기초 (3) | 2026.07.25 |
| Git (3) | 2026.07.15 |