동적으로 생성되는 팝업, 무한 스크롤로 추가되는 리스트, 또는 외부 타사(Third-party) 위젯의 DOM 변화를 실시간으로 감지해야 하는 문제를 경험해봤을 것이다. 다만 정확한 원인이나 효율적인 감지 방법을 모른 채 구식 이벤트를 쓰다가 브라우저가 먹통이 되거나 메모리 누수가 발생하는 경우가 많다.
과거에는 구식 Mutation Events를 가져와 복사해 붙여넣곤 했다. 이번에는 MutationObserver가 정확히 뭔지, 왜 구식 이벤트를 대체해야 하는지, 그리고 어떻게 실무에 적용하는지 완벽하게 정리해서 소개하겠다.
웹 애플리케이션에서 DOM 변경을 감지할 때 가장 먼저 떠올리는 구식 방법은 DOMNodeInserted나 DOMSubtreeModified 같은 Mutation Events였다. 하지만 이 방식은 동기(Synchronous)로 동작한다. DOM이 하나 추가될 때마다 이벤트가 즉시 발생하고, 그 내부에서 또 다른 DOM을 조작하면 감지 이벤트가 연쇄적으로 폭발해 브라우저 렌더링 엔진을 멈추게 만들었다. 이러한 성능 이슈 때문에 W3C에서는 해당 이벤트를 완전히 Deprecated(폐기) 처리했다.
이를 대체하기 위해 등장한 표준 API가 바로 MutationObserver다. MutationObserver는 DOM의 변경 사항을 즉시 처리하지 않고, 변화를 모아서 비동기(Asynchronous) 마이크로태스크(Microtask) 단위로 한 번에 콜백 함수에 전달한다. 덕분에 대량의 DOM 변경이 발생해도 화면 정지 현상 없이 안전하게 모니터링할 수 있다.
두 방식의 차이점을 한눈에 파악할 수 있도록 핵심 항목을 비교 표로 정리했다.
| 비교 항목 | 구식 Mutation Events (DOMNodeInserted 등) | 현대적 MutationObserver API |
|---|---|---|
| 동작 방식 | 동기 방식(DOM 변경 시 즉시 실행) | 비동기 방식(마이크로태스크 큐에서 배칭 처리) |
| 성능 영향 | 잦은 DOM 변경 시 연쇄 이벤트 폭발로 UI 멈춤 | 변화를 수집하여 한 번에 전달하므로 렌더링 성능 우수 |
| 표준 상태 | Deprecated (사용 중단 권장) | HTML5 W3C 표준 API |
| 메모리 관리 | 이벤트 리스너 제거 누수 위험 높음 | disconnect() 메서드로 명확하게 감지 중단 가능 |
| 세부 옵션 | 자식 노드 추가/삭제 위주의 단순 감지 | 속성(attributes), 텍스트(characterData), 자식 노드 세부 지정 가능 |
자주 하는 실수 중 하나는 감지 콜백 함수 내부에서 다시 DOM을 수정하여 무한 루프에 빠지는 것이다. 구식 이벤트 방식과 올바른 MutationObserver 구현 방식을 살펴보자.
✗ 잘못된 방법 (구식 Mutation Events 사용 및 동기 폭발 위험)
// 구식 방식으로 DOM 변경 감지 (Deprecated)
const targetNode = document.getElementById('chat-box');
targetNode.addEventListener('DOMNodeInserted', function (event) {
// ✗ 동기 방식으로 동작하여 렌더링 성능에 치명적임
// ✗ 여기서 targetNode에 새 엘리먼트를 넣으면 무한 루프 발생 위험
console.log('새 노드가 추가됨:', event.target);
const autoReply = document.createElement('div');
autoReply.innerText = '자동 응답';
targetNode.appendChild(autoReply); // 렌더링 스레드 차단 및 콜스택 초과 발생 가능
});이 코드는 새 노드가 삽입될 때마다 즉시 이벤트를 호출하며, 내부에서 다시 appendChild를 실행하면 스택 오버플로우가 발생해 브라우저 탭이 강제 종료된다.
✓ 올바른 방법 (MutationObserver와 disconnect 활용)
// 1. 감지 대상 엘리먼트 선택
const targetNode = document.getElementById('chat-box');
// 2. 옵션 설정 (자식 노드 추가/삭제 및 하위 트리 전체 감지)
const config = { childList: true, subtree: true };
// 3. 콜백 함수 정의
const callback = function (mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
// 요소 노드(Node.ELEMENT_NODE)인 경우만 처리
if (node.nodeType === 1) {
console.log('추가된 동적 엘리먼트:', node);
// 무한 루프 방지를 위해 일시적으로 감지 중단 후 다시 시작하는 기법
observer.disconnect();
// 필요한 DOM 작업 수행
node.classList.add('highlight');
// 작업 완료 후 재연결
observer.observe(targetNode, config);
}
});
}
}
};
// 4. 인스턴스 생성 및 감지 시작
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
// 필요한 시점(예: 컴포넌트 언마운트 시)에 완전히 감지 종료
// observer.disconnect();
[실행 결과]
추가된 동적 엘리먼트: <div class="user-message">안녕하세요!</div>
// UI 정지 없이 안전하게 'highlight' 클래스가 추가되고 감지가 지속됨
MutationObserver를 실무에 도입할 때 반드시 체크해야 할 주의사항 두 가지가 있다.
SPA(Single Page Application) 환경에서 페이지를 이동하거나 컴포넌트가 화면에서 사라졌음에도 Observer를 해제하지 않으면, 관찰 대상이 GC(Garbage Collector) 수집 대상에서 제외되어 메모리 누수가 발생한다. 더 이상 감지가 필요 없거나 컴포넌트가 데스트로이될 때 반드시 observer.disconnect()를 호출해야 한다.
observe(target, config)를 호출할 때 childList, attributes, characterData 중 적어도 하나는 반드시 true로 설정해야 한다. 아무것도 설정하지 않거나 빈 객체를 전달하면 TypeError가 발생하며 스크립트 실행이 중단된다.
MutationObserver는 동적 UI 변경을 효율적으로 추적하는 강력한 도구다. 비동기 배칭 방식과 명확한 해제 습관이 모여서 웹 애플리케이션의 렌더링 성능을 극대화한다는 점을 잊지 말자. 이 글의 실전 예제 코드를 참고해 동적 DOM 감지 로직을 개선하면, 렌더링 병목 없는 쾌적한 웹 서비스를 얻을 수 있을 것이다.