[JavaScript 시리즈 17편] 퍼포먼스 기초: 페인트·리플로와 지연 로딩

English version

성능을 높이려면 브라우저가 화면을 그리는 단계부터 이해해야 합니다. 이번 글은 렌더링 파이프라인과 지연 로딩 패턴을 다룹니다. “리플로와 지연 로딩”이 핵심, requestAnimationFrame 스케줄링은 선택 심화입니다.

이번 글에서 새로 나오는 용어

  1. 리플로: 요소 크기·위치가 바뀌어 레이아웃 계산이 다시 일어나는 비용 큰 단계입니다.
  2. 리페인트: 레이아웃은 그대로 두고 색상이나 그림자만 다시 칠하는 과정입니다.
  3. DocumentFragment: 화면에 붙이기 전에 DOM 조각을 임시로 담아두는 컨테이너라 리플로 횟수를 줄입니다.
  4. 지연 로딩: 요소가 실제로 화면에 들어올 때만 네트워크 요청을 보내는 전략입니다.
  5. 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 테스트와 디버깅 루틴을 살펴봅니다.

💬 댓글

이 글에 대한 의견을 남겨주세요