또 하나의 성능 도구를 만들었다
성능 도구는 이미 차고 넘친다. Chrome DevTools의 Performance 탭은 프레임 단위로 메인 스레드를 까볼 수 있고, React DevTools Profiler는 어떤 컴포넌트가 몇 번 리렌더됐는지 보여준다. web-vitals 라이브러리는 Core Web Vitals를 정확히 집계해준다. 그런데 나는 react-perfscope라는 걸 또 만들었다.
이유는 하나였다. 기존 도구는 전부 내 앱 바깥에 있다. 버그는 내 화면 안에서, 내가 어떤 버튼을 누른 그 순간에 일어나는데, 측정은 다른 탭, 다른 창에서 한다. 문제를 재현하고 → 탭을 전환하고 → 기록을 멈추고 → 그래프를 다시 읽는 사이에, 방금 그 '순간'은 이미 흘러가 버린다.
react-perfscope의 출발점은 그 거리를 없애는 거였다. 측정 도구를 측정 대상 안으로 집어넣는 것. import 한 줄이면 앱 위에 작은 패널이 떠서, 강제 리플로우, 레이아웃 시프트, 롱 태스크, 웹 바이탈, 네트워크, 인터랙션, React 렌더를 그 자리에서 기록한다.
// entry 파일 맨 위에 (예: src/main.tsx)
import 'react-perfscope/auto'설정 파일도, 프로바이더도, 래핑도 없다. 이 한 줄로 끝난다는 게 react-perfscope가 내건 약속이다. 그런데 이 약속을 지키는 게 생각보다 어려웠다.
import 한 줄 뒤에 숨은 타이밍
react-perfscope/auto는 사이드 이펙트 import다. 모듈을 불러오는 것만으로 전체 UI가 부팅된다. 문제는 이게 반드시 react-dom보다 먼저 와야 한다는 거다.
React는 컴포넌트 렌더 정보를 __REACT_DEVTOOLS_GLOBAL_HOOK__이라는 전역 객체로 흘려보낸다. React DevTools 확장이 컴포넌트를 들여다보는 것도 이 통로를 통해서다. 그런데 React는 react-dom이 평가되는 그 시점에 이 훅을 한 번 캡처한다. 즉 훅을 가로채려면 react-dom이 로드되기 전에 내 훅을 심어둬야 한다. 한 박자라도 늦으면 렌더는 영영 안 잡힌다.
여기서 배운 게 있다. "zero-config"는 복잡성을 없앤다는 뜻이 아니다. 사용자에게서 설정을 없앴다는 뜻일 뿐이다. 복잡성은 사라지지 않고 누군가 떠안아야 하는데, 좋은 도구는 그걸 만든 사람 쪽으로 옮긴다. 그래서 react-perfscope는 번들러 플러그인(@react-perfscope/vite, @react-perfscope/webpack)을 따로 제공해서, 사용자가 import 순서를 신경 쓰지 않아도 이 타이밍이 자동으로 보장되게 했다.
직접 마운트를 제어하고 싶은 사람을 위한 수동 API도 남겨뒀다. recorder를 만들고, 필요한 수집기만 골라 끼우고, 원하는 위치에 패널을 띄운다.
import { createRecorder, createRenderCollector, mount } from 'react-perfscope'
const recorder = createRecorder()
recorder.use(createRenderCollector())
const unmount = mount({ recorder, position: 'top-right' })recorder에 collector를 끼우는 이 구조가 패키지 전체의 뼈대다. core 패키지가 recorder와 수집기들을 갖고 있고, react 패키지가 렌더 수집기를, ui 패키지가 패널을 담당한다. 메타 패키지인 react-perfscope는 이 셋을 한데 묶어 다시 내보낸다. 각 조각이 한 가지 일만 하고 잘 정의된 인터페이스로만 이어져 있으니까, 수집기 하나를 고쳐도 나머지가 안 흔들린다.
숫자가 아니라 사건
처음엔 단순하게 생각했다. 측정값을 다 긁어서 패널에 예쁘게 뿌리면 되겠지. INP 312ms, CLS 0.18, 롱 태스크 4건. 숫자는 금방 모였다.
그런데 숫자만 보고 있으면 정작 "그래서 뭘 고치라고?" 같은 질문이 안 풀린다. INP가 나쁘다는 건 알겠는데, 그게 어떤 인터랙션 때문이고, 그 인터랙션이 왜 느렸는지를 숫자는 말해주지 않는다. 지표는 증상만 보여주지, 원인은 말해주지 않는다.
그래서 만든 게 시그널을 에피소드로 묶는 단계다. 흩어진 신호들을 시간순으로 엮어서, "이 클릭 → 이 롱 태스크 → 이 강제 리플로우 → 이 레이아웃 시프트"가 하나의 이야기로 이어지게 한다. 따로 보면 그냥 숫자 네 개지만, 엮어 놓으면 인과가 보인다.
여기에 한 가지를 더 붙였다. 강제 리플로우가 잡히면 그 자리의 콜 스택을 함께 기록하고, 소스맵까지 풀어서 보여준다. 그러면 패널은 "어딘가에서 레이아웃이 thrashing되고 있다"가 아니라 **"이 파일 이 줄이 범인이다"**라고 짚어준다.

스택을 게으르게(lazy) 붙이고 소스맵을 비동기로 해석하는 건, 측정이 앱을 느리게 만들지 않으면서 이걸 해내려는 타협이었다.
이 과정에서 내 생각이 바뀌었다. 성능 도구의 가치는 측정이 아니라 원인을 짚어주는 것(attribution)에 있다. 숫자를 뽑는 건 브라우저 API가 이미 다 해준다. 어려운 건, 그리고 도구가 진짜로 쓸모를 만드는 지점은, 그 숫자를 사용자가 고칠 수 있는 한 줄의 코드로 되돌려주는 데 있다.
측정이 대상을 바꾼다
성능 도구를 만들다 보면 피할 수 없는 모순과 마주친다. 관찰자 효과다. 모든 렌더와 모든 태스크를 후킹하면 그 후킹 자체가 비용이고, 그러면 내가 측정하는 앱은 내가 실제로 배포하는 앱과 미묘하게 달라진다. 온도계가 물의 온도를 바꾸는 것처럼. 도구의 비용을 0으로 만들 수는 없으니, 적어도 그 비용이 결론을 오염시키지 않게 막아야 한다.
첫 번째 장치는, 도구가 절대 자기를 범인으로 지목하지 않는 것이다. self-profiling 수집기는 롱 태스크가 잡히면 그 구간의 자바스크립트 콜 스택을 JS Self-Profiling API로 잘게 샘플링해서, 그 시간을 가장 많이 잡아먹은 사용자 코드 프레임을 찾아낸다. 이때 node_modules도, 프레임워크도, react-perfscope 자신의 코드도 의도적으로 건너뛴다. 그래서 패널이 "여기가 느리다"고 가리키는 곳은 언제나 측정 도구가 아니라 사용자 코드다.
두 번째 장치는 더 단순하고 더 중요하다. 프로덕션에는 절대 따라가지 않는다. react-perfscope/auto는 NODE_ENV가 production이면 아무것도 하지 않고, 번들러 플러그인도 프로덕션 빌드에서 스스로 빠진다. 성능을 재는 도구가 정작 사용자의 성능을 깎아먹으면 앞뒤가 바뀐 거다. 측정은 개발 단계에서 끝나는 게 맞다.
자기를 검증하는 도구
마지막 질문은 이거였다. 이 도구를 내가 어떻게 믿지? 패널에 뜨는 수집기 세트가 실제로 의도한 대로 돌고 있다는 걸 무엇이 보장하나.
흔한 함정이 있다. 테스트는 통과하는데, 테스트가 검증하는 구성이 사용자가 실제로 받는 구성과 다른 경우다. 검사는 다 통과인데 정작 현장에선 다르게 동작하는 것이다.
react-perfscope는 이걸 구조로 막았다. 어떤 수집기들을 켤지 조립하는 일을 createConfiguredRecorder()라는 함수 하나에 단일 소스로 모아뒀다. 그리고 /auto 부트스트랩과 E2E 검증 하네스가 같은 함수를 호출한다. 그래서 검증 하네스는 사용자가 받는 것과 다른 수집기 세트를 절대로 테스트할 수 없다. 둘이 어긋날 가능성이 코드 구조상 아예 막혀 있다.
작은 설계지만, 나는 이게 이 프로젝트에서 가장 마음에 드는 부분이다. "테스트와 실제가 어긋날 수 있다"는 걸 규율로 막는 대신 구조로 막았으니까. 사람이 조심해서 지키는 것보다, 애초에 어긋날 수 없게 만들어놓는 쪽이 훨씬 튼튼하다.
도구를 만들면 문제가 다시 보인다
0.1.0에서 0.7.1까지 오는 동안 바뀐 건 기능 목록이 아니었다. 성능이라는 문제를 보는 내 방식이 바뀌었다.
전에는 성능을 '숫자'로 봤다. LCP 몇 초, 번들 몇 KB, 리렌더 몇 회. 도구를 만들고 난 지금은 '사건의 연쇄'로 본다. 어떤 입력이 어떤 작업을 깨우고, 그게 어떻게 메인 스레드를 붙잡고, 그 끝에 사용자가 무엇을 느끼는가. 숫자는 그 연쇄의 한 단면일 뿐이다.
도구를 만든다는 건 결국 그 문제를 가장 깊이 이해하는 방법이었다. 강제 리플로우를 잡는 코드를 짜려면 브라우저가 언제 레이아웃을 다시 계산하는지를 속속들이 알아야 하고, 렌더를 후킹하려면 React가 자기 내부를 어떻게 노출하는지를 알아야 한다. 쓰는 사람일 때는 그냥 지나쳤을 디테일들이, 만드는 사람이 되니 전부 결정해야 할 질문으로 다가왔다.
react-perfscope는 npm과 GitHub에 있다. MIT 라이선스고, 도움이 됐다면 GitHub 스타 하나가 큰 힘이 된다. 그런데 이 글이 정말 남기고 싶은 건 도구 자체보다, 무언가를 직접 만들어보면 그 분야가 다르게 보인다는 그 흔하고도 진부한 사실이다. 진부한데, 매번 직접 겪어봐야만 알게 된다.