🎨 ProgressBar 리페인트 최소화 (feat. transform: scaleX)

이전 글에서 훅 분리로 불필요한 리렌더링을 줄였다면,
이번엔 300ms마다 발생하는 브라우저 렌더링 파이프라인 비용 자체를 줄여봤습니다.

📖 리페인트(Repaint)란?

리페인트는 브라우저가 요소의 시각적 스타일이 바뀌었을 때 픽셀을 다시 그리는 작업입니다.

예를 들어 background-color, color, border 같은 속성이 바뀌면 레이아웃은 그대로지만,
해당 영역의 픽셀을 다시 계산해서 화면에 그려야 합니다. 이게 리페인트입니다.

리플로우(Reflow)와 혼동되기 쉬운데, 차이는 다음과 같습니다.

리플로우 (Reflow)리페인트 (Repaint)
발생 조건width, height, margin 등 레이아웃이 바뀔 때color, background 등 시각적 스타일만 바뀔 때
비용더 큼 (레이아웃 재계산 + 리페인트까지 발생)상대적으로 작음
영향 범위해당 요소 + 주변 요소까지 재계산해당 요소 영역만 다시 그림

리플로우가 발생하면 리페인트도 항상 따라옵니다.
반대로 리페인트는 리플로우 없이도 발생할 수 있습니다.

ProgressBar는 300ms마다 업데이트됩니다.
width를 쓰면 리플로우 → 리페인트가 반복되고, transform을 쓰면 둘 다 건너뜁니다.




🚨 문제 발견

초기 구현: input[type="range"]

처음 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'
    />
  );
};

위와 같이 간단하게 만들 수 있었지만 두 가지 문제가 있었습니다. 🥹

  1. 커스텀 스타일링 한계 - 브라우저마다 기본 스타일이 달라 디자인 일관성 유지가 어려움
  2. 성능 문제 - 브라우저가 내부적으로 value 변화를 처리하는 방식이 GPU 가속과 거리가 있음

그래서 커스텀 div 기반으로 다시 만들기로 했고,이 과정에서 렌더링 파이프라인에 대해 고민하게 됐습니다.




🔍 원인 분석: 브라우저 렌더링 파이프라인

브라우저가 화면을 그리는 과정은 3단계입니다.

Layout (Reflow) → Paint (Repaint) → Composite
  • Layout: 요소의 위치와 크기를 계산
  • Paint: 픽셀을 실제로 그림
  • Composite: GPU가 레이어를 합성해 화면에 출력

단계가 앞으로 갈수록 비용이 큽니다.
width 같은 레이아웃 속성을 변경하면 Layout부터 전체 파이프라인을 다시 실행합니다.

width를 쓰면 어떻게 될까?

// 300ms마다 이렇게 업데이트한다면...
<div style={{ width: `${progress}%` }} />

currentTime 업데이트 (300ms마다)
  ↓
width 변경
  ↓
Layout 재계산 (Reflow) 💥
  ↓
Paint (Repaint) 💥
  ↓
Composite

음악 재생 내내 300ms마다 Layout + Paint 비용이 발생합니다.




💡 해결: transform: scaleX()

transform과 opacity는 브라우저가 Composite 단계에서만 처리합니다.
GPU가 레이어를 합성하는 것이라 CPU 부담이 거의 없습니다.

currentTime 업데이트 (300ms마다)
  ↓
transform 변경
  ↓
Composite만 실행 ✅ (Layout, Paint 건너뜀)

After: 커스텀 div + transform: scaleX()

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>
  );
});

핵심 포인트 3가지

1. transform: scaleX(0 ~ 1)

// progress가 60%라면
transform: scaleX(0.6) // 0부터 1 사이 값으로 표현

width를 60%로 변경하는 대신, 요소를 X축으로 0.6배 늘립니다.
브라우저 입장에서는 Layout 재계산 없이 GPU 레이어만 다시 합성하면 됩니다.

2. origin-left

className='origin-left' // transform-origin: left

scaleX는 기본적으로 요소의 중앙을 기준으로 늘어납니다.
origin-left를 설정해야 왼쪽 기준으로 늘어나 진행바처럼 동작합니다.

origin-center (기본):  ←[====중앙====]→  // 양쪽으로 늘어남
origin-left:           [====왼쪽→    ]   // 왼쪽에서 오른쪽으로 늘어남 ✅

3. will-change: transform

className='will-change-transform'

브라우저에게 "이 요소는 곧 transform이 자주 바뀔 거야" 라고 미리 알려줍니다.
브라우저는 해당 요소를 별도의 GPU 레이어로 미리 분리해두어 합성 비용을 더 낮춥니다.

⚠️ will-change는 남발하면 오히려 메모리 낭비가 됩니다.
실제로 애니메이션이 자주 발생하는 요소에만 사용하는 것이 좋습니다.




결과 비교




🎓 회고

1. CSS 속성마다 렌더링 비용이 다르다

모든 CSS 변경이 동일한 비용을 갖지 않습니다.
특히 자주 바뀌는 값이라면 어떤 속성을 쓰느냐가 체감 성능에 영향을 줍니다.

비용 낮음 ← transform, opacity
비용 높음 → width, height, top, left, margin, padding

자주 업데이트되는 애니메이션 요소라면 transform/opacity 사용을 우선 고려해보세요!!

2. will-change는 힌트, 만능 해결책이 아니다

will-change-transform을 모든 요소에 붙인다고 빨라지지 않습니다.
GPU 레이어 분리는 메모리를 소비하므로, 실제로 변화가 잦은 요소에만 적용해야 합니다.




다음 글에는 가상리스트 (React Virtual) 에 대해 다뤄보려고 합니다!

지금까지 긴 글 읽어주셔서 감사합니다 :)

💬 비슷한 문제를 겪으셨거나, 더 좋은 해결 방법이 있다면 댓글로 공유해주세요!

profile
Frontend Developer 😆 | PM

0개의 댓글