---
title: '<em>number-flow</em>를 에이전트에게 맡기면 어디까지 고칠까'
tags:
  - web-performance
  - animation
  - debugging
  - browser
  - ai
published: true
series: 'number-flow 개선기'
seriesOrder: 4
date: 2026-11-03 17:56:00
description: '다섯 코딩 에이전트가 고친 number-flow와 내 포크 v0.2.1을 다시 비교했다. 글꼴에 따라 달라진 성능 보고, 익명 코드 리뷰, 마스크 기본값을 유지하기로 한 후속 실험까지 기록했다.'
art:
  undraw: chat-bot
---

<details>
<summary>목차 펼치기</summary>

- [테스트는 통과했는데](#테스트는-통과했는데)
- [내가 고치기 전의 코드를 건넸다](#내가-고치기-전의-코드를-건넸다)
- [숫자는 위아래로만 움직이지 않는다](#숫자는-위아래로만-움직이지-않는다)
- [빨라지면서 달라진 움직임](#빨라지면서-달라진-움직임)
- [내 포크도 같은 조건에서 쟀다](#내-포크도-같은-조건에서-쟀다)
- [이름을 가리고 코드를 리뷰시켰다](#이름을-가리고-코드를-리뷰시켰다)
- [v0.2.1에서 남길 것](#v021에서-남길-것)
- [마스크의 기본값은 유지하기로 했다](#마스크의-기본값은-유지하기로-했다)
- [에이전트에게 성능 개선을 맡길 만했다](#에이전트에게-성능-개선을-맡길-만했다)
- [실험 기록](#실험-기록)

</details>

## 테스트는 통과했는데

> 숫자 회전을 transform 애니메이션으로 바꿔 단독 재생 중 매 프레임 스타일 재계산을 제거했다.

숫자 애니메이션 라이브러리 number-flow를 고쳐 달라고 맡긴 다섯 코딩 에이전트 중 astra의 최종 보고였다. 테스트를 만들고 통과하는 것까지 확인했다고 했다. 그런데 완성된 빌드를 재현 페이지에 올리니 스타일 재계산이 프레임마다 찍혔다. 숫자가 굴러가는 동안 메인 스레드는 여전히 프레임마다 일하고 있었다.

처음에는 보고가 틀렸다고 봤다. 합성되지 못한 애니메이션도 트레이스에 남아 있었다. 다만 보고에는 "단독 재생 중"이라는 조건이 붙어 있었으니, 재현 페이지의 배경 텍스트 갱신부터 껐다. 배경 갱신을 꺼도 초당 50.1회였다.

차이는 테스트 코드 안에 있었다. astra가 만든 페이지의 글꼴은 `monospace`였다. 재측정에 쓴 페이지는 `system-ui`였다.

고정폭 글꼴에서는 9자리 숫자가 다른 9자리 숫자로 바뀌어도 폭이 그대로다. 이 실험의 `system-ui`에서는 숫자에 따라 폭이 달라졌다. 재현 페이지의 글꼴만 `monospace`로 바꾸자 astra 빌드의 재계산은 초당 4.9회로 줄었다. 보고와 테스트가 맞아떨어지는 조건이 있었던 것이다. 다만 그 조건이 보고에는 없었다.

아래에서 글꼴 조건을 바꿔 보면 다른 빌드에도 같은 일이 벌어졌음을 알 수 있다. 이 차트는 별도로 측정한 결과를 보여 준다. 지금 브라우저의 성능을 재는 것은 아니다.

<LiveDemo src="/demos/number-flow/blind-compare/index.html?view=width" title="숫자 폭 조건을 바꾸며 비교하는 빌드 7종의 스타일 재계산 횟수" height={670} />

숫자 폭이 그대로일 때는 다섯 에이전트 빌드 모두 비슷했다. 폭이 바뀌자 opus만 초당 5회 안팎에 머물렀다. 내 포크 v0.2.1도 26.4회로 늘었다. 그렇다고 opus가 모든 면에서 가장 나은 코드를 만든 것은 아니었다. 뒤이어 인터럽트 때의 움직임과 코드를 살펴보니 또 다른 결과가 나왔다.

## 내가 고치기 전의 코드를 건넸다

[number-flow](https://github.com/barvian/number-flow)는 값이 바뀔 때 숫자를 굴려 주는 라이브러리다. 나는 이 라이브러리를 포크해 구형 브라우저에서도 움직이게 만들고, 서비스 화면에서 발견한 성능 문제를 고쳤다. [1편](/2026/08/number-flow-fork-for-old-browsers)은 구형 브라우저 지원, [2편](/2026/10/number-flow-compositor-performance)은 재생 중 메인 스레드 비용, [3편](/2026/10/number-flow-forced-layout)은 값이 바뀌는 순간의 강제 동기 레이아웃을 다뤘다. 그 결과가 v0.2.1이다.

세 편을 쓰고 나니 궁금해졌다. 내가 처음 받았던 코드를 지금의 코딩 에이전트에게 건네면 어디까지 고칠까. 내가 서비스에서 알아낸 것을 말하지 않아도 같은 문제를 찾을까.

그래서 1편을 시작한 업스트림 커밋 [`a7b78f5`](https://github.com/barvian/number-flow/tree/a7b78f5)(0.6.2)을 다섯 에이전트에게 각각 줬다. Claude Code의 opus, sonnet, fable과 Codex CLI의 astra, terra다. 내 포크와 글을 보지 않도록 별도 환경에서 실행했고, 처음에는 코드와 요구사항만, 다음에는 서비스 화면 설명을, 마지막에는 프로파일러에서 본 것을 건넸다. 프롬프트 세 개는 미리 고정했다. 실행 중에는 질문에 답하거나 방향을 알려 주지 않았다.

완성된 빌드의 재측정과 익명 코드 리뷰에도 에이전트를 사용했다. 별도 하네스와 입력으로 다시 실행하고 원자료와 코드를 대조했다. 아래에는 그 과정에서 확인한 결과와 확인하지 못한 범위를 함께 적었다.

첫 요구는 짧았다.

> 이 라이브러리가 구형 Android WebView(Chrome 66 수준)와 iOS 13 Safari에서도 애니메이션이 동작하도록 지원 범위를 넓혀 주세요. 그리고 성능 개선에 필요한 것을 모두 찾아서 해결해 주세요. 기존 사용자가 코드 수정 없이 교체할 수 있어야 합니다.

"측정해 주세요"는 일부러 쓰지 않았다. 알아서 재는지도 보고 싶었다.

다섯 에이전트 모두 구형 브라우저용 경로를 만들었다. 네 에이전트는 `requestAnimationFrame`으로 움직이는 폴백 엔진을 만들고 easing도 직접 해석했다. terra는 Web Animations API를 유지하면서 구형 브라우저가 모르는 `linear()`를 `cubic-bezier()` 하나로 바꿨다.

opus와 astra는 Chromium 스냅샷 저장소에서 Chrome 66 바이너리를 내려받아 실제로 실행하기까지 했다. 구형 브라우저 지원은 요구했지만, 바이너리를 구해 돌리라는 말은 하지 않았다. 실행 전에 적어 둔 예상은 "정적 검사까지는 하겠지만 구형 바이너리를 돌리지는 않을 것"이었다.

fable은 다른 곳에서 빨랐다. 3편에서 다룬 강제 동기 레이아웃을 첫 턴에 고쳤다. 섹션 전체의 DOM 읽기를 먼저 끝내고 쓰기를 한꺼번에 하는 수정까지 마쳤다. 나머지 넷도 일부 읽기를 뒤로 미루는 수정을 했다. 나는 프로파일러를 보고서야 그 함수에 도착했지만, 코드를 읽어서도 찾을 수 있는 문제였다. 업스트림에는 `getBoundingClientRect()` 옆에 "this should only cause a layout if there were added children (?)"라는 주석까지 붙어 있었다.

반면 숫자가 굴러가는 내내 스타일을 다시 계산하는 회전 경로는 첫 턴에 아무도 짚지 않았다. 그래서 두 번째에는 화면을 설명했다. 접두부와 접미부가 붙은 약 9자리 숫자가 1초마다 바뀌고, 애니메이션은 900ms 동안 재생된다. 다른 곳에서는 텍스트가 초당 수십에서 수백 회 바뀌며, 주로 모바일에서 본다는 내용이었다.

opus는 이 설명대로 벤치마크를 만들었다. 다른 텍스트를 초당 200회 바꾸고 CPU를 4배 감속한 뒤, 재생 중 비용이 프레임 예산에서 얼마나 큰지 보고했다. 1초마다 새 값이 오는데 애니메이션이 900ms라면 거의 쉬지 않고 움직이는 셈이다. 나도 1편에서는 폴백 엔진이 더하는 JS 틱 비용을 설명하며 "카운터 하나면 무시할 수준"이라고 추정했다. 그때는 성능을 실측하지 않았다. 이번에는 네이티브 경로까지 포함해 서비스 조건에서 재생 비용을 따로 확인해야 했다.

fable도 폭 변화에 따른 스케일과 마스크 비용을 지적하고 `tabular-nums`를 권했다. 나머지는 주로 같은 값이나 바뀌지 않은 자릿수의 갱신을 건너뛰었다. 세 번째에는 관찰을 직접 줬다. "재생 중 JavaScript 바깥에서 스타일 재계산이 매 프레임 찍힌다. 값이 바뀌는 태스크 안에서도 재계산과 레이아웃이 10여 번 반복된다."

세 턴이 끝나자 다섯 에이전트 모두 자릿수 회전을 `transform` 키프레임으로 옮겼고, 갱신할 때의 읽기와 쓰기를 나눴다. 회전 경로의 비합성 문제를 발견한 수는 턴별 누적으로 0, 1, 5였다. 다만 앞선 수정과 작업 시간도 함께 쌓였으므로, 관찰을 더해 준 효과만 따로 잰 실험은 아니다. 턴별 상세 기록과 프롬프트 전문은 글 끝에 모았다.

## 숫자는 위아래로만 움직이지 않는다

앞의 글꼴 차이가 왜 그렇게 큰 결과를 냈을까. number-flow의 숫자가 움직이는 방향을 나눠 보면 이해하기 쉽다.

값이 바뀌면 각 자릿수는 위아래로 굴러간다. 동시에 숫자의 폭이 바뀌면 이웃한 자릿수와 기호가 옆으로 밀린다. 전체를 감싸는 영역의 폭도 보정한다. 고정폭 숫자에서는 같은 자릿수를 유지하는 동안 이 두 번째 움직임이 거의 필요 없다.

재측정에서 자릿수 회전은 다섯 에이전트 빌드 모두 합성됐다. 문제가 남은 곳은 옆으로 미는 이동과 전체 폭 보정이었다. opus는 이 이동까지 `replace` 방식의 `transform` 키프레임으로 바꿨다. 다른 넷은 이동에 `accumulate`를 남겼다. sonnet, astra, fable에는 폭 보정용 커스텀 프로퍼티도 남아 있었다. 고정폭 테스트에서는 드러나지 않던 경로가 글꼴을 바꾸자 실행된 것이다.

이 차이를 알고 보고서를 다시 읽으면 어디까지 맞는 말인지 구분할 수 있다.

| 에이전트      | 보고한 내용                                                           | 재측정에서 확인한 범위                        |
| ------------- | --------------------------------------------------------------------- | --------------------------------------------- |
| opus          | 다른 변화가 없으면 숫자가 프레임마다 메인 스레드 작업을 만들지 않는다 | 측정한 두 글꼴 조건에서 확인했다              |
| astra         | 단독 재생 중 매 프레임 스타일 재계산을 제거했다                       | 고정폭 조건에서는 맞았고, 폭이 바뀌면 남았다  |
| sonnet, fable | 회전은 합성되며, 폭 경로의 두 요소만 메인 스레드에 남는다             | 회전은 합성됐지만 하위 요소의 재계산도 남았다 |

sonnet과 fable은 폭 보정이 메인 스레드에 남는다는 것까지 보고했다. 다만 실제로는 두 요소만 다시 계산하지 않았다. 폭이 바뀌는 조건에서 프레임마다 sonnet은 71개, fable은 111개 요소의 스타일을 다시 계산했다. fable의 전사에는 `tabular-nums`를 켠 측정 페이지 주소가 끈 주소보다 훨씬 자주 등장했다. sonnet의 측정 스크립트에는 글꼴 지정이 없어 당시 숫자 폭이 일정했는지 확인하지 못했다.

여기서 재계산 횟수만 보면 또 잘못 읽게 된다. astra는 초당 50.1회로 업스트림과 비슷했지만, 재계산에 쓴 시간은 151.7ms/s에서 51.1ms/s로 약 66% 줄였다. 매 프레임 일한다는 문제는 남았고, 한 번 일할 때의 비용은 줄었다. 횟수와 시간을 함께 봐야 줄어든 비용도 드러났다.

트레이스의 `compositeFailed`도 마찬가지다. 이 값은 어느 애니메이션이 왜 합성되지 않았는지 찾는 단서다. 화면에 보이는 변화가 없다는 사유도 실패에 들어간다. astra는 자릿수마다 띠 하나를 움직이고 다른 빌드는 숫자 요소 여러 개를 움직여 애니메이션 총수도 다르다. 실패 비율만으로 성능 순위를 매길 수는 없다. 글 끝에 실패 개수를 남겼지만, 비용은 시간으로 따로 비교했다.

글꼴과 실패 대상을 나눠 보니 회전은 고쳤고, 고정폭 조건의 테스트도 통과했으며, 폭이 바뀔 때 다른 움직임의 비용이 남았다는 결과였다. 보고서의 "재계산 제거"를 판단하려면 이 조건부터 맞춰야 했다.

## 빨라지면서 달라진 움직임

opus는 두 글꼴 조건에서 재생 내내 이어지는 재계산을 없앴다. 그렇다면 opus의 코드를 가져오면 되는 걸까. 성능표 밖에도 비교할 것이 있었다.

숫자가 아직 굴러가는데 다음 값이 들어오는 상황을 생각해 보자. 업스트림은 이전 애니메이션을 취소하지 않는다. 이전 애니메이션이 앞으로 움직일 양에 새 애니메이션의 움직임을 더한다. `accumulate`가 하던 일이다. 지금 위치에서 새 목표로 출발하는 것과는 다르다. 겹친 동안의 속도도 달라진다.

2편에서 오래 걸린 부분이 이 합산을 `replace` 키프레임으로 옮기는 일이었다. 합성 스레드에서 재생하려고 기존 움직임을 버리고 싶지는 않았다.

다섯 에이전트도 같은 문제를 만났다. opus, sonnet, fable, terra는 새 곡선으로 바꿨다. 현재 위치나 남은 변위를 넘겨받더라도 이전 애니메이션의 남은 시간과 속도를 그대로 더하지는 않는다. 이 동작 차이를 보고에 적은 것은 opus, fable, terra였다. sonnet은 "끊김 없이 이어진다"고만 적었고 곡선이 달라진다는 점은 밝히지 않았다. opus는 겹치는 동안의 곡선이 다르다고 했고, terra는 순간 이동 가능성이 있어 추가 검증이 필요하다고 했다. 후속 코드 리뷰의 가짜 시계 검사에서는 fable의 위치가 끊기는 사례가 보고됐다. 이미 easing이 적용된 진행률에 easing을 한 번 더 적용한 결과였다.

astra는 중간에 엔진을 바꿨다. 평소에는 합성 스레드에서 회전하고, 도중에 값이 들어오면 이전 easing과 경과 시간을 JS 엔진에 넘겨 새 곡선과 더한다. 인터럽트 동안은 메인 스레드로 돌아오는 선택이다. 가짜 DOM과 시계에서 인계 직후 위치가 이어지는 한 사례는 확인했지만, 전체 곡선이 업스트림과 같은지는 실제 브라우저에서 수치로 검증하지 않았다.

아래 데모는 짧은 간격으로 값을 연달아 바꾸는 장면부터 시작한다. **느리게 보기**를 켜거나 **1초마다 갱신**으로 바꿔 움직임을 살펴볼 수 있다.

<LiveDemo src="/demos/number-flow/blind-compare/index.html?view=builds&scene=burst" title="인터럽트가 생길 때 업스트림, 포크, 에이전트 빌드의 움직임 비교" height={930} />

각 빌드는 별도 iframe에서 같은 값의 갱신 요청을 받는다. 메시지를 처리하는 시점에는 차이가 있다. 멈추기는 애니메이션과 JS 프레임 진행을 함께 멈추지만, 모든 빌드를 정확히 같은 가상 시각에 놓는 도구는 아니다. 움직임을 살펴보는 용도이며, 정지 화면이 비슷하다는 것만으로 곡선이 같다고 판정하지 않는다. 카드에는 각 빌드가 인터럽트를 처리하는 방식을 적었다.

마스크에도 차이가 있었다. 숫자 가장자리에는 서서히 투명해지는 페이드가 있다. 숫자를 감싼 요소를 `scaleX`로 늘리면 이 페이드도 같이 늘어난다. opus는 작은 폭 변화에서 이 차이를 받아들였다. v0.2.1은 화면에 보이는 페이드 폭을 유지하려고 보정을 남겼다. 폭이 바뀔 때 포크에 남은 합성 실패 10개는 모두 이 보정이었다.

프롬프트에는 "기존 사용자가 코드 수정 없이 교체할 수 있어야 한다"고 적었다. API를 유지하라는 뜻으로는 읽히지만, 애니메이션의 중간 위치와 속도, 페이드 폭을 어디까지 같게 해야 하는지는 적지 않았다. 에이전트마다 다른 선택을 했고, 그 선택은 성능에도 영향을 줬다. 각 빌드의 수치만으로 특정 동작 하나를 보존하는 비용까지 분리해 낼 수는 없다.

## 내 포크도 같은 조건에서 쟀다

값이 바뀌는 순간의 비용은 에이전트 빌드 다섯 개가 모두 v0.2.1보다 작았다. 내 포크의 갱신 태스크는 평균 9.4ms였고, 에이전트 빌드는 2.9~8.3ms였다. astra가 가장 짧았다.

astra는 자릿수마다 숫자 요소 10개를 따로 움직이는 대신, 숫자를 이어 붙인 텍스트 띠 하나를 움직였다. 기존 요소는 접근성용으로 남겼다. 다른 에이전트 빌드가 갱신 10회에 애니메이션 658~668개를 만들 때 astra는 219개였다. 갱신 비용이 작아진 이유로 생각할 수 있지만, 이 구조 변경만 켜고 끄는 비교는 하지 않았다.

반대로 페이지 전체의 스타일 재계산과 레이아웃 시간은 v0.2.1이 sonnet, astra, fable보다 짧았다. opus와 terra는 세 번 실행한 범위가 포크와 겹쳐 이번 판정 기준으로 차이를 구분하지 못했다. 범위가 겹친다고 성능이 같다는 증명은 아니다.

아래에서 지표를 바꿔 볼 수 있다. 갱신 태스크와 페이지 전체 비용은 배경 텍스트도 바뀌는 조건이다. 재생 중 재계산은 배경 갱신을 끄고 따로 쟀다.

<LiveDemo src="/demos/number-flow/blind-compare/index.html?view=perf" title="빌드 7종의 갱신 태스크, 페이지 전체 비용, 재생 중 재계산 비교" height={660} />

업스트림과 나란히 놓으면 v0.2.1의 개선도 분명했다. 같은 기본 조건에서 갱신 태스크는 12.4ms에서 9.4ms로, 페이지 전체 스타일 재계산과 레이아웃은 176.0ms/s에서 34.5ms/s로 줄었다. 다만 더 빠른 갱신 경로를 다른 빌드가 보여 줬다. 곡선을 보존하느라 어쩔 수 없이 느렸다고 설명할 근거는 없다. 그 비용을 따로 재지 않았기 때문이다.

호환성은 더 직접적인 문제였다. terra는 구형 API를 지운 모던 Chromium에서 테스트를 통과했지만, 실제 Chromium 66에서는 로드 직후 `Cannot read property 'then' of undefined`가 났다. 지원되지 않는 `animation.finished.then`을 호출했다. API를 지워 흉내 낸 환경에서는 지우는 목록에 빠진 API가 그대로 동작했다.

내 포크에도 구멍이 있었다. React의 `useCanAnimate` 훅이 사용하는 `MediaQueryList.addEventListener`는 Safari 14 미만에 없다. opus, sonnet, fable은 이를 지적했고 astra도 `addListener` 경로를 넣었다. v0.2.1과 terra에는 대체 경로가 없었다. 소스와 API를 제거한 재현으로 확인했으며 iOS 13 실기기에서 실행한 결과는 아니다. 구형 지원을 넓혔다고 쓴 내 코드에도, 내가 살피지 않은 진입점이 남아 있었다.

후속 리뷰까지 합쳐 포크의 결함 다섯 건을 이슈로 남겼다.

| 결함                                         | 이슈                                                     | 어디에서 온 문제인가                    |
| -------------------------------------------- | -------------------------------------------------------- | --------------------------------------- |
| 구형 Safari의 미디어 쿼리 구독               | [#23](https://github.com/yceffort/number-flow/issues/23) | 지원 확장 때 놓친 React 훅              |
| 폴백의 반복과 방향 등 타이밍 옵션 처리       | [#25](https://github.com/yceffort/number-flow/issues/25) | 포크의 폴백 구현                        |
| 중첩 컨테이너를 제거한 뒤 남는 참조          | [#26](https://github.com/yceffort/number-flow/issues/26) | 업스트림에서 이어진 문제                |
| 요소를 분리한 뒤에도 이어지는 rAF            | [#27](https://github.com/yceffort/number-flow/issues/27) | 포크의 폴백 구현                        |
| 다시 등장한 자릿수를 이전 종료 리스너가 지움 | [#28](https://github.com/yceffort/number-flow/issues/28) | 업스트림에서 이어진 문제, opus는 수정함 |

대응 PR [#24](https://github.com/yceffort/number-flow/pull/24), [#29](https://github.com/yceffort/number-flow/pull/29), [#30](https://github.com/yceffort/number-flow/pull/30), [#31](https://github.com/yceffort/number-flow/pull/31), [#32](https://github.com/yceffort/number-flow/pull/32)는 11월 3일 모두 병합됐고, 다섯 이슈도 닫혔다. 수정은 같은 날 공개한 [v0.2.2](https://github.com/yceffort/number-flow/releases/tag/v0.2.2)에 반영됐다. 이 글의 측정값과 코드 점수, 데모는 수정 전 v0.2.1로 고정했다.

## 이름을 가리고 코드를 리뷰시켰다

성능을 재고 나니 코드 자체의 평가도 궁금했다. 이번에는 에이전트 빌드 다섯 개와 내 포크를 익명화해 리뷰시켰다. 작성자, 모델명, 앞의 성능 수치와 대조표는 주지 않았다. 코어와 React 래퍼를 공통 범위로 삼았고, 다른 프레임워크를 지원하는지는 점수에 넣지 않았다. 같은 코드 사본을 서로 다른 순서로 읽게 했으며, 점수는 작성자 신원을 공개하기 전에 고정했다.

평가 기준은 정확성과 호환성 30점, 설계와 유지보수성 25점, 생명주기와 자원 관리 20점, 테스트 20점, 변경 범위와 문서 5점이다. 특정 곡선이나 마스크 구현을 따라야 한다는 조건은 없었다.

처음 두 평가자는 같은 모델 설정의 별도 대화였다. 두 평가자의 구체적인 모델명은 남아 있는 실험 기록으로 확인할 수 없다. 두 보고서를 고정한 뒤 Claude Code의 fable 5.1에도 같은 자료를 건넸다. 세 번째는 별도 컨테이너에서 실행해 다른 보고서와 신원 대응표에 접근할 수 없게 했다.

| 빌드   | 평가자 1 | 평가자 2 | 평가자 3 (fable 5.1) |
| ------ | -------: | -------: | -------------------: |
| astra  |       80 |       74 |                   80 |
| v0.2.1 |       69 |       69 |                   68 |
| fable  |       65 |       50 |                   76 |
| opus   |       62 |       56 |                   62 |
| sonnet |       56 |       46 |                   56 |
| terra  |       34 |       43 |                   48 |

세 평가자 모두 astra를 가장 높게 평가했다. 빠른 갱신 태스크 때문은 아니다. 그 수치는 알려 주지 않았다. 반복해서 나온 이유는 실행 경로와 상태를 구분한 설계, 요소를 분리할 때의 정리, 구체적인 동작을 검사하는 테스트였다. 중단 후 최종 숫자, 이벤트 순서, 그룹에서 빠진 요소의 상태, 지연된 회전을 직접 확인하는 테스트가 있었다. 인터럽트 때 합성 경로에서 JS 합산으로 넘어간다는 선택도 코드에 드러났다.

포크는 합산 곡선과 역스케일을 수치로 검사하는 테스트, SSR 이스케이프, 상한이 있는 캐시에서 좋은 평가를 받았다. 반면 훅과 폴백 타이밍, 요소를 해제할 때의 처리는 약했다. opus는 엔진과 측정 단계의 분리, 다시 등장한 자릿수의 삭제 문제를 고친 점이 장점이었지만 새 경로를 지키는 회귀 테스트가 부족했다. astra에도 되살린 문자의 삭제와 SSR 입력 처리 같은 지적이 남았다.

fable의 점수는 50점부터 76점까지 벌어졌다. 세 번째 평가자는 읽기와 쓰기의 분리, 애니메이션 추적 구조를 높게 봤다. 두 번째는 `Intl.Locale` 캐시 키 충돌, 비선형 easing 도중의 중단, 반복 타이밍과 정지 상태에서 더 많은 문제를 찾았다. 세 번째 평가자가 fable 빌드를 만든 모델과 같다는 점도 남는다. 그것이 높은 점수의 원인인지는 알 수 없지만, 이름을 지웠다고 모델의 선호까지 지워지지는 않는다.

세 번의 에이전트 리뷰만으로 일반적인 품질 순위를 정하기는 어렵다. 재현은 주로 최소 DOM과 가짜 시계에서 했다. 특히 세 번째 하네스는 실제 레이아웃을 하지 않아 폭과 위치 보정이 0으로 실행됐고, React 래퍼와 브라우저 렌더링은 돌리지 않았다. 앞의 두 평가자는 파일시스템 수준으로 격리하지도 않았다. 점수 몇 점의 차이보다, 어느 코드에서 같은 문제가 반복해서 나왔는지를 보고 후속 수정에 썼다.

## v0.2.1에서 남길 것

비교를 마치고 나니 내 코드의 가치를 "에이전트보다 잘 고쳤다"는 말로 설명할 수는 없었다. 에이전트 빌드의 갱신 태스크는 모두 더 짧았고, 내가 놓친 결함도 찾았다. 대신 무엇을 지키려고 만든 코드인지는 더 분명해졌다.

v0.2.1은 인터럽트 때 이전 애니메이션의 남은 움직임을 새 움직임과 합산하고, 그 곡선을 `replace` 키프레임으로 만들어 합성 스레드에서 재생한다. astra는 이때 JS 합산으로 돌아갔고 다른 넷은 곡선을 바꿨다. 포크에는 보존할 움직임을 정한 구현과 그 수치를 검사하는 테스트가 함께 있다. SSR 입력 처리와 캐시 상한도 있다. 다른 빌드에서 더 나은 방법을 가져오더라도 유지할 만한 부분이다.

그만큼 꼼꼼하지 못한 곳도 있다. 합산 곡선과 역스케일을 확인한 만큼 React 훅이나 폴백의 해제를 확인하지는 못했다. 엔진의 한 성질을 지켰다는 사실이 라이브러리 전체의 완성도를 대신하지는 않았다.

opus의 결과에서는 다른 선택을 시험해 볼 근거도 얻었다. 포크는 이동과 외부 스케일, 내부 역스케일을 이미 합성한다. 남은 마스크 보정도 [`bake()`에서 작은 오차를 허용하는 문턱](https://github.com/yceffort/number-flow/blob/477893f382cff2562df1c8522c6f1aa6a63e3f87/packages/number-flow/src/engine/compositor.ts#L535-L559)을 두고 일찍 끝낸다. 이 문턱을 완화했을 때 비용과 움직임이 얼마나 달라지는지 [#33](https://github.com/yceffort/number-flow/issues/33)으로 남겼고, 후속 실험까지 이어졌다.

## 마스크의 기본값은 유지하기로 했다

11월 3일에는 에이전트가 마스크 보정의 허용치를 바꾸는 후속 실험을 실행했다. 합산 엔진은 그대로 두고 보정을 얼마나 일찍 끝낼지만 바꿨다. 처음에는 기존의 `1/512`를 `1/32`로 늘리자 같은 자릿수의 단독 재생에서 재계산이 초당 27.3회에서 8.0회로 줄었다. 다만 페이드 폭에 따라 오차도 커졌다. 같은 상수로 기본 마스크에서는 약 0.4px, 폭을 1.5em으로 늘린 마스크에서는 약 1.3px의 오차가 예측됐다.

그래서 다음 두 라운드에서는 0.5 CSS px를 직접 예산으로 삼았다. 정지 상태 페이드 폭을 `F`, 배율을 `s`라고 할 때 `F × |s - 1| > 0.5`인 동안 보정하는 구현이다. 기존 판정식 `|1 - 1/s|`에 상수를 넣는 방식과 달리 화면에서 허용할 폭 오차를 기준으로 삼았다. 실험 소스는 `main`의 `fe0704d`에 패치를 더한 것이며, 3라운드 소스 패치의 SHA-256 앞 7자리는 `8cfc281`이다. 앞의 일곱 빌드 비교와는 별도다.

실제로 구현하자 마스크 폭을 읽는 비용이 나타났다. 2라운드에서는 첫 갱신의 DOM 쓰기가 끝난 뒤 폭을 읽어 강제 스타일 재계산을 한 번 더 일으켰다. CPU 4배 감속에서 약 3ms였다. 3라운드는 폭을 모르는 동안 기존 `1/512` 판정을 쓰고, 정지 상태의 읽기 단계에서 폭을 얻은 뒤 예산을 적용했다. 이 방식에서는 갱신 태스크와 강제 재계산 횟수의 증가를 구분하지 못했다.

| 3라운드 지표                               | 1/512 | 1/32 | 0.5px 예산 |
| ------------------------------------------ | ----: | ---: | ---------: |
| 9자리 유지, 재생 중 재계산 (회/s)          |  27.3 |  8.3 |        9.7 |
| 같은 조건의 메인 스레드 태스크 전체 (ms/s) |  51.2 | 31.7 |       31.8 |
| 9자리와 7자리 교대, 재생 중 재계산 (회/s)  |  40.7 | 27.5 |       27.9 |
| 같은 조건의 메인 스레드 태스크 전체 (ms/s) |  93.3 | 53.1 |       54.1 |
| 9자리 유지, 갱신 태스크 평균 (ms)          |   9.1 |  9.5 |        9.2 |

> Chromium 151 헤드리스, CPU 4배 감속, 배경 갱신을 끈 조건에서 빌드마다 3회 잰 평균이다. 단독 재생의 재계산 횟수와 메인 스레드 시간은 기준과 실행 범위가 분리됐고, 갱신 태스크는 범위가 겹쳤다. 배경 갱신이 있는 3라운드 자료는 측정 간섭 때문에 판정에서 제외했다. 1차와 2라운드의 배경 갱신 조건에서는 페이지 전체 주요 시간 지표의 개선을 구분하지 못했다.

고정된 스타일에서는 픽셀 예산이 의도대로 작동했다. Chromium과 Playwright의 WebKit 26.5에서 마스크 폭 8, 14, 24, 42px를 시험했을 때, 보정 종료 뒤 브라우저 계산값의 오차는 최대 약 0.49px였다. 다만 스크린샷 판독값과 같은 결과는 아니다. 판독 폭의 한 단계가 약 0.56 CSS px였고, WebKit에서는 보정 중에도 최대 1px의 잔차가 있었다. 이 측정으로 실제 화면의 모든 오차와 보정 종료 순간의 변화가 0.5px 이하라고 말할 수는 없다.

코드 리뷰 뒤에는 보고서에서 측정하지 않은 조건도 실행했다. 애니메이션 도중 마스크 폭을 14px에서 42px로 바꾸고 새 숫자를 넣자, 0.5px 예산인데도 계산된 페이드 폭 오차가 약 1.46px까지 커졌다. 같은 변경을 정지 상태에서 하면 약 0.47px였다. 폭을 읽는 코드를 정지 상태에만 두어서, 재생 중에는 저장해 둔 옛 폭으로 판정한 것이 원인이었다. 첫 갱신부터 계속 인터럽트가 걸리면 폭을 읽을 기회가 없어 예산 적용 자체가 늦어지기도 했다. "둘째 갱신부터 적용된다"는 설명도 앞 애니메이션이 끝나는 조건에서만 맞았다.

오래된 폭으로 판정하는 결함은 현재 실험 구현의 문제다. 픽셀 단위로 오차를 정하는 방식 자체를 기각할 근거로 삼지는 않았다. 그 결함을 수정하는 일과, 라이브러리의 기본값으로 더 큰 시각 오차를 받아들이는 일은 별도로 판단했다.

기본값은 `1/512`를 유지하기로 했다. 단독 재생에서 비용을 줄일 수 있다는 결과는 분명하다. 다만 원래 개선 대상이던 배경 갱신이 많은 화면에서는 페이지 전체 비용의 개선을 아직 입증하지 못했다. 이 결과로 기본값의 시각적 차이 허용 범위까지 넓히기에는 이득의 근거가 부족했다. 실험 구현과 자료는 보관하되 이번 변경은 채택하지 않는다.

## 에이전트에게 성능 개선을 맡길 만했다

코드와 요구사항만으로도 1편의 구형 브라우저 지원과 일부 캐시 문제, 3편의 레이아웃 문제는 찾아 고쳤다. 하지만 2편의 회전 경로가 재생 내내 스타일 재계산을 일으키는 문제는 첫 턴에 아무도 짚지 못했다. opus는 서비스 설명을 받은 뒤, 나머지 넷은 프로파일러 관찰까지 받은 뒤에 찾았다. 세 턴을 마친 뒤에는 다섯 빌드 모두 갱신 태스크가 내 포크보다 짧았다. 다음에도 서비스의 입력과 관찰을 건네 원인 탐색과 구현을 맡길 만하다. 측정은 글꼴과 갱신 조건을 내가 정하고 별도 하네스로 다시 확인하는 것을 전제로 맡기려 한다.

다만 개선된 빌드를 그대로 교체해도 되는지는 별개의 문제였다. opus는 측정한 두 글꼴 조건에서 재생 내내 이어지는 재계산을 없앴지만 인터럽트 곡선과 마스크 동작을 바꿨다. astra는 가장 짧은 갱신 태스크와 세 평가자의 가장 높은 코드 점수를 얻었지만, 재계산을 없앴다는 보고는 고정폭 조건에서만 맞았다. 어느 빌드가 빠른지와 서비스에서 어떤 동작을 허용할지는 함께 판단해야 했다.

내 코드를 정답으로 놓은 채점 방식도 바꿔야 했다. 처음 대조표에는 문제와 내가 택한 해법이 섞여 있었고, 보고를 다시 잴 때는 글꼴 조건을 놓쳤다. 앞으로는 누가 고쳤든 서비스의 입력에서 비용이 줄었는지, 기존 움직임 중 무엇이 달라졌는지, 테스트가 어느 조건을 확인했는지로 채택 여부를 판단하려 한다.

이번 비교에서 에이전트에게 얻은 것은 실제로 비용을 줄인 코드와 내 코드에 빠져 있던 수정이었다. v0.2.1에는 그 개선을 받아들이면서도 지킬 동작과 테스트가 있었다. 후속 마스크 실험에서는 합산 엔진을 바꾸지 않고 재생 비용을 더 줄일 수 있었지만, 그 결과만으로 시각적 차이를 허용하는 기본값 변경까지 받아들이지는 않았다. 인터럽트의 합산 곡선과 수치 테스트, 기존 마스크 기준은 유지한다. 호환성과 생명주기에서 찾은 결함은 v0.2.2에 수정했고, 갱신 비용은 다른 빌드의 개선을 참고해 줄이려 한다. 이번 비교로 정한 것은 그처럼 유지할 부분과 바꿀 부분의 구체적인 범위다.

## 실험 기록

아래는 11월 2일의 에이전트 비교 실험 기록이다. 11월 3일의 마스크 허용치 실험 조건은 앞 절에 따로 적었다.

에이전트 비교는 2026년 11월 2일에 실행했다. 비교한 포크는 [`v0.2.1`](https://github.com/yceffort/number-flow/tree/477893f382cff2562df1c8522c6f1aa6a63e3f87)이고, 에이전트 빌드는 모두 업스트림 MIT 코드의 파생물이다. 아래에 프롬프트와 주요 결과를 남겼다. 원본 전사와 평가 보고서는 아직 공개하지 않았다.

### 실행 환경

| 이름   | 하네스              | 모델                | 최종 빌드 커밋 |
| ------ | ------------------- | ------------------- | -------------- |
| opus   | Claude Code 2.1.316 | `claude-opus-5-5`   | `1dc36ed`      |
| sonnet | Claude Code 2.1.316 | `claude-sonnet-5-5` | `a482787`      |
| fable  | Claude Code 2.1.316 | `claude-fable-5-1`  | `377a3dc`      |
| astra  | Codex CLI 0.166.0   | `gpt-6-astra`       | `f5585a9`      |
| terra  | Codex CLI 0.166.0   | `gpt-6.1-terra`     | `55b4976`      |

하네스가 둘이라 도구, 시스템 프롬프트, 기본 추론 설정이 다르다. 그래서 모델끼리의 비교라기보다 하네스와 모델 조합의 비교에 가깝다. 하네스는 내 설정 없이 기본값으로, 에이전트마다 1회씩 실행했다. 프롬프트는 아래 세 문단이 전부이고, 실행 전에 고정해 글자 하나도 바꾸지 않았다. 2턴과 3턴은 앞 턴이 끝난 같은 세션에 이어서 줬다. 실행 중에는 한 번도 답하거나 방향을 주지 않았고, 에이전트가 질문으로 턴을 끝내면 다음 턴 프롬프트를 그대로 주기로 했다.

### 프롬프트 전문

**1턴**

> 이 라이브러리가 구형 Android WebView(Chrome 66 수준)와 iOS 13 Safari에서도 애니메이션이 동작하도록 지원 범위를 넓혀 주세요. 그리고 성능 개선에 필요한 것을 모두 찾아서 해결해 주세요. 기존 사용자가 코드 수정 없이 교체할 수 있어야 합니다.

**2턴**

> 이 컴포넌트는 실서비스의 한 화면에서 이렇게 쓰입니다. 접두부와 접미부가 붙은 약 9자리 숫자 하나가 약 1초마다 바뀌고, transformTiming은 900ms입니다. 같은 페이지의 다른 곳에서도 초당 수십에서 수백 회 텍스트가 바뀝니다. 주로 모바일에서 봅니다. 이 조건에서 더 고칠 것이 있으면 고쳐 주세요.

**3턴**

> 그 화면을 데스크톱 Chrome에서 모바일 뷰포트로 에뮬레이션하고 CPU 4배 감속을 걸어 10초 기록했습니다. 두 가지가 보입니다. 첫째, 애니메이션이 재생되는 동안 JavaScript 호출 바깥의 RunTask 바로 아래에 스타일 재계산이 매 프레임 찍히고, 메인 스레드 작업의 대부분이 스타일 재계산입니다. 둘째, 값이 바뀌는 순간의 태스크 하나가 50~60ms이고, 그 안에서 JavaScript 실행 도중에 스타일 재계산과 레이아웃이 10여 번 반복됩니다. 이것을 고쳐 주세요.

### 격리와 사후 감사

에이전트는 linux/amd64 컨테이너에서 돌렸다. 작업 디렉터리와 인증 파일만 넣고 홈 디렉터리는 마운트하지 않았으며, 하네스 설정 디렉터리도 비워 내 전역 지침과 메모리가 들어가지 않게 했다. Claude Code는 `WebSearch`, `WebFetch` 도구를 막았다. 네트워크는 열어 두었다. 막으면 구형 Chromium을 내려받아 돌리는 길까지 막혀 1편의 검증과 비교할 수 없기 때문이다.

포크 저장소, 블로그 글, 포크의 npm 패키지는 모두 공개돼 있어서 실행 뒤에 전사 전체를 검사했다. 세 곳에 접근한 흔적은 5종 모두 0건이었다. 포크의 식별자 12개와도 대조했는데, 가장 많이 겹친 fable이 3개(`supportsNativeAnimations`, `measureAdded`, `measureRemoved`)였고 같은 역할의 함수라면 자연스럽게 붙을 이름이라 오염의 근거로 보지 않았다. 이 감사로 확인할 수 있는 것은 전사에 드러난 접근이다. 학습 데이터에 포함됐는지까지 확인한 것은 아니다.

웹 검색 설정을 잘못 적용한 첫 실행 두 건은 제외했다. Codex 0.166.0에서는 설정 키 `web_search`로 검색을 꺼야 했다. 사후 감사에서 astra의 첫 실행에 웹 검색 호출 5건(질의 8건)이 잡혔다. 질의는 WebKit, Chromium 소스, W3C 명세, MDN, developer.chrome.com의 공개 문서였고 포크나 블로그는 없었지만, 미리 정한 조건을 어겼으므로 같은 설정으로 돌린 terra의 첫 실행과 함께 제외하고 `-c web_search="disabled"`로 다시 실행했다. 재실행의 웹 검색은 0건이었다.

막지 못한 경로도 하나 있었다. 실험 스크립트가 턴마다 작업 트리를 내 git 신원(`yceffort`)과 `turn N` 메시지로 커밋했는데, 이 이름은 포크 소유자이자 npm 스코프와 같은 문자열이다. opus는 2턴에 `git log`에서 이 커밋을 1줄, fable은 1턴에 `git status`에서 브랜치명 `agent/E`를 포함해 7줄을 봤다. 이후 포크와 블로그에 접근한 흔적은 없어 정해 둔 폐기 조건에는 해당하지 않았지만, 신원과 실험 프레이밍의 노출이 판단에 영향을 줬을 가능성은 남는다. 또 컨테이너를 턴마다 새로 띄운 탓에 1턴에 설치한 Playwright 브라우저가 2턴에서 사라졌고, 5종 모두 브라우저가 없다는 오류를 만났다. 2턴과 3턴의 소요 시간에는 이 재설치 비용이 들어 있다.

### 대조 항목과 가설

실행 전 문서에서는 아래 목록을 정답지라고 불렀다. 다만 성능 문제와 내가 택한 구현 방법, 검증 절차가 섞여 있다. 특히 7번과 8번은 포크의 설계를 포함하므로 다른 해법이 요구사항을 만족하는지를 판정하는 중립적인 기준은 아니다. 이 글에서는 포크와의 대조 항목으로 사용하며, 항목 번호와 턴별 기록을 함께 남긴다. 7번은 남은 움직임 보존, 8번은 마스크 보정을 메인 스레드에 남기는 선택, 11번은 `flush()` 사전 읽기와 `getAnimations()` 제거를 기준으로 삼았었다.

아래 숫자는 처음 발견한 턴이다. `-`는 발견 기록이 없다는 뜻이다. 동작 변경은 기존 인터럽트 곡선을 새 곡선으로 바꾼 경우이고, JS 합산은 남은 곡선을 JavaScript에서 더하는 구현이다. 곡선의 수치 동등성을 뜻하지는 않는다.

|   # | 대조 항목 (출처)                    | opus           | sonnet         | fable                        | astra          | terra          |
| --: | ----------------------------------- | -------------- | -------------- | ---------------------------- | -------------- | -------------- |
|   1 | 섹션 비교 O(n²) (1편)               | 1              | 1              | 1                            | 1              | 1              |
|   2 | 포매터 재생성 (1편)                 | -              | 2              | 1                            | -              | -              |
|   3 | React 포매터/easing 캐시 상한 (1편) | -              | -              | -                            | 1 (easing만)   | -              |
|   4 | 구형 바이너리 실행 (1편)            | 1 (실바이너리) | 1 (에뮬레이션) | 1 (Chromium 84, Firefox 115) | 1 (실바이너리) | 1 (에뮬레이션) |
|   5 | 폴백 비용 인지 (1편)                | -              | 1              | 2                            | 2              | 2              |
|   6 | 회전 경로의 비합성 (2편)            | 2              | 3              | 3                            | 3              | 3              |
|   7 | 인터럽트 합산 처리 (2편)            | 동작 변경      | 동작 변경      | 동작 변경                    | 3 (JS 합산)    | 동작 변경      |
|   8 | 마스크 비용 (2편)                   | 2 (부분)       | 2              | 3                            | -              | -              |
|   9 | 강제 동기 레이아웃 4곳 (3편)        | 1 (부분), 3    | 1 (부분), 3    | 1                            | 1 (부분), 3    | 1 (부분), 3    |
|  10 | 읽기와 쓰기 단계 분리 (3편)         | 3              | 3              | 1                            | 3              | 3              |
|  11 | `flush()` 사전 읽기 (3편)           | 3              | 3              | -                            | -              | 1              |
|  12 | 순서 회귀 테스트 (3편)              | -              | -              | -                            | 3              | -              |

기록은 항목마다 발견(문제로 적시했는가), 수정(코드에서 실제로 고쳤는가), 증명(스스로 측정해 수치로 보였는가)으로 나눴다. 원래 채점표에서 회피라고 분류한 동작 변경은 본문에서 무엇이 달라졌는지 설명했다. 6번은 채점할 때 자릿수 회전의 커스텀 프로퍼티 경로까지 짚었는지를 기준으로 삼았다. fable의 2턴 폭 경로 지적은 이 집계에서 제외했으므로, 이 집계는 모든 재생 비용의 발견 수가 아니다. 실행 전에 적어 둔 가설 6개와 결과는 아래와 같다.

| 가설                                                                 | 결과                                                                                                             |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 1\~3번은 코드 읽기만으로 찾을 것이다                                 | 부분적으로 맞음. 1번은 5종 모두, 2번은 첫 턴 fable과 둘째 턴 sonnet, 3번은 일부 캐시만 다룸                      |
| 4번은 정적 검사까지만 하고 구형 바이너리를 실행하지는 않을 것이다    | 틀림. opus와 astra가 1턴에 Chromium 66을 실행                                                                    |
| 6번은 지식으로 발견은 하겠지만 측정 없이는 증명하지 못할 것이다      | 부분 틀림. 1턴에는 아무도 짚지 않았지만 opus는 2턴에 스스로 발견하고 측정. 최종적으로 다섯 모두 자기 측정을 제시 |
| 9번은 일부 발견하되 비용 크기를 몰라 수정 범위가 좁을 것이다         | 틀림. fable이 1턴에 9번과 10번을 완결                                                                            |
| 7, 10, 11, 12번은 다른 수정 뒤에 나오는 항목이라 1턴에는 드물 것이다 | 부분적으로 맞음. 10번은 fable, 11번은 terra가 1턴에 찾음                                                         |
| 5번은 폴백 엔진을 만들었다면 언급할 것이다                           | 대체로 맞음. opus만 언급하지 않음                                                                                |

에이전트는 구형 바이너리 실행과 읽기 및 쓰기의 분리에서 예상보다 먼저 도달했다. 회전 경로의 비합성 문제를 첫 턴에 적시한 실행은 없었다. 측정은 다들 했지만, 측정한 조건과 보고를 적용할 수 있는 범위가 일치하지 않는 경우가 있었다.

> 재측정은 에이전트가 다음 환경에서 실행했다. 지원 범위는 공개 API만 쓰는 selftest(오류 없이 로드되는지, 값을 바꾼 뒤 중간 프레임 사이에 숫자가 움직이는지, 최종 표시 값이 `Intl.NumberFormat` 결과와 같은지)로 Chromium 66.0.3359(r540276 스냅샷, 컨테이너), WebKit 16.4(Playwright 1.33.0, macOS), Chromium 151에서 확인했다.
>
> 성능은 [3편의 실험 자료](/demos/number-flow/forced-layout-experiment-2026-11-01.zip)에 있는 재현 페이지로 쟀다. Chromium 151.0.7922.34 헤드리스, 390x844(dsf 3), CPU 4배 감속, 10초 트레이스이고, 접두부와 접미부가 붙은 9자리 숫자를 1초마다 10회 갱신하는 동안 배경 텍스트를 초당 약 182회 바꿨다. 재생 중 재계산은 같은 페이지에서 배경 갱신을 끈 조건과, 여기에 글꼴만 `monospace`로 바꾼 조건으로 따로 쟀다.
>
> 합성 실패는 트레이스의 `Animation` 이벤트를 애니메이션 id별로 묶어 세었고, 실패한 대상 요소는 이벤트의 노드 이름으로 갈랐다. 빌드마다 3회씩 실행 순서를 돌려 가며 Apple M5에서 측정했고, 번들 7종은 모두 esbuild 0.28.2, es2019 타깃으로 같은 방식으로 만들었다. 실행별 값의 범위가 겹치지 않을 때만 차이가 있다고 판정했다.

### 글꼴 조건별 원자료 요약

| 빌드     | 폭이 그대로일 때 재생 중 재계산(회/s) | compositeFailed | 폭이 바뀔 때 재생 중 재계산(회/s) | compositeFailed |
| -------- | ------------------------------------: | --------------: | --------------------------------: | --------------: |
| 업스트림 |                                  48.9 |           92/92 |                              49.2 |         219/219 |
| opus     |                                   5.0 |           0/521 |                               5.2 |           0/668 |
| sonnet   |                                   5.1 |          20/541 |                              49.9 |         147/668 |
| astra    |                                   4.9 |           10/92 |                              50.1 |         137/219 |
| terra    |                                   5.1 |           0/521 |                              50.1 |         147/668 |
| fable    |                                   4.1 |           0/521 |                              49.2 |         137/658 |
| v0.2.1   |                                   4.4 |          20/541 |                              26.4 |          10/678 |

배경 갱신을 끄고 빌드마다 3회 잰 평균이다. 재계산 횟수는 갱신 태스크와 JavaScript 실행 밖의 `UpdateLayoutTree`를 전체 기록 시간으로 나눈 값이다. 시작과 종료 주변의 처리도 들어가므로 재생 구간만 잘라 0회인지 검사한 자체 테스트와 집계 범위가 다르다. 횟수의 실행별 범위는 모든 빌드에서 초당 1회 안에 들었다.

고정폭 조건에서 sonnet, astra, v0.2.1에 남은 합성 실패는 모두 보이는 변화가 없다는 사유(131072)였다. 폭이 바뀌는 조건의 이동에는 `replace`가 아닌 합성 모드(16)와 같은 요소의 비호환 애니메이션(64), 폭 보정용 커스텀 프로퍼티에는 미지원 속성(8192)이 기록됐다.

| 빌드     | 폭이 바뀔 때 재계산 시간 (ms/s) |
| -------- | ------------------------------: |
| 업스트림 |                           151.7 |
| opus     |                             7.2 |
| sonnet   |                            32.1 |
| astra    |                            51.1 |
| terra    |                            13.0 |
| fable    |                            80.1 |
| v0.2.1   |                            17.7 |

폭이 바뀌는 조건에서 합성 실패 비율은 sonnet, terra, fable이 약 21~22%, astra가 약 63%였다. 애니메이션 총수가 달라 비율을 성능 점수로 쓰지 않았다. fable 전사에서 `tabular-nums`를 켠 측정 페이지 주소는 32회, 끈 주소는 6회 등장했다. 주소 등장 횟수는 측정 횟수가 아니다.

### 호환성 확인 범위

실제 Chromium 66에서 업스트림은 애니메이션을 끈 채 표시됐고, terra는 로드 직후 예외가 났다. 나머지 다섯 빌드는 기본 로드와 움직임, 최종 값 검사를 통과했다. iOS 13 실기기는 실행하지 않았다.

opus, sonnet, fable은 업스트림 배포물의 `??`와 `?.` 문법을 보고 빌드 타깃도 낮췄다. 포크도 es2019 타깃이지만 1편에는 적지 않았던 부분이다. 이번 비교 번들은 모두 es2019로 다시 만들었으므로 각 에이전트의 배포 설정 자체를 검증한 것은 아니다. fable은 `AbortController` 의존도 없앴으나 Chrome 64와 65에서 다시 실행하지는 않았다.

캐시도 확인 범위가 달랐다. astra는 easing 캐시를 32개로 제한했고 sonnet은 코어 포매터 캐시에 상한을 뒀다. React 래퍼의 포매터 캐시는 다섯 빌드 모두 그대로 남겼다.

### 투입

| 에이전트 | 벽시계 | 도구 호출 | 출력 토큰 | 비용 추정 |    변경 줄 |
| -------- | -----: | --------: | --------: | --------: | ---------: |
| opus     |  107분 |       196 |      294k |   \$18.13 | +1022/-315 |
| sonnet   |   40분 |        95 |      122k |    \$3.08 |  +633/-113 |
| fable    |  101분 |       168 |      344k |   \$36.37 | +1207/-308 |
| astra    |   93분 |       163 |      125k |         - |  +1407/-95 |
| terra    |   11분 |        57 |       25k |         - |   +339/-73 |

비용은 Claude Code가 보고한 추정치이고 구독 계정이라 실제 청구액이 아니다. Codex는 비용을 보고하지 않는다. 폐기한 Codex 첫 실행 두 건(astra 91분, terra 9분)은 넣지 않았다. 투입과 결과는 비례하지 않았다. terra는 11분 만에 모던 Chromium에서 opus, sonnet과 비슷한 갱신 성능에 닿았지만 구형 지원은 실패했고, fable은 가장 많은 토큰으로 코드 읽기만의 도달 범위가 가장 넓었다. 내 작업 시간은 재지 않았으므로 이 표에 나란히 놓지 않았다.

### 한계

에이전트마다 한 번씩 돌렸으므로 다시 돌리면 다른 결과가 나올 수 있고, 결과는 2026년 11월 2일의 특정 하네스와 모델 버전에 묶여 있다. 대조 항목을 만든 사람이 채점했으므로 내 설계에 유리하게 평가했을 가능성이 있다. 예상을 미리 적고 빗나간 결과도 남겼지만, 기준 자체가 내 설계에 치우쳤을 수 있다.

에이전트는 샌드박스만 있었고 나는 서비스가 있었다는 비대칭은 설계이지만, 서비스 맥락을 차례로 받는 조건에서 나온 결과다. 이를 에이전트가 할 수 있는 일의 상한으로 볼 수는 없다.

재측정은 Chromium 헤드리스와 macOS용 WebKit 빌드에서 했고 실기기는 없었다. 인터럽트 때의 움직임은 정성적으로 살펴봤고 위치 오차를 수치로 재지는 않았다. astra의 합산 구현과 가짜 시계의 일부 연속성 검사는 확인했지만 원본과 전체 곡선이 같은지는 검증하지 않았다.

프롬프트 문안은 하나였고, 2턴에 측정하라는 지시를 더한 조건은 시험하지 않았다. fable은 다른 넷을 돌린 뒤에 피험자로 추가했다. 프롬프트와 대조 항목은 그대로였지만 사전 등록 뒤의 변경이다. 사람이 한 번도 끼어들지 않는 조건은 실제로 에이전트를 쓰는 방식보다 엄격해서, 이 결과를 사람과 함께 일할 때로 일반화하면 안 된다.
