면접에서 "브라우저에 URL을 입력하면 무슨 일이 일어나나요?"라는 질문을 받은 적이 있다. DNS 조회부터 TCP 연결, HTML 파싱까지는 어렵지 않게 답했는데, "그 다음에 화면이 어떻게 그려지나요?"에서 막혔다. "DOM 트리를 만들고... 렌더링하고..."라고 얼버무렸다. 면접관이 고개를 끄덕이긴 했는데, 내가 봐도 얕은 답이었다.
그때는 그냥 면접 지식이라고 생각했다. 렌더링 파이프라인을 외우면 면접에서 한 문제 더 맞출 수 있겠지, 정도. 그런데 실무에서 성능 문제를 디버깅하면서 이 지식이 진짜 쓸모 있다는 걸 알게 됐다. 파이프라인을 설명하는 글은 인터넷에 넘치니까, 이 글에서는 파이프라인을 알고 나서 실제로 문제를 해결한 세 가지 이야기를 하려고 한다.
파이프라인을 간단하게만 짚고
브라우저가 HTML을 받아서 픽셀을 화면에 찍기까지 5단계를 거친다.
JavaScript → Style → Layout → Paint → Composite
각 단계가 뭘 하는지는 짧게 요약한다.
- Style: CSS 규칙을 DOM 요소에 매칭하고 최종 computed style을 계산한다.
- Layout: 각 요소의 크기와 위치를 계산한다.
width,height,padding,margin,top,left같은 속성이 여기서 처리된다. - Paint: 배경색, 텍스트, 테두리, 그림자 등 실제 픽셀을 채운다.
- Composite: 페인트된 레이어들을 합성해서 최종 화면을 만든다. GPU가 담당한다.
핵심은 이거다. 어떤 CSS 속성을 바꾸느냐에 따라 파이프라인의 어디서부터 다시 실행되는지가 달라진다.
width를 바꾸면? Layout부터 다시. 가장 비싸다.background-color를 바꾸면? Paint부터 다시. Layout은 안 한다.transform이나opacity를 바꾸면? Composite만 다시. 가장 싸다. GPU가 처리하니까 메인 스레드도 안 막는다.
web.dev의 렌더링 퍼포먼스 가이드에서 이걸 세 가지 경로로 정리하고 있다. 전체 파이프라인을 타는 경로, Paint부터 타는 경로, Composite만 타는 경로. 그리고 프레임 예산이라는 개념을 쓴다. 60fps를 유지하려면 프레임 하나에 16.66ms가 주어지는데, 브라우저의 오버헤드를 빼면 실제로 개발자가 쓸 수 있는 시간은 약 10ms라는 거다. 10ms 안에 한 프레임의 작업을 끝내야 버벅거림이 없다.
이 정도만 알고 있으면 된다. 이제 이걸로 실제 문제를 해결한 이야기를 하겠다.
이야기 1: 매 프레임마다 Layout을 돌리고 있었다
커머스 프로젝트에서 모바일 슬라이드 메뉴를 만들었다. 햄버거 버튼을 누르면 왼쪽에서 메뉴가 슬라이드인 되는 전형적인 패턴이다. 처음에는 left 속성으로 구현했다.
.menu {
position: fixed;
top: 0;
left: -280px;
width: 280px;
height: 100vh;
transition: left 0.3s ease;
}
.menu.open {
left: 0;
}데스크탑에서는 부드러웠다. QA 팀이 "모바일에서 끊김이 있어요"라고 리포트했을 때, "디바이스 성능 문제겠지" 하고 넘겼다. 그런데 저사양 안드로이드에서 직접 확인해보니 진짜 뚝뚝 끊겼다. 애니메이션 중간에 프레임이 드롭되는 게 눈에 보였다.
Chrome DevTools의 Performance 탭을 녹화해봤다. 프레임 차트에서 보라색(Layout)이 매 프레임마다 큰 덩어리로 찍히고 있었다. left 값이 변할 때마다 Layout이 재계산되고 있었던 거다. 메뉴뿐 아니라, 메뉴 뒤에 있는 페이지 콘텐츠의 레이아웃까지 영향을 받고 있었다.
transform으로 바꿨다.
.menu {
position: fixed;
top: 0;
left: 0;
width: 280px;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.menu.open {
transform: translateX(0);
}Performance 탭을 다시 확인했다. 보라색이 사라졌다. 초록색(Composite)만 얇게 찍혔다. 저사양 안드로이드에서도 부드럽게 동작했다. transform은 Composite 단계에서만 처리되니까 Layout과 Paint를 건너뛰고, GPU가 레이어를 움직이기만 하면 되는 거다.
이 한 번의 경험으로 "위치 이동 애니메이션은 무조건 transform을 쓴다"가 몸에 각인됐다. top, left, right, bottom으로 애니메이션하는 코드를 보면 바로 PR에 코멘트를 남기게 됐다.
이야기 2: will-change가 오히려 성능을 악화시킨 건
두 번째 경험은 좀 뼈아팠다. 첫 번째 경험 이후에 "GPU 가속이 좋구나"라는 걸 배웠고, 좀 과하게 적용했다. 상품 리스트 페이지에서 카드 호버 애니메이션이 있었는데, 모든 카드에 will-change: transform을 넣었다.
.productCard {
will-change: transform;
transition: transform 0.2s;
}
.productCard:hover {
transform: translateY(-4px);
}"미리 레이어를 만들어두면 호버할 때 더 빠르겠지"라는 생각이었다. 상품이 12개인 페이지에서는 괜찮았다. 문제는 무한 스크롤로 상품이 100개, 200개 쌓이기 시작했을 때 터졌다. 페이지가 점점 느려지다가 탭이 크래시나는 리포트가 들어왔다.
Chrome DevTools의 Layers 패널을 열어봤다. 레이어가 200개 넘게 찍혀 있었다. will-change: transform은 요소를 별도의 합성 레이어로 승격시킨다. 레이어가 많아지면 GPU 메모리를 많이 쓰고, 레이어 합성 자체에도 비용이 든다. 상품 200개 = 레이어 200개 = GPU 메모리 폭발.
해결법은 두 가지였다. 우선 will-change를 CSS에서 제거하고, JavaScript로 호버 시에만 동적으로 붙이는 방법을 썼다.
function ProductCard({ product }: Props) {
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.style.willChange = "transform";
};
const handleMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.currentTarget.style.willChange = "auto";
};
return (
<div
className="product-card"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* ... */}
</div>
);
}호버가 시작될 때 레이어를 만들고, 끝나면 해제한다. 동시에 존재하는 레이어 수가 최대 1~2개로 제한된다.
사실 더 좋은 해결법은 will-change 자체를 안 쓰는 거였다. transform 애니메이션은 브라우저가 이미 잘 최적화하고 있어서, 명시적으로 will-change를 붙이지 않아도 대부분의 경우 충분히 부드럽다. will-change는 정말 복잡한 애니메이션에서 마지막 수단으로만 쓰는 게 맞다.
/* 이건 절대 하면 안 된다 */
* {
will-change: transform;
}이런 코드를 Stack Overflow에서 본 적이 있다. "성능 개선 팁"이라면서. 정반대의 결과를 가져온다.
이야기 3: Forced Reflow로 리스트 렌더링이 3초 걸린 건
대시보드 페이지에 데이터 테이블이 있었다. 행이 100개 정도인데, 각 행의 높이를 측정해서 가상 스크롤에 쓰는 로직이 있었다. 테이블을 렌더링할 때 체감상 3초 정도 멈추는 현상이 있었다.
Performance 탭을 보니 Layout이 반복적으로 수백 번 찍혀 있었다. 코드를 확인해보니 이런 패턴이었다.
// 문제의 코드
rows.forEach((row) => {
const height = row.offsetHeight; // 읽기 → Forced Reflow
row.style.minHeight = height + "px"; // 쓰기
row.dataset.measured = "true";
});루프를 돌면서 읽기와 쓰기를 반복하고 있었다. offsetHeight를 읽으려면 브라우저가 최신 레이아웃을 계산해야 한다. 그런데 바로 직전에 style.minHeight를 바꿨으니까, 레이아웃이 무효화된 상태다. 그래서 매 반복마다 레이아웃 전체를 다시 계산한다. 행이 100개면 Layout 계산이 100번 일어나는 거다. 이걸 Forced Reflow, 또는 Layout Thrashing이라고 한다.
리플로우를 트리거하는 속성들이 따로 있다.
// 이것들을 읽으면 Forced Reflow가 발생할 수 있다
element.offsetTop;
element.offsetHeight;
element.scrollTop;
element.clientHeight;
element.getComputedStyle();
element.getBoundingClientRect();해결은 읽기를 먼저 다 하고, 쓰기를 나중에 모아서 하는 거다.
// 수정된 코드: 읽기를 먼저, 쓰기를 나중에
const heights: number[] = [];
rows.forEach((row) => {
heights.push(row.offsetHeight); // 읽기만
});
rows.forEach((row, i) => {
row.style.minHeight = heights[i] + "px"; // 쓰기만
row.dataset.measured = "true";
});이렇게 바꾸니까 Layout 계산이 1~2번으로 줄었다. 3초 걸리던 게 50ms로 줄었다. 60배 차이.
React에서는 직접 DOM을 조작하는 일이 적어서 이 문제를 덜 마주치지만, useLayoutEffect에서 DOM 측정을 할 때 주의해야 한다.
function useElementSize(ref: RefObject<HTMLElement>) {
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
if (!ref.current) return;
const { width, height } = ref.current.getBoundingClientRect();
setSize({ width, height });
}, []);
return size;
}
useLayoutEffect는 Paint 전에 실행되기 때문에, DOM 측정 후 상태를 업데이트해도 화면 깜빡임이 없다. useEffect에서 하면 화면이 한 번 그려진 후에 업데이트되어서 레이아웃이 깜빡일 수 있다.
DevTools가 "감"을 "측정"으로 바꿔준다
세 가지 이야기에서 공통점이 있다. 전부 Chrome DevTools의 Performance 탭으로 확인했다는 거다.
- Performance 탭을 열고 녹화 버튼을 누른다.
- 문제가 되는 인터랙션을 실행한다.
- 녹화를 멈추고 프레임 차트를 본다.
보라색이 Layout, 초록색이 Paint, 노란색이 JavaScript다. Layout이 매 프레임 큰 블록으로 찍히면 레이아웃 쪽을 의심하면 된다.
Rendering 탭에서 "Paint flashing"을 켜면 Paint가 일어나는 영역이 초록색으로 깜빡인다. 스크롤할 때 전체 페이지가 초록색으로 번쩍이면 뭔가 문제가 있다는 뜻이다. 고정 헤더가 매 프레임마다 리페인트되고 있거나, 불필요한 영역이 다시 그려지고 있다는 거다.
Layers 패널은 will-change 이슈를 디버깅할 때 쓴다. 레이어가 예상보다 많으면 불필요한 레이어 승격이 일어나고 있을 가능성이 높다.
렌더링 예산이라는 사고방식
웹 성능에 대해 깊이 쓰는 한 엔지니어가 "성능 예산"이라는 개념을 강조한다. JavaScript 예산, 이미지 예산처럼, 렌더링에도 예산이 있다는 거다. 한 프레임에 10ms. 이 예산 안에서 JavaScript 실행, Style 계산, Layout, Paint, Composite을 전부 끝내야 한다.
이 사고방식이 좋은 이유는, "이 코드가 느린가?"라는 모호한 질문을 "이 코드가 프레임 예산 10ms를 초과하는가?"라는 측정 가능한 질문으로 바꿔주기 때문이다. Performance 탭에서 한 프레임이 10ms를 넘으면 문제고, 안 넘으면 괜찮은 거다. 감이 아니라 숫자로 판단할 수 있다.
INP(Interaction to Next Paint)라는 Core Web Vitals 지표도 같은 맥락이다. 사용자가 클릭이나 키 입력을 했을 때, 다음 프레임이 화면에 그려지기까지 걸리는 시간을 측정한다. 200ms 이하가 권장값이다. 렌더링 파이프라인의 어느 단계에서 시간이 오래 걸리는지를 알아야, INP를 개선할 방향이 보인다.
면접 지식이 아니었다
렌더링 파이프라인을 처음 공부했을 때는 면접용이라고 생각했다. left 대신 transform을 쓰라는 팁도 "그냥 외워두면 되는 것" 정도였다. 직접 성능 문제를 마주하고 나서야 이게 실무 도구라는 걸 깨달았다.
슬라이드 메뉴의 left를 transform으로 바꿨을 때 Performance 탭에서 보라색이 사라지는 걸 눈으로 확인한 순간, will-change를 잘못 써서 레이어가 200개 생긴 걸 Layers 패널에서 발견한 순간, Forced Reflow를 분리해서 3초가 50ms로 줄어든 순간. 이런 경험이 쌓이면 렌더링 파이프라인은 외우는 지식이 아니라, 문제를 보는 눈이 된다.
코드를 쓸 때 무의식적으로 "이건 Layout을 트리거하나?" "이건 Composite만으로 되나?"를 생각하게 된다. 성능 문제가 생겼을 때 어디부터 봐야 하는지 감이 온다. 브라우저와 같은 편에 서서 작업하는 느낌이랄까. 브라우저가 어떻게 일하는지를 이해하면, 브라우저에게 효율적인 일을 시킬 수 있다.
