DOM 조작
DOM(Document Object Model)은 HTML 문서를 트리 구조로 표현하는 인터페이스입니다. JavaScript로 DOM을 조작하면 페이지 구조, 스타일, 내용을 동적으로 변경할 수 있습니다.
DOM 트리 구조
HTML 문서는 노드(Node)들의 계층적 트리로 표현됩니다.
document
└── html
├── head
│ ├── title (텍스트: "My Page")
│ └── meta
└── body
├── h1 (텍스트: "제목")
├── div.container
│ ├── p (텍스트: "단락 1")
│ └── ul
│ ├── li (텍스트: "항목 1")
│ └── li (텍스트: "항목 2")
└── footer
// 노드 타입
// Node.ELEMENT_NODE (1) - <div>, <p> 등
// Node.TEXT_NODE (3) - 텍스트 내용
// Node.COMMENT_NODE (8) - <!-- 주석 -->
const h1 = document.querySelector('h1');
console.log(h1.nodeType); // 1 (ELEMENT_NODE)
console.log(h1.nodeName); // 'H1'
console.log(h1.nodeValue); // null (요소 노드는 null)
const textNode = h1.firstChild;
console.log(textNode.nodeType); // 3 (TEXT_NODE)
console.log(textNode.nodeValue); // '제목'
querySelector / querySelectorAll
CSS 선택자로 요소를 탐색합니다.
// querySelector: 첫 번째 매칭 요소 반환
const title = document.querySelector('h1');
const btn = document.querySelector('.btn-primary');
const input = document.querySelector('input[type="email"]');
const firstItem = document.querySelector('ul > li:first-child');
// querySelectorAll: 모든 매칭 요소 NodeList 반환
const allButtons = document.querySelectorAll('button');
const allInputs = document.querySelectorAll('input, select, textarea');
// NodeList는 Array가 아니므로 Array 메서드 사용 시 변환 필요
const buttonsArray = Array.from(allButtons);
const buttonsSpread = [...allButtons];
// forEach는 NodeList에서 직접 사용 가능
allButtons.forEach(btn => {
btn.addEventListener('click', handleClick);
});
// 특정 요소 내에서 탐색 (컨텍스트 지정)
const container = document.querySelector('.container');
const innerItems = container.querySelectorAll('li'); // .container 내의 li만
// 여러 선택자
document.querySelector('[data-id="123"]'); // 데이터 속성
document.querySelector(':not(.disabled)'); // 부정 선택자
document.querySelector('.parent > .direct-child'); // 직계 자식
기타 탐색 메서드
// ID로 탐색 (가장 빠름)
const app = document.getElementById('app');
// 클래스로 탐색 (HTMLCollection 반환 - 살아있는 컬렉션)
const items = document.getElementsByClassName('item');
// 태그명으로 탐색
const divs = document.getElementsByTagName('div');
// 가족 관계 탐색
const parent = element.parentElement;
const children = element.children; // HTMLCollection (요소 노드만)
const childNodes = element.childNodes; // NodeList (텍스트 노드 포함)
const firstChild = element.firstElementChild;
const lastChild = element.lastElementChild;
const nextSibling = element.nextElementSibling;
const prevSibling = element.previousElementSibling;
// closest: 가장 가까운 조상 요소 탐색
const listItem = document.querySelector('li');
const parentList = listItem.closest('ul'); // 가장 가까운 ul
const section = listItem.closest('section'); // 가장 가까운 section
요소 생성 및 삽입
createElement와 textContent
// 요소 생성
const div = document.createElement('div');
div.className = 'card';
div.id = 'card-1';
div.textContent = '카드 내용'; // XSS 안전
// 텍스트 노드 생성
const textNode = document.createTextNode('텍스트 내용');
// appendChild: 마지막 자식으로 삽입
const container = document.querySelector('.container');
container.appendChild(div);
// insertBefore: 특정 요소 앞에 삽입
const referenceNode = container.querySelector('.existing');
container.insertBefore(div, referenceNode);
// 속성 설정
div.setAttribute('data-id', '123');
div.setAttribute('aria-label', '카드 설명');
div.getAttribute('data-id'); // '123'
div.removeAttribute('aria-label');
div.hasAttribute('data-id'); // true
// 여러 속성 한번에 설정
Object.assign(div, {
id: 'card-1',
className: 'card active',
title: '카드 제목',
tabIndex: 0
});
insertAdjacentHTML
문자열 HTML을 특정 위치에 삽입합니다. innerHTML보다 효율적입니다.
const container = document.querySelector('.container');
// 위치 옵션:
// 'beforebegin' - 요소 앞 (같은 레벨)
// 'afterbegin' - 요소의 첫 번째 자식으로
// 'beforeend' - 요소의 마지막 자식으로
// 'afterend' - 요소 뒤 (같은 레벨)
container.insertAdjacentHTML('beforeend', `
<div class="card">
<h3>새 카드</h3>
<p>카드 내용입니다.</p>
</div>
`);
container.insertAdjacentHTML('afterbegin', '<div class="banner">공지사항</div>');
container.insertAdjacentHTML('beforebegin', '<div class="header-area">헤더</div>');
// insertAdjacentElement: 요소 삽입
const newItem = document.createElement('li');
newItem.textContent = '새 항목';
existingItem.insertAdjacentElement('afterend', newItem);
// insertAdjacentText: 텍스트 삽입 (XSS 안전)
element.insertAdjacentText('beforeend', '추가 텍스트');
append / prepend / replaceWith / remove
ES2015+ 메서드로 더 직관적인 조작이 가능합니다.
const list = document.querySelector('ul');
// append: 마지막에 추가 (여러 개, 텍스트 가능)
const li1 = document.createElement('li');
const li2 = document.createElement('li');
list.append(li1, li2, '텍스트도 가능');
// prepend: 처음에 추가
list.prepend(document.createElement('li'));
// replaceWith: 요소 교체
const oldItem = document.querySelector('.old');
const newItem = document.createElement('div');
oldItem.replaceWith(newItem);
// remove: 요소 제거
const target = document.querySelector('.remove-me');
target.remove();
// cloneNode: 요소 복사
const original = document.querySelector('.template');
const clone = original.cloneNode(true); // true: 자식 포함 깊은 복사
clone.id = 'clone-1';
document.body.appendChild(clone);
DocumentFragment로 성능 최적화
DOM 조작은 리플로우(Reflow)와 리페인트(Repaint)를 유발합니다. DocumentFragment를 사용하면 조작을 일괄 처리할 수 있습니다.
// 나쁜 방법: 매번 DOM 조작 → 1000번의 리플로우
const list = document.querySelector('#large-list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `항목 ${i}`;
list.appendChild(li); // 매번 DOM 업데이트 발생!
}
// 좋은 방법: DocumentFragment로 일괄 처리 → 1번의 리플로우
const list = document.querySelector('#large-list');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `항목 ${i}`;
fragment.appendChild(li); // 메모리상에서만 조작
}
list.appendChild(fragment); // 한 번만 DOM에 삽입
// 실전: 데이터 배열을 리스트로 렌더링
function renderUserList(users) {
const list = document.querySelector('#user-list');
const fragment = document.createDocumentFragment();
users.forEach(user => {
const li = document.createElement('li');
li.className = 'user-item';
li.innerHTML = `
<img src="${user.avatar}" alt="${user.name}">
<span class="name">${user.name}</span>
<span class="email">${user.email}</span>
`;
li.dataset.userId = user.id;
fragment.appendChild(li);
});
list.replaceChildren(fragment); // 기존 내용 모두 교체 후 삽입
}
// replaceChildren으로 내용 초기화
list.replaceChildren(); // 인수 없으면 모든 자식 제거
가상 DOM 개념 이해 (React 비교)
실제 DOM 조작은 비용이 큽니다. React 등의 프레임워크는 가상 DOM(Virtual DOM)을 사용합니다.
// 실제 DOM 조작 비용이 높은 이유
// 1. 레이아웃 계산 (Reflow)
// 2. 화면 그리기 (Repaint)
// 3. 레이어 합성 (Composite)
// 순수 JavaScript로 가상 DOM 개념 이해
// Virtual DOM은 실제 DOM의 자바스크립트 표현
const virtualDOM = {
type: 'div',
props: { className: 'container' },
children: [
{
type: 'h1',
props: {},
children: ['제목']
},
{
type: 'p',
props: { className: 'text' },
children: ['내용']
}
]
};
// Virtual DOM → 실제 DOM 변환
function createElement(vnode) {
if (typeof vnode === 'string') {
return document.createTextNode(vnode);
}
const el = document.createElement(vnode.type);
// props 적용
Object.entries(vnode.props || {}).forEach(([key, value]) => {
if (key === 'className') {
el.className = value;
} else {
el.setAttribute(key, value);
}
});
// 자식 노드 재귀 생성
(vnode.children || []).forEach(child => {
el.appendChild(createElement(child));
});
return el;
}
// React의 diff 알고리즘 개념
// 이전 Virtual DOM과 새 Virtual DOM을 비교하여
// 변경된 부분만 실제 DOM에 반영 (최소한의 DOM 조작)
dataset 활용
data-* 속성을 통해 HTML 요소에 커스텀 데이터를 저장합니다.
// HTML: <div id="user" data-user-id="123" data-user-name="Alice" data-is-admin="true">
const userEl = document.getElementById('user');
// dataset으로 접근 (camelCase 자동 변환)
userEl.dataset.userId; // '123' (항상 문자열!)
userEl.dataset.userName; // 'Alice'
userEl.dataset.isAdmin; // 'true' (문자열!)
// 값 설정
userEl.dataset.lastLogin = new Date().toISOString();
// HTML: data-last-login="2024-03-15T..."
// 값 삭제
delete userEl.dataset.isAdmin;
// 타입 변환이 필요함 (항상 문자열)
const userId = Number(userEl.dataset.userId); // 123
const isAdmin = userEl.dataset.isAdmin === 'true'; // boolean
// 실전: 이벤트 위임에서 활용
document.querySelector('#product-list').addEventListener('click', (e) => {
const button = e.target.closest('[data-action]');
if (!button) return;
const { action, productId } = button.dataset;
switch (action) {
case 'add-to-cart':
addToCart(productId);
break;
case 'wishlist':
addToWishlist(productId);
break;
case 'delete':
deleteProduct(productId);
break;
}
});
// JSON 저장
const config = { theme: 'dark', lang: 'ko', version: 2 };
element.dataset.config = JSON.stringify(config);
const restored = JSON.parse(element.dataset.config);
classList 활용
const el = document.querySelector('.card');
// 기본 조작
el.classList.add('active'); // 클래스 추가
el.classList.remove('hidden'); // 클래스 제거
el.classList.toggle('selected'); // 있으면 제거, 없으면 추가
el.classList.contains('active'); // 포함 여부 (boolean)
el.classList.replace('old', 'new'); // 교체
// 여러 클래스 동시 조작
el.classList.add('visible', 'loaded', 'ready');
el.classList.remove('loading', 'placeholder');
// toggle에 두 번째 인수: 강제 추가/제거
el.classList.toggle('active', isActive); // isActive가 true면 추가, false면 제거
// 실전: 테마 전환
const themeToggle = document.querySelector('#theme-toggle');
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('dark-theme');
localStorage.setItem('theme', document.documentElement.classList.contains('dark-theme') ? 'dark' : 'light');
});
// 실전: 모달 표시/숨김
function showModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.add('visible');
document.body.classList.add('modal-open');
modal.setAttribute('aria-hidden', 'false');
}
function hideModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.remove('visible');
document.body.classList.remove('modal-open');
modal.setAttribute('aria-hidden', 'true');
}
// 여러 요소 클래스 관리
function setActiveTab(tabId) {
// 모든 탭 비활성화
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
tab.setAttribute('aria-selected', 'false');
});
// 선택한 탭 활성화
const activeTab = document.querySelector(`[data-tab="${tabId}"]`);
activeTab?.classList.add('active');
activeTab?.setAttribute('aria-selected', 'true');
}
실전 예제: 동적 테이블 렌더링
function renderTable(data, columns) {
const fragment = document.createDocumentFragment();
const table = document.createElement('table');
table.className = 'data-table';
// 헤더 생성
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
columns.forEach(col => {
const th = document.createElement('th');
th.textContent = col.label;
th.dataset.field = col.field;
th.classList.add('sortable');
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// 바디 생성
const tbody = document.createElement('tbody');
data.forEach(row => {
const tr = document.createElement('tr');
tr.dataset.id = row.id;
columns.forEach(col => {
const td = document.createElement('td');
td.textContent = col.format ? col.format(row[col.field]) : row[col.field];
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
fragment.appendChild(table);
return fragment;
}
// 사용
const users = [
{ id: 1, name: 'Alice', age: 30, email: 'alice@example.com' },
{ id: 2, name: 'Bob', age: 25, email: 'bob@example.com' },
];
const columns = [
{ field: 'name', label: '이름' },
{ field: 'age', label: '나이', format: v => `${v}세` },
{ field: 'email', label: '이메일' },
];
document.querySelector('#table-container').appendChild(renderTable(users, columns));
고수 팁
1. innerHTML 대신 textContent 사용
// 위험: XSS 공격 가능
const userInput = '<script>alert("xss")</script>';
element.innerHTML = userInput; // 스크립트 실행됨!
// 안전: textContent는 HTML로 파싱하지 않음
element.textContent = userInput; // 텍스트로 표시됨
2. 레이아웃 스래싱(Layout Thrashing) 방지
// 나쁜 예: 읽기와 쓰기를 번갈아 하면 매번 리플로우 발생
elements.forEach(el => {
const height = el.offsetHeight; // 읽기 (리플로우)
el.style.height = `${height * 2}px`; // 쓰기 (레이아웃 무효화)
// 다음 반복에서 offsetHeight 읽기 시 리플로우 재발생!
});
// 좋은 예: 읽기와 쓰기 분리
const heights = elements.map(el => el.offsetHeight); // 모두 읽기
elements.forEach((el, i) => {
el.style.height = `${heights[i] * 2}px`; // 모두 쓰기
});
3. IntersectionObserver와 MutationObserver 활용 (8.5장 참조)
// 요소가 뷰포트에 들어올 때 클래스 추가 (스크롤 애니메이션)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.animated').forEach(el => observer.observe(el));
4. 요소 존재 확인 패턴
// Optional chaining으로 안전하게 접근
document.querySelector('#optional-element')?.classList.add('active');
document.querySelector('#form')?.addEventListener('submit', handleSubmit);
// 여러 요소 처리 시 null 체크
const modal = document.querySelector('#modal');
if (modal) {
modal.classList.add('visible');
}