JavaScript DOM 조작 | 웹 페이지 동적으로 제어하기
이 글의 핵심
JavaScript DOM 조작에 대한 실전 가이드입니다. 웹 페이지 동적으로 제어하기 등을 예제와 함께 상세히 설명합니다.
들어가며
DOM이란?
DOM (Document Object Model)은 HTML 문서를 트리 구조의 객체로 옮겨 둔 것입니다. 브라우저가 문서를 그리기 위한 내부 표현이며, JavaScript는 이 트리의 노드를 읽고 바꿔 화면과 동작을 갱신합니다.
<!DOCTYPE html>
<html>
<head>
<title>제목</title>
</head>
<body>
<h1 id="title">안녕하세요</h1>
<p class="content">내용</p>
</body>
</html>
DOM 트리:
document
└─ html
├─ head
│ └─ title
└─ body
├─ h1#title
└─ p.content
DOM 트리 구조 조금 더 보기
document: 트리의 진입점입니다.document.documentElement는<html>,document.body는<body>입니다.- 노드(Node): 요소뿐 아니라 텍스트·주석도 노드입니다. 예:
<p>안녕</p>에서"안녕"은 텍스트 노드입니다. - 부모·자식·형제: 각 노드는
parentElement,children,nextElementSibling등으로 트리 상의 위치를 따라갈 수 있습니다(아래 «기타 선택 메서드» 절 참고). - 렌더 트리와의 관계: 브라우저는 HTML을 파싱해 DOM을 만들고, CSS와 함께 화면에 그립니다. DOM을 바꾸면(텍스트·속성·자식 추가/삭제) 화면이 갱신됩니다.
실무에서는 개발자 도구 Elements 패널에서 트리를 펼치며 구조를 확인하는 습관이 중요합니다.
1. 요소 선택
getElementById
// ID로 선택 (가장 빠름)
const title = document.getElementById("title");
console.log(title.textContent); // 안녕하세요
querySelector / querySelectorAll
// CSS 선택자 사용 (첫 번째 요소)
const title = document.querySelector("#title");
const content = document.querySelector(".content");
const firstP = document.querySelector("p");
// 모든 요소 선택
const allPs = document.querySelectorAll("p");
console.log(allPs.length); // p 태그 개수
// 복잡한 선택자
const link = document.querySelector("div.container > a.link");
const items = document.querySelectorAll("ul li:nth-child(odd)");
// NodeList 순회
allPs.forEach(p => {
console.log(p.textContent);
});
기타 선택 메서드
// 클래스로 선택
const elements = document.getElementsByClassName("content");
// 태그로 선택
const paragraphs = document.getElementsByTagName("p");
// 자식 요소
const parent = document.getElementById("parent");
const children = parent.children; // HTMLCollection
const firstChild = parent.firstElementChild;
const lastChild = parent.lastElementChild;
// 형제 요소
const element = document.getElementById("myElement");
const next = element.nextElementSibling;
const prev = element.previousElementSibling;
// 부모 요소
const parent = element.parentElement;
2. 요소 조작
텍스트 변경
const title = document.getElementById("title");
// textContent: 순수 텍스트
title.textContent = "새로운 제목";
// innerHTML: HTML 포함 (XSS 주의!)
title.innerHTML = "새로운 <strong>제목</strong>";
// innerText: 화면에 보이는 텍스트 (스타일 고려)
title.innerText = "제목";
innerHTML vs textContent 실무 가이드
| 구분 | textContent | innerHTML |
|---|---|---|
| 내용 | 순수 텍스트만 | HTML 문자열을 파싱해 DOM에 반영 |
| XSS | 사용자 입력을 넣어도 태그로 실행되지 않음 | 신뢰할 수 없는 문자열을 넣으면 스크립트 삽입 위험 |
| 성능 | 대체로 단순·안전 | HTML 파싱 비용 + 보안 이슈 |
규칙: 사용자 입력이나 API 응답을 화면에 넣을 때는 **textContent**를 기본으로 하고, 정말 HTML이 필요하면 먼저 이스케이프하거나 DOMPurify 같은 라이브러리를 검토합니다. 리스트를 동적으로 만들 때는 **createElement + appendChild**가 innerHTML 문자열 조립보다 추적이 쉬운 경우가 많습니다.
// ✅ 안전: 텍스트만 표시
el.textContent = userInput;
// ⚠️ 위험: userInput에 <script>가 들어갈 수 있음
el.innerHTML = userInput;
속성 조작
const link = document.querySelector("a");
// 속성 가져오기
console.log(link.getAttribute("href"));
// 속성 설정
link.setAttribute("href", "https://google.com");
link.setAttribute("target", "_blank");
// 속성 제거
link.removeAttribute("target");
// 직접 접근
link.href = "https://google.com";
link.id = "myLink";
link.className = "link active";
// classList (클래스 조작)
link.classList.add("highlight");
link.classList.remove("active");
link.classList.toggle("selected"); // 있으면 제거, 없으면 추가
console.log(link.classList.contains("highlight")); // true
스타일 변경
const box = document.getElementById("box");
// 인라인 스타일
box.style.color = "red";
box.style.backgroundColor = "yellow";
box.style.fontSize = "20px";
// 여러 스타일 한 번에
Object.assign(box.style, {
color: "blue",
backgroundColor: "lightgray",
padding: "10px",
borderRadius: "5px"
});
// 계산된 스타일 가져오기
const styles = window.getComputedStyle(box);
console.log(styles.color); // rgb(0, 0, 255)
3. 요소 생성과 삭제
요소 생성
// 요소 생성
const div = document.createElement("div");
div.textContent = "새 요소";
div.className = "box";
div.id = "newBox";
// 속성 설정
div.setAttribute("data-id", "123");
// 추가
document.body.appendChild(div); // body 끝에 추가
// 특정 위치에 추가
const container = document.getElementById("container");
const firstChild = container.firstElementChild;
container.insertBefore(div, firstChild); // 첫 번째 자식 앞에
// insertAdjacentHTML
container.insertAdjacentHTML("beforeend", "<p>새 단락</p>");
// beforebegin: 요소 앞
// afterbegin: 첫 자식 앞
// beforeend: 마지막 자식 뒤
// afterend: 요소 뒤
요소 삭제
const element = document.getElementById("myElement");
// 방법 1: remove()
element.remove();
// 방법 2: removeChild()
const parent = element.parentElement;
parent.removeChild(element);
// 모든 자식 제거
const container = document.getElementById("container");
container.innerHTML = ""; // 간단하지만 이벤트 리스너 제거 안 됨
// 또는
while (container.firstChild) {
container.removeChild(container.firstChild);
}
요소 복제
const original = document.getElementById("original");
// 얕은 복제 (자식 제외)
const shallowClone = original.cloneNode(false);
// 깊은 복제 (자식 포함)
const deepClone = original.cloneNode(true);
document.body.appendChild(deepClone);
4. 이벤트 처리
이벤트 리스너
const button = document.getElementById("myButton");
// 이벤트 리스너 추가
button.addEventListener("click", function(event) {
console.log("클릭됨!");
console.log("이벤트 타입:", event.type);
console.log("타겟:", event.target);
});
// 화살표 함수
button.addEventListener("click", (e) => {
console.log("클릭됨!");
});
// 이벤트 리스너 제거
function handleClick(e) {
console.log("클릭!");
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
// 한 번만 실행
button.addEventListener("click", () => {
console.log("한 번만 실행");
}, { once: true });
이벤트 전파: 캡처링과 버블링
이벤트는 DOM 트리를 따라 두 단계로 전파됩니다.
- 캡처링(capturing):
window→ 대상 요소 방향(위에서 아래로). - 타깃(target): 실제 이벤트가 발생한 요소.
- 버블링(bubbling): 대상 요소 →
window방향(아래에서 위로). 대부분의 이벤트는 버블링합니다.
addEventListener의 세 번째 인자로 캡처 단계에서만 실행할지 정합니다.
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
outer.addEventListener(
"click",
() => console.log("outer 캡처"),
{ capture: true }
);
inner.addEventListener("click", () => console.log("inner 타깃"));
outer.addEventListener("click", () => console.log("outer 버블"));
// inner 클릭 시 로그 순서(대표적): outer 캡처 → inner 타깃 → outer 버블
event.target: 실제로 이벤트가 발생한 가장 안쪽 요소.event.currentTarget: 리스너가 붙은 요소(위임 시 부모일 수 있음).
버블링을 막으려면 event.stopPropagation()을 사용합니다(꼭 필요할 때만 — 상위 핸들러까지 막습니다).
주요 이벤트
// 마우스 이벤트
element.addEventListener("click", e => {}); // 클릭
element.addEventListener("dblclick", e => {}); // 더블클릭
element.addEventListener("mouseenter", e => {}); // 마우스 진입
element.addEventListener("mouseleave", e => {}); // 마우스 이탈
element.addEventListener("mousemove", e => {}); // 마우스 이동
// 키보드 이벤트
input.addEventListener("keydown", e => {
console.log("키:", e.key);
console.log("코드:", e.code);
if (e.key === "Enter") {
console.log("엔터 입력!");
}
});
input.addEventListener("keyup", e => {});
input.addEventListener("keypress", e => {}); // deprecated
// 폼 이벤트
form.addEventListener("submit", e => {
e.preventDefault(); // 기본 동작 막기
console.log("폼 제출");
});
input.addEventListener("input", e => {
console.log("입력값:", e.target.value);
});
input.addEventListener("change", e => {
console.log("변경됨:", e.target.value);
});
// 윈도우 이벤트
window.addEventListener("load", () => {
console.log("페이지 로드 완료");
});
window.addEventListener("resize", () => {
console.log("창 크기:", window.innerWidth, window.innerHeight);
});
window.addEventListener("scroll", () => {
console.log("스크롤 위치:", window.scrollY);
});
이벤트 객체
button.addEventListener("click", (event) => {
// 이벤트 타입
console.log(event.type); // click
// 타겟 요소
console.log(event.target); // 클릭된 요소
console.log(event.currentTarget); // 이벤트 리스너가 등록된 요소
// 마우스 위치
console.log(event.clientX, event.clientY); // 뷰포트 기준
console.log(event.pageX, event.pageY); // 문서 기준
// 기본 동작 막기
event.preventDefault();
// 이벤트 전파 중단
event.stopPropagation();
});
이벤트 위임 (Event Delegation)
// ❌ 각 요소에 이벤트 등록 (비효율)
const items = document.querySelectorAll(".item");
items.forEach(item => {
item.addEventListener("click", () => {
console.log("클릭:", item.textContent);
});
});
// ✅ 부모에 이벤트 등록 (효율적)
const list = document.getElementById("list");
list.addEventListener("click", (e) => {
if (e.target.classList.contains("item")) {
console.log("클릭:", e.target.textContent);
}
});
// 동적 요소에도 작동
const newItem = document.createElement("li");
newItem.className = "item";
newItem.textContent = "새 항목";
list.appendChild(newItem); // 자동으로 이벤트 적용됨
5. 실전 예제
예제 1: To-Do 리스트
<!DOCTYPE html>
<html>
<head>
<title>To-Do 리스트</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 500px;
margin: 50px auto;
}
.todo-item {
padding: 10px;
margin: 5px 0;
border: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.completed {
text-decoration: line-through;
opacity: 0.6;
}
button {
padding: 5px 10px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>To-Do 리스트</h1>
<input type="text" id="todoInput" placeholder="할 일 입력">
<button id="addBtn">추가</button>
<div id="todoList"></div>
<script>
const input = document.getElementById("todoInput");
const addBtn = document.getElementById("addBtn");
const todoList = document.getElementById("todoList");
// 할 일 추가
function addTodo() {
const text = input.value.trim();
if (!text) {
alert("할 일을 입력하세요!");
return;
}
// 요소 생성
const todoItem = document.createElement("div");
todoItem.className = "todo-item";
const span = document.createElement("span");
span.textContent = text;
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "삭제";
todoItem.appendChild(span);
todoItem.appendChild(deleteBtn);
todoList.appendChild(todoItem);
// 입력창 초기화
input.value = "";
input.focus();
}
// 이벤트 리스너
addBtn.addEventListener("click", addTodo);
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
addTodo();
}
});
// 이벤트 위임: 완료/삭제
todoList.addEventListener("click", (e) => {
const todoItem = e.target.closest(".todo-item");
if (e.target.tagName === "SPAN") {
// 완료 토글
todoItem.classList.toggle("completed");
} else if (e.target.tagName === "BUTTON") {
// 삭제
todoItem.remove();
}
});
</script>
</body>
</html>
예제 2: 탭 UI
<!DOCTYPE html>
<html>
<head>
<title>탭 UI</title>
<style>
.tabs {
display: flex;
border-bottom: 2px solid #ddd;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
}
.tab.active {
border-bottom: 3px solid #007bff;
color: #007bff;
}
.tab-content {
padding: 20px;
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="tabs">
<button class="tab active" data-tab="tab1">탭 1</button>
<button class="tab" data-tab="tab2">탭 2</button>
<button class="tab" data-tab="tab3">탭 3</button>
</div>
<div id="tab1" class="tab-content active">
<h2>탭 1 내용</h2>
<p>첫 번째 탭입니다.</p>
</div>
<div id="tab2" class="tab-content">
<h2>탭 2 내용</h2>
<p>두 번째 탭입니다.</p>
</div>
<div id="tab3" class="tab-content">
<h2>탭 3 내용</h2>
<p>세 번째 탭입니다.</p>
</div>
<script>
const tabs = document.querySelectorAll(".tab");
const contents = document.querySelectorAll(".tab-content");
tabs.forEach(tab => {
tab.addEventListener("click", () => {
// 모든 탭 비활성화
tabs.forEach(t => t.classList.remove("active"));
contents.forEach(c => c.classList.remove("active"));
// 클릭된 탭 활성화
tab.classList.add("active");
const targetId = tab.getAttribute("data-tab");
document.getElementById(targetId).classList.add("active");
});
});
</script>
</body>
</html>
예제 3: 모달 (Modal)
<!DOCTYPE html>
<html>
<head>
<title>모달</title>
<style>
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 10px;
max-width: 500px;
}
.close {
float: right;
cursor: pointer;
font-size: 24px;
}
</style>
</head>
<body>
<button id="openModal">모달 열기</button>
<div id="modal" class="modal">
<div class="modal-content">
<span class="close">×</span>
<h2>모달 제목</h2>
<p>모달 내용입니다.</p>
</div>
</div>
<script>
const openBtn = document.getElementById("openModal");
const modal = document.getElementById("modal");
const closeBtn = document.querySelector(".close");
// 모달 열기
openBtn.addEventListener("click", () => {
modal.classList.add("active");
});
// 모달 닫기
closeBtn.addEventListener("click", () => {
modal.classList.remove("active");
});
// 배경 클릭 시 닫기
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("active");
}
});
// ESC 키로 닫기
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
modal.classList.remove("active");
}
});
</script>
</body>
</html>
예제 4: 동적 리스트 (추가·삭제 패턴)
입력값으로 <li>를 만들고, 삭제 버튼은 createElement로 붙이거나 이벤트 위임으로 처리합니다. 아래는 위임만 사용하는 짧은 패턴입니다.
<ul id="itemList"></ul>
<input type="text" id="itemInput" placeholder="항목">
<button type="button" id="addBtn">추가</button>
<script>
const list = document.getElementById("itemList");
const input = document.getElementById("itemInput");
const addBtn = document.getElementById("addBtn");
function addItem() {
const text = input.value.trim();
if (!text) return;
const li = document.createElement("li");
li.textContent = text; // 텍스트만 (HTML 주입 방지)
const del = document.createElement("button");
del.type = "button";
del.textContent = "삭제";
del.dataset.action = "delete";
li.appendChild(del);
list.appendChild(li);
input.value = "";
}
addBtn.addEventListener("click", addItem);
list.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action='delete']");
if (!btn) return;
btn.closest("li")?.remove();
});
</script>
예제 5: 폼 검증 (클라이언트)
HTML5 required, pattern, type="email" 로 1차 검증을 하고, JavaScript로 메시지 표시·포커스를 보강합니다. 최종 검증은 서버에서 다시 해야 합니다.
<form id="signup" novalidate>
<label>
이메일
<input type="email" id="email" name="email" required>
</label>
<span id="emailError" class="error" aria-live="polite"></span>
<label>
비밀번호 (8자 이상)
<input type="password" id="password" name="password" minlength="8" required>
</label>
<span id="pwError" class="error" aria-live="polite"></span>
<button type="submit">가입</button>
</form>
<script>
const form = document.getElementById("signup");
const email = document.getElementById("email");
const password = document.getElementById("password");
const emailError = document.getElementById("emailError");
const pwError = document.getElementById("pwError");
function validateEmailField() {
emailError.textContent = "";
if (!email.validity.valid) {
emailError.textContent = email.validationMessage || "이메일 형식을 확인하세요.";
return false;
}
return true;
}
function validatePasswordField() {
pwError.textContent = "";
if (password.value.length < 8) {
pwError.textContent = "비밀번호는 8자 이상이어야 합니다.";
return false;
}
return true;
}
email.addEventListener("blur", validateEmailField);
password.addEventListener("blur", validatePasswordField);
form.addEventListener("submit", (e) => {
const okEmail = validateEmailField();
const okPw = validatePasswordField();
if (!okEmail || !okPw) {
e.preventDefault();
return;
}
// e.preventDefault(); 후 fetch()로 전송 등
console.log({ email: email.value });
});
</script>
6. 폼 처리
폼 이벤트
<form id="myForm">
<input type="text" id="username" name="username" required>
<input type="email" id="email" name="email" required>
<input type="password" id="password" name="password" required>
<button type="submit">제출</button>
</form>
<script>
const form = document.getElementById("myForm");
form.addEventListener("submit", (e) => {
e.preventDefault(); // 페이지 새로고침 방지
// FormData 사용
const formData = new FormData(form);
const data = {
username: formData.get("username"),
email: formData.get("email"),
password: formData.get("password")
};
console.log(data);
// 또는 직접 접근
const username = document.getElementById("username").value;
const email = document.getElementById("email").value;
// 유효성 검사
if (username.length < 3) {
alert("사용자명은 3자 이상이어야 합니다");
return;
}
// API 전송
fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => console.log(result))
.catch(error => console.error(error));
});
// 실시간 유효성 검사
const username = document.getElementById("username");
username.addEventListener("input", (e) => {
const value = e.target.value;
if (value.length < 3) {
username.style.borderColor = "red";
} else {
username.style.borderColor = "green";
}
});
</script>
7. 자주 하는 실수와 해결법
실수 1: DOMContentLoaded 전에 접근
// ❌ HTML 로드 전 실행
const button = document.getElementById("myButton"); // null!
button.addEventListener("click", () => {}); // TypeError
// ✅ DOMContentLoaded 대기
document.addEventListener("DOMContentLoaded", () => {
const button = document.getElementById("myButton");
button.addEventListener("click", () => {
console.log("클릭!");
});
});
// ✅ 또는 script를 body 끝에 배치
실수 2: innerHTML로 이벤트 리스너 제거
// ❌ innerHTML은 이벤트 리스너 제거
const container = document.getElementById("container");
const button = document.createElement("button");
button.textContent = "클릭";
button.addEventListener("click", () => console.log("클릭!"));
container.appendChild(button);
container.innerHTML = ""; // 이벤트 리스너도 제거됨
// ✅ removeChild 사용
while (container.firstChild) {
container.removeChild(container.firstChild);
}
실수 3: 이벤트 전파
// 이벤트 버블링
<div id="parent">
<button id="child">버튼</button>
</div>
document.getElementById("parent").addEventListener("click", () => {
console.log("부모 클릭");
});
document.getElementById("child").addEventListener("click", (e) => {
console.log("자식 클릭");
// e.stopPropagation(); // 전파 중단
});
// 버튼 클릭 시:
// 자식 클릭
// 부모 클릭 (버블링)
8. 연습 문제
문제 1: 카운터
증가/감소 버튼이 있는 카운터를 만드세요.
<div id="counter">
<button id="decrease">-</button>
<span id="count">0</span>
<button id="increase">+</button>
</div>
<script>
let count = 0;
const countSpan = document.getElementById("count");
document.getElementById("increase").addEventListener("click", () => {
count++;
countSpan.textContent = count;
});
document.getElementById("decrease").addEventListener("click", () => {
count--;
countSpan.textContent = count;
});
</script>
문제 2: 동적 리스트
입력한 항목을 리스트에 추가하고 삭제할 수 있게 하세요.
<input type="text" id="itemInput">
<button id="addItem">추가</button>
<ul id="itemList"></ul>
<script>
const input = document.getElementById("itemInput");
const addBtn = document.getElementById("addItem");
const list = document.getElementById("itemList");
function addItem() {
const text = input.value.trim();
if (!text) return;
const li = document.createElement("li");
li.innerHTML = `
${text}
<button class="delete">삭제</button>
`;
list.appendChild(li);
input.value = "";
}
addBtn.addEventListener("click", addItem);
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") addItem();
});
// 이벤트 위임
list.addEventListener("click", (e) => {
if (e.target.classList.contains("delete")) {
e.target.parentElement.remove();
}
});
</script>
정리
핵심 요약
-
요소 선택:
getElementById(): ID로 선택querySelector(): CSS 선택자querySelectorAll(): 모든 요소
-
요소 조작:
- 텍스트:
textContent(기본),innerHTML(신뢰할 수 있는 입력만) - 속성:
getAttribute(),setAttribute() - 스타일:
style,classList
- 텍스트:
-
요소 생성/삭제:
- 생성:
createElement() - 추가:
appendChild(),insertBefore() - 삭제:
remove(),removeChild()
- 생성:
-
이벤트:
- 등록:
addEventListener() - 제거:
removeEventListener() - 위임: 부모에 이벤트 등록
- 전파: 캡처링(
capture: true)과 버블링 이해
- 등록:
-
이벤트 객체:
event.target: 이벤트 발생 요소event.preventDefault(): 기본 동작 막기event.stopPropagation(): 전파 중단
베스트 프랙티스
- ✅
querySelector우선 사용 - ✅ 이벤트 위임 활용
- ✅
DOMContentLoaded대기 - ✅
innerHTML대신createElement(보안) - ✅ 이벤트 리스너 정리
다음 단계
- JavaScript 클래스
- JavaScript 모듈
- JavaScript 에러 처리
관련 글
- JavaScript 시작하기 | 웹 개발의 필수 언어 완벽 입문
- C++ Kafka 고급 활용 | 스트림 처리·트랜잭션·정확히 한 번 전달 완벽 가이드 [#52-6]
- C++ 스크립팅 엔진 통합 | Lua·Python·JavaScript 바인딩 완벽 가이드 [#55-3]
- C++ JavaScript 스크립팅 완벽 가이드 | V8 임베딩·컨텍스트·C++↔JS 바인딩 [실전]
- HTML/CSS 시작하기 | 웹 개발 첫걸음