자바스크립트 성능 최적화 - 렌더링 차단, 디바운스와 스로틀, 가상 스크롤
이 단원에서 배우는 것
17단원에서 긴 태스크가 화면을 멈춘다는 것을, 18단원에서 메모리가 새면 느려진다는 것을, 20단원에서 갱신 범위를 좁히는 원리를 봤다. 이번 단원은 그 조각들을 사용자가 체감하는 지표로 묶는다. 스터디 관리 대시보드에 참가자가 3만 명 들어왔을 때 "검색이 버벅인다", "스크롤이 끊긴다"는 제보를 어떻게 숫자로 바꾸고, 무엇부터 고칠지 정하는 과정이다.
- 추측 대신 LCP·INP·CLS와
performanceAPI로 문제를 수치화한 뒤 고칠 순서를 정한다. - 렌더링 차단과 레이아웃 스래싱이라는 두 가지 구조적 병목을 제거한다.
- 디바운스·스로틀·가상 스크롤을 직접 구현하고, 각각이 무엇을 대가로 무엇을 얻는지 판단한다.
기준은 2024년 3월부터 Core Web Vitals의 응답성 지표가 FID에서 INP로 교체된 이후다. scheduler.yield처럼 최근 브라우저에만 있는 API는 그때그때 표시한다.
왜 필요한가
"검색이 느리다"는 제보를 받고 이런 코드를 발견했다고 하자.
searchInput.addEventListener("input", (e) => {
const keyword = e.target.value.trim();
const found = members.filter((m) => m.name.includes(keyword));
listBox.replaceChildren(...found.map(createRow));
});
여기서 성급하게 손대기 쉬운 곳이 세 군데다. filter를 인덱스로 바꾸거나, createRow를 문자열 연결로 바꾸거나, 워커로 옮기는 것이다. 셋 다 며칠씩 걸리는 작업인데, 어느 것이 병목인지 아직 모른다. "김"을 입력했을 때 결과가 2만 건이면 진짜 범인은 filter가 아니라 DOM 노드 2만 개 생성이다. 반대로 결과가 열 건인데도 느리다면 범인은 한 글자마다 전체를 다시 그리는 호출 빈도다.
성능 작업에서 가장 비싼 실수는 느린 코드를 못 찾는 것이 아니라 느리지 않은 코드를 고치는 것이다. 그래서 이 단원은 측정에서 시작한다.
문법과 예제
먼저 재고 나서 고친다
가장 가벼운 방법은 구간 측정이다. console.time보다 performance API가 나은 이유는 측정 결과가 개발자 도구 Performance 패널의 타임라인에 함께 표시되기 때문이다.
function measure(label, fn) {
const start = performance.now();
const result = fn();
performance.measure(label, { start, end: performance.now() });
return result;
}
searchInput.addEventListener("input", (e) => {
const keyword = e.target.value.trim();
const found = measure("검색", () => members.filter((m) => m.name.includes(keyword)));
measure("렌더", () => listBox.replaceChildren(...found.map(createRow)));
});
이렇게 두 구간을 나눠 재면 앞의 고민이 30초 만에 끝난다. 검색 3ms, 렌더 480ms가 나왔다면 filter는 건드릴 이유가 없다.
사용자 체감 지표는 세 가지를 본다.
| 지표 | 측정 대상 | 양호 기준 | 주된 원인 |
|---|---|---|---|
| LCP | 가장 큰 콘텐츠가 그려질 때까지 | 2.5초 이하 | 렌더링 차단 리소스, 느린 서버 응답 |
| INP | 상호작용부터 다음 페인트까지 | 200ms 이하 | 긴 태스크, 무거운 이벤트 처리 |
| CLS | 레이아웃이 밀린 정도 | 0.1 이하 | 크기 미지정 이미지, 나중에 삽입되는 요소 |
실제 사용자 환경의 값을 수집하려면 PerformanceObserver를 쓴다.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, Math.round(entry.duration), entry.target);
}
}).observe({ type: "event", durationThreshold: 100, buffered: true });
개발자 기기는 대체로 사용자 기기보다 빠르다. 개발자 도구 Performance 패널에서 CPU를 4배 또는 6배 느리게 설정하고 측정해야 저가형 안드로이드에 가까운 값이 나온다. 이 설정 없이 낸 수치는 과대평가다.
렌더링 차단 없애기
첫 화면이 늦게 나오는 문제는 대개 자바스크립트 코드가 아니라 로딩 방식에서 온다.
<!-- HTML 파싱을 멈추고 내려받아 실행한다 -->
<script src="/dashboard.js"></script>
<!-- 파싱과 병행해 내려받고, 파싱이 끝난 뒤 순서대로 실행한다 -->
<script src="/dashboard.js" defer></script>
<!-- 내려받는 즉시 실행한다. 순서 보장 없음 -->
<script src="/analytics.js" async></script>
<!-- 모듈은 기본이 defer 다 -->
<script type="module" src="/dashboard.js"></script>
앱 코드는 defer 또는 type="module", 서로 순서가 상관없는 측정 스크립트는 async다. CSS는 기본적으로 렌더링을 막으므로, 첫 화면에 필요 없는 스타일은 media 속성으로 조건을 붙이거나 나중에 불러온다. 첫 화면에 안 보이는 이미지에는 loading="lazy"를 붙인다.
번들 크기 자체를 줄이는 방법은 22단원에서 코드 스플리팅으로 다룬다. 여기서는 "당장 필요 없는 코드는 나중에"라는 원칙만 기억한다.
레이아웃 스래싱
같은 작업량인데 100배 차이가 나는 대표적 사례다.
// 나쁜 예 — 읽기와 쓰기를 번갈아 한다
for (const row of rows) {
const height = row.offsetHeight; // 읽기: 레이아웃 강제 계산
row.style.height = height + 4 + "px"; // 쓰기: 레이아웃 무효화
}
브라우저는 스타일 변경을 모아 뒀다가 한꺼번에 계산한다. 그런데 offsetHeight, getBoundingClientRect, scrollTop처럼 레이아웃 결과가 필요한 값을 읽으면 그 자리에서 계산을 강제한다. 위 반복문은 읽기와 쓰기가 번갈아 나와 행 수만큼 레이아웃을 다시 계산한다. 읽기를 전부 끝내고 쓰기를 몰아 하면 계산은 한 번이다.
// 좋은 예 — 읽기 단계와 쓰기 단계를 분리한다
const heights = rows.map((row) => row.offsetHeight); // 읽기만
rows.forEach((row, i) => {
row.style.height = heights[i] + 4 + "px"; // 쓰기만
});
개발자 도구 Performance 패널에서 이 문제는 Recalculate Style과 Layout 막대가 촘촘하게 반복되는 모양으로 나타난다. 보라색 막대가 빗살처럼 늘어서 있으면 스래싱을 의심한다.
디바운스와 스로틀
둘 다 호출 빈도를 줄이지만 언제 실행하느냐가 다르다.
| 디바운스 | 스로틀 | |
|---|---|---|
| 규칙 | 마지막 호출 후 n ms 조용하면 실행 | n ms에 한 번만 실행 |
| 연속 입력 중 | 한 번도 실행 안 됨 | 일정 간격으로 계속 실행 |
| 쓰는 곳 | 검색 입력, 자동 저장, 창 크기 조절 완료 | 스크롤 위치 추적, 마우스 이동, 진행률 |
function debounce(fn, wait) {
let timer = null;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
}
debounced.cancel = () => clearTimeout(timer);
return debounced;
}
function throttle(fn, interval) {
let last = 0;
let timer = null;
let lastArgs = null;
let lastThis = null;
return function throttled(...args) {
lastArgs = args; // 항상 최신 인자로 갱신한다
lastThis = this;
const now = Date.now();
const remain = interval - (now - last);
if (remain <= 0) {
clearTimeout(timer);
timer = null;
last = now;
fn.apply(lastThis, lastArgs);
} else if (timer === null) {
timer = setTimeout(() => { // 마지막 호출을 버리지 않는다
last = Date.now();
timer = null;
fn.apply(lastThis, lastArgs); // 억눌린 호출 중 가장 마지막 값
}, remain);
}
};
}
throttle에서 else 분기가 핵심이다. 이 분기가 없으면 마지막 호출이 유실된다. 스크롤을 멈춘 순간의 위치가 반영되지 않아 헤더가 어중간한 상태로 남는 버그가 여기서 나온다. debounced.cancel을 둔 이유는 18단원 때문이다. 화면을 떠날 때 대기 중인 타이머를 취소하지 않으면 사라진 DOM을 갱신하려다 예외가 난다.
적용하면 앞의 검색 코드는 이렇게 된다.
const runSearch = debounce((keyword) => {
const found = members.filter((m) => m.name.includes(keyword));
listBox.replaceChildren(...found.map(createRow));
}, 200);
searchInput.addEventListener("input", (e) => runSearch(e.target.value.trim()));
스크롤이나 마우스 이동처럼 화면 갱신이 목적이라면 시간 기반 스로틀보다 requestAnimationFrame이 낫다. 화면 주사율에 정확히 맞춰 프레임당 한 번만 실행되고, 백그라운드 탭에서는 자동으로 멈춘다.
function rafThrottle(fn) {
let scheduled = false;
let lastArgs = null;
return function (...args) {
lastArgs = args;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
fn.apply(this, lastArgs);
});
};
}
window.addEventListener("scroll", rafThrottle(updateHeader), { passive: true });
{ passive: true }는 "이 리스너는 preventDefault를 부르지 않는다"는 약속이다. 이게 없으면 브라우저는 스크롤을 취소할지 모르니 리스너 실행이 끝날 때까지 스크롤을 기다린다. 터치 기기에서 체감 차이가 크다.
가상 스크롤
참가자 3만 명을 그대로 그리면 DOM 노드가 3만 개다. 초기 렌더가 수 초 걸리고 메모리도 수백 MB를 먹는다. 가상 스크롤은 화면에 보이는 만큼만 실제 노드를 만든다.
원리는 세 줄로 요약된다. 첫째, 전체 높이만큼의 빈 공간을 만들어 스크롤바 길이를 실제처럼 유지한다. 둘째, scrollTop으로 지금 보이는 구간의 인덱스를 계산한다. 셋째, 그 구간의 항목만 그리고 위치를 옮긴다.
const ROW_HEIGHT = 40;
const OVERSCAN = 5; // 위아래 여유분
function renderVirtualList(container, items) {
const spacer = document.createElement("div");
spacer.style.height = items.length * ROW_HEIGHT + "px";
const viewport = document.createElement("div");
viewport.style.position = "relative";
viewport.append(spacer);
container.append(viewport);
function update() {
const start = Math.max(0, Math.floor(container.scrollTop / ROW_HEIGHT) - OVERSCAN);
const visibleCount = Math.ceil(container.clientHeight / ROW_HEIGHT) + OVERSCAN * 2;
const end = Math.min(items.length, start + visibleCount);
const rows = [];
for (let i = start; i < end; i++) {
const row = createRow(items[i]);
row.style.position = "absolute";
row.style.top = i * ROW_HEIGHT + "px";
row.style.height = ROW_HEIGHT + "px";
rows.push(row);
}
viewport.replaceChildren(spacer, ...rows);
}
container.addEventListener("scroll", rafThrottle(update), { passive: true });
update();
}
3만 개를 그리던 것이 화면 높이에 따라 20~30개로 줄어든다. 대가도 분명하다. Ctrl+F 브라우저 검색이 보이는 부분만 찾고, 스크린 리더가 전체 목록을 읽지 못하며(aria-setsize, aria-posinset으로 보완한다), 인쇄하면 한 화면만 나온다. 항목 높이가 제각각이면 위치 계산이 훨씬 복잡해진다. 수백 건 규모에서는 쓰지 않는다. 코드 복잡도와 접근성 손실이 이득보다 크다. 실무에서는 직접 만들기보다 검증된 라이브러리를 쓰고, 가상화 이전에 페이지네이션이나 무한 스크롤로 해결되는지부터 확인한다.
목록이 길지만 항목이 단순하다면 CSS만으로 해결되기도 한다. content-visibility: auto는 화면 밖 요소의 렌더링을 브라우저가 알아서 건너뛰게 한다. 접근성과 검색을 유지하면서 초기 렌더 비용을 크게 줄일 수 있어 가상 스크롤보다 먼저 시도할 값어치가 있다.
긴 작업 쪼개기
17단원에서 setTimeout(0)으로 태스크를 나눴다. 최신 브라우저에는 이 목적의 전용 API가 있다.
async function summarizeAll(members, onProgress) {
let total = 0;
for (let i = 0; i < members.length; i++) {
total += members[i].sessions;
if (i % 2000 === 0) {
onProgress(i);
// Chrome 129+ : 양보하되 우선순위를 잃지 않는다
if ("scheduler" in globalThis && "yield" in scheduler) {
await scheduler.yield();
} else {
await new Promise((r) => setTimeout(r, 0));
}
}
}
return total;
}
setTimeout으로 양보하면 그 사이 들어온 다른 태스크가 먼저 처리돼 전체 작업이 뒤로 밀릴 수 있다. scheduler.yield()는 브라우저에 렌더링 기회를 주면서도 남은 작업이 우선순위를 유지한다. 아직 지원 범위가 좁으니 위처럼 대체 경로를 함께 둔다.
실무에서 자주 틀리는 것
1. 디바운스 함수를 매번 새로 만든다
// 나쁜 예 — 호출할 때마다 새 debounce 가 만들어져 전혀 지연되지 않는다
searchInput.addEventListener("input", (e) => {
debounce(() => search(e.target.value), 200)();
});
debounce가 상태(timer)를 클로저에 담고 있으므로, 매번 새로 만들면 각자 자기 타이머만 갖는다. 결국 모든 호출이 200ms 뒤에 그대로 실행된다. 증상은 "디바운스를 걸었는데 요청 수가 그대로"다. 리액트 같은 프레임워크에서 컴포넌트가 다시 그려질 때마다 debounce를 새로 호출하는 것도 정확히 같은 버그이며, 이쪽이 훨씬 흔하다. 디바운스된 함수는 한 번만 만들어 재사용해야 한다.
2. 스로틀을 걸어 놓고 마지막 상태를 잃는다
위 throttle의 else 분기를 뺀 구현이 인터넷에 흔하다. 스크롤 중에는 잘 동작하고, 멈추는 순간 마지막 200ms의 이동이 반영되지 않는다. 스크롤 위치에 따라 목차 항목을 강조하는 기능이라면 "멈춘 위치와 강조된 항목이 한 칸 어긋남"으로 나타난다. 재현이 애매해서 "가끔 이상하다"로 넘어가기 쉽다. 라이브러리를 쓸 때도 leading/trailing 옵션의 기본값을 확인한다.
3. 재 보지 않고 메모이제이션을 붙인다
const cache = new Map();
function attendanceRate(member, total) {
const key = `${member.id}:${total}`; // 키 문자열 생성 비용
if (cache.has(key)) return cache.get(key);
const rate = Math.round((member.sessions / total) * 100); // 나눗셈 한 번
cache.set(key, rate);
return rate;
}
계산 자체가 나눗셈 한 번인데 키 문자열을 만들고 해시 조회를 한다. 캐시 쪽이 더 느리고, 게다가 18단원의 무한 증가 캐시가 하나 더 생겼다. 메모이제이션은 계산이 조회보다 확실히 비쌀 때만 이득이다. 붙이기 전에 원래 함수의 실행 시간을 먼저 재고, 붙인 뒤 다시 재서 비교한다.
4. 마이크로 벤치마크의 숫자를 믿는다
const t0 = performance.now();
for (let i = 0; i < 1e6; i++) sum += arr[i];
console.log(performance.now() - t0);
이런 측정은 실제와 자주 어긋난다. 반복 실행 중 JIT가 코드를 최적화해 뒤로 갈수록 빨라지고, 결과를 쓰지 않으면 계산 자체가 제거되기도 한다. 그리고 무엇보다 실제 병목은 대개 자바스크립트 실행이 아니라 DOM 조작, 레이아웃, 네트워크다. 마이크로 벤치마크로 배열 메서드 두 개를 비교해 5% 차이를 발견하는 동안, 옆에서 400ms짜리 레이아웃이 돌고 있다. 측정은 Performance 패널 녹화처럼 전체 그림을 보는 도구로 시작한다.
5. 스크롤 리스너에서 DOM을 읽고 쓴다
// 나쁜 예 — 스크롤할 때마다 레이아웃을 강제한다
window.addEventListener("scroll", () => {
const top = header.getBoundingClientRect().top;
header.style.opacity = top < 0 ? "0.5" : "1";
});
스크롤 이벤트는 초당 수십 번 발생하고, 그때마다 getBoundingClientRect가 레이아웃을 강제한다. 게다가 passive 옵션이 없어 스크롤 자체가 리스너를 기다린다. 요소가 화면에 들어왔는지만 알면 되는 경우라면 스크롤 이벤트 대신 IntersectionObserver를 쓴다. 브라우저가 별도로 계산해 주므로 메인 스레드 비용이 거의 없다. 위치 값이 꼭 필요하면 rafThrottle과 { passive: true }를 함께 건다.
지표의 정확한 정의와 측정 방법은 web.dev의 Web Vitals 문서에 정리돼 있다.
스스로 확인하기
- 다음 코드에서 디바운스가 전혀 동작하지 않는 이유를 설명하고 고쳐 쓰라.
input.addEventListener("input", (e) => { debounce(() => search(e.target.value), 300)(); }); - 스크롤 위치에 따라 헤더 스타일을 바꾸는 기능에 디바운스와 스로틀 중 무엇을 써야 하는가? 그 이유와, 더 나은 대안 두 가지를 쓰라.
- 참가자 800명 목록에서 스크롤이 끊긴다. 가상 스크롤을 도입하기 전에 확인하거나 시도할 것을 세 가지 쓰라.
정답
debounce는 대기 상태를 클로저의timer변수에 보관한다. 이벤트가 발생할 때마다debounce를 새로 호출하면 매번timer가null인 새 함수가 만들어지므로 취소할 이전 타이머가 없다. 결과적으로 모든 입력이 300ms 뒤에 그대로 실행된다. 디바운스된 함수를 리스너 등록 바깥에서 한 번만 만들어야 한다.const runSearch = debounce((v) => search(v), 300);을 선언한 뒤 리스너에서runSearch(e.target.value)를 호출한다.- 스로틀이다. 디바운스는 스크롤이 멈출 때까지 한 번도 실행되지 않아 스크롤하는 동안 헤더가 바뀌지 않는다. 스로틀은 일정 간격으로 계속 실행되므로 이동 중에도 반영된다. 다만 더 나은 선택지가 있다. 첫째, 화면 갱신이 목적이므로 시간 기반 스로틀 대신
requestAnimationFrame기반 스로틀을 쓰면 프레임당 정확히 한 번 실행되고 백그라운드 탭에서 자동으로 멈춘다. 둘째, "특정 요소가 화면을 벗어났는가" 같은 판정이라면IntersectionObserver로 대체해 스크롤 리스너 자체를 없앨 수 있다. 어느 쪽이든 리스너에는{ passive: true }를 준다. - 첫째, Performance 패널을 CPU 4배 감속으로 녹화해 병목이 스크립트인지 레이아웃인지 페인트인지 확인한다. 800건은 노드 수 자체가 문제일 규모가 아니므로 다른 원인일 가능성이 높다. 둘째, 스크롤 리스너에서
getBoundingClientRect같은 강제 레이아웃이 일어나는지 보고, 있으면 읽기와 쓰기를 분리하거나IntersectionObserver로 바꾼다. 셋째, 각 행에 무거운 그림자·필터·애니메이션 같은 CSS가 걸려 있는지 확인하고,content-visibility: auto를 적용해 화면 밖 렌더링 비용을 줄여 본다. 그래도 부족하면 그때 가상 스크롤이나 페이지네이션을 검토한다.