본문으로 건너뛰기
Advertisement

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');
}
Advertisement