본문으로 건너뛰기
Advertisement

성능 최적화 API

브라우저는 성능 최적화를 위한 다양한 현대적 API를 제공합니다. IntersectionObserver, MutationObserver, requestAnimationFrame, Web Worker, ResizeObserver를 활용하면 부드럽고 반응성 좋은 웹 애플리케이션을 만들 수 있습니다.


IntersectionObserver

요소가 뷰포트(또는 특정 조상 요소)와 교차하는지 비동기적으로 감지합니다. 스크롤 이벤트 대비 성능이 크게 향상됩니다.

Lazy Loading 구현

// 이미지 지연 로딩
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;

const img = entry.target;
const src = img.dataset.src;

if (src) {
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
}

observer.unobserve(img); // 로드 후 관찰 중단
});
}, {
// rootMargin: 교차 감지 마진 (뷰포트에서 200px 전에 미리 로드)
rootMargin: '200px 0px',
threshold: 0 // 1픽셀이라도 보이면 트리거
});

// 모든 지연 로드 이미지 관찰 시작
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});

// HTML
// <img data-src="image.jpg" src="placeholder.jpg" alt="이미지">

무한 스크롤 구현

// 무한 스크롤
class InfiniteScroll {
#observer;
#sentinel;
#isLoading = false;
#page = 1;

constructor(container, fetchMore) {
// sentinel: 목록 끝에 배치하는 감지 요소
this.#sentinel = document.createElement('div');
this.#sentinel.className = 'scroll-sentinel';
container.appendChild(this.#sentinel);

this.#observer = new IntersectionObserver(async (entries) => {
const entry = entries[0];
if (!entry.isIntersecting || this.#isLoading) return;

this.#isLoading = true;
this.#sentinel.classList.add('loading');

try {
const hasMore = await fetchMore(this.#page++);

if (!hasMore) {
this.#observer.disconnect();
this.#sentinel.remove();
}
} finally {
this.#isLoading = false;
this.#sentinel.classList.remove('loading');
}
}, {
rootMargin: '300px', // 300px 전에 미리 로드
threshold: 0
});

this.#observer.observe(this.#sentinel);
}

reset() {
this.#page = 1;
this.#isLoading = false;
}
}

// 사용
const container = document.querySelector('#post-list');

const infiniteScroll = new InfiniteScroll(container, async (page) => {
const response = await fetch(`/api/posts?page=${page}&limit=10`);
const { posts, hasMore } = await response.json();

posts.forEach(post => {
const el = createPostElement(post);
container.insertBefore(el, container.lastElementChild);
});

return hasMore;
});

스크롤 애니메이션

// 요소가 뷰포트에 들어올 때 애니메이션
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('fade-in');
// 한 번 애니메이션 후 관찰 중단
animationObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.1, // 10% 이상 보일 때
rootMargin: '-50px 0px' // 50px 더 들어와야 트리거
});

document.querySelectorAll('.animate-on-scroll').forEach(el => {
animationObserver.observe(el);
});

// 여러 임계값으로 진행률 추적
const progressObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const ratio = entry.intersectionRatio;
entry.target.style.opacity = ratio;
entry.target.style.transform = `translateY(${(1 - ratio) * 30}px)`;
});
}, {
threshold: Array.from({ length: 101 }, (_, i) => i / 100) // 0~1 사이 101개
});

MutationObserver

DOM의 변경사항을 비동기적으로 감지합니다. 속성 변경, 자식 노드 추가/제거, 텍스트 변경을 감지할 수 있습니다.

// 기본 사용법
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
console.log('타입:', mutation.type);
// 'childList': 자식 노드 변경
// 'attributes': 속성 변경
// 'characterData': 텍스트 내용 변경

if (mutation.type === 'childList') {
console.log('추가된 노드:', mutation.addedNodes);
console.log('제거된 노드:', mutation.removedNodes);
}

if (mutation.type === 'attributes') {
console.log('변경된 속성:', mutation.attributeName);
console.log('이전 값:', mutation.oldValue);
}
});
});

// 관찰 시작
const target = document.querySelector('#dynamic-content');
observer.observe(target, {
childList: true, // 자식 노드 추가/제거 감지
attributes: true, // 속성 변경 감지
subtree: true, // 모든 자손 노드 포함
attributeOldValue: true, // 이전 속성값 기록
characterData: true, // 텍스트 변경 감지
characterDataOldValue: true
});

// 관찰 중단
observer.disconnect();

실전: 동적으로 추가되는 요소 초기화

// 서드파티 코드나 동적 로딩으로 추가되는 요소 처리
function initializeNewElements(elements) {
elements.forEach(el => {
if (el.classList?.contains('tooltip')) {
new Tooltip(el);
}
if (el.classList?.contains('code-block')) {
highlightCode(el);
}
});
}

const observer = new MutationObserver((mutations) => {
const addedElements = [];

mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
addedElements.push(node);
// 자손 요소도 포함
addedElements.push(...node.querySelectorAll('*'));
}
});
});

if (addedElements.length > 0) {
initializeNewElements(addedElements);
}
});

observer.observe(document.body, { childList: true, subtree: true });

// 테마 변경 감지
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.attributeName === 'class') {
const isDark = document.documentElement.classList.contains('dark');
updateTheme(isDark ? 'dark' : 'light');
}
});
});

themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'] // class 속성만 감지
});

requestAnimationFrame

브라우저의 다음 리페인트(repaint) 전에 콜백을 실행합니다. 애니메이션에 최적화된 타이밍을 제공합니다.

60fps 애니메이션

// 기본 애니메이션 루프
function animate(timestamp) {
// timestamp: DOMHighResTimeStamp (ms 단위, 매우 정밀)
updateAnimation(timestamp);
requestAnimationFrame(animate); // 다음 프레임 예약
}

const animationId = requestAnimationFrame(animate);

// 취소
cancelAnimationFrame(animationId);

// 실전: 부드러운 스크롤 애니메이션
function smoothScrollTo(targetY, duration = 500) {
const startY = window.scrollY;
const distance = targetY - startY;
const startTime = performance.now();

function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);

// ease-in-out 이징 함수
const eased = progress < 0.5
? 2 * progress * progress
: -1 + (4 - 2 * progress) * progress;

window.scrollTo(0, startY + distance * eased);

if (progress < 1) {
requestAnimationFrame(step);
}
}

requestAnimationFrame(step);
}

// 실전: 카운터 애니메이션
function animateCounter(element, from, to, duration = 1000) {
const startTime = performance.now();

function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);

// ease-out
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(from + (to - from) * eased);
element.textContent = current.toLocaleString();

if (progress < 1) {
requestAnimationFrame(update);
}
}

requestAnimationFrame(update);
}

// 실전: 게임 루프
class GameLoop {
#isRunning = false;
#animationId;
#lastTime = 0;
#fps = 60;
#fpsInterval = 1000 / 60;

start(updateFn) {
this.#isRunning = true;

const loop = (timestamp) => {
if (!this.#isRunning) return;

const elapsed = timestamp - this.#lastTime;

if (elapsed >= this.#fpsInterval) {
const delta = elapsed / 1000; // 초 단위 델타 타임
this.#lastTime = timestamp - (elapsed % this.#fpsInterval);
updateFn(delta);
}

this.#animationId = requestAnimationFrame(loop);
};

this.#animationId = requestAnimationFrame(loop);
}

stop() {
this.#isRunning = false;
cancelAnimationFrame(this.#animationId);
}
}

setInterval 대비 장점

// 나쁜 방법: setInterval (CPU 낭비, 탭 비활성 시에도 실행)
setInterval(() => {
updateGame(); // 화면이 실제로 업데이트되지 않아도 계속 실행!
}, 1000 / 60);

// 좋은 방법: requestAnimationFrame
// - 브라우저 렌더링 최적 타이밍에 실행
// - 탭이 비활성화되면 자동으로 중단 (배터리 절약)
// - 디스플레이 주사율에 맞춰 실행 (144Hz면 144fps)
function gameLoop(timestamp) {
updateGame(timestamp);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);

Web Worker

Web Worker는 메인 스레드와 별도의 스레드에서 JavaScript를 실행합니다. CPU 집약적인 작업을 메인 스레드 블로킹 없이 처리할 수 있습니다.

// worker.js (별도 파일)
// Web Worker 내에서는 DOM 접근 불가
// 하지만 fetch, setTimeout, IndexedDB 등은 사용 가능

self.addEventListener('message', (e) => {
const { type, data } = e.data;

switch (type) {
case 'SORT': {
const sorted = [...data].sort((a, b) => a - b);
self.postMessage({ type: 'SORT_DONE', result: sorted });
break;
}

case 'FIBONACCI': {
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
const result = fib(data.n);
self.postMessage({ type: 'FIB_DONE', result });
break;
}

case 'PROCESS_IMAGE': {
// 이미지 픽셀 처리
const { pixels, width, height } = data;
const result = applyGrayscaleFilter(pixels, width, height);
// Transferable Objects로 복사 없이 전달
self.postMessage({ type: 'IMAGE_DONE', result }, [result.buffer]);
break;
}
}
});

function applyGrayscaleFilter(pixels, width, height) {
const output = new Uint8ClampedArray(pixels.length);
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i+1] + pixels[i+2]) / 3;
output[i] = output[i+1] = output[i+2] = avg;
output[i+3] = pixels[i+3];
}
return output;
}
// main.js - 메인 스레드
const worker = new Worker('./worker.js');

// 메시지 수신
worker.addEventListener('message', (e) => {
const { type, result } = e.data;
switch (type) {
case 'SORT_DONE':
displaySortedResults(result);
break;
case 'FIB_DONE':
displayFibonacci(result);
break;
}
});

// 에러 처리
worker.addEventListener('error', (e) => {
console.error('워커 에러:', e.message, e.filename, e.lineno);
});

// 메시지 전송
worker.postMessage({ type: 'SORT', data: largeArray });
worker.postMessage({ type: 'FIBONACCI', data: { n: 40 } });

// 워커 종료
worker.terminate();

// Promise 래퍼로 사용하기 편리하게
class WorkerClient {
#worker;
#pending = new Map();
#idCounter = 0;

constructor(scriptUrl) {
this.#worker = new Worker(scriptUrl);
this.#worker.addEventListener('message', ({ data }) => {
const { id, result, error } = data;
const pending = this.#pending.get(id);
if (!pending) return;

this.#pending.delete(id);
if (error) pending.reject(new Error(error));
else pending.resolve(result);
});
}

request(type, data) {
return new Promise((resolve, reject) => {
const id = ++this.#idCounter;
this.#pending.set(id, { resolve, reject });
this.#worker.postMessage({ id, type, data });
});
}

terminate() {
this.#worker.terminate();
}
}

// 사용
const workerClient = new WorkerClient('./worker.js');
const sorted = await workerClient.request('SORT', hugeArray);

Inline Worker (별도 파일 없이)

// Blob URL로 인라인 워커 생성
function createInlineWorker(fn) {
const code = `
self.onmessage = function(e) {
const result = (${fn.toString()})(e.data);
self.postMessage(result);
};
`;
const blob = new Blob([code], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url); // 메모리 해제
return worker;
}

// 사용
const sortWorker = createInlineWorker((data) => {
return [...data].sort((a, b) => a - b);
});

sortWorker.onmessage = (e) => console.log('정렬 결과:', e.data);
sortWorker.postMessage([5, 3, 1, 4, 2]);

ResizeObserver

요소의 크기 변화를 감지합니다. window.resize 이벤트보다 더 정밀한 감지가 가능합니다.

// 기본 사용
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`요소 크기 변경: ${width}x${height}`);

// borderBoxSize: 테두리 포함 크기
const borderBoxSize = entry.borderBoxSize[0];
console.log(`테두리 포함: ${borderBoxSize.inlineSize}x${borderBoxSize.blockSize}`);

// contentBoxSize: 패딩 제외 크기
const contentBoxSize = entry.contentBoxSize[0];
});
});

resizeObserver.observe(document.querySelector('.responsive-chart'));

// 관찰 중단
resizeObserver.unobserve(element);
resizeObserver.disconnect(); // 전체 중단

// 실전: 반응형 차트
class ResponsiveChart {
#observer;
#chart;
#container;

constructor(container, chartLibrary) {
this.#container = container;

this.#observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.#onResize(width, height);
});

this.#observer.observe(container);
this.#initChart(container.clientWidth, container.clientHeight, chartLibrary);
}

#initChart(width, height, lib) {
this.#chart = lib.createChart(this.#container, { width, height });
}

#onResize(width, height) {
this.#chart?.resize(width, height);
}

destroy() {
this.#observer.disconnect();
this.#chart?.destroy();
}
}

// 실전: CSS 변수 업데이트
const containerObserver = new ResizeObserver(([entry]) => {
const { width } = entry.contentRect;
// CSS 변수로 컨테이너 너비 전달
document.documentElement.style.setProperty('--container-width', `${width}px`);

// 브레이크포인트 클래스 업데이트
const el = entry.target;
el.classList.toggle('sm', width < 640);
el.classList.toggle('md', width >= 640 && width < 1024);
el.classList.toggle('lg', width >= 1024);
});

성능 측정 API

// Performance API로 정밀 측정
const start = performance.now();
// 작업 수행
const end = performance.now();
console.log(`실행 시간: ${(end - start).toFixed(2)}ms`);

// 마커로 측정
performance.mark('작업-시작');
heavyOperation();
performance.mark('작업-종료');
performance.measure('작업-시간', '작업-시작', '작업-종료');

const measure = performance.getEntriesByName('작업-시간')[0];
console.log(`측정 결과: ${measure.duration.toFixed(2)}ms`);

// PerformanceObserver로 실시간 모니터링
const perfObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.duration > 50) { // 50ms 이상 긴 작업
console.warn(`긴 작업 감지: ${entry.name} - ${entry.duration.toFixed(2)}ms`);
}
});
});

perfObserver.observe({ entryTypes: ['measure', 'longtask'] });

고수 팁

1. Observer 패턴 공통 유틸리티

// 여러 Observer 일괄 관리
class ObserverManager {
#observers = new Set();

add(observer) {
this.#observers.add(observer);
return observer;
}

disconnectAll() {
this.#observers.forEach(o => o.disconnect());
this.#observers.clear();
}
}

const observerManager = new ObserverManager();
observerManager.add(new IntersectionObserver(handler1));
observerManager.add(new MutationObserver(handler2));
observerManager.add(new ResizeObserver(handler3));

// 페이지 이탈 시 정리
window.addEventListener('beforeunload', () => observerManager.disconnectAll());

2. requestAnimationFrame으로 DOM 읽기/쓰기 분리

// 레이아웃 스래싱 방지
function batchDOMUpdates(readFn, writeFn) {
requestAnimationFrame(() => {
const data = readFn(); // 읽기 (리플로우)
requestAnimationFrame(() => {
writeFn(data); // 쓰기 (다음 프레임에서)
});
});
}

3. Web Worker + SharedArrayBuffer로 데이터 공유

// SharedArrayBuffer: 워커와 메인 스레드가 메모리 공유
// Atomics: 동기화 원자적 연산
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);

// 워커에 전달 (복사 없이 공유)
worker.postMessage({ buffer: sharedBuffer });

// 워커에서
// const sharedArray = new Int32Array(e.data.buffer);
// Atomics.add(sharedArray, 0, 1); // 원자적 증가
Advertisement