김지효

애니메이션 시 텍스트 움찔거림, 원인과 해결하기

BlogWeb

문제 상황

블로그를 조금 더 인터랙티브하게 만들고 싶어서 포스트 목록에 애니메이션을 추가했다. 그런데 자세히 보면 애니메이션이 동작할 때 텍스트가 조금씩 움찔거리는 현상이 보인다.

Motion(구 framer-motion)을 사용해서 translatescale, opacity 값에 애니메이션을 주었는데, Chrome DevTools의 Performance 탭으로 분석해보니 예상과 달리 Paint 단계가 반복적으로 발생하고 있었다.

아래 캡처한 Performance Tab 이미지를 보면 Paint 호출이 계속 반복되고 있음을 확인할 수 있다.

Screenshot 2025-07-08 at 5.31.27 PM.png

브라우저의 렌더링 파이프라인은 Style → Layout → Paint → Composite 순으로 구성되어 있다. Composite 이전 단계인 Style, Layout, Paint는 모두 브라우저의 메인 쓰레드에서 실행된다.

메인 쓰레드는 렌더링 외에도 사용자 입력 처리나 스크립트 실행 등 여러 가지 일을 동시에 처리하기 때문에, 이 단계들이 지나치게 자주 실행되면 사이트의 반응성이 떨어진다.

반면에 Composite 단계는 GPU에서 처리되기 때문에 메인 쓰레드의 부담을 줄일 수 있다. 그래서 Layout과 Paint 단계를 건너뛰고 Composite 단계에서만 렌더링을 처리하는 것이 성능 면에서 더 유리하다.

transform, scale, opacity 같은 CSS 속성은 Layout과 Paint 단계를 거치지 않고 Composite 단계에서만 처리된다. 그래서 원칙적으로는 메인 쓰레드에 부담을 주지 않고 GPU에서만 렌더링이 일어나야 한다.

그러나 위에서 본 것처럼 실제로는 Paint가 반복적으로 발생하고 있다. 결국 무엇인가가 예상치 않게 Paint 단계를 계속 트리거하고 있다는 뜻이다.

Paint가 반복적으로 발생한 이유

먼저 애니메이션되는 요소가 컴포지팅 레이어로 제대로 분리되었는지 확인해보았다. Chrome DevTools의 Performance 탭에서 화면을 녹화한 뒤, 스냅샷을 선택하면 해당 시점의 레이어 구성을 직접 볼 수 있다.

아래 이미지를 보면, 요소들이 컴포지팅 레이어로 분리되어 있고, 각 레이어가 왜 분리되었는지도 친절하게 표시된다. 예를 들어 (Compositing Reasons: Has an active accelerated opacity animation or transition) 같은 식으로 가속 처리가 적용된 이유가 함께 나온다.

already-handled-in-composition.png

두번째로, 자식 요소 중에 Pain를 유발시키는 것이 있는지 확인해보았다. 많은 디버깅 끝에 결국 문제가 되는 요소를 찾아냈다. 아래 코드는 문제의 원인이 된 부분이다.

<div className="w-[90px] h-[65px] sm:w-[130px] sm:h-[90px] overflow-hidden rounded bg-card group">
  <div className="relative w-full h-full group-hover:scale-120 transition-transform duration-300">
    <Image
      fill
      sizes="(max-width: 640px) 90px, 130px"
      alt=""
      className="w-full h-full object-center object-cover"
      src={post.thumbnail}
    />
  </div>
</div>

위 코드에서 Next.js의 <Image />fill prop을 사용하면 position: absolute가 적용된다. 여기에 object-fit: cover까지 조합되면, 부모의 transform이 변경될 때마다 Paint가 반복 발생했다.

object-fit: cover는 Paint 단계에서 처리되는 속성이다. 이미지의 원본 비율을 유지하면서 요소 박스에 맞게 잘라내는(crop) 영역을 계산하고, 그 결과를 디스플레이 리스트에 기록하는 것이 Paint의 역할이다. 그런데 이 <img /> 요소가 position: absolute로 배치되어 있으면, 부모 컴포지팅 레이어의 transform이 변경될 때 브라우저가 해당 레이어의 디스플레이 리스트를 무효화(invalidate)한다. 절대 위치 자식의 렌더링이 변경되었을 수 있다고 판단하기 때문이다. 디스플레이 리스트가 무효화되면 Paint를 다시 수행해야 하고, 이것이 매 프레임 반복되면서 성능 문제로 이어진 것이다.

fill prop을 제거하고 width/height를 명시해 정적으로 포지션되게 만들면, 이미지가 레이어 내에서 고정된 위치를 가지므로 디스플레이 리스트 무효화가 발생하지 않는다.

<div className="w-[130px] h-[90px] overflow-hidden rounded">
  <Image
    width={130}
    height={90}
    sizes="(max-width: 640px) 90px, 130px"
    alt=""
    className="w-[130px] h-[90px] object-center object-cover rounded group-hover:scale-120 transition-transform duration-300"
    src={post.thumbnail}
  />
</div>

적용 후 Perfomance Tab의 Event Log를 보니 Paint 단계가 생략된 것을 확인할 수 있었다.

Screenshot 2025-07-08 at 5.54.51 PM.png

그래도 여전히 움찔거린다.

Motion은 JS로 transform 값을 직접 변경한다. JS를 사용하던 CSS를 사용하던 transform 변경에 따른 업데이트는 컴포지팅 단계에서 처리된다.

앞서 확인했듯 애니메이션되는 각 요소는 이미 개별적인 컴포지팅 레이어로 분리돼 처리됐다.

같은 애니메이션을 Motion을 사용하지 않고 CSS Animation을 사용해서 구현해 보았을 때는 움찔거림이 없었다. 컴포지팅 레이어 또한 동일하게 분리되는 듯 보였다.

논리적으로는 Motion은 CSS animation과 같은 렌더링 경로를 타야 하는데, 왜 움찔거림이 발생하는지 의문이다.

will-change를 사용하면 해결되긴 하는데

Motion의 예제 중 Split Text 에서 Text를 애니메이션할 때 will-change: transform이 함께 사용되는 것을 볼 수 있다. 내 컴포넌트에도 will-change: transform을 적용하니 떨림 현상 없이 애니메이션이 부드럽게 동작했다.

그런데 왜 그런지 이유가 궁금하다.

will-change 는 미리 명시적으로 애니메이션될 요소를 컴포지팅 레이어로 분리시킨다. 이번 문제의 경우에는 브라우저가 이미 잘 알아서 컴포지팅 레이어로 분리해주었기 때문에 필요없다고 생각했다.

이 문제에 대한 이유를 명확하게 설명해놓은 글을 찾을 수 없었다. DevTools의 Performance 탭만으로는 한계가 있어서, 더 낮은 수준의 데이터를 확인해보기로 했다.

Chrome Tracing으로 파고들기

Chrome에는 chrome://tracing이라는 도구가 있다. DevTools의 Performance 탭보다 훨씬 상세한 수준의 트레이싱 데이터를 수집할 수 있는데, 특히 컴포지터(cc) 내부의 raster 작업이나 타일 관리 같은 이벤트까지 볼 수 있다.

수동으로 chrome://tracing을 열어서 녹화할 수도 있지만, 조건을 통제하고 비교하기 위해 Puppeteer의 Chrome DevTools Protocol(CDP)을 사용해 프로그래밍적으로 트레이싱을 수집했다. 방법은 이렇다.

  1. 테스트 페이지 준비: 동일한 애니메이션(translateY + scale + opacity, requestAnimationFrame 사용)을 will-change: transform 유무로 나눠 두 개의 HTML 파일을 만들었다.

  2. Puppeteer로 트레이싱 수집: page.tracing.start()로 트레이싱을 시작하고, 애니메이션이 끝날 때까지 기다린 뒤 page.tracing.stop()으로 결과를 JSON 파일로 저장했다. 카테고리는 cc, gpu, blink, blink.animations, disabled-by-default-cc 등 컴포지터 내부 동작을 볼 수 있는 것들을 포함했다.

await page.tracing.start({
  path: traceOutputPath,
  categories: [
    'cc',
    'gpu',
    'blink',
    'blink.animations',
    'disabled-by-default-cc',
    'disabled-by-default-cc.debug',
  ],
});
 
await page.goto(testPageUrl);
await new Promise((resolve) => setTimeout(resolve, 5000)); // 애니메이션 완료 대기
 
await page.tracing.stop();
  1. 결과 분석: 수집된 JSON에서 RasterTask, RasterizerTaskImpl::RunOnWorkerThread 등 raster 관련 이벤트의 수와 총 소요 시간을 집계해 비교했다.

이렇게 해서 DevTools에서는 보이지 않던 raster 단계의 차이를 수치로 확인할 수 있었다.

Paint ≠ Raster

여기서 핵심적인 사실 하나를 짚고 넘어가야 한다. 브라우저의 렌더링 파이프라인에서 Paint와 Raster는 별개 단계다.

  • Paint: 디스플레이 리스트(그리기 명령 목록)를 생성하는 단계
  • Raster: 디스플레이 리스트를 실제 비트맵(타일)으로 변환하는 단계

DevTools의 “Paint flashing”이나 Performance 탭에서 보이는 Paint는 디스플레이 리스트 생성을 의미한다. Paint가 없더라도 기존 디스플레이 리스트를 기반으로 타일을 다시 래스터라이징(re-raster)하는 작업은 별도로 발생할 수 있다.

Chrome Tracing으로 확인한 결과

동일한 애니메이션(translateY + scale + opacity, 4초, linear)을 will-change: transform 유무로 나누어 트레이싱을 수집했다. 카테고리는 cc, gpu, blink, blink.animations, disabled-by-default-cc 등을 포함했다.

will-change: transform 없음:

  • Raster tasks: 490회
  • Raster duration: 87.0ms
  • Paint events: 0회

will-change: transform 있음:

  • Raster tasks: 32회
  • Raster duration: 5.0ms
  • Paint events: 0회

양쪽 모두 Paint는 0이다. 그러나 Raster 작업은 약 15배 차이가 났다. will-change: transform이 없으면 애니메이션 도중 매 프레임 타일을 다시 래스터라이징하고 있었다.

원인: Re-rastering

Chrome 공식 블로그에서 이렇게 설명하고 있다.

Starting in Chrome 53, all content is re-rastered when its transform scale changes, if it does not have the will-change: transform CSS property. In other words, will-change: transform means "please animate it fast".

Chrome 53부터 will-change: transform이 없는 합성 레이어의 transform scale이 변경되면 콘텐츠를 새로운 스케일에 맞춰 다시 래스터라이징한다. 이는 확대/축소 시 텍스트나 이미지가 흐려지지 않고 선명하게 유지되도록 하기 위한 동작이다. 순수한 translate만 변경되는 경우에는 비트맵의 해상도가 달라질 이유가 없으므로 re-raster가 발생하지 않는다.

이 블로그의 Motion 애니메이션에는 scale(0.97 → 1) 변환이 포함되어 있었기 때문에 re-rastering의 대상이 되었다.

will-change: transform이 없는 경우:

  • 레이어는 분리되어 있고, Paint도 발생하지 않는다
  • 그러나 scale이 변경될 때마다 해당 레이어의 타일을 새 스케일에 맞춰 re-raster 한다
  • 텍스트 래스터라이저(Skia)는 글리프를 그릴 때 서브픽셀 위치에 따라 안티앨리어싱 패턴을 다르게 적용한다. Skia 공식 문서에서도 이를 명시하고 있다.

    Glyphs at different sub-pixel positions may differ on pixel edge coverage.

  • Chromium의 텍스트 렌더링 설계 문서는 더 직접적으로 설명한다.

    Subpixel text positioning enables rasterizing glyphs differently based on their sub-pixel origin position.

  • 즉 같은 글자라도 서브픽셀 위치가 12.3px일 때와 12.7px일 때 래스터라이징 결과가 미세하게 달라진다. 매 프레임 re-raster가 발생하면서 프레임마다 글리프의 모양이 조금씩 바뀌고, 이것이 움찔거림(jitter)으로 보인다. Text Rendering Hates You에서도 이 현상을 정확히 짚고 있다.

    Characters jiggle as each glyph bounces between different subpixel snappings and hints on each frame.

will-change: transform이 있는 경우:

  • 브라우저가 해당 요소의 비트맵을 고정 텍스처로 취급한다
  • transform이 변경되어도 re-raster 없이 GPU가 기존 텍스처를 이동/스케일만 수행한다
  • 이때 GPU는 바이리니어 필터링(bilinear filtering)으로 서브픽셀 보간을 처리하기 때문에, 동일한 비트맵이 부드럽게 이동하며 프레임 간 일관성이 유지된다

CSS 애니메이션에서는 왜 문제가 없었는가

같은 애니메이션을 CSS @keyframes로 구현했을 때 움찔거림이 없었던 이유도 같은 맥락이다. CSS 애니메이션은 @keyframesanimation-duration 등의 정보가 선언적으로 제공되어, 브라우저가 애니메이션 시작 전에 해당 요소를 re-raster하지 않을 고정 텍스처로 준비할 수 있다. will-change: transform을 명시한 것과 동일한 최적화가 자동으로 적용되는 셈이다.

반면 JavaScript로 매 프레임 style.transform을 직접 변경하는 경우(Motion 등), 브라우저 입장에서는 이것이 연속적인 애니메이션인지 단발적인 스타일 변경인지 사전에 알 수 없다. 그래서 텍스트 선명도를 위해 매번 re-raster를 수행하게 되고, 이 과정에서 jitter가 발생한다.

will-change: transform은 브라우저에게 “이 요소의 transform이 자주 바뀔 예정이니, 비트맵을 고정해두고 GPU 텍스처 이동만으로 처리해도 된다”고 명시적으로 알려주는 힌트인 것이다.

참고