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 남용

다음에 이런 경우를 만나면:

  1. 서드파티 스크립트 감지 → MutationObserver
  2. 동적 요소 추가 감지 → childList
  3. 속성 변경 감지 → attributes + attributeFilter
  4. 텍스트 변경 감지 → characterData

여러분도 MutationObserver를 사용해보신 적 있나요? 어떤 케이스에 활용하셨는지 궁금합니다!

Happy observing! 👀🔍

참고 자료

댓글