이전 글에서 훅 분리로 불필요한 리렌더링을 줄였다면,
이번엔 300ms마다 발생하는 브라우저 렌더링 파이프라인 비용 자체를 줄여봤습니다.
리페인트는 브라우저가 요소의 시각적 스타일이 바뀌었을 때 픽셀을 다시 그리는 작업입니다.
예를 들어 background-color, color, border 같은 속성이 바뀌면 레이아웃은 그대로지만,
해당 영역의 픽셀을 다시 계산해서 화면에 그려야 합니다. 이게 리페인트입니다.
리플로우(Reflow)와 혼동되기 쉬운데, 차이는 다음과 같습니다.
| 리플로우 (Reflow) | 리페인트 (Repaint) | |
|---|---|---|
| 발생 조건 | width, height, margin 등 레이아웃이 바뀔 때 | color, background 등 시각적 스타일만 바뀔 때 |
| 비용 | 더 큼 (레이아웃 재계산 + 리페인트까지 발생) | 상대적으로 작음 |
| 영향 범위 | 해당 요소 + 주변 요소까지 재계산 | 해당 요소 영역만 다시 그림 |
리플로우가 발생하면 리페인트도 항상 따라옵니다.
반대로 리페인트는 리플로우 없이도 발생할 수 있습니다.
ProgressBar는 300ms마다 업데이트됩니다.
width를 쓰면 리플로우 → 리페인트가 반복되고, transform을 쓰면 둘 다 건너뜁니다.
처음 ProgressBar는 네이티브 HTML 요소로 구현했습니다.
const ProgressBar = () => {
const { duration, currentTime, playerRef, setCurrentTime } = usePlayer();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const newTime = Number(e.target.value);
setCurrentTime(newTime);
playerRef.seekTo(newTime, true);
};
...
return (
<input
type='range'
min='0'
max={duration || 0}
value={currentTime}
step='1'
onChange={handleChange}
className='w-full h-1'
/>
);
};
위와 같이 간단하게 만들 수 있었지만 두 가지 문제가 있었습니다. 🥹
그래서 커스텀 div 기반으로 다시 만들기로 했고,이 과정에서 렌더링 파이프라인에 대해 고민하게 됐습니다.
브라우저가 화면을 그리는 과정은 3단계입니다.
Layout (Reflow) → Paint (Repaint) → Composite
단계가 앞으로 갈수록 비용이 큽니다.
width 같은 레이아웃 속성을 변경하면 Layout부터 전체 파이프라인을 다시 실행합니다.
// 300ms마다 이렇게 업데이트한다면...
<div style={{ width: `${progress}%` }} />
currentTime 업데이트 (300ms마다)
↓
width 변경
↓
Layout 재계산 (Reflow) 💥
↓
Paint (Repaint) 💥
↓
Composite
음악 재생 내내 300ms마다 Layout + Paint 비용이 발생합니다.
transform과 opacity는 브라우저가 Composite 단계에서만 처리합니다.
GPU가 레이어를 합성하는 것이라 CPU 부담이 거의 없습니다.
currentTime 업데이트 (300ms마다)
↓
transform 변경
↓
Composite만 실행 ✅ (Layout, Paint 건너뜀)
const ProgressBar = memo(function ProgressBar({ className }: { className?: string }) {
const { duration, currentTime, playerRef, setCurrentTime } = usePlayerTime();
const progressBarRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
...
return (
<div className={`progress-bar relative w-full ${className || ''}`}>
<div
ref={progressBarRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className='h-[3px] hover:h-[6px] bg-white/60 cursor-pointer relative overflow-hidden touch-none'
>
{/* ✅ width 대신 transform: scaleX() */}
<div
className='progress absolute inset-0 h-full origin-left will-change-transform'
style={{
transform: `scaleX(${progress / 100})`,
background: 'linear-gradient(90deg, #5E9F94 0%, #78B7AC 65%, #A9D8CF 100%)',
}}
/>
</div>
</div>
);
});
// progress가 60%라면
transform: scaleX(0.6) // 0부터 1 사이 값으로 표현
width를 60%로 변경하는 대신, 요소를 X축으로 0.6배 늘립니다.
브라우저 입장에서는 Layout 재계산 없이 GPU 레이어만 다시 합성하면 됩니다.
className='origin-left' // transform-origin: left
scaleX는 기본적으로 요소의 중앙을 기준으로 늘어납니다.
origin-left를 설정해야 왼쪽 기준으로 늘어나 진행바처럼 동작합니다.
origin-center (기본): ←[====중앙====]→ // 양쪽으로 늘어남
origin-left: [====왼쪽→ ] // 왼쪽에서 오른쪽으로 늘어남 ✅
className='will-change-transform'
브라우저에게 "이 요소는 곧 transform이 자주 바뀔 거야" 라고 미리 알려줍니다.
브라우저는 해당 요소를 별도의 GPU 레이어로 미리 분리해두어 합성 비용을 더 낮춥니다.
⚠️ will-change는 남발하면 오히려 메모리 낭비가 됩니다.
실제로 애니메이션이 자주 발생하는 요소에만 사용하는 것이 좋습니다.

모든 CSS 변경이 동일한 비용을 갖지 않습니다.
특히 자주 바뀌는 값이라면 어떤 속성을 쓰느냐가 체감 성능에 영향을 줍니다.
비용 낮음 ← transform, opacity
비용 높음 → width, height, top, left, margin, padding
자주 업데이트되는 애니메이션 요소라면 transform/opacity 사용을 우선 고려해보세요!!
will-change-transform을 모든 요소에 붙인다고 빨라지지 않습니다.
GPU 레이어 분리는 메모리를 소비하므로, 실제로 변화가 잦은 요소에만 적용해야 합니다.
다음 글에는 가상리스트 (React Virtual) 에 대해 다뤄보려고 합니다!
지금까지 긴 글 읽어주셔서 감사합니다 :)
💬 비슷한 문제를 겪으셨거나, 더 좋은 해결 방법이 있다면 댓글로 공유해주세요!