---
title: '<em>number-flow</em> 성능 개선기: 애니메이션을 합성 스레드로 옮기기'
tags:
  - web-performance
  - animation
  - browser
  - oss
  - css
published: true
date: 2026-10-07 16:05:00
description: '구형 브라우저를 지원하도록 만든 number-flow 포크에서, 모던 브라우저의 애니메이션도 매 프레임 스타일을 재계산하고 있었다. 0.2.0에서 가산 합성의 움직임을 transform과 opacity 키프레임으로 옮긴 과정과 성능 측정, 마스크와 인터럽트에 남은 비용을 기록한다.'
series: 'number-flow 개선기'
seriesOrder: 2
art:
  undraw: charts
---

## Table of Contents

## 숫자 하나가 매 프레임 스타일을 다시 계산하고 있었다

숫자 하나를 1초에 한 번 바꾸는 페이지에서, 메인 스레드는 초당 218ms를 일하고 있었다. 애니메이션의 지속 시간만 0으로 바꾸자 41ms로 줄었다. JavaScript 실행 시간은 거의 같았고, 가장 크게 줄어든 것은 스타일 재계산이었다. 애니메이션을 브라우저에 맡기고 있어도 메인 스레드의 작업은 계속되고 있었다.

[1편](/2026/08/number-flow-fork-for-old-browsers)에서는 number-flow를 구형 브라우저로 이식한 과정을 다뤘다. 최신 CSS 기능이 없는 브라우저에는 `requestAnimationFrame` 기반의 폴백을 제공하고, 모던 브라우저에서는 원본의 Web Animations API(WAAPI) 경로를 유지했다. 자릿수를 비교하는 로직과 DOM, 마스크 스타일을 보존한 채 구동부만 바꾸는 작업이었다.

그 글의 마지막에는 네이티브 경로의 커스텀 프로퍼티 애니메이션도 매 프레임 스타일 재계산을 거친다고 적었다. 다만 실제 비용은 측정하지 않았고, 카운터 하나라면 무시할 수준일 것이라고 추정했다. 이번에 확인한 페이지에서는 그 추정을 유지하기 어려웠다. 값이 자주 바뀌고 애니메이션이 길게 이어지면 카운터 하나도 페이지 작업의 상당 부분을 차지했다.

그래서 [0.2.0](https://github.com/yceffort/number-flow/releases/tag/v0.2.0)에서는 모던 브라우저의 구동부도 바꿨다. CSS 커스텀 프로퍼티에서 위치를 유도하던 애니메이션을, 브라우저가 합성 스레드(compositor thread)에서 실행할 수 있는 `transform`과 `opacity` 키프레임으로 옮겼다. 이동 경로를 미리 계산해 전달하고, 재생 중에 메인 스레드가 해야 하는 일을 줄였다.

어려운 부분은 애니메이션이 끝나기 전에 다음 값이 들어오는 경우였다. 원작자도 이 지점에서 합성을 포기했다. 업스트림 [이슈 #183](https://github.com/barvian/number-flow/issues/183)에 같은 합성 실패가 보고되자 원작자는 다음과 같이 답했다.

> accumulated animations aren't compositable on Chrome ATM. I made a version that was fully compositable before launching but opted for the current version because the interruptibility felt worth it (I couldn't come up with a compositable version that handled interruptions as well)

합성할 수 있는 버전을 만들어 봤지만 인터럽트를 그만큼 자연스럽게 처리하지 못해서, 지금의 `accumulate` 방식을 택했다는 설명이다. 0.2.0은 이 둘을 함께 얻으려고 했다. 실행 방식은 `replace`로 바꾸고, 남아 있는 애니메이션들의 기여를 새 키프레임에 합쳐 넣어 `accumulate`가 만들던 움직임을 유지했다. 1편의 rAF 폴백이 매 프레임 하던 것과 같은 합산을, 이번에는 키프레임을 만들 때 한 번 한다.

> 코드 분석은 2026년 9월 30일 릴리즈한 `v0.2.0`, 커밋 [`3438c3e`](https://github.com/yceffort/number-flow/tree/3438c3eed55c65a07d99b634ca2c0fe75becf470)를 기준으로 한다. 성능 개선은 [PR #11](https://github.com/yceffort/number-flow/pull/11)에 들어 있으며, 변경 전 코드는 그 PR의 기준 커밋 [`92e7d01`](https://github.com/yceffort/number-flow/tree/92e7d01eb83fb2d988260032213fe402ec694258)이다. 본문의 측정값은 [이슈 #10](https://github.com/yceffort/number-flow/issues/10)과 PR을 만들 때 기록한 결과다. 키프레임 재계산 결과 예시와 구형 WebKit의 화면 측정만 글을 쓰면서 새로 확인했다.

## JavaScript보다 스타일 재계산이 컸다

문제를 기록한 [이슈 #10](https://github.com/yceffort/number-flow/issues/10)의 조건은 다음과 같다. `@yceffort/number-flow`와 React 래퍼는 모두 0.1.0이고, React 19를 사용했다. 숫자는 약 1초마다 갱신되고 페이지의 다른 부분에는 초당 약 10회의 업데이트가 들어왔다. `transformTiming`은 900ms였다.

데스크톱 Chrome에서 모바일 뷰포트를 에뮬레이션했고, CPU 감속과 확장 프로그램은 사용하지 않았다. 같은 부하의 11초 구간을 비교하되, 비교군에서는 `Element.prototype.animate`가 받는 `duration`을 0으로 강제했다. 따라서 아래 표는 라이브러리 업그레이드 전후가 아니라, 애니메이션이 반복되는 상태와 지속 시간을 없앤 상태의 차이다.

| 메인 스레드 작업                   | 애니메이션 실행 | 지속 시간 0 |
| ---------------------------------- | --------------: | ----------: |
| 전체                               |         218ms/s |      41ms/s |
| 스타일 재계산 (`UpdateLayoutTree`) |         136ms/s |       6ms/s |
| JavaScript (`FunctionCall`)        |          37ms/s |      35ms/s |
| Paint, PrePaint, Layerize          |          28ms/s |       1ms/s |

여기서 `ms/s`는 실제 시간 1초 동안 메인 스레드가 해당 작업에 사용한 밀리초다. 숫자 하나가 바뀌는 데 걸린 시간과는 다른 값이다.

전체 차이는 초당 177ms로, 애니메이션을 실행한 상태의 약 81%였다. JavaScript는 초당 2ms 차이였지만 스타일 재계산은 130ms 차이가 났다. 이 결과에서는 React 렌더링이나 이벤트 핸들러보다 애니메이션이 만드는 브라우저 작업을 먼저 볼 이유가 있었다.

갱신은 초당 한 번이었지만 애니메이션은 900ms 동안 계속됐다. 다음 값이 올 때까지 대부분의 시간에 숫자가 움직였던 것이다. 업데이트 함수의 호출 횟수가 적다는 사실만으로 그 함수가 시작한 애니메이션의 비용까지 작다고 판단할 수는 없었다.

트레이스에서는 스타일 재계산 1,073건이 JavaScript 호출 안이 아닌 `RunTask` 바로 아래에 있었다. 애플리케이션이 레이아웃을 읽어서 동기 스타일 계산을 강제하는 경우와 구분되는 패턴이었다. 동시에 shadow root 안에서 최대 19개의 WAAPI 애니메이션이 실행됐고, 기록된 애니메이션 이벤트에는 모두 합성 실패를 나타내는 `compositeFailed` 값이 있었다. 업스트림 이슈 #183에도 같은 현상이 DevTools의 `Effect has composite mode other than "replace"`, `Unsupported CSS property: --_number-flow-d-opacity` 경고로 보고돼 있다.

## 브라우저에 맡겼는데 왜 메인 스레드가 바쁠까

`transform`은 레이아웃을 바꾸지 않지만, 그 값을 매 프레임 CSS 변수로부터 계산해야 한다면 스타일 재계산은 남는다. 합성 스레드가 메인 스레드 없이 움직임을 이어 가려면 재생할 값이 `transform`이나 `opacity` 키프레임으로 직접 주어져야 한다. 최종 속성이 `transform`이라는 사실만으로 그 앞의 계산 과정까지 합성 스레드로 옮겨지지는 않는다.

[변경 전 `engine/index.ts`](https://github.com/yceffort/number-flow/blob/92e7d01eb83fb2d988260032213fe402ec694258/packages/number-flow/src/engine/index.ts)의 네이티브 분기는 전달받은 키프레임을 다음과 같이 실행했다.

```ts
el.animate(keyframes as PropertyIndexedKeyframes, {
  ...timing,
  composite: 'accumulate',
})
```

자릿수 스핀에서는 `--_number-flow-d`라는 등록된 커스텀 프로퍼티를 애니메이션했다. 등록된 커스텀 프로퍼티는 `@property`로 값의 자료형과 상속 여부를 지정한 CSS 변수다. number-flow는 이 변수에 숫자의 이동량을 넣고, 각 숫자 요소가 상속받은 값으로 CSS `mod()`와 `round()` 수식을 계산하게 했다. 최종 결과는 `transform`이지만 그 결과를 얻으려면 매 프레임 스타일 계산을 거쳐야 했다.

`mod()` 수식이 필요한 이유도 자릿수의 움직임과 관련 있다. 한 자릿수가 9에서 다음 0으로 넘어갈 때 숫자의 위치는 순환한다. 이 수식은 각 숫자가 현재 위치에서 얼마나 떨어져 있는지, 위와 아래 중 어디에 있어야 하는지를 구한다. 1편의 rAF 폴백은 브라우저에 없는 이 계산을 JavaScript로 옮겼고, 이번에는 같은 계산의 결과를 미리 키프레임에 담았다.

폭과 가로 위치도 커스텀 프로퍼티를 거쳐 `transform`으로 바뀌었다. 일반 `transform`을 직접 쓰는 이동 애니메이션도 있었지만, 기존의 `accumulate` 합성에서는 이번 트레이스의 합성 가속을 받지 못했다. 커스텀 프로퍼티를 계산하는 경로와 애니메이션을 겹쳐 합치는 방식 모두 살펴야 했다.

1편의 rAF 엔진으로 강제 전환해도 이 문제는 해결되지 않는다. 그 엔진은 같은 CSS가 소비할 값을 JavaScript로 계산해 인라인 스타일에 쓴다. 스타일 재계산은 남고 JavaScript 작업이 추가된다. 모던 브라우저의 성능을 개선하려면 브라우저에 넘기는 애니메이션 자체를 바꿔야 했다.

## CSS 수식의 결과를 키프레임으로 넘긴다

새 엔진의 기본 아이디어를 간단한 이동으로 표현하면 다음과 같다. 라이브러리 코드는 아니고, 브라우저에 넘기는 정보의 형태만 보여주는 예다.

```js
element.animate(
  [{transform: 'translateX(20px)'}, {transform: 'translateX(0px)'}],
  {
    duration: 900,
    easing: 'ease-out',
    composite: 'replace',
  },
)
```

시작할 때는 오른쪽으로 20px 이동한 상태이고, 900ms 뒤에는 원래 위치에 도착한다. 그 사이의 값은 브라우저가 `easing`에 맞춰 보간한다. 이 경로로 재생할 수 있다면 애플리케이션이 매 프레임 위치를 쓰거나, CSS 변수를 다시 계산해 이동량을 얻을 필요가 없다.

실제 number-flow에서는 자릿수 스핀, 폭 변화, 등장과 퇴장이 겹친다. [`engine/compositor.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/compositor.ts)는 이를 종류별 채널로 관리한다. 여기서 채널은 한 요소의 같은 종류 애니메이션을 함께 계산하는 단위다. `classify()`가 요청의 종류와 시작 델타를 추출하고, `bake()`가 그 델타로부터 실제 화면 속성을 계산한다. 델타는 기준값과의 차이다.

예를 들어 한 자릿수를 3에서 7로 바꿀 때 최종 기준값을 먼저 7로 두고, -4의 델타가 0으로 줄어들게 만들 수 있다. 시작은 `7 + (-4) = 3`이고 끝은 `7 + 0 = 7`이다. 실제 스핀은 여기에 순환 방향과 각 숫자의 위치 계산을 더하지만, 목표값과 움직임을 분리하는 원리는 같다.

아래 예제에서 시간을 움직이면 목표값 7은 그대로 있고 델타만 변한다. 스핀 위치가 4.5라면 숫자 4가 올라가고 5가 들어오는 중간 상태다. 아래의 숫자 목록은 그 순간 마스크 안에 들어오는 요소를 보여준다.

<LiveDemo src="/demos/number-flow/animation-lab.html?scene=delta" title="목표값 7에 남은 델타를 더해 3에서 7까지 움직이는 설명용 애니메이션" height={700} />

| 채널      | 기존에 애니메이션하던 값                 | 새 키프레임                                 |
| --------- | ---------------------------------------- | ------------------------------------------- |
| `tx`      | 가로 이동 `transform`의 가산 합성        | 합쳐진 `translateX()`                       |
| `spin`    | 자릿수 델타 `--_number-flow-d`           | 숫자 요소별 `translateY()`                  |
| `number`  | 위치 델타와 폭 델타                      | 바깥 요소의 이동과 배율, 안쪽 요소의 역변환 |
| `opacity` | 불투명도 델타 `--_number-flow-d-opacity` | 계산된 `opacity`                            |

기존 CSS가 어떤 시점에 만들어 냈을 값을 JavaScript에서 미리 계산하고, 그 결과를 WAAPI에 전달한다. 새 경로의 `el.animate(keyframes, timing)`에는 `composite`를 지정하지 않아 기본값인 `replace`가 적용된다. `replace`는 해당 애니메이션이 속성값을 대체하는 방식이다. 기존 `accumulate`처럼 여러 요청의 효과를 브라우저가 더해 주는 동작은 직접 준비해야 한다.

DOM은 그대로다. 각 자릿수가 0부터 9까지의 숫자 요소를 가지고, 현재 숫자와 나머지 숫자의 접근성 상태를 구분하는 구조를 유지했다. 스핀 위치를 계산하는 `digitYPercent()`도 1편의 rAF 엔진에서 쓰던 수식을 공유한다. 다만 모던 브라우저에서는 그 함수를 매 프레임 호출하는 대신, 업데이트 시점에 키프레임을 만드는 데 사용한다.

애니메이션 요청의 실행 시점도 모았다. [`engine/index.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/index.ts)의 `animate()`는 네이티브 경로에서 즉시 실행하지 않고 `queue()`에 요청을 넣는다. [`lite.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/lite.ts)의 `didUpdate()`가 접두부와 숫자, 접미부의 갱신을 마친 뒤 `flush(this)`를 호출한다.

`flush()`는 필요한 스타일과 크기를 먼저 읽고, 그다음 애니메이션을 교체한다. 요소 하나의 애니메이션을 바꾼 뒤 다음 요소의 스타일을 읽는 일을 반복하면, 브라우저가 그 사이마다 변경 결과를 계산해야 할 수 있다. 읽기와 변경을 모으는 것은 이런 반복을 줄이기 위한 선택이다.

새 애니메이션들의 `startTime`에는 같은 `document.timeline.currentTime`을 지정한다. 자릿수마다 키프레임을 만드는 시간이 달라도 같은 시점에서 출발하게 하기 위해서다. 애니메이션을 실행하지 않는 업데이트에서는 `discard()`로 대기 중인 요청도 버린다. 숫자가 이미 정적인 최종값으로 바뀌었는데 이전 요청이 뒤늦게 실행되는 일을 막는다.

## 움직이는 도중 새 값이 들어오면

`accumulate`를 쓰던 이유는 애니메이션 중단을 자연스럽게 처리하기 위해서였다. 각 애니메이션은 어떤 델타에서 0으로 줄어들고, 같은 속성에 여러 애니메이션이 겹치면 그 기여를 더한다. 새 값이 들어와도 이전 애니메이션은 남은 감속을 계속한다.

`replace`로 바꾼 뒤 현재 위치에서 새 애니메이션을 시작하기만 하면 이 동작이 달라진다. 시작 위치를 맞추더라도, 이전 애니메이션이 앞으로 어떤 속도로 움직일지는 사라진다. 1편의 폴백에서 활성 트윈을 모두 합산했던 것과 같은 이유로, 이번에도 이전 애니메이션들의 남은 진행을 보존해야 했다.

그 차이를 아래 예제로 볼 수 있다. 두 숫자 모두 3에서 7로 움직이다가 600ms에 목표가 5로 바뀐다. 왼쪽은 이전 이동의 남은 델타와 새 델타를 함께 계산한다. 오른쪽은 그 순간의 위치만 가져와 새 `ease-out` 애니메이션을 시작한다. 이 예제의 easing은 뒤로 갈수록 느려지는 간단한 곡선으로 통일했다.

<LiveDemo src="/demos/number-flow/animation-lab.html?scene=interrupt" title="시작 위치를 맞춘 단순 교체와 남은 기여를 합산하는 방식의 차이" height={900} />

`새 값이 들어오는 순간`을 누르면 두 숫자의 위치는 같다. 시간을 조금 더 움직이면 그래프와 숫자의 위치가 벌어진다. 왼쪽에서는 이전 이동의 감속과 새 이동의 감속이 함께 남아 있고, 오른쪽에서는 새 곡선 하나만 진행하기 때문이다. 0.2.0이 보존하려는 것은 왼쪽의 합산 결과다. 새 입력을 받기 전후의 속도를 무조건 같게 만드는 것이 아니라, 기존 `accumulate`가 만들던 이후의 움직임을 재현한다.

새 엔진은 그 정보를 `Contribution`에 저장한다. 시작 델타 `from`, 시작 시점 `start`, 지연 시간 `delay`, 지속 시간 `duration`, easing 함수가 각각 남는다. 새 업데이트가 들어오면 실행 중인 WAAPI 애니메이션의 `currentTime`을 읽고, 기존 기여들의 시간을 새 시작점에 맞춘다.

```ts
const activeEnd = (c: Contribution) => c.start + c.delay + c.duration

const shift = (contribs: Contribution[], by: number) =>
  contribs
    .map((c) => ({...c, start: c.start - by}))
    .filter((c) => activeEnd(c) > 0)
```

예를 들어 900ms 동안 30px에서 0으로 줄어드는 애니메이션이 있다고 하자. 250ms 뒤에 600ms 동안 -12px에서 0으로 줄어드는 요청이 추가됐다. 새 애니메이션을 시작한 뒤의 시간을 `t`라고 하면, 합쳐야 할 값은 각 애니메이션의 활성 구간에서 다음과 같다.

```text
첫 번째 기여 =  30 × (1 - 첫 번째 easing((250 + t) / 900))
두 번째 기여 = -12 × (1 - 두 번째 easing(t / 600))
합쳐진 이동량 = 첫 번째 기여 + 두 번째 기여
```

활성 구간이 끝난 기여는 0이다. 첫 번째에는 `900 - 250 = 650ms`가 남았고 두 번째는 600ms이므로, 새 애니메이션은 650ms 뒤에 끝난다. 새 요청의 지속 시간만 따르면 마지막 50ms의 움직임을 잃는다. [`compositor.test.ts`의 인터럽트 테스트](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/test/compositor.test.ts#L126-L148)가 이 조건에서 합산 결과와 새 키프레임을 비교한다.

코드의 `shift()`에서 시작 시간을 빼는 이유도 이 예와 연결된다. 원래 시작 시점이 0이고 250ms가 지났다면, 새 기준에서 기존 애니메이션의 시작은 -250ms다. 새 애니메이션의 시간이 0일 때 기존 기여를 계산하면 이미 250ms 진행한 상태가 나온다. 과거의 시작점을 저장해 둬야 이전 easing을 처음부터 다시 시작하지 않는다.

이렇게 얻은 앞으로의 합산 곡선을 하나의 `replace` 애니메이션에 담는다. 교체되는 기존 애니메이션에는 `cancel()` 대신 `finish()`를 호출한다. `cancel()`은 `finished` Promise를 거절하지만, `finish()`는 완료시키기 때문이다. `lite.ts`가 이 Promise들을 모아 `animationsfinish` 이벤트를 관리하므로 종료 방식도 기존 수명 주기와 맞아야 한다.

## 매번 촘촘한 키프레임을 만들지는 않는다

남은 움직임을 모두 계산하면 업데이트 시점의 일이 늘어난다. 그래서 새 애니메이션 하나만 시작하는 경우와, 여러 기여를 합쳐야 하는 경우를 나눴다.

새 요청 하나라면 가능한 한 원래 easing을 그대로 브라우저에 넘긴다. `bake()`의 `fresh` 분기다. 시간에 따라 변하는 곡선을 다시 만드는 대신, easing을 적용한 진행률에 맞춰 속성의 키프레임을 배치한다. 단순 가로 이동은 시작과 끝만으로 표현할 수 있다. 스핀은 숫자가 보이기 시작하거나 사라지는 경계에도 키프레임이 필요하다. 폭 보정의 역배율 `1 / scale`은 비선형이어서, 이 경우에는 진행률을 64개 구간으로 나눈 샘플을 사용한다.

진행률과 시간을 나누는 이유를 조금 더 풀어 보면 이렇다. 전체 시간의 50%가 지났더라도 `ease-out`에서는 이동 거리의 50%보다 멀리 와 있을 수 있다. easing은 시간을 이동 진행률로 바꾸는 함수이고, 키프레임은 그 진행률에서 속성이 어떤 값일지를 정한다. 새 애니메이션 하나라면 시간 변환은 원래 easing에 맡기고, 숫자가 나타나거나 사라지는 위치만 정확히 기록하면 된다.

인터럽트가 들어오거나 스핀과 폭 보정에 범위를 벗어나는 easing을 쓰면 시간에 따른 곡선을 계산한다. [`easing.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/easing.ts)가 반환하는 정보도 이에 맞춰 늘었다. 예전에는 진행률을 계산하는 함수만 필요했지만, 이제는 `linear()`의 구간 위치인 `stops`와 출력이 0에서 1을 벗어날 수 있는지 나타내는 `overshoots`도 전달한다.

`linear()`는 구간별 선형 함수이므로 꺾이는 시점을 샘플에 포함한다. `cubic-bezier()`처럼 곡선인 경우에는 `1000 / 240`ms 간격으로 값을 계산한다. 이 240은 재생 중의 프레임률과 무관하고, 키프레임을 준비할 때 곡선을 샘플링하는 간격이다. 같은 업데이트에서 시간 구성이 같은 채널들은 `grid()`의 결과를 공유한다.

여기서 CSS의 `linear()`와 일정한 속도를 뜻하는 `linear` 키워드도 구분해야 한다. `linear(0, 0.7 40%, 1)`처럼 쓰면 시간의 40% 지점에 진행률 0.7을 지나도록 꺾인 선을 만들 수 있다. 점과 점 사이는 직선이어도 전체 속도는 일정하지 않다. 새 엔진은 이 표현으로 여러 기여를 합한 시간 곡선을 브라우저에 전달한다.

1차원 채널에서는 합산 델타의 시간 곡선을 하나의 CSS `linear()` easing으로 만들고, 각 대상에는 그 델타를 화면 속성으로 바꾸는 데 필요한 키프레임만 둔다. 예를 들어 스핀은 같은 델타 곡선을 공유하지만 숫자마다 화면 안으로 들어오는 구간이 다르다. `piecewise()`가 숫자의 이동이 꺾이거나 감기는 경계를 추가하고, `simplify()`가 오차 범위 안에서 생략할 수 있는 점을 줄인다.

[`bake()`의 마지막 부분](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/compositor.ts#L566-L597)이 이 일을 한다. 샘플마다 합산 델타를 앞으로 지나갈 최솟값 `lo`와 최댓값 `hi` 사이의 비율로 바꿔 `linear()`의 점으로 쓰고, 키프레임은 `lo`와 `hi` 사이에서 각 대상의 함수가 꺾이는 곳에만 둔다.

```ts
// When re-baking, the summed delta follows one curve over time, shared by
// all of the channel's targets. It becomes the easing (a linear() through
// the normalized samples), so each target only needs keyframes where its
// own function bends:
const shared = !fresh && hi > lo
const curve = shared
  ? {
      duration: end,
      endDelay,
      easing: `linear(${simplify(
        samples.map((s) => [s.x, (s.v[0]! - lo) / (hi - lo)]),
        [tol / (hi - lo)],
      )
        .map(([x, g]) => `${g} ${x! * 100}%`)
        .join(', ')})`,
    }
  : timing
return targets.map(({el: target, F, breaks, frame}) => ({
  el: target,
  keyframes: (shared
    ? piecewise(
        [
          {x: 0, v: [lo]},
          {x: 1, v: [hi]},
        ],
        F,
        breaks,
      )
    : simplify(piecewise(samples, F, breaks), [tol])
  ).map(([offset, y]) => ({offset, ...frame(y!)})),
  timing: curve,
}))
```

앞 절의 30px와 -12px 예제를 v0.2.0 코드에 그대로 넣으면 아래 애니메이션 하나가 만들어진다. 인터럽트 테스트와 같이 첫 번째 요청에는 스프링 모양의 `linear()` easing을, 두 번째 요청에는 `ease-in-out`을 썼다. 값은 소수점 넷째 자리, 위치는 둘째 자리에서 반올림했고 `linear()`의 점 37개 중 일부만 옮겼다.

```js
keyframes: [
  {offset: 0, transform: 'translateX(-7.9272px)'},
  {offset: 1, transform: 'translateX(0.0490px)'},
]
timing: {
  duration: 650,
  easing: 'linear(0.5220 0%, 0.4272 1.99%, 0.3508 3.85%, …, 0.0001 22.21%, …, 1 91.67%, 0.9980 98.44%, 0.9939 100%)',
}
```

키프레임은 두 개뿐이다. 가로 이동은 델타를 그대로 `translateX()`에 쓰므로 꺾이는 곳이 없고, 앞으로 지나갈 범위의 양 끝만 남는다. 시간에 따른 움직임은 전부 `linear()`에 들어 있다. 시작 진행률 0.5220을 키프레임에 대입하면 `-7.9272 + (0.0490 + 7.9272) × 0.5220 = -3.763px`이고, 앞의 공식에 `t = 0`을 넣은 값과 같다. 첫 번째 기여는 스프링이라 빠르게 줄고 두 번째 기여는 `ease-in-out`이라 처음에 천천히 줄어서, 이동량은 22% 지점(약 144ms)에서 -7.93px까지 더 왼쪽으로 간다. 600ms 무렵 두 번째 기여가 거의 사라지면 첫 번째 요청의 남은 기여만 남아 이동량이 0.049px까지 양수가 되고, 650ms에 0이 된다. 앞에서 새 요청의 지속 시간만 따르면 잃는다고 한 마지막 50ms가 이 구간이다.

아예 화면에 들어오지 않는 숫자는 애니메이션 대상에서 제외한다. 자릿수 안에 숫자 요소가 10개 있다고 해서 10개를 모두 움직일 필요는 없다. 전체 진행 중 계속 위아래 100% 위치에 머물러 마스크 밖에 있는 숫자는 그대로 둔다. DOM을 줄이지 않고도 실제 애니메이션 개수를 줄일 수 있는 부분이었다.

다만 현재 보이는 숫자만 고르는 것은 아니다. 3에서 7로 움직인다면 중간에 지나가는 4, 5, 6도 대상이 될 수 있다. `bake()`는 합산 델타가 앞으로 통과할 범위를 보고, 그 구간 안에서 한 번이라도 화면에 들어오는 숫자를 찾는다. 지금은 가려져 있어도 잠시 뒤 나타날 숫자는 애니메이션해야 한다.

곡선 샘플링과 점 생략이 들어가므로 결과가 수학적으로 완전히 같지는 않다. 단순화 허용 오차는 위치 0.01 CSS px, 불투명도 0.0001이다. 여러 변환이 겹친 최종 화면의 오차는 이 상수로 정해지지 않으므로, 뒤에서 브라우저로 같은 시점의 결과를 따로 비교했다.

## 마스크에는 메인 스레드 작업을 남겼다

모든 애니메이션을 `transform`과 `opacity`로 바꾸지는 못했다. 숫자의 가장자리를 부드럽게 잘라내는 마스크가 남았다.

number-flow는 숫자의 폭이 바뀔 때 바깥 요소에 `scaleX()`를 적용한다. 그 안의 숫자까지 납작해지지 않도록 안쪽 요소에는 역배율을 적용한다. 새 엔진도 [이 변환을 직접 키프레임으로 만든다](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/compositor.ts#L440-L462). 바깥 요소는 `translateX(dx) scaleX(scale)`, 안쪽 요소는 `scaleX(1 / scale) translateX(-dx)`다.

예를 들어 이전 숫자의 폭이 120px이고 새 숫자의 폭이 100px이라면, 새 폭에 맞춘 요소를 처음에는 1.2배로 보여주고 배율을 1로 줄일 수 있다. 이때 안쪽 숫자에 `1 / 1.2`배를 적용하면, 숫자에 실제로 곱해지는 배율은 `1.2 × (1 / 1.2) = 1`이다. 바깥 영역의 폭이 변하는 동안 글자 자체가 옆으로 늘어나거나 납작해지는 것을 막는 구조다.

그런데 바깥 요소의 마스크도 배율의 영향을 받는다. [`styles.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/styles.ts)에서 마스크의 페이드 폭을 `--scale-x`로 나누는 이유가 여기에 있다. 화면에서 보이는 페이드 폭을 유지하려면, 숫자의 폭이 변하는 동안 마스크도 보정해야 한다.

마스크는 숫자를 어느 지점부터 흐리게 만들고 어디에서 완전히 가릴지 결정한다. 가로 페이드 폭을 24px로 정했더라도 바깥 요소가 1.6배로 늘어나면 화면에서는 38.4px에 걸쳐 흐려진다. 마스크 내부의 폭을 미리 `24 / 1.6 = 15px`로 줄여야 화면에서 다시 24px가 된다. 아래 예제에서는 차이가 잘 보이도록 페이드 폭을 크게 설정했다.

<LiveDemo src="/demos/number-flow/animation-lab.html?scene=mask" title="폭이 변할 때 숫자 모양과 마스크의 페이드 폭을 각각 보정하는 원리" height={850} />

두 예제 모두 안쪽 숫자에는 역배율을 적용한다. 오른쪽은 마스크의 폭까지 보정하므로 아래 주황색 막대가 24px를 유지한다. 왼쪽은 숫자의 모양은 유지해도 흐려지는 영역의 폭이 달라진다. `transform` 키프레임만으로는 남겨 둘 수 없었던 시각적 차이가 이 부분이었다.

마스크를 정지 상태로 고정하면 메인 스레드 작업을 더 줄일 수 있었다. 하지만 [PR의 비교](https://github.com/yceffort/number-flow/pull/11)에서는 자릿수가 바뀔 때 페이드 폭이 최대 6.6px 달라졌다. 기존 움직임과 모양을 유지하려는 조건에서 제외하기 어려운 차이라서, 이 최적화는 넣지 않았다.

대신 `--_number-flow-d-width` 애니메이션을 마스크 보정용으로 남기되, 필요한 구간까지만 실행한다. `bake()`는 `Math.abs(1 - 1 / scale)`이 `1 / 512`를 넘는 마지막 샘플을 찾고 그다음 샘플까지 마스크를 갱신한다. 코드에 기록한 기준은 마스크를 정지 상태로 돌려도 알파값 차이가 8비트 알파 한 단계(`1 / 255`)보다 작아지는 시점이다. 모서리에서는 차이가 약 1.42배까지 커지지만 `1.42 / 512`도 `1 / 255`보다 작다. 폭이 수렴한 뒤까지 커스텀 프로퍼티를 계속 애니메이션하지 않도록 범위를 줄였다.

남은 스타일 변경이 자릿수 전체로 퍼지는 것도 막았다. 기존의 가로 위치 변수는 상속되는 프로퍼티였지만, 실제로 이를 소비하는 것은 `.number`와 `.number__inner`였다. 그 아래 `.section`에서 값을 재설정했다. 아래는 스타일 템플릿의 변수명을 펼친 형태다.

```css
.number__inner > .section {
  --scale-x: 1;
  --_number-flow-dx: 0px;
}
```

이 변경은 구형 브라우저의 rAF 경로에도 적용된다. 애니메이션 엔진을 바꾸는 것과 별개로, 상위 요소의 폭과 위치 보정이 모든 숫자의 스타일 재계산으로 이어지지 않도록 상속 범위를 줄인 것이다.

숫자에 `font-variant-numeric: tabular-nums`를 적용하면 이 비용을 줄일 여지도 생긴다. 해당 기능을 지원하는 글꼴에서는 숫자들의 폭이 같아지므로, 자릿수와 기호 구성이 유지되는 갱신은 폭 변화 없이 처리할 수 있다. 마스크 보정 자체가 필요 없는 업데이트가 늘어난다. 뒤의 측정에서 일반 숫자와 고정 폭 숫자를 나눠 본 이유다.

## 기존 경로로 돌아가야 하는 입력도 있다

공개 API로 받을 수 있는 모든 타이밍을 새 엔진이 재현하지는 않는다. [`toContribution()`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/engine/compositor.ts#L121-L152)은 유한한 양수 `duration`, 한 번의 재생, 기본 방향 등의 조건을 검사한다. `steps()` 같은 불연속 easing이나 반복 재생, 역방향, `auto`나 `none`이 아닌 `fill` 설정 등은 기존 WAAPI `accumulate` 경로로 보낸다. 사용자가 `::part()`로 이동이나 불투명도에 관련된 기본 스타일을 바꾼 경우에도, 새 계산이 그 스타일을 덮어쓸 수 있는 대상은 기존 경로를 유지한다.

진행 중인 애니메이션에 이런 입력이 들어올 수도 있다. 이때 기존 기여를 버리고 시작하면 앞에서 보존하려던 움직임이 다시 끊긴다. `flush()`의 `demote` 처리는 남아 있는 기여마다 원래 형태의 애니메이션을 복원하고, `startTime`을 과거의 시작점으로 돌린다. 그 위에 새 `accumulate` 요청을 추가한다. 새 엔진으로 옮길 때뿐 아니라, 기존 엔진으로 돌아갈 때도 시간 정보를 가지고 있어야 했다.

정리하면 0.2.0에서 합성 스레드로 넘어간 것은 이동과 페이드의 재생이다. 업데이트를 처리하고 키프레임을 계산하는 일, 마스크 보정, 호환성 예외는 메인 스레드에 남고, 구형 브라우저는 계속 rAF 엔진을 사용한다. 아래 도식에서 위의 줄은 새 값이 들어온 순간에 하는 일이고, 아래 두 줄은 그 뒤 애니메이션을 재생하는 경로다. 노드를 누르면 해당하는 0.2.0 소스 파일과 줄 번호가 나온다. GitHub 소스 링크는 새 탭에서 연 화면에서 열린다. 뷰어의 고정 메뉴는 영어다.

<LiveDemo src="/demos/number-flow/engine-path.html?present=1" title="업데이트 때 메인 스레드에서 키프레임을 계산하고 이동과 페이드는 합성 스레드에서 재생하는 0.2.0의 애니메이션 경로" height={600} />

## 성능은 얼마나 달라졌나

개선 전후 비교는 최초 이슈의 측정과 별도로 진행했다. 아래 수치는 [PR #11](https://github.com/yceffort/number-flow/pull/11)에 기록한 결과다. 비교 대상도 업스트림 라이브러리와 포크가 아니라, 포크의 성능 개선 전후다.

> Apple M5에서 Playwright Chromium 151을 헤드리스로 실행하고, 10초 구간의 메인 스레드 작업 시간을 초당 평균으로 환산했다. 1배는 CPU 감속 없음, 6배와 20배는 DevTools CPU 감속 설정이다. 각 칸은 변경 전과 변경 후이며 단위는 ms/s다. 초당 1회 갱신 시나리오는 900ms `cubic-bezier` 타이밍을 사용한다.

먼저 애니메이션이 실제로 합성 스레드로 넘어갔는지 확인했다. 이슈에서 원인을 찾을 때처럼 trace의 `Animation` 이벤트에 기록된 `compositeFailed`를 애니메이션별로 셌다. 숫자 하나를 초당 1회 갱신하는 시나리오에서 변경 전에는 애니메이션 15개가 모두 0이 아닌 값을 기록했다. 변경 후에는 숫자 요소마다 애니메이션을 나눠 개수가 39개로 늘었지만, 그중 37개가 0이었다. 실패한 2개는 메인 스레드에 남기기로 한 마스크 보정용 `--_number-flow-d-width` 애니메이션이었다. 이 집계는 아래 표를 만든 측정에서 함께 얻었고, PR 본문에는 적지 않았다.

CPU 감속이 없는 조건만 막대로 비교하면 다음과 같다. 막대가 짧을수록 메인 스레드가 초당 사용한 시간이 적다.

<BarCompare title="0.2.0 성능 개선 전후의 메인 스레드 작업 시간" unit="ms/s" before="변경 전" after="변경 후" rows="숫자 하나|199|59;고정 폭 숫자|152|14;300ms마다 갱신|149|119;10개 행|455|177" />

| 시나리오                                    |       1배 |       6배 |      20배 |
| ------------------------------------------- | --------: | --------: | --------: |
| 숫자 하나, 초당 1회 갱신                    |  199 → 59 |  244 → 42 | 864 → 149 |
| 같은 조건, `tabular-nums`                   |  152 → 14 |  125 → 14 |  534 → 55 |
| 300ms마다 진행 중인 애니메이션에 새 값 입력 | 149 → 119 | 366 → 148 | 986 → 606 |
| 10개 행, 각각 초당 1회 갱신                 | 455 → 177 | 998 → 690 | 990 → 983 |

CPU 감속이 없는 첫 시나리오에서 메인 스레드 작업은 약 70% 줄었다. 고정 폭 숫자에서는 약 91% 줄었다. 앞서 본 마스크의 잔여 작업 때문에 두 조건의 개선 후 수치도 59ms/s와 14ms/s로 차이가 났다. 최초 문제를 확인한 218ms/s와 이 표의 변경 후 59ms/s를 직접 이어 붙이지 않은 것은 측정 조건과 구간이 다르기 때문이다.

인터럽트가 잦으면 개선 폭이 작아졌다. 300ms마다 값이 바뀌는 조건에서 감속 없는 결과는 149ms/s에서 119ms/s였다. 같은 PR에 따르면 1배 조건에서 인터럽트가 걸린 업데이트 한 번의 처리 시간이 약 5ms에서 15ms로 늘었다. 매 프레임의 스타일 계산을 줄이는 대신, 업데이트 때 남은 기여들을 다시 합치고 키프레임을 만드는 계산이 추가됐기 때문이다.

메인 스레드 시간과 프레임 누락도 따로 봐야 했다. 10개 행을 20배 감속으로 실행한 경우 메인 스레드는 990ms/s에서 983ms/s로 거의 그대로였다. 그런데 PR에 기록한 10초 구간의 드롭 프레임은 533개에서 0개가 됐다. 합성 스레드에서 애니메이션을 진행하는 것과 페이지의 메인 스레드 여유가 늘어나는 것은 서로 다른 결과다.

감속 배수를 실제 저사양 기기의 성능으로 환산하는 데도 한계가 있었다. PR의 보조 측정에서 6배 감속은 순수 JavaScript 루프를 5.9배 느리게 했지만, 짧은 프레임별 스타일 작업에는 약 1.6배만 반영됐다. 감속 조건의 절대값이 실제 저사양 기기보다 낮게 나올 가능성이 높다는 뜻이고, 저사양 Android WebView에서 직접 잰 값은 아직 없다.

## 같은 움직임인지는 별도로 확인했다

메인 스레드 시간을 줄였더라도 숫자의 이동 방식이 달라졌다면 같은 라이브러리의 개선이라고 설명하기 어렵다. 특히 인터럽트 순간의 위치와 이후 진행을 보존하는 것이 이번 구현의 조건이었다.

저장소의 [`compositor.test.ts`](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/test/compositor.test.ts)는 WAAPI를 모의 구현해 전달된 키프레임과 타이밍을 기록한다. 시간이 흐를 때 새 키프레임이 만드는 값을, 원래 기여를 합산한 값과 비교한다. 중간에 새 값이 들어오는 이동과 스핀, 퇴장 중 재등장하는 요소의 불투명도, 바깥과 안쪽 요소의 역변환, 지원하지 않는 타이밍으로 전환하는 경우 등을 검사한다. 이 테스트가 검증하는 것은 계산이고, 브라우저에서의 결과는 따로 비교했다.

브라우저 비교 결과는 [PR에 별도로 기록했다](https://github.com/yceffort/number-flow/pull/11). Chromium과 WebKit 26에서 모든 애니메이션을 멈춘 뒤, 비교할 시간을 지정하는 테스트 장치로 변경 전후를 맞췄다. 두 화면을 나란히 재생해 눈으로 비교하면 시작 시간이 조금만 어긋나도 위치가 달라 보일 수 있다. 같은 시점으로 정지시켜 위치와 불투명도, 마스크 폭을 읽어야 구현의 차이를 비교할 수 있다. 인터럽트와 부호 전환, 자릿수 변화, `continuous` 플러그인, 별도 스핀 타이밍, 오버슈트, 지연, 한 태스크 안에서의 연속 갱신 등 15개 시나리오가 대상이었다.

기록된 위치 차이는 0.026px, 불투명도 차이는 0.00011, 마스크 페이드 폭 차이는 0.024px 이내였다. DOM은 같았고 애니메이션 종료 후 스크린샷도 같았다. 진행 중에는 픽셀 차이가 남았지만, 변경 전 화면을 0.005px만 옮겨도 같은 수준의 차이가 생겼다. 글자의 서브픽셀 위치가 양자화되면서 생기는 차이다. 이 브라우저 비교 하네스와 성능 측정의 원본 트레이스는 0.2.0 태그에 포함돼 있지 않아 PR의 결과를 인용했다.

1편에서 남겨 둔 Safari 문제는 다시 측정해 보니 화면 문제가 아니었다. WebKit 17.4와 18.2에서 selftest가 폭 스케일과 등장 페이드를 실패로 보고했지만, 실패는 `getComputedStyle()`이 보고한 값에서만 나타났다. 재생 화면을 녹화한 프레임에서는 변경 전 코드도 두 효과가 WebKit 26.5와 같이 그려졌다. 측정 내용은 [1편](/2026/08/number-flow-fork-for-old-browsers)의 Safari 절 정정에 적었다. 0.2.0은 두 효과를 `transform`과 `opacity` 키프레임으로 직접 애니메이션하므로 `getComputedStyle()`도 애니메이션 중인 값을 보고한다. PR에 기록한 macOS용 두 WebKit 빌드에서 selftest 44개가 모두 통과했고, [`test-webkit-versions.mjs`](https://github.com/yceffort/number-flow/blob/v0.2.0/scripts/test-webkit-versions.mjs)의 알려진 실패 목록도 비웠다. 달라진 것은 화면이 아니라 검사가 읽는 값이다.

마스크 보정은 여전히 커스텀 프로퍼티를 거친다. `--_number-flow-d-width` 애니메이션이 `.number` 요소에서 실행되고, [같은 요소의](https://github.com/yceffort/number-flow/blob/v0.2.0/packages/number-flow/src/styles.ts#L164-L183) `--scale-x`를 거쳐 `-webkit-mask-size`에 들어간다. selftest는 마스크가 그려지는지만 검사하므로, 글을 쓰면서 [따로 측정했다](https://github.com/yceffort/blog-experiments/tree/main/number-flow-webkit-mask). `::part(number)`에 검은 배경을 깔아 마스크가 그 가장자리를 흐리게 만들게 하고, 숫자를 9에서 123456으로 바꾸는 동안 화면에서 보이는 페이드 폭을 스크린샷과 재생 녹화 프레임으로 쟀다. WebKit 17.4와 18.2, 26.5 모두 배율이 바뀌는 동안 페이드 폭이 `0.5em`인 16px 안팎에 머물렀다. 마스크 폭을 `1.5em`으로 바꾼 정지 화면은 47.8px로 읽혀서, 이 측정이 16px가 아닌 값도 잡아낸다는 것을 확인했다.

이 릴리즈에서 다시 실행한 브라우저는 구형 Chromium 100과 114, WebKit 16.4와 17.4, 18.2다. Firefox는 Playwright 바이너리가 실행되지 않았고, Chromium 66부터 87까지는 Rosetta가 없어 이번에는 확인하지 못했다.

## 합산을 업데이트 시점으로 옮긴 결과

원작자가 합성을 포기한 이유는 인터럽트였다. `accumulate`는 겹친 애니메이션을 브라우저가 더해 주지만 합성 스레드로 갈 수 없었고, `replace`로 바꾸면 이전 움직임이 끊겼다. 적어도 number-flow의 움직임에서는 둘 중 하나를 골라야 하는 문제가 아니었다. 브라우저가 매 프레임 하던 합산을 새 값이 들어온 순간 한 번 미리 해 두면, 그 결과를 `replace` 애니메이션 하나에 담을 수 있었다.

그 대가는 업데이트 시점으로 옮겨 갔다. 인터럽트가 걸린 업데이트 한 번은 약 5ms에서 15ms로 무거워졌고, 300ms마다 값이 바뀌는 조건에서는 개선 폭이 작았다. 마스크 보정도 메인 스레드에 남았다. 값이 약 1초마다 바뀌는 카운터에서는 이 교환이 크게 유리했고, `tabular-nums`로 폭 변화까지 없애면 남는 작업도 적었다. 갱신 간격과 숫자 개수에 따라 판단이 달라질 수 있다고 생각한다.

이 계산이 가능했던 것은 1편에서 구형 브라우저를 위해 CSS 수식과 easing을 JavaScript로 옮겨 두었기 때문이다. 이 변경은 업스트림에 제안하지 않고 이 포크에서만 유지할 생각이다. 변경 코드는 [0.2.0 태그](https://github.com/yceffort/number-flow/tree/v0.2.0)에, 측정 조건과 비교 결과는 [PR #11](https://github.com/yceffort/number-flow/pull/11)에 남겨 뒀다.
