MutationObserver + PerformanceObserver로 CLS 원인 추적과 LCP 측정 자동화하기
솔직히 말하면, 저도 처음에 "폴링이면 충분하지 않나?" 하고 넘어갔다가 프로덕션에서 CPU 사용률이 치솟는 걸 직접 겪은 뒤에야 Observer API를 진지하게 파게 됐습니다. setInterval로 DOM을 반복 확인하거나, scroll 이벤트에 핸들러를 덕지덕지 붙이는 방식은 코드가 단순해 보여도 실제로는 메인 스레드를 꾸준히 갉아먹습니다. Observer 패턴은 "변화가 생겼을 때만 알려줘"라는 구독 방식이라 훨씬 영리하게 동작합니다. 그리고 두 API를 조합하면 단독으로 쓸 때는 불가능한 일도 할 수 있게 됩니다.
그 조합이 바로 이 글의 주제입니다. MutationObserver로 DOM 삽입 이력을 추적하고, PerformanceObserver의 layout-shift 타임스탬프와 교차 분석하면 어떤 서드파티 스크립트가 CLS를 유발하는지 특정할 수 있고, 지연 로딩 이미지가 LCP 후보가 되는 순간도 잡아낼 수 있습니다. 외부 라이브러리 없이, 바닐라 JavaScript로만요. 이 방식을 쓰고 나서 광고팀과 "우리 코드 탓이 아니다"라고 자신 있게 말할 수 있게 됐는데, 예시 2가 그때 처음 만들었던 코드입니다.
핵심 개념
MutationObserver: 마이크로태스크 큐에서 일괄 실행되는 DOM 감시자
MutationObserver는 DOM 트리에서 발생하는 변화(노드 추가/삭제, 속성 변경, 텍스트 변경)를 비동기 배치로 수신합니다. 과거의 MutationEvent(DOM3 Events)는 변화가 생길 때마다 즉시 동기 이벤트를 발생시키다 보니 렌더링 파이프라인을 막는 문제가 심각했습니다. MutationObserver는 이를 대체하면서, 변화들을 모아두었다가 콜백으로 일괄 전달합니다.
여기서 "비동기"라는 표현이 약간 오해를 일으킬 수 있는데, 정확히는 마이크로태스크 큐(microtask queue) 에서 실행됩니다. setTimeout(fn, 0)처럼 다음 태스크로 밀리는 게 아니라, 현재 자바스크립트 실행이 끝나는 즉시 마이크로태스크로 실행된다는 뜻입니다. 그래서 콜백 안에서 무거운 DOM 조작을 하면 렌더링이 여전히 블로킹될 수 있다는 점을 염두에 두면 좋습니다.
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('자식 노드 변경:', mutation.addedNodes, mutation.removedNodes);
}
if (mutation.type === 'attributes') {
console.log(`속성 변경: ${mutation.attributeName}`);
}
}
});
observer.observe(document.body, {
childList: true, // 직계 자식 노드 감시
subtree: true, // 모든 하위 트리 포함
attributes: true, // 속성 변경 감시
characterData: true // 텍스트 콘텐츠 변경 감시
});
// 꼭 해제해야 합니다 — 빠뜨리면 메모리 누수로 이어집니다
observer.disconnect();observe() 옵션 조합이 중요합니다. subtree: true 없이는 직계 자식만 감시하고, childList: true 없이는 노드 추가/삭제를 감지하지 못합니다. 처음엔 옵션을 전부 켜두고 싶겠지만, 감시 범위가 넓을수록 콜백 호출 빈도가 올라가니 필요한 것만 켜두는 편이 좋습니다.
PerformanceObserver: 성능 타임라인을 실시간으로 구독
PerformanceObserver는 브라우저가 내부적으로 기록하는 Performance Timeline 항목을 비동기로 수신합니다. LCP, CLS, INP 같은 Core Web Vitals가 여기에 포함됩니다. Lighthouse로만 성능을 측정하던 시절과 달리, 이제는 실제 사용자 환경(RUM)에서 직접 지표를 수집하는 게 업계 흐름입니다.
// LCP 측정 — 가장 큰 콘텐츠가 화면에 그려진 시점
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1]; // 최신 LCP 후보
console.log('LCP:', lastEntry.startTime, 'ms', lastEntry.element);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// CLS 측정 — 누적 레이아웃 이동 점수
let clsValue = 0;
const clsObserver = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 사용자 입력 직후 500ms 내 발생한 이동은 제외 (의도된 변화)
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
console.log('CLS 누적:', clsValue);
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
buffered: true옵션을 꼭 챙겨두는 것을 권장합니다. Observer를 등록하기 이전에 이미 기록된 성능 항목도 소급해서 받을 수 있어, 스크립트가 페이지 초기에 로드되지 않더라도 정확한 데이터를 얻을 수 있습니다. 다만 브라우저 내부 버퍼 크기에는 제한이 있고,largest-contentful-paint처럼 페이지 생명주기 전체에서 업데이트되는 타입은 Observer 등록 시점에 따라 초기 항목이 유실될 수도 있다는 점을 알아두면 좋습니다.
두 Observer의 타임스탬프는 모두 performance.now() 기반의 DOMHighResTimeStamp를 사용합니다. Date.now()와 달리 밀리초 단위 소수점까지 제공하며, 기준점이 navigation start로 동일하기 때문에 두 Observer의 타임스탬프를 직접 비교할 수 있습니다. 아래 예시 2에서 교차 분석이 가능한 이유가 바로 이 때문입니다.
현재(2025~2026) Core Web Vitals 3대 지표는 LCP / INP / CLS로 확정됐습니다. 2024년 3월에 FID(First Input Delay)가 INP(Interaction to Next Paint)로 교체됐고, 아직도 FID 기반 코드를 참조하는 문서들이 많으니 주의가 필요합니다.
INP(Interaction to Next Paint): 사용자 입력(클릭, 탭, 키 입력)에 대한 응답으로 다음 화면 업데이트가 일어나기까지의 지연 시간. FID가 첫 번째 입력만 측정했던 것과 달리, INP는 페이지 생애 전반의 인터랙션 응답성을 평가합니다.
실전 적용
예시 1: SPA 라우트 전환 시 렌더 완료 시점 자동 감지
React, Vue 같은 SPA에서는 전통적인 페이지뷰 추적이 작동하지 않습니다. window.location이 바뀌더라도 실제 DOM 업데이트가 완료되는 시점은 프레임워크 내부에서 결정되기 때문입니다. MutationObserver로 #app 컨테이너의 자식 변화를 감지하면 렌더 완료 시점을 포착할 수 있습니다.
한 가지 중요한 한계를 먼저 짚고 넘어가면, popstate 이벤트만으로는 React Router나 Next.js App Router처럼 history.pushState()를 직접 호출하는 SPA 라우터의 전환을 잡지 못합니다. pushState는 popstate를 발생시키지 않기 때문입니다. 실무에서 완전하게 적용하려면 history.pushState를 monkey-patch하거나 프레임워크의 라우터 이벤트를 구독하는 방식을 추가해야 합니다. 여기서는 원리를 보여주는 형태로 시작해보겠습니다.
function trackSPANavigation() {
let navigationStart = performance.now();
const mutationObserver = new MutationObserver((mutations) => {
const hasContentChange = mutations.some(
(m) => m.addedNodes.length > 0 && m.type === 'childList'
);
if (hasContentChange) {
const renderTime = performance.now() - navigationStart;
// 실제 RUM 엔드포인트로 교체해서 사용하시면 됩니다
console.log('[analytics]', { type: 'spa-route-render', duration: renderTime });
}
});
const appEl = document.getElementById('app');
mutationObserver.observe(appEl, {
childList: true,
subtree: true,
});
const onPopState = () => {
navigationStart = performance.now();
};
window.addEventListener('popstate', onPopState);
// disconnect()와 removeEventListener를 묶어 반환 — React useEffect cleanup 패턴
return () => {
mutationObserver.disconnect();
window.removeEventListener('popstate', onPopState);
};
}
// React에서 사용할 때:
// useEffect(() => {
// return trackSPANavigation();
// }, []);| 포인트 | 설명 |
|---|---|
navigationStart |
popstate 이벤트 시점을 기준으로 리셋해 라우트 전환 시간을 정밀 측정 |
hasContentChange |
addedNodes가 있는 childList 변화만 필터링해 불필요한 분석 전송 방지 |
| cleanup 반환 | disconnect() + removeEventListener를 묶어 반환해 누수 방지 |
예시 2: 동적 광고 삽입으로 인한 CLS 원인 추적
한 번은 광고 플랫폼 팀에서 "CLS가 0.28인데 다 너네 코드 문제 아니냐"고 우기는 상황이 있었습니다. 직접 범인을 특정해야 했는데, 그때 처음 만든 게 이 패턴입니다. MutationObserver로 DOM 삽입 로그를 쌓아두고, PerformanceObserver의 layout-shift 타임스탬프와 교차 분석하면 어느 노드가 레이아웃 이동을 유발했는지 좁혀낼 수 있습니다.
const domInsertionLog = [];
// DOM에 새로 삽입되는 모든 Element 노드를 시간 순으로 기록
const mutObs = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
domInsertionLog.push({
tag: node.tagName,
time: performance.now(),
// node.src는 IMG/SCRIPT 외 노드에 없으므로 getAttribute로 안전하게 접근
src: node.getAttribute('src') || node.dataset?.adId || 'unknown',
});
}
});
});
});
mutObs.observe(document.body, { childList: true, subtree: true });
// layout-shift 발생 시 직전 삽입된 노드를 의심 목록으로 출력
const perfObs = new PerformanceObserver((list) => {
list.getEntries().forEach((shift) => {
if (!shift.hadRecentInput) {
// 100ms는 경험적 기본값입니다. 광고처럼 지연 삽입이 긴 케이스는 200~300ms로 넓혀볼 수 있습니다
const suspects = domInsertionLog.filter(
(log) => Math.abs(log.time - shift.startTime) < 100
);
console.warn('CLS 유발 의심 노드:', suspects, '이동값:', shift.value);
}
});
});
perfObs.observe({ type: 'layout-shift', buffered: true });두 Observer의 타임스탬프가 모두 performance.now() 기반(DOMHighResTimeStamp)이라 직접 비교가 가능합니다. 100ms 허용 오차는 경험적인 기본값이고, 광고 네트워크처럼 비동기 로딩이 길게 걸리는 경우엔 200~300ms로 넓혀가며 조절해볼 수 있습니다.
예시 3: 지연 로딩 이미지의 LCP 후보 추적
Lazy-load 이미지는 초기 HTML에 없고 스크롤 후에 삽입되기 때문에, LCP 후보가 되는지 추적하기 까다롭습니다. MutationObserver로 새로 삽입된 IMG 태그를 감지하고, PerformanceObserver로 LCP 엔트리의 element 필드와 비교하면 연결할 수 있습니다.
Map의 키를 node.src(문자열)로 쓰면, 동일한 URL의 이미지가 캐러셀이나 가상 스크롤에서 반복 등장할 때 이전 타임스탬프를 덮어쓰게 됩니다. 노드 자체를 키로 쓰는 WeakMap이 더 안전합니다.
// src 문자열 대신 노드 자체를 키로 사용 — 동일 URL 이미지 반복 삽입에도 안전
const imageLoadMap = new WeakMap();
const imgObserver = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node.tagName === 'IMG') {
imageLoadMap.set(node, performance.now());
node.addEventListener('load', () => {
const insertedAt = imageLoadMap.get(node);
if (insertedAt !== undefined) {
const loadDuration = performance.now() - insertedAt;
console.log(`이미지 로드 완료: ${node.src} (${loadDuration.toFixed(0)}ms)`);
}
});
}
});
});
});
imgObserver.observe(document.body, { childList: true, subtree: true });
// LCP 후보가 위에서 감지한 이미지인지 확인
const lcpObserver = new PerformanceObserver((list) => {
const entry = list.getEntries().at(-1);
if (entry.element?.tagName === 'IMG') {
console.log('LCP 이미지:', entry.element.src, entry.startTime, 'ms');
}
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });예시 4: 동적 컴포넌트 접근성 자동 감사
앞의 세 예시는 두 Observer를 조합하는 패턴인데, MutationObserver 단독으로도 매우 유용한 케이스가 있습니다. 디자인 시스템이나 CMS에서 동적으로 삽입되는 컴포넌트는 alt, aria-label 같은 접근성 속성이 빠지기 쉽습니다. CI 파이프라인의 정적 감사로는 런타임에 삽입되는 컴포넌트를 잡기 어려운데, MutationObserver를 이용하면 사용자가 실제로 인터랙션하는 시점에 삽입된 컴포넌트까지 감사할 수 있습니다.
const a11yObserver = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return; // Element 노드만 처리
if (node.tagName === 'IMG' && !node.alt) {
console.warn('alt 속성 누락:', node);
}
if (node.getAttribute('role') === 'button' && !node.getAttribute('aria-label')) {
console.warn('aria-label 누락 버튼:', node);
}
});
});
});
a11yObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
});
// 감사가 끝난 뒤에는 반드시 해제해두면 좋습니다
// a11yObserver.disconnect();장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 폴링 불필요 | 이벤트 기반으로 동작해 CPU 부담을 최소화 |
| 배치 처리 | 변화를 모아서 콜백에 일괄 전달해 불필요한 호출 감소 |
| 브라우저 지원 | 모든 에버그린 브라우저에서 폴리필 없이 사용 가능 |
| RUM 가능 | Lab 도구 없이 실제 사용자 환경에서 Core Web Vitals 수집 |
| 소급 수집 | buffered: true로 Observer 등록 전 항목도 수신 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Safari 미지원 (적용 범위 제한) | layout-shift, largest-contentful-paint, event 등 핵심 타입이 Chromium 계열에서만 완전 지원. Safari는 현재 미지원이며 지원 로드맵이 공개되지 않은 상태 |
Chromium 기반 RUM에 집중하거나 폴백 처리 |
| 무한 루프 위험 | 콜백 내부에서 DOM 조작 시 MutationObserver가 자신이 유발한 변화를 다시 감지 | 플래그 변수로 자기 유발 변화 차단 |
| 메모리 누수 | disconnect() 없이 컴포넌트 언마운트 시 Observer가 메모리에 남음 |
React useEffect cleanup, Vue onUnmounted에서 반드시 해제 |
| 성능 오버헤드 | subtree: true + 고빈도 DOM 업데이트 환경에서 콜백 폭증 가능 |
감시 대상을 최소 범위로 명시적으로 제한 |
| 최종값 타이밍 | LCP는 페이지 언로드 직전까지 업데이트될 수 있어 최종값 확정 시점 주의 필요 | visibilitychange 이벤트에서 최종값 전송 |
실무에서 가장 흔한 실수
-
disconnect()누락 — SPA에서 컴포넌트가 언마운트되고 재마운트될 때마다 Observer가 중복 생성돼 동일한 이벤트가 여러 번 분석 서버로 전송되는 버그가 생깁니다. 예시 1처럼 cleanup 함수를 반환하는 패턴을 만들어두면, ReactuseEffect리턴 함수에 넘기는 것만으로 누수를 막을 수 있습니다. -
콜백 내부 DOM 조작 — Observer 콜백 안에서 아무 생각 없이 DOM을 수정하면 그 수정이 다시 Observer를 트리거해 무한 루프에 빠집니다. 아래처럼 플래그로 막아두는 게 안전합니다.
javascriptlet isObserving = true; const obs = new MutationObserver((mutations) => { if (!isObserving) return; isObserving = false; doSomethingWithDOM(); isObserving = true; }); -
LCP를 첫 번째 엔트리로 읽기 —
getEntries()의 첫 번째 항목이 LCP라고 착각하는 경우가 있는데, LCP 엔트리는 더 큰 요소가 나타날 때마다 추가됩니다. 항상entries.at(-1)또는entries[entries.length - 1]로 마지막 항목을 읽어야 최종 LCP 후보를 얻을 수 있습니다.
마치며
이 두 API를 조합하면 얻게 되는 가장 실질적인 변화는, 서드파티 팀에 "CLS가 높습니다"라고 말하는 대신 "광고 컨테이너가 삽입된 뒤 83ms 시점에 0.14 레이아웃 이동이 발생했습니다"라고 콘솔 스크린샷과 함께 말할 수 있게 된다는 것입니다. 범인을 특정하는 순간 대화의 무게가 달라집니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 페이지의 CLS를 직접 측정해볼 수 있습니다 — 브라우저 콘솔에 위
clsObserver코드를 붙여넣고 페이지를 스크롤하면서 레이아웃 이동이 얼마나 발생하는지 확인해볼 수 있습니다. 0.1 미만이면 Good, 0.25 이상이면 Poor 구간입니다. -
서드파티 스크립트의 CLS 기여분을 추적해볼 수 있습니다 — 예시 2의 코드를 광고나 채팅 위젯이 삽입되는 페이지에 적용해보면, 어느 서드파티가 레이아웃 이동을 유발하는지 콘솔에서 바로 확인할 수 있습니다.
-
지연 로딩 이미지가 LCP에 미치는 영향을 직접 볼 수 있습니다 — 예시 3의 코드를 콘솔에 붙여넣고 스크롤하면, 삽입되는 이미지의 로드 시간과 LCP 후보 여부를 실시간으로 관찰할 수 있습니다.
참고 자료
- MutationObserver | MDN
- PerformanceObserver | MDN
- Largest Contentful Paint (LCP) | web.dev
- Web Vitals | web.dev
- Custom Metrics with PerformanceObserver | web.dev
- GoogleChrome/web-vitals | GitHub
- Reporting Core Web Vitals With The Performance API | Smashing Magazine
- Monitor Core Web Vitals with web-vitals.js | DebugBear
- Performance Observer Blog | Chrome for Developers
- PerformanceObserver | Can I use