60fps와 16.67ms 예산
대부분의 디스플레이는 초당 60번 화면을 갱신합니다. 1초를 60으로 나누면 16.67ms . 브라우저는 매 프레임마다 이 시간 안에 JavaScript 실행, 스타일 계산, 레이아웃, 페인트를 모두 끝내야 합니다.
16.67ms를 초과하면 해당 프레임은 드롭(dropped) 됩니다. 사용자 눈에는 화면이 멈추거나 버벅이는 것으로 보입니다. 60fps를 유지한다는 것은 매 프레임의 전체 작업을 이 예산 안에 완료한다는 뜻입니다.
실제로 브라우저 자체도 일부 시간을 사용하므로, JavaScript에 주어진 시간은 10ms 이내 가 이상적입니다. 나머지는 스타일 계산, 레이아웃, 페인트에 필요합니다.
이벤트 루프와 렌더링
이벤트 루프 시리즈에서 태스크 큐와 마이크로태스크 큐를 다뤘습니다. 렌더링은 이 루프의 일부로, 다음 순서로 처리됩니다.
- 태스크(Task) 실행 -- 이벤트 핸들러, setTimeout 콜백 등
- 마이크로태스크(Microtask) 전부 처리 -- Promise.then, MutationObserver 등
- requestAnimationFrame 콜백 실행
- Style -- CSS 규칙 적용, 계산된 스타일 생성
- Layout -- 요소의 위치와 크기 계산
- Paint -- 픽셀 그리기
렌더링 파이프라인 시리즈에서 4~6단계를 자세히 다뤘습니다. 핵심은 rAF 콜백이 렌더링 직전 에 실행된다는 점입니다. 화면에 반영될 변경을 rAF 안에서 수행하면 정확히 한 프레임 뒤에 반영됩니다.
프레임 타임라인
아래 시각화에서 정상 프레임, 드롭된 프레임, 그리고 최적화된 프레임을 단계별로 확인하세요.
첫 번째 단계는 정상적인 프레임입니다. 모든 작업이 16ms 안에 끝나고 여유 시간(idle)이 남습니다. 두 번째 단계에서는 무거운 스크롤 핸들러가 25ms를 차지하면서 프레임이 드롭됩니다. 세 번째 단계에서 passive 리스너와 rAF 패턴으로 문제를 해결합니다.
requestAnimationFrame
requestAnimationFrame(이하 rAF)은 다음 렌더링 직전에 콜백을 실행 합니다. setTimeout이나 setInterval과 달리 브라우저의 렌더링 사이클에 정확히 맞춰 동작합니다.
setTimeout과의 차이
// setTimeout: 렌더링 타이밍과 무관하게 실행
// 프레임 중간에 실행될 수 있고, 프레임을 놓칠 수 있음
setTimeout(() => {
element.style.transform = "translateX(100px)";
}, 16);
// rAF: 다음 렌더링 직전에 정확히 실행
// 브라우저가 최적의 타이밍을 보장
requestAnimationFrame(() => {
element.style.transform = "translateX(100px)";
});setTimeout(fn, 16)은 "약 16ms 뒤에 태스크 큐에 넣어달라"는 요청입니다. 실제 실행 시점은 큐 상태에 따라 달라지고, 렌더링 타이밍과 어긋날 수 있습니다. 반면 rAF는 브라우저가 "지금 렌더링하려고 하니 그 전에 실행해"라고 보장합니다.
애니메이션 루프
rAF의 가장 일반적인 사용법은 애니메이션 루프입니다.
let startTime = null;
function animate(currentTime) {
if (!startTime) startTime = currentTime;
const elapsed = currentTime - startTime;
// 300ms 동안 0px에서 300px로 이동
const progress = Math.min(elapsed / 300, 1);
element.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);rAF 콜백은 DOMHighResTimeStamp 를 인자로 받습니다. 이전 프레임과의 시간 차이를 계산하여 프레임 독립적인 애니메이션을 구현할 수 있습니다.
탭 비활성화 시 동작
rAF는 탭이 비활성 상태일 때 자동으로 일시 정지 됩니다. 백그라운드 탭에서 불필요한 렌더링 작업을 수행하지 않으므로 CPU와 배터리를 절약합니다. setTimeout은 비활성 탭에서도 (빈도가 줄어들긴 하지만) 계속 실행됩니다. 애니메이션에 setTimeout을 쓰면 백그라운드에서 불필요한 연산이 계속되는 셈입니다.
스크롤 이벤트 최적화
문제: 초당 수백 번의 이벤트
스크롤 이벤트는 사용자가 스크롤할 때 초당 수백 번 발생할 수 있습니다. 모든 이벤트마다 무거운 작업을 수행하면 프레임 예산을 쉽게 초과합니다.
// 나쁜 예: 매 스크롤 이벤트마다 무거운 작업
window.addEventListener("scroll", () => {
// getBoundingClientRect()는 강제 리플로우를 유발
const rect = element.getBoundingClientRect();
element.style.transform = `translateY(${window.scrollY * 0.5}px)`;
recalculateLayout(); // 비용이 큰 함수
});화면은 16.67ms마다 한 번 갱신되는데, 스크롤 이벤트는 그보다 훨씬 자주 발생합니다. 대부분의 핸들러 호출은 화면에 반영되지 않는 헛수고 입니다.
rAF + 플래그 패턴
가장 효과적인 해결책은 rAF와 플래그를 조합하는 것입니다.
let ticking = false;
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
// 실제 작업은 여기서 프레임당 한 번만 실행
updateParallax(window.scrollY);
ticking = false;
});
ticking = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });ticking 플래그가 핵심입니다. 첫 스크롤 이벤트에서 rAF를 예약하고 플래그를 올립니다. 같은 프레임 내에 스크롤 이벤트가 다시 발생해도 이미 예약된 rAF가 있으므로 무시합니다. rAF 콜백이 실행되면 플래그를 내려 다음 프레임의 예약을 허용합니다.
이 패턴으로 작업 빈도를 스크롤 이벤트 발생 횟수에서 디스플레이 갱신 횟수(60fps) 로 줄일 수 있습니다.
throttle과의 비교
lodash의 throttle도 비슷한 역할을 합니다. 하지만 차이가 있습니다.
// throttle: 고정된 시간 간격으로 제한
const throttled = throttle(handleScroll, 16);
window.addEventListener("scroll", throttled);
// rAF: 브라우저의 실제 렌더링 타이밍에 맞춤
// 디스플레이 주사율이 120Hz면 자동으로 8.33ms 간격으로 조정throttle(fn, 16)은 16ms 간격을 하드코딩합니다. 120Hz 디스플레이에서는 프레임을 놓칠 수 있고, 탭이 비활성일 때도 계속 실행됩니다. rAF는 디스플레이 주사율에 자동으로 적응하고 비활성 탭에서 멈춥니다.
passive: true의 중요성
{ passive: true } 옵션은 브라우저에 "이 핸들러는 preventDefault()를 호출하지 않겠다"고 알려줍니다.
// passive 없음: 브라우저는 핸들러 실행이 끝날 때까지 스크롤 대기
window.addEventListener("wheel", handler);
// passive 선언: 브라우저는 핸들러와 스크롤을 동시에 처리
window.addEventListener("wheel", handler, { passive: true });왜 이것이 중요할까요? wheel이나 touchmove 이벤트 핸들러는 preventDefault()로 스크롤 자체를 취소할 수 있습니다. 브라우저는 핸들러가 이를 호출할지 미리 알 수 없으므로 , 핸들러 실행이 끝날 때까지 스크롤을 멈추고 기다립니다. passive: true로 선언하면 브라우저는 안심하고 핸들러 완료를 기다리지 않고 즉시 스크롤을 처리합니다. 참고로 scroll 이벤트는 cancelable: false이므로 passive 옵션과 무관합니다.
터치 기반 디바이스에서 특히 효과가 큽니다. 대부분의 모던 브라우저는 최상위 touch/wheel 이벤트에 대해 기본적으로 passive를 적용하지만, 명시적으로 선언하는 것이 좋습니다.
CSS 힌트
JavaScript 최적화 외에도 CSS로 렌더링 성능을 개선할 수 있습니다. 렌더링 파이프라인 시리즈에서 다룬 레이아웃, 페인트, 합성 단계와 직접 연결됩니다.
will-change
.animated-element {
will-change: transform;
}will-change는 브라우저에게 "이 속성이 곧 변경될 것"이라고 알려줍니다. 브라우저는 해당 요소를 별도의 합성 레이어(compositor layer) 로 승격시켜, 변경 시 다른 요소에 영향을 주지 않고 GPU에서 독립적으로 처리합니다. 다만 남용하면 메모리 사용량이 증가하므로, 실제로 애니메이션이 적용되는 요소에만 사용해야 합니다.
contain
.widget {
contain: layout paint;
}contain 속성은 요소의 레이아웃과 페인트 범위를 해당 요소 내부로 제한합니다. 이 요소 내부의 변경이 외부 레이아웃이나 페인트에 영향을 주지 않는다고 브라우저에 보장하는 것입니다. 브라우저는 이 정보를 활용해 불필요한 재계산을 건너뛸 수 있습니다.
content-visibility
.offscreen-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px;
}content-visibility: auto는 뷰포트 밖의 요소에 대해 렌더링 작업을 완전히 건너뜁니다 . 긴 페이지에서 보이지 않는 섹션의 레이아웃과 페인트를 생략하여 초기 렌더링 시간을 크게 단축합니다. contain-intrinsic-size로 예상 크기를 지정하면 스크롤바가 안정적으로 동작합니다.
다음 단계
이 시리즈에서 다룬 내용을 정리합니다.
- DOM 트리와 탐색 -- 문서가 어떻게 트리 구조로 표현되고 탐색되는지
- 이벤트 버블링과 캡처링 -- 이벤트가 DOM 트리를 따라 전파되는 흐름
- 이벤트 위임 -- 전파 메커니즘을 활용한 효율적인 이벤트 관리
- MutationObserver와 IntersectionObserver -- DOM 변화를 비동기적으로 감지하는 패턴
- requestAnimationFrame과 스크롤 최적화 -- 렌더링 사이클에 맞춘 성능 최적화
DOM 트리의 구조에서 시작하여, 이벤트의 흐름과 위임, 변화 감지, 그리고 렌더링 타이밍까지 살펴봤습니다. 이 지식은 렌더링 파이프라인 시리즈, 이벤트 루프 시리즈와 함께 브라우저가 코드를 실행하고 화면을 그리는 전체 과정을 이해하는 데 기반이 됩니다.
다음 시리즈에서는 네트워크 를 다룹니다. HTTP 요청이 어떻게 전송되고, 브라우저가 리소스를 어떻게 가져오는지 살펴보겠습니다.