성능을 높이려면 브라우저가 화면을 그리는 단계부터 이해해야 합니다. 이번 글은 렌더링 파이프라인과 지연 로딩 패턴을 다룹니다. “리플로와 지연 로딩”이 핵심, requestAnimationFrame 스케줄링은 선택 심화입니다.
이번 글에서 새로 나오는 용어
- 리플로: 요소 크기·위치가 바뀌어 레이아웃 계산이 다시 일어나는 비용 큰 단계입니다.
- 리페인트: 레이아웃은 그대로 두고 색상이나 그림자만 다시 칠하는 과정입니다.
- DocumentFragment: 화면에 붙이기 전에 DOM 조각을 임시로 담아두는 컨테이너라 리플로 횟수를 줄입니다.
- 지연 로딩: 요소가 실제로 화면에 들어올 때만 네트워크 요청을 보내는 전략입니다.
- requestAnimationFrame: 브라우저가 다음 페인트 직전에 실행시켜 주는 함수로, 측정·애니메이션을 한 프레임으로 묶을 수 있습니다.
핵심 개념
- 리플로(Reflow, 레이아웃 재계산): DOM 크기나 위치가 변할 때 레이아웃 계산이 다시 일어나는 단계입니다. 비용이 큰 작업입니다.
- 리페인트(Repaint, 다시 칠하기): 레이아웃은 그대로지만 색상·그림자가 바뀌어 다시 칠하는 단계입니다.
- 레이아웃 스래싱(Layout Thrashing, 레이아웃 남용): 읽기와 쓰기 작업을 번갈아 실행해 레이아웃을 반복 계산하는 실수입니다.
- 지연 로딩(Lazy Loading, 필요한 순간 로드): 이미지나 컴포넌트를 화면에 보일 때만 가져오는 전략입니다.
loading="lazy"속성이나IntersectionObserver를 사용합니다.
코드로 확인하기
먼저 DocumentFragment로 DOM 조작을 모읍니다. 시작하기 전에 DOM 조작 비용을 단순히 느껴보는 미니 예제를 확인합니다.
const list = document.querySelector("ul");
for (let i = 0; i < 3; i += 1) {
list?.append(`아이템 ${i}`);
}
➡️ 위처럼 “추가 → 렌더 → 추가 → 렌더”를 반복하면 느려질 수 있다는 감각만 잡아 두면, DocumentFragment의 목적이 쉽게 이해됩니다.
const list = document.querySelector(".card-list");
function batchUpdate(cards) {
const fragment = document.createDocumentFragment();
cards.forEach((card) => {
const li = document.createElement("li");
li.className = "card";
li.innerHTML = `<h3>${card.title}</h3><p>${card.summary}</p>`;
fragment.appendChild(li);
});
list?.appendChild(fragment);
}
문서를 직접 조작하지 않고 DocumentFragment에 모아두면 리플로 횟수를 줄일 수 있습니다.
다음으로 performance.now()로 레이아웃 시간을 측정합니다. 즉, “얼마나 시간이 걸렸는지 숫자로 기록”하는 단계입니다.
function measureLayout(fn) {
const start = performance.now();
fn();
const end = performance.now();
console.log(`레이아웃 작업: ${(end - start).toFixed(2)}ms`);
}
measureLayout(() => batchUpdate(largeCardList));
performance.now()를 통해 레이아웃 작업 시간을 측정할 수 있습니다.
이미지는 지연 로딩 속성과 관찰자 API로 최적화합니다.
const heroImg = document.querySelector(".hero img");
heroImg?.setAttribute("loading", "lazy");
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const target = entry.target;
if (!(target instanceof HTMLImageElement)) return;
const src = target.dataset.src;
if (src) {
target.src = src;
target.addEventListener("load", () => target.classList.add("is-loaded"));
obs.unobserve(target);
}
});
});
document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));
IntersectionObserver로 이미지를 필요할 때만 로드하면 초기 페이지 무게를 줄일 수 있습니다. 기억법: “화면에 보이는 순간만 data-src를 진짜 src로 바꿔 준다”.
레이아웃 값을 읽을 때는 requestAnimationFrame에 묶어 한 번만 실행합니다. 이 부분은 선택 심화로, 필요할 때만 추가하세요.
let scheduled = false;
function scheduleMeasure() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
const height = list?.getBoundingClientRect().height;
console.log("리스트 높이", height);
scheduled = false;
});
}
window.addEventListener("resize", scheduleMeasure);
requestAnimationFrame 안에서 레이아웃 값을 읽으면 리플로를 한 번으로 제한할 수 있습니다.
왜 중요한가
- 레이아웃 스래싱을 방지하면 대량 데이터를 렌더링할 때도 프레임 드랍을 줄일 수 있습니다.
- 지연 로딩은 이미지를 많이 사용하는 랜딩 페이지에서 핵심 최적화입니다.
- 렌더링 파이프라인을 이해하면 DevTools Performance 탭을 해석할 수 있습니다.
실습
- 따라 하기:
DocumentFragment로 카드 리스트를 렌더링하고 기존 방식 대비 시간을 측정합니다. - 확장하기:
IntersectionObserver로 이미지와 통계 카드 두 가지 요소에 지연 로딩을 적용합니다. - 디버깅: 의도적으로 레이아웃 스래싱을 만들고
Performance레코드에서 레이아웃 횟수가 늘어나는지 확인합니다. - 완료 기준: 지연 로딩 후 초기 네트워크 요청 수가 줄고, 레이아웃 측정 로그가 한 프레임당 한 번만 찍히면 실습이 끝납니다.
마무리
성능 기초를 챙기면 대시보드나 긴 리스트를 다루는 프로젝트에서도 안정적인 경험을 제공할 수 있습니다. 다음 글에서는 DOM 테스트와 디버깅 루틴을 살펴봅니다.
💬 댓글
이 글에 대한 의견을 남겨주세요