DOM 성능 최적화 — Layout Thrashing, DocumentFragment, MutationObserver
DOM을 직접 조작하면 왜 느려질까?
innerHTML로 한 번에 넣는 것과appendChild를 100번 호출하는 것은 정말 차이가 날까?
프론트엔드 개발을 하다 보면 "DOM 조작은 비싸다"는 말을 자주 듣습니다. 하지만 왜 비싼지, ** 어떻게** 줄일 수 있는지를 구체적으로 아는 것이 중요합니다. 이 글에서는 DOM 조작이 성능에 미치는 영향과 실전 최적화 기법들을 정리합니다.
1. DOM 조작이 왜 비싼가
브라우저가 화면을 그리는 과정은 DOM → CSSOM → Render Tree → Layout → Paint → Composite 순서입니다. (자세한 내용은 브라우저 렌더링 원리 글을 참고하세요.)
핵심은 이것입니다: DOM을 변경하면 이 파이프라인이 다시 돌아간다 는 것입니다.
- Reflow(Layout): 요소의 크기·위치가 바뀌면 레이아웃을 전부 다시 계산
- Repaint: 색상·그림자 같은 시각적 속성만 바뀌면 다시 그리기
- **DOM 조작 1번 = 최소 Repaint 1번 **, 크기 변경이면 Reflow까지
브라우저는 성능을 위해 DOM 변경을 ** 배치(batch)**로 모아서 한 번에 처리하려고 합니다. 하지만 우리가 특정 패턴으로 코드를 작성하면, 이 배치 최적화를 무력화시킵니다.
2. Layout Thrashing — 강제 동기 레이아웃
문제: 읽기↔쓰기 반복 패턴
브라우저는 DOM 변경을 큐에 모아뒀다가 한 번에 처리합니다. 그런데 offsetWidth, getBoundingClientRect() 같은 ** 레이아웃 속성을 읽으면 **, 브라우저는 "지금까지 쌓인 변경사항을 즉시 반영"해야 정확한 값을 줄 수 있습니다. 이것이 ** 강제 동기 레이아웃(Forced Synchronous Layout)**입니다.
// ❌ 나쁜 패턴 — Layout Thrashing
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const width = box.offsetWidth; // 읽기 → 강제 레이아웃
box.style.width = width * 2 + 'px'; // 쓰기 → 레이아웃 무효화
// 다음 반복에서 또 읽기 → 또 강제 레이아웃... 반복!
});
N개의 요소가 있으면 N번의 강제 레이아웃 이 발생합니다. 요소가 1,000개면 1,000번의 레이아웃 계산이 프레임 하나에 몰립니다.
해결: Read-Then-Write 패턴
읽기를 먼저 일괄 수행하고, 쓰기를 나중에 일괄 수행합니다.
// ✅ 좋은 패턴 — 읽기 먼저, 쓰기 나중
const boxes = document.querySelectorAll('.box');
// 1단계: 읽기를 전부 먼저
const widths = Array.from(boxes).map(box => box.offsetWidth);
// 2단계: 쓰기를 전부 나중에
boxes.forEach((box, i) => {
box.style.width = widths[i] * 2 + 'px';
});
// 레이아웃 계산은 딱 1번!
Layout을 유발하는 속성들
공부하다 보니 생각보다 많은 속성이 강제 레이아웃을 트리거합니다.
| 유형 | 속성/메서드 |
|---|---|
| 크기·위치 | offsetWidth, offsetHeight, offsetTop, offsetLeft |
| 스크롤 | scrollTop, scrollHeight, scrollWidth |
| 클라이언트 | clientWidth, clientHeight, clientTop |
| 메서드 | getBoundingClientRect(), getComputedStyle() |
이 속성들을 루프 안에서 DOM 쓰기와 번갈아 사용하면 Layout Thrashing이 발생합니다. Chrome DevTools의 Performance 탭에서 보라색 "Layout" 블록이 반복되면 의심해 보세요.
3. DocumentFragment — 벌크 삽입의 핵심
개념
DocumentFragment는 메모리 상의 가벼운 DOM 컨테이너 입니다. 실제 DOM 트리에 속하지 않으므로, Fragment에 자식을 추가해도 리플로우가 발생하지 않습니다. Fragment를 DOM에 삽입하면 Fragment 자체는 사라지고 자식 노드만 이동 됩니다.
개별 삽입 vs DocumentFragment
// ❌ 느린 패턴 — 매번 DOM에 삽입 (리플로우 1,000번)
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `항목 ${i}`;
list.appendChild(li); // 매번 리플로우 가능
}
// ✅ 빠른 패턴 — DocumentFragment 사용 (리플로우 1번)
const list = document.getElementById('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); // 한 번에 삽입, 리플로우 1번
innerHTML과의 비교
// innerHTML — 문자열 연결 방식
const items = Array.from({ length: 1000 },
(_, i) => `<li>항목 ${i}</li>`
).join('');
list.innerHTML = items; // 파싱 + 삽입 1번
세 가지 방식의 대략적인 성능 순서입니다.
| 방식 | 리플로우 횟수 | 특징 |
|---|---|---|
개별 appendChild | 최대 N번 | 가장 느림 |
DocumentFragment | 1번 | 안전하고 유연함 |
innerHTML | 1번 | 빠르지만 이벤트 리스너 유실, XSS 주의 |
innerHTML은 빠르지만, 기존 자식의 이벤트 리스너가 모두 사라지고 XSS 취약점이 생길 수 있습니다. 동적 콘텐츠에는DocumentFragment가 더 안전합니다.
4. requestAnimationFrame — 렌더링과 동기화
왜 setTimeout(0)이 아닌 rAF인가
setTimeout(fn, 0)은 "가능한 빨리" 실행되지만, 브라우저의 렌더링 주기와는 무관합니다. 렌더링 직후에 실행될 수도 있고, 두 프레임 사이 어중간한 시점에 실행될 수도 있습니다.
requestAnimationFrame은 다음 리페인트 직전 에 콜백을 실행합니다. 브라우저가 "지금 그릴 준비가 됐다"고 알려주는 타이밍입니다.
// ❌ setTimeout — 렌더링 타이밍과 무관
setTimeout(() => {
element.style.transform = `translateX(${pos}px)`;
}, 0);
// ✅ rAF — 다음 프레임 직전에 실행
requestAnimationFrame(() => {
element.style.transform = `translateX(${pos}px)`;
});
애니메이션 루프
let start = null;
function animate(timestamp) {
if (!start) start = timestamp;
const progress = timestamp - start;
// 500ms 동안 오른쪽으로 300px 이동
const distance = Math.min(progress / 500, 1) * 300;
element.style.transform = `translateX(${distance}px)`;
if (progress < 500) {
requestAnimationFrame(animate); // 다음 프레임 예약
}
}
requestAnimationFrame(animate);
스크롤 핸들러 최적화
스크롤 이벤트는 초당 수십 번 발생합니다. rAF로 프레임당 1번만 처리하면 됩니다.
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
// 스크롤 위치에 따른 DOM 업데이트
header.classList.toggle('shrink', window.scrollY > 100);
ticking = false;
});
ticking = true;
}
});
rAF는 탭이 비활성화되면 자동으로 일시 정지됩니다.setTimeout은 계속 실행되어 불필요한 리소스를 소모합니다.
5. MutationObserver — DOM 변화 감시
기본 사용법
MutationObserver는 DOM 트리의 변화를 비동기적으로 감시합니다. setInterval로 DOM을 폴링하는 것보다 훨씬 효율적입니다.
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
console.log('자식 노드가 변경됨:', mutation.addedNodes);
}
if (mutation.type === 'attributes') {
console.log('속성 변경:', mutation.attributeName);
}
});
});
// 관찰 시작
observer.observe(document.getElementById('target'), {
childList: true, // 자식 추가/제거 감시
attributes: true, // 속성 변경 감시
subtree: true, // 하위 트리 전체 감시
characterData: true // 텍스트 내용 변경 감시
});
실전 사례: 아직 없는 요소 대기
서드파티 스크립트가 나중에 삽입하는 요소를 감지해야 할 때 유용합니다.
function waitForElement(selector) {
return new Promise(resolve => {
// 이미 존재하면 즉시 반환
const existing = document.querySelector(selector);
if (existing) return resolve(existing);
const observer = new MutationObserver((_, obs) => {
const el = document.querySelector(selector);
if (el) {
obs.disconnect(); // 찾으면 즉시 정리
resolve(el);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
// 사용
const widget = await waitForElement('.third-party-widget');
widget.style.border = '2px solid red';
폴링 vs MutationObserver
| 방식 | 동작 | CPU 사용 |
|---|---|---|
setInterval 폴링 | 변화 없어도 계속 확인 | 높음 |
MutationObserver | 변화가 있을 때만 콜백 | 낮음 |
정리: disconnect()
더 이상 감시가 필요 없으면 반드시 disconnect()를 호출해야 합니다. 그렇지 않으면 옵저버가 계속 메모리를 점유합니다.
// 컴포넌트 해제 시
observer.disconnect();
// 또는 한 번만 감시하고 싶을 때
const observer = new MutationObserver((mutations, obs) => {
// 처리 후 즉시 해제
obs.disconnect();
});
6. 가상 DOM과의 비교
React 같은 프레임워크가 인기 있는 이유 중 하나가 바로 DOM 조작 문제를 자동으로 해결해 주기 때문입니다.
| 항목 | 직접 DOM 조작 | 가상 DOM (React) |
|---|---|---|
| 변경 감지 | 개발자가 직접 관리 | diff 알고리즘이 자동 감지 |
| ** 배치 업데이트** | Read-Then-Write 수동 적용 | 자동 배칭 (React 18+) |
| ** 최소 업데이트** | 개발자 책임 | 변경된 부분만 실제 DOM 반영 |
| ** 러닝커브** | 낮음 | 높음 |
| ** 오버헤드** | 없음 | diff 계산 비용 |
가상 DOM이 마법은 아닙니다. 가상 DOM도 결국 실제 DOM을 조작하는 것이고, diff 계산이라는 추가 비용이 있습니다. 하지만 개발자가 Layout Thrashing 같은 실수를 하기 어렵게 만들어 주는 것이 큰 장점입니다.
바닐라 JS로 DOM을 다룰 때는 이 글의 최적화 기법이 필수이고, React를 쓸 때는 프레임워크가 대신 해주는 것들을 이해하면 더 나은 코드를 짤 수 있습니다.
7. 실전 체크리스트: DOM 성능 최적화 5가지 규칙
- ** 읽기와 쓰기를 분리하라** — 루프 안에서 레이아웃 속성 읽기와 스타일 쓰기를 번갈아 하지 않기
- ** 벌크 삽입에는 DocumentFragment를 쓰라** — 여러 요소를 한 번에 DOM에 추가할 때 리플로우를 1번으로 줄이기
- ** 애니메이션과 시각 업데이트는 rAF에 넣어라** —
setTimeout대신requestAnimationFrame으로 브라우저 렌더링 주기와 동기화 - DOM 감시는 MutationObserver를 쓰라 —
setInterval폴링 대신 변경 이벤트 기반으로 감시하고, 불필요해지면disconnect() - CSS로 해결할 수 있으면 JS를 쓰지 마라 —
transform,opacity는 Composite만 트리거하므로 Reflow 없이 애니메이션 가능
마무리
DOM 조작 최적화의 핵심은 결국 ** 브라우저가 최적화할 수 있도록 방해하지 않는 것 **입니다.
- Layout Thrashing은 "읽기-쓰기 분리"로 해결
- 대량 삽입은 DocumentFragment로 묶기
- 렌더링 타이밍은 rAF에 맡기기
- DOM 감시는 MutationObserver로 효율적으로
이 기법들은 바닐라 JS뿐 아니라, React 같은 프레임워크를 사용할 때도 왜 useEffect 안에서 DOM을 직접 조작하면 안 되는지, 왜 상태 배칭이 중요한지를 이해하는 기반이 됩니다.