
나름 3D 스크롤 애니메이션을 구현했는데, 마우스 내릴 때 갑자기? 누적되서 화면이 뚝... 뚝 ... 끊기면서 버벅인 경험 한번씩 있으신가요?
제가 그랬습니다.
웹 기능 구현은 했지만, 특정 구간에 프레임 드랍이 있어서.. 원인 파악후 고쳐야겠다는 결심을 했습니다.
기존에 짜뒀던 로직을 개선해서 반복적으로 소모되는 불필요한 연산이나 흐름을 줄이며 CPU 사용량을 최적화할수도 있고, 프레임 드랍이나 버벅임이 발생하는 렌더링 병목 구간의 원인을 프로파일링해 해결하는 것도 성능 최적화가 아닐까.. 생각합니다.
우리가 부드럽다고 느끼는 화면 일반적으로 60fps를 만들기 위해서, 브라우저는 16.6ms 안에 대부분의 렌더링 과정을 끝내야 합니다. JavaScript 실행, 스타일 계산, 레이아웃, 페인트, 컴포지팅 등의 렌더링 작업을 모두 마쳐야 합니다. 그렇지 않으면 버벅임이 발생합니다. 이는 iOS 모바일 생태계에서도 비슷한 원리로 동작됩니다.
앗.. 참고로, React의 렌더링 엔진은 Fiber입니다.(관련 개념 정리)

버벅임이 발생하는.. 그 구간을 찍어봤습니다..
스크롤 한 번에 무려 4,800ms 동안 메인 스레드가 뻗어버리는 대참사가 발생했더라구요.
단순히 "코드가 비효율적이다"를 넘어서, 도대체 브라우저 내부에서 어떤 물리적인 일이 벌어지고 있었길래 이런 병목이 생겼는지 Chrome DevTools를 들고 다이브 해보았습니다!
메인 스레드를 4초나 잡아먹은 스크롤 이벤트

Chrome DevTools의 Performance 탭을 열어 바텀업(Bottom-Up) 방식으로 콜스택을 뜯어보았습니다.
여기서 main 섹션에서 특정 부분인 저 렌더링 보라색 구간만 포커싱하도록 위치를 정해두면 아래처럼 #1 바텀업 사진을 볼 수 있습니다.
내가 마우스로 선택한 구간에서 그 선택한 구간 안에서만 실행되는 js 함수, 이벤트들이 보여지게 됩니다.

그리고 그 바텀 업 탭에서 볼 수 있듯, script:js779:31 ~ script.js:781:38 소스코드들을 살펴봐야 합니다.
| Scroll (ScrollLayer) | 4,820 ms | 4,820 ms | Browser Native |
| Task (RunTask) | 1,936 ms | 7,660 ms | Main Thread Task |
| animateSpring | 46 ms | 219 ms | script.js:778 |
| Layout (Reflow) | 27 ms | 27 ms | script.js:780 |
분석 결과, 범인은 바로 스크롤 이벤트와 관련된 Main Thread 점유와 레이아웃 강제 발생(Forced Reflow)이었습니다.
가장 긴 Long Task가 무려 3,042ms를 기록했는데, 도대체 저 Scroll 4.8초는 어디서 튀어나온 걸까요?
여기서 한 가지 의문이 드는데요. 표를 보면 자바스크립트 함수인 animateSpring의 Total Time은 219ms밖에 안 됩니다.
저 219ms는 순수하게 JS 코드를 실행한 시간입니다.
하지만 진짜 문제는 JS 연산 자체가 아닙니다. 저 JS 코드가 브라우저의 무거운 렌더링 파이프라인(Layout..)을 강제로 끌고 오면서 발생한 어마어마한 오버헤드가 모두 Scroll 시간에 합산되어 버린 것입니다.
근본 원인: 브라우저의 Pixel Pipeline 붕괴와 Layout Thrashing
도대체 어떤 코드가 브라우저를 이렇게 괴롭히고 있었을까요? 원인을 이해하려면 먼저 브라우저가 화면을 그리는 5단계 과정, 즉 Pixel Pipeline을 알아야 합니다.
JS/CSS > Style > Layout > Paint > Composite
브라우저는 이 과정을 16.6ms 안에 끝내야 60fps를 유지할 수 있습니다. 특히 크기나 위치를 계산하는 Layout 단계는 화면 전체 요소의 기하학적 구조를 다시 계산해야 하므로 연산 비용이 매우 비쌉니다.
그래서 브라우저는 아주 똑똑하게(or 게으르게) 일합니다. JS에서 스타일을 여러 번 변경하더라도 매번 Layout을 계산하지 않고, 변경 사항들을 렌더링 큐(Queue)에 모아두었다가(Batching) 프레임이 끝날 때 한 번만 계산하려고 합니다.
이제 제 코드를 보겠습니다.
requestAnimationFrame에 의해 매 프레임 호출되는 애니메이션 루프입니다.
수정 전 (Before): 브라우저를 가혹하게 학대하는 코드입니다..
function animateSpring() {
let needsUpdate = false;
// 문제 1: DOM의 속성을 '읽어옵니다(Read)'
const vh = snapContainer.clientHeight;
stackCards.forEach((card, idx) => {
const tyPx = s.translateY * (vh / 100);
// 문제 2: 그리고 스타일을 '변경(Write)' 합니다.
card.style.transform = `translateY(${tyPx}px)`;
});
if (needsUpdate) requestAnimationFrame(animateSpring);
...
}
이 코드가 아쉬운 이유는 Read와 Write가 섞여 있기 때문입니다.
루프를 돌면서 요소의 style.transform을 이용해 위치를 변경(Write)했습니다.
브라우저는 이 변경 사항을 렌더링 큐에 메모해 둡니다.
그런데 다음 루프(혹은 다음 프레임의 첫 줄)에서 곧바로 snapContainer.clientHeight를 읽어오라고(Read) 명령합니다.
이 순간 브라우저 내부에서는 대참사가 일어납니다.
기억해야 할 점은.. 메인스레드가(JS Engine-> js 코드 실행) 개발자가 작성한 js 코드를 한줄 한줄 읽어내려간다고 바로 화면에 보여지는게 아닙니다.
위에서 언급 했지만 크게 이벤트, 렌더링, 효율성 이 3개를 기억해야 합니다.
1. 화면 그리는 시점
화면 그리는 Rendering Lifecycle(1초에 60번 보통 화면 그림) 이 그리는 각각의 타임이 js가 실행되는 시간과는 별도의 단계로 동작하지만 메인스레드에서 순차적으로 실행됩니다. 자바스크립트 이벤트 처리가 끝나면, 바뀐것들을 모아뒀다가 화면에 한번에 그리는 "렌더링 파이프라인[Style -> Layout -> Paint]을 동작합니다."
2. 이벤트 시점 (제 코드에서 requestAnimationFrame 이 함수는 "다음 화면 그리기 전에 이 js코드 실행해줘!")
3. 효율성(뭉텅이로 업데이트)
clientHeight이것도 레이아웃 강제하는 그런속성 입니다.
1. requestAnimationFrame이 실행됩니다. (화면 그리기 직전 시점)
2. JS가 카드의 transform 스타일을 바꿉니다. (브라우저는 효율성을 위해 "오케이, 이따 한 번에 그릴게" 하고 메모만 해둠)
3. 그런데 바로 다음 줄에서 JS가 요소의 clientHeight(높이)를 물어봅니다.
4. 브라우저는 패닉에 빠집니다.
"아직 화면 그릴 타이밍(렌더링 시점)도 안 됐고, 효율적으로 한 번에 처리하려고 렌더링 큐에 쌓아두기만 했는데 지금 당장 높이(clientHeight)를 내놓으라고 하면, 우리가 약속한 '화면 그리는 시점'과 '효율성' 규칙을 다 깨부수고 지금 당장 억지로 렌더링(Layout)을 해야 하잖아!!!!!!"
어쩔 수 없지.. 모아둔 변경 사항을 지금 당장 모두 적용하고 Layout 싹 다 다시 계산해!!!"
box.style.height = "300px"; // 쓰기(Write)
const h = box.clientHeight; // 읽기(Read)
브라우저는 아직 Layout 계산을 하지 않았는데? 갑자기 높이를 물어보니까..
브라우저 입장에선 Write -> 아직 Layout 안 한 상태 -> clientHeight 읽음 -> 강제 레이아웃..
이를 Forced Synchronous Layout (동기적 강제 레이아웃) 이라고 부릅니다. 브라우저가 배치(Batch) 처리를 포기하고, 값을 반환하기 위해 렌더링 파이프라인을 억지로 즉시 실행하는 것이죠.
그리고 이 과정이 requestAnimationFrame이나 스크롤 이벤트 안에서 초당 60번씩 무한 반복되는 현상을 Layout Thrashing(레이아웃 스래싱) 이라고 합니다.
가벼워야 할 스크롤 작업 도중에 무거운 레이아웃 재계산이 강제로 반복되니, 16.6ms는 커녕 4초짜리 거대한 병목 Task 덩어리가 발생하게 된 것입니다.
추가로 파해치기!: Transform은 원래 GPU가 처리하는 거 아닌가요?
transform이나 opacity 속성은 원래 메인 스레드의 Layout과 Paint 단계를 건너뛰고, GPU가 담당하는 Compositor Thread(합성 스레드) 에서 매우 빠르게 처리되어야 정상입니다.
하지만 위 코드처럼 우리가 JS로 clientHeight를 냅다 읽어버리는 순간, Compositor Thread가 일하기도 전에 메인 스레드가 Layout 단계를 강제로 실행해야만 했습니다. 즉, GPU의 가속 혜택을 전혀 받지 못하고 CPU(Main Thread)가 모든 짐을 떠안게 된 것입니다.
개선: Layout 정보를 캐싱하여 Forced Layout 제거
기존 코드에서는 애니메이션이 실행되는 매 프레임(최대 초당 60회) clientHeight를 읽고 있었습니다.
clientHeight는 레이아웃 정보가 필요한 속성이기 때문에, 브라우저가 아직 Layout을 계산하지 않은 상태라면 Forced Layout(Forced Reflow)을 즉시 수행하게 됩니다.
Before ❌ ❌ ❌
function animateSpring() {
stackCards.forEach((card, idx) => {
// 매 프레임마다 Layout 정보 조회
const vh = snapContainer.clientHeight;
const tyPx = s.translateY * (vh / 100);
// Style 변경
card.style.transform = `translateY(${tyPx}px)`;
});
requestAnimationFrame(animateSpring);
}
브라우저 내부 동작
Animation Frame
Style 변경 (Write)
↓
clientHeight 읽기 (Read)
↓
❗ Forced Layout 발생
↓
Style 변경 (Write)
이 과정이 스크롤 중 반복되면서 메인 스레드에 적지 않은 4초 부담을 주었습니다.
After
레이아웃 정보는 매 프레임마다 바뀌는 값이 아니므로, 초기 1회와 브라우저 크기가 변경되는 resize 시점에만 clientHeight를 읽어 메모리에 캐싱하도록 변경했습니다.
// Layout 정보를 저장할 캐시
let cachedVh = 0;
// 초기 1회 + resize 시에만 Layout 정보 읽기
function updateLayoutCache() {
if (snapContainer) {
cachedVh = snapContainer.clientHeight;
}
}
window.addEventListener("resize", updateLayoutCache);
updateLayoutCache();
function animateSpring() {
let needsUpdate = false;
stackCards.forEach((card, idx) => {
// DOM을 다시 읽지 않고 캐시된 값만 사용
const tyPx = s.translateY * (cachedVh / 100);
// transform만 변경
card.style.transform = `translateY(${tyPx}px)`;
});
if (needsUpdate) {
requestAnimationFrame(animateSpring);
}
}
브라우저 내부 동작
초기 1회 또는 resize
Read (clientHeight)
↓
캐시에 저장
────────────────────────
매 Animation Frame
캐시된 값 읽기 (Memory)
↓
transform 변경 (Write)
개선 효과
clientHeight조회를 매 프레임 → 초기 1회 + resize 시점으로 변경- Animation Loop에서 DOM Read 제거
- Forced Layout(Forced Reflow) 제거
- 메인 스레드 부담 감소
- 스크롤 및 애니메이션이 더욱 부드럽게 동작
결과: 매끄러운 60fps 스크롤의 탄생
코드를 이렇게 단 몇 줄 수정하고 난 뒤, 캐시를 비우고 다시 새로고침을 해보았습니다.

결과는 대성공이었습니다! 스크롤을 빠르게 움직일 때에도 자바스크립트가 브라우저의 레이아웃 과정을 더 이상 방해하지 않았습니다. 개발자 도구의 메인 스레드 차트에서도 붉은색으로 칠해져 있던 Layout 병목이 말끔하게 사라지고, 모든 애니메이션이 Compositor Thread로 부드럽게 떠넘겨버렸네욤.
단순히 "이 코드는 느리다"고 외우는 것을 넘어서, 브라우저의 렌더링 파이프라인[Pixel Pipeline]과 스레드 구조가 어떻게 맞물려 돌아가는지를 이해하면 좀 더 나은? '성능 최적화'를 이룰 수 있다는 걸 다시 한번 느꼈습니다..
혹시 여러분의 프로젝트에서도 requestAnimationFrame이나 scroll 이벤트 리스너 안에서 무심코 offsetWidth, clientHeight, getBoundingClientRect() 같은 속성을 섞어서 읽고 쓰고 있진 않으신가요? 요거요거, 숨어있는 성능 킬러일지도 모릅니다.
'Frontend Note' 카테고리의 다른 글
| [FE] 트래픽 100만이 몰려도 서버비 0원?! "html2canvas"로 이미지 만들어보자긔 React, Html.. (0) | 2026.05.13 |
|---|