MutationObserver
이런 경험 있으신가요?
여러분도 이런 상황을 겪어본 적 있나요? 서드파티 라이브러리가 DOM에 요소를 동적으로 추가하는데, 그 타이밍을 정확히 알아야 하는 경우. 또는 사용자가 입력한 내용에 따라 DOM이 변경될 때마다 특정 로직을 실행해야 하는 상황.
저도 최근에 정확히 이 문제를 만났습니다. 광고 스크립트가 비동기로 DOM에 광고 요소를 삽입하는데, 그 순간을 포착해서 레이아웃을 재조정해야 했어요. setTimeout으로 계속 체크하는 건 너무 비효율적이고, 그렇다고 광고가 언제 로드될지 알 수도 없었습니다.
바로 그때 MutationObserver가 등장합니다.
MutationObserver가 뭔가요?
MutationObserver는 DOM 트리의 변경사항을 감지하는 Web API입니다. 마치 DOM에 설치한 감시 카메라처럼, 특정 요소나 그 하위 요소들의 변화를 실시간으로 관찰하고 알려줍니다.
왜 필요한가요?
과거에는 DOM 변경을 감지하기 위해 다음과 같은 방법들을 사용했습니다.
// ❌ 옛날 방식 1: Mutation Events (비추천, deprecated)
element.addEventListener('DOMNodeInserted', handler);
// ❌ 옛날 방식 2: 폴링 (비효율적)
setInterval(() => {
if (element.children.length > previousCount) {
// 변경 감지!
}
}, 100);
// ❌ 옛날 방식 3: Mutation Events (성능 문제)
element.addEventListener('DOMSubtreeModified', handler);
문제점:
- Mutation Events: 성능 문제로 deprecated됨
- 폴링: CPU 낭비, 정확한 타이밍 포착 불가
- 비효율적: 모든 변경마다 동기적으로 실행
// ✅ 현대적 방식: MutationObserver
const observer = new MutationObserver((mutations) => {
console.log('DOM이 변경되었습니다!', mutations);
});
observer.observe(element, {
childList: true, // 자식 노드 추가/제거 감지
attributes: true, // 속성 변경 감지
characterData: true, // 텍스트 변경 감지
subtree: true // 하위 트리 전체 감지
});
장점:
- 비동기: 성능에 영향 없음
- 정확: 변경 발생 시점에 정확히 알림
- 배치 처리: 여러 변경사항을 한 번에 처리
- 세밀한 제어: 무엇을 관찰할지 선택 가능
기본 사용법
1단계: Observer 생성
// MutationObserver 인스턴스 생성
const observer = new MutationObserver((mutations, observer) => {
// mutations: 변경사항 배열
// observer: 현재 observer 인스턴스
mutations.forEach(mutation => {
console.log('변경 타입:', mutation.type);
console.log('변경된 노드:', mutation.target);
});
});
2단계: 관찰 시작
const targetElement = document.querySelector('#my-element');
observer.observe(targetElement, {
// 관찰할 변경 사항을 설정
childList: true, // 자식 노드의 추가/제거
attributes: true, // 속성 변경
characterData: true, // 텍스트 내용 변경
subtree: true, // 하위 트리 전체
// 추가 옵션
attributeOldValue: true, // 이전 속성 값 기록
characterDataOldValue: true, // 이전 텍스트 값 기록
attributeFilter: ['class', 'style'] // 특정 속성만 감지
});
3단계: 관찰 중지
// 관찰 중지
observer.disconnect();
// 처리되지 않은 변경사항 가져오기
const pendingMutations = observer.takeRecords();
console.log('미처리 변경사항:', pendingMutations);
실전 예제
예제 1: 동적으로 추가되는 요소 감지
시나리오: 채팅 앱에서 새 메시지가 추가될 때마다 자동 스크롤
// 채팅 컨테이너
const chatContainer = document.querySelector('#chat-messages');
// Observer 생성
const chatObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// 새 노드가 추가되었는지 확인
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
// 메시지 요소인 경우에만 처리
if (node.nodeType === Node.ELEMENT_NODE &&
node.classList.contains('message')) {
console.log('새 메시지 추가됨:', node.textContent);
// 자동 스크롤
chatContainer.scrollTop = chatContainer.scrollHeight;
// 알림 표시 (선택사항)
showNotification('새 메시지가 도착했습니다!');
}
});
}
});
});
// 관찰 시작
chatObserver.observe(chatContainer, {
childList: true, // 자식 노드 추가 감지
subtree: false // 직접 자식만 감지 (성능 최적화)
});
// 사용 예시
function addMessage(text, sender) {
const messageEl = document.createElement('div');
messageEl.className = 'message';
messageEl.innerHTML = `
<span class="sender">${sender}</span>
<span class="text">${text}</span>
`;
chatContainer.appendChild(messageEl);
// → Observer가 자동으로 감지하고 스크롤!
}
핵심 포인트:
mutation.addedNodes: 추가된 노드 목록node.nodeType === Node.ELEMENT_NODE: 요소 노드만 처리 (텍스트 노드 제외)subtree: false: 직접 자식만 감지하여 성능 향상
예제 2: 속성 변경 감지 (Dark Mode 토글)
시나리오: data-theme 속성 변경을 감지하여 UI 업데이트
// HTML의 data-theme 속성 감지
const htmlElement = document.documentElement;
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' &&
mutation.attributeName === 'data-theme') {
const oldValue = mutation.oldValue; // 이전 값
const newValue = htmlElement.getAttribute('data-theme'); // 새 값
console.log(`테마 변경: ${oldValue} → ${newValue}`);
// 테마에 따른 처리
if (newValue === 'dark') {
loadDarkModeAssets();
updateChartColors('dark');
} else {
loadLightModeAssets();
updateChartColors('light');
}
}
});
});
themeObserver.observe(htmlElement, {
attributes: true, // 속성 변경 감지
attributeOldValue: true, // 이전 값 기록
attributeFilter: ['data-theme'] // data-theme만 감지
});
// 사용 예시
function toggleTheme() {
const currentTheme = htmlElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
htmlElement.setAttribute('data-theme', newTheme);
// → Observer가 자동으로 감지하고 처리!
}
핵심 포인트:
attributeOldValue: true: 이전 값과 새 값 비교 가능attributeFilter: 특정 속성만 감지하여 성능 향상mutation.oldValue: 이전 속성 값 (옵션 활성화 필요)
예제 3: 텍스트 변경 감지 (입력 검증)
시나리오: contenteditable 요소의 텍스트 변경을 실시간 검증
const editableDiv = document.querySelector('[contenteditable]');
const textObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'characterData') {
const newText = mutation.target.textContent;
const oldText = mutation.oldValue;
console.log(`텍스트 변경: "${oldText}" → "${newText}"`);
// 실시간 검증
validateText(newText);
updateCharCount(newText.length);
}
});
});
textObserver.observe(editableDiv, {
characterData: true, // 텍스트 변경 감지
characterDataOldValue: true, // 이전 텍스트 기록
subtree: true // 하위 텍스트 노드도 감지
});
function validateText(text) {
const maxLength = 280;
if (text.length > maxLength) {
editableDiv.classList.add('error');
showWarning(`최대 ${maxLength}자까지 입력 가능합니다.`);
} else {
editableDiv.classList.remove('error');
}
}
function updateCharCount(count) {
document.querySelector('#char-count').textContent = `${count} / 280`;
}
핵심 포인트:
characterData: true: 텍스트 노드 변경 감지subtree: true: 하위 텍스트 노드도 감지- contenteditable과 완벽한 호환
예제 4: 서드파티 스크립트 감지 (광고 로딩)
시나리오: 비동기로 삽입되는 광고를 감지하여 레이아웃 조정
// 광고 컨테이너
const adContainer = document.querySelector('#ad-container');
const adObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
// 광고 요소 감지
if (node.nodeType === Node.ELEMENT_NODE &&
node.matches('.ad-unit, iframe[src*="doubleclick"]')) {
console.log('광고가 로드되었습니다!');
// 광고 로드 완료 대기
if (node.tagName === 'IFRAME') {
node.addEventListener('load', () => {
adjustLayout();
trackAdImpression(node);
});
} else {
adjustLayout();
trackAdImpression(node);
}
}
});
}
});
});
adObserver.observe(adContainer, {
childList: true,
subtree: true // 중첩된 요소도 감지
});
function adjustLayout() {
// 광고 높이만큼 컨텐츠 영역 조정
const adHeight = adContainer.offsetHeight;
document.querySelector('#content').style.marginTop = `${adHeight + 20}px`;
}
function trackAdImpression(adElement) {
// 광고 노출 추적
console.log('Ad impression tracked:', adElement);
}
핵심 포인트:
- 서드파티 스크립트는 언제 실행될지 모름
subtree: true로 중첩 구조도 감지- iframe 로드 완료는 별도 처리 필요
실전 활용: Infinite Scroll
완전한 구현 예제
class InfiniteScroll {
constructor(container, options = {}) {
this.container = container;
this.isLoading = false;
this.page = 1;
// 옵션
this.threshold = options.threshold || 2; // 마지막에서 N개 남았을 때 로드
this.onLoadMore = options.onLoadMore || (() => {});
// Observer 설정
this.setupObserver();
}
setupObserver() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// 새 아이템이 추가되면 로딩 상태 해제
this.isLoading = false;
// 스크롤 위치 확인
this.checkScrollPosition();
}
});
});
this.observer.observe(this.container, {
childList: true,
subtree: false
});
// 초기 스크롤 체크
window.addEventListener('scroll', () => this.checkScrollPosition());
}
checkScrollPosition() {
if (this.isLoading) return;
const children = this.container.children;
const totalItems = children.length;
if (totalItems === 0) return;
// 마지막에서 N개 남은 아이템
const triggerItem = children[totalItems - this.threshold];
if (!triggerItem) return;
// 뷰포트에 들어왔는지 확인
const rect = triggerItem.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight;
if (isVisible) {
this.loadMore();
}
}
async loadMore() {
this.isLoading = true;
this.page++;
console.log(`페이지 ${this.page} 로딩 중...`);
try {
await this.onLoadMore(this.page);
// 새 아이템이 추가되면 Observer가 자동으로 감지!
} catch (error) {
console.error('로딩 실패:', error);
this.isLoading = false;
}
}
destroy() {
this.observer.disconnect();
}
}
// 사용 예시
const scroll = new InfiniteScroll(
document.querySelector('#product-list'),
{
threshold: 3,
onLoadMore: async (page) => {
// API 호출
const products = await fetchProducts(page);
// DOM에 추가
const list = document.querySelector('#product-list');
products.forEach(product => {
const item = createProductCard(product);
list.appendChild(item);
// → Observer가 자동으로 감지하고 isLoading = false!
});
}
}
);
// 정리
// scroll.destroy();
왜 MutationObserver를 사용했나요?
- 새 아이템 추가를 자동으로 감지
isLoading상태를 정확한 타이밍에 해제- API 응답 시간과 무관하게 동작
성능 최적화
1. 필요한 것만 감지하기
// ❌ 나쁜 예: 모든 변경사항 감지
observer.observe(element, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true
});
// ✅ 좋은 예: 필요한 것만 감지
observer.observe(element, {
childList: true, // 자식 노드만 필요
subtree: false, // 직접 자식만
attributeFilter: ['class'] // class 속성만
});
2. Debounce 활용
let timeoutId;
const observer = new MutationObserver((mutations) => {
// 기존 타이머 취소
clearTimeout(timeoutId);
// 새 타이머 설정 (300ms 디바운스)
timeoutId = setTimeout(() => {
processChanges(mutations);
}, 300);
});
3. 특정 조건에서만 처리
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// 특정 클래스를 가진 요소만 처리
if (mutation.target.classList.contains('important')) {
handleMutation(mutation);
}
});
});
4. disconnect/observe 전략
// 대량 DOM 조작 전에 관찰 중지
observer.disconnect();
// DOM 조작
for (let i = 0; i < 1000; i++) {
container.appendChild(createItem(i));
}
// 다시 관찰 시작
observer.observe(container, config);
흔한 실수 TOP 5
1. disconnect 안 하기
// ❌ 메모리 누수!
function setupObserver() {
const observer = new MutationObserver(callback);
observer.observe(element, config);
// observer를 정리하지 않음!
}
// ✅ 정리하기
class Component {
mount() {
this.observer = new MutationObserver(callback);
this.observer.observe(this.element, config);
}
unmount() {
this.observer.disconnect();
this.observer = null;
}
}
2. 무한 루프
// ❌ 무한 루프 발생!
const observer = new MutationObserver(() => {
element.textContent = 'Updated!'; // 또 다시 mutation 발생!
});
observer.observe(element, {
characterData: true,
subtree: true
});
// ✅ 조건부 처리
const observer = new MutationObserver(() => {
const newText = 'Updated!';
// 같은 값이면 변경하지 않음
if (element.textContent !== newText) {
element.textContent = newText;
}
});
// ✅ 또는 일시 중지
const observer = new MutationObserver(() => {
observer.disconnect(); // 잠시 중지
element.textContent = 'Updated!';
observer.observe(element, config); // 다시 시작
});
3. subtree를 잘못 사용
// ❌ 성능 문제: 모든 하위 요소 감지
observer.observe(document.body, {
childList: true,
subtree: true // body의 모든 하위 요소!
});
// ✅ 필요한 범위만 감지
observer.observe(specificContainer, {
childList: true,
subtree: false // 직접 자식만
});
4. NodeList vs Array 혼동
// ❌ mutation.addedNodes는 NodeList (유사 배열)
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// filter는 Array 메서드!
const elements = mutation.addedNodes.filter(n => n.nodeType === 1); // 에러!
});
});
// ✅ Array로 변환
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
const elements = Array.from(mutation.addedNodes)
.filter(n => n.nodeType === Node.ELEMENT_NODE);
});
});
5. 타이밍 문제
// ❌ Observer 설정 전에 DOM 변경
element.appendChild(child);
const observer = new MutationObserver(callback);
observer.observe(element, config);
// → 이미 추가된 child는 감지 못함!
// ✅ Observer 먼저 설정
const observer = new MutationObserver(callback);
observer.observe(element, config);
element.appendChild(child); // 이제 감지됨!
브라우저 지원
MutationObserver는 모든 모던 브라우저에서 지원됩니다.
| 브라우저 | 버전 |
|---|---|
| Chrome | 26+ |
| Firefox | 14+ |
| Safari | 7+ |
| Edge | 12+ |
| IE | 11+ |
폴리필이 필요한가요? IE 10 이하를 지원해야 한다면 mutation-observer polyfill을 사용하세요.
실전 체크리스트
MutationObserver를 사용할 때 다음을 확인하세요:
- 필요한 옵션만 사용했는가? (childList, attributes, characterData 중 필요한 것만)
- subtree는 정말 필요한가? (성능에 큰 영향)
- attributeFilter로 특정 속성만 감지하는가?
- 컴포넌트 언마운트 시 disconnect 호출하는가?
- 무한 루프 가능성은 없는가?
- 대량 DOM 조작 시 일시 중지하는가?
- 디바운스가 필요한가?
- NodeList를 Array로 변환하는가?
마무리
MutationObserver는 DOM 변경을 감지하는 현대적이고 효율적인 방법입니다.
핵심 요약:
- ✅ 비동기: 성능에 영향 없음
- ✅ 정확: 변경 시점을 정확히 포착
- ✅ 배치 처리: 여러 변경을 한 번에
- ✅ 세밀한 제어: 필요한 것만 감지
- ⚠️ 주의사항: disconnect, 무한 루프, subtree 남용
다음에 이런 경우를 만나면:
- 서드파티 스크립트 감지 → MutationObserver
- 동적 요소 추가 감지 → childList
- 속성 변경 감지 → attributes + attributeFilter
- 텍스트 변경 감지 → characterData
여러분도 MutationObserver를 사용해보신 적 있나요? 어떤 케이스에 활용하셨는지 궁금합니다!
Happy observing! 👀🔍
댓글