1. 작업 내용
백엔드가 100ms마다 로봇 1,000대의 위치를 WebSocket으로 밀어넣습니다. 이번 주는 그걸 React가 어떻게 받아서 그리느냐입니다.
로봇 1,000대 × 초당 10 tick = 초당 1만 건의 상태 변화. 이걸 React에 그대로 흘리면 어떻게 되는지가 출발점이었습니다.
- Vite + React 19 + TypeScript + Jotai + Tailwind 세팅
- WS 수신 파이프라인 (전송 계층과 이벤트 버스 분리)
- 렌더 전략 3종을 나란히 구현 — 하나를 고르려는 게 아니라 셋을 재려고
- 리렌더 측정 벤치마크 (브라우저 없이
pnpm bench 하나로 재현)
|
전략 |
| A |
useState에 로봇 배열 통째로 — LLM에게 시키면 나오는 코드 |
| B |
Jotai atomFamily — 로봇당 원자 분리 |
| C |
B + 가상화 — 화면 밖 행은 아예 만들지 않음 |
WebSocket 연결은 **React 바깥(모듈 수준)**에 뒀습니다. 변형을 바꿀 때마다 컴포넌트가 언마운트되는데, 그때마다 재연결되면 재연결 직후의 첫 프레임 지연이 측정에 섞입니다. 그리고 전송(WebSocket)과 이벤트 버스를 분리했습니다 — 벤치마크가 소켓 없이 같은 경로로 스냅샷을 흘릴 수 있어야 재현 가능한 측정이 되기 때문입니다. 측정을 위해 코드를 비튼 게 아니라 원래 있어야 할 경계입니다.
2. Deep Dive 해본 것
가설: "리렌더 폭발은 상태를 원자 단위로 쪼개면 풀린다"
useState에 배열을 통째로 넣으면 로봇 1대가 움직여도 전체가 리렌더됩니다. Jotai atomFamily로 로봇당 원자를 만들면 바뀐 로봇만 리렌더될 것이다 — 이게 Jotai를 쓰는 표준적인 이유고, 저도 그렇게 예상했습니다.
측정 방법 — 두 가지를 따로 잰다
React DevTools Profiler는 손으로 켜고 끄고 캡처해야 합니다. 3회 중앙값을 재려면 사람이 30번 클릭해야 하고, 스크린샷은 다른 사람이 재현할 수 없습니다. <Profiler> 콜백으로 같은 데이터를 모으면 명령 하나로 끝납니다.
|
무엇을 답하나 |
커밋 횟수·시간 (<Profiler>) |
얼마나 느린가 |
| 행 렌더 호출 횟수 (직접 카운트) |
왜 느린가 |
앞의 것만 보면 "커밋 100번, 총 2.7초"인데 그게 1,000개 행 때문인지 1개 행이 느려서인지 모릅니다. 뒤의 것이 있어야 원인이 갈립니다.
결과 ① — 우리 도메인(전원이 매 tick 이동)
로봇 1,000대 × 100프레임(=10초분) · 100% 이동
| 방식 |
커밋 |
행 렌더 |
프레임당 |
A 대비 |
A. useState 배열 |
100 |
100,000 |
1000.0 |
— |
B. Jotai atomFamily |
100 |
100,000 |
1000.0 |
1.0배 |
| D. A + 가상화 |
100 |
2,700 |
27.0 |
37배 |
| C. B + 가상화 |
100 |
2,700 |
27.0 |
37배 |
B의 행 렌더가 A와 똑같습니다. 100,000 → 100,000.
그리고 D와 C도 똑같습니다. 2,700 → 2,700. D는 가상화만 넣은 것이고 C는
거기에 atomFamily를 얹은 것인데, 차이가 없습니다. 37배는 전부 가상화
몫이었습니다.
처음엔 C(B+가상화)만 재고 "37배"라고 했는데, 그 37배가 가상화 덕인지
atomFamily 덕인지 구분하지 못한 상태였습니다. 변수를 하나씩만 바꿔야
원인이 갈립니다. 그래서 D를 따로 만들었습니다.
· A → D = 가상화의 순수 효과 → 37배
· D → C = atomFamily의 순수 효과 → 1.0배 (없음)
가설이 틀렸습니다. 이유는 도메인에 있습니다 — FleetOps는 모든 로봇이 매 tick 움직입니다. 원자 1,000개가 전부 매번 바뀌니, 원자를 쪼개도 리렌더되는 컴포넌트 수는 하나도 안 줄어듭니다.
자물쇠를 1,000개로 나눴는데 도둑이 1,000개를 다 여는 상황입니다.
B가 아낀 게 하나 있기는 합니다. 부모가 리렌더되지 않습니다. A는 매 tick 리스트 컨테이너가 리렌더되며 <Row> element 1,000개를 새로 만들고 이전 것과 비교(reconciliation)합니다. B는 컨테이너가 manifest(안 바뀜)만 읽으므로 그 비용이 사라집니다. 다만 이게 얼마나 이득인지는 이번 측정으로 말할 수 없습니다 — 아래 「시간은 못 쟀다」 참고.
결과 ② — 조건을 바꾸면 가설이 살아납니다
코드는 한 글자도 안 고치고, 데이터만 "10%만 움직임"으로 바꿨습니다.
| 방식 |
행 렌더 |
프레임당 |
A 대비 |
A. useState 배열 |
100,000 |
1000.0 |
— |
B. Jotai atomFamily |
10,900 |
109.0 |
9.2배 |
| C. B + 가상화 |
2,700 |
27.0 |
37배 |
1.0배가 9.2배가 됐습니다. 바뀐 건 코드가 아니라 데이터의 성질뿐입니다.
atomFamily의 효과는 라이브러리의 성질이 아니라 도메인의 성질입니다.
"몇 %가 동시에 바뀌는가"가 정하고, 그 숫자는 벤치마크가 아니라 내 서비스에 물어봐야 합니다.
채팅 읽음 표시, 알림 뱃지, 폼 필드처럼 일부만 자주 바뀌는 화면이면 atomFamily가 정확히 기대한 만큼 값을 합니다. 우리처럼 전원이 매 tick 바뀌면 아닙니다.
결과 ③ — 실제 승부는 가상화에서 났습니다
37배. 행 렌더가 프레임당 1000 → 27입니다.
화면에 보이는 게 27행뿐이라(600px ÷ 28px + 여유분) 나머지 973행은 만들 이유가 없습니다. 그리고 만들지 않으면:
- 렌더 함수가 실행되지 않고
- DOM 노드가 없어서 브라우저 레이아웃 계산 대상에서 빠지고
- 원자 구독도 안 하므로 그 로봇이 움직여도 아무 일이 안 일어납니다
마지막 항목이 중요합니다. 가상화는 렌더만 줄이는 게 아니라 구독 자체를 줄입니다. B와 C의 차이가 여기서 벌어집니다.
그리고 로봇이 5,000대로 늘어도 27행은 그대로입니다. 화면 크기가 정하지 데이터 크기가 정하지 않으니까요. A는 5,000행이 됩니다.
흔한 오해는 "화면 밖 행을 display:none으로 숨기는 것"인데, 그러면 아무것도 안 아낍니다. DOM도 렌더도 그대로입니다. 아예 만들지 않아야 합니다.
결과 ④ — 재현되는 숫자와 옳은 숫자는 다르다
가상화 효과를 처음엔 200배(프레임당 5행)로 냈습니다. 네 번 돌려서 매번 정확히 5.0이 나왔고, 다른 머신에서도 5.0이 나왔습니다. 완벽히 재현됐는데 전부 틀린 값이었습니다.
원인은 VirtualList가 뷰포트 높이를 el.clientHeight로 구한다는 것이었습니다. 벤치가 도는 happy-dom에는 레이아웃 엔진이 없어서 clientHeight가 항상 0입니다.
높이 0 → 보이는 행 0개 → OVERSCAN 5개만 렌더 → "프레임당 5행"
즉 "화면에 아무것도 안 보이는 상태"를 재고 있었습니다. 에러도 경고도 없었고, 표는 멀쩡히 나왔고, 숫자만 5배 컸습니다.
뷰포트 높이를 명시적으로 주입하도록 고치고 다시 재서 나온 게 위의 37배입니다.
여기서 나온 게 이번 주 가장 큰 교훈입니다.
재현성은 정확성의 증거가 아니라 결정론의 증거입니다.
같은 잘못된 전제 위에서 재면 잘못된 값이 완벽하게 재현됩니다.
그리고 왜 A와 B는 안 틀렸는지가 판단 기준을 줍니다. A·B가 재는 것은 "매 tick 몇 개 컴포넌트가 실행되나"로 레이아웃과 무관합니다. 반면 가상화는 정의상 화면 크기에 의존합니다. 측정값이 환경에 안 좌우되려면 측정 대상 자체가 환경에 안 좌우되어야 하는데, 그걸 구분하지 못한 것이 이번 실수였습니다.
결과 ⑤ — 측정 도구가 측정을 바꾼다
측정을 끝내고 "이거 StrictMode를 켠 채로 잰 것 아닌가"라는 의문이 들었습니다. 코드를 보면 "안 켰다"까지는 알 수 있지만, 켰을 때 얼마나 달라지는지는 재봐야 압니다.
| 방식 |
커밋 |
행 렌더 |
프레임당 |
| A · 구독해제 O · StrictMode 끔 |
100 |
100,000 |
1000.0 |
| A · 구독해제 O · StrictMode 켬 |
100 |
200,000 |
2000.0 |
| A′ · 구독해제 누락 · 끔 |
100 |
100,000 |
1000.0 |
| A′ · 구독해제 누락 · 켬 |
100 |
200,000 |
2000.0 |
구독 해제를 제대로 해도 정확히 2배가 됩니다.
원인은 중복 구독이 아니라 StrictMode가 렌더 함수 자체를 두 번 호출하는 것이었습니다(순수하지 않은 렌더를 드러내려고). 커밋 횟수가 100으로 동일한 게 증거입니다 — 커밋이 2배가 아니라 커밋당 렌더가 2배입니다.
이중 렌더는 개발 모드 전용이라 프로덕션에선 안 일어납니다. 켠 채로 재면 실제 사용자가 겪지 않는 2배를 재게 되므로 벤치에서만 뺍니다. 앱의 StrictMode는 그대로 둡니다 — 버그 탐지기를 끄는 건 문제를 없애는 게 아니라 안 보이게 하는 것이니까요.
그리고 A′(구독 해제 누락)가 A와 같은 숫자가 나와서 누수 재현에 실패했습니다. 마운트가 한 번뿐이고, 리스너가 2개여도 setState 두 번이 한 커밋으로 배칭되기 때문입니다. 실패한 실험이지만 지우지 않았습니다 — 왜 재현이 안 됐는지가 배칭을 이해하는 지점이라서요.
시간은 못 쟀다 — 이 환경에서는
같은 코드, 같은 설정(A · 100% 이동)을 네 번 돌렸습니다.
|
1회 |
2회 |
3회 |
4회 |
편차 |
| 행 렌더 |
100,000 |
100,000 |
100,000 |
100,000 |
0% |
| 총 렌더(ms) |
2723.1 |
4433.1 |
3551.6 |
5603.1 |
2.1배 |
| 최대 커밋(ms) |
169.79 |
360.94 |
227.16 |
880.56 |
5.2배 |
이 정도 편차면 시간으로는 어떤 주장도 못 합니다. 실제로 A와 B의 시간 순위가 실행마다 뒤집혔습니다.
|
1회 |
3회 |
| B 총 렌더 |
2211.5 (A보다 빠름) |
4816.7 (A보다 느림) |
처음엔 1회 결과만 보고 **"atomFamily가 리렌더 1회의 크기를 절반으로 줄였다"**고 썼다가, 3회에서 정반대가 나와 철회했습니다. 부모 리렌더가 사라지므로 크기가 줄었을 것이라는 추론은 여전히 그럴듯합니다. 다만 추론이 그럴듯하다고 측정이 되는 건 아닙니다.
그래서 이 글의 모든 배수는 행 렌더 "횟수" 기준입니다.
시간이 필요한 주장(reconciliation 비용, 60fps 예산 초과 여부)은 전부 브라우저 실측으로 미뤘습니다.
지금 말할 수 있는 건 구조적인 것뿐입니다 — A는 프레임당 1,000개 컴포넌트를 실행하고, C는 27개를 실행한다. "그래서 몇 fps냐"는 브라우저에서 재야 합니다.
조용히 무너지는 최적화는 테스트로 고정했습니다
B가 10% 케이스에서 9.2배를 내는 건 TelemetryBridge의 비교 한 줄 덕분입니다.
if (prev.x === t.x && prev.y === t.y && ...) continue
Jotai는 참조가 바뀌면 구독자를 깨우므로, 이 비교 없이 매번 새 객체를 넣으면 안 변한 로봇까지 리렌더됩니다. 그런데 이 줄을 지워도 타입 에러도 런타임 에러도 안 나고 화면도 똑같습니다. 리렌더만 10배가 됩니다.
실제로 지우고 돌려서 100 → 1,000으로 실패하는 걸 확인했습니다. 실패할 수 있음을 확인해야 의미 있는 테스트입니다. 반대 방향(과하게 걸러내서 아무것도 안 그림)도 두 번째 테스트로 막았습니다 — 첫 테스트만 있으면 "항상 0회 렌더"로도 통과하니까요.
3. 알게된 것
1. 최적화 기법에는 전제가 있고, 그 전제는 도메인이 정한다.
"atomFamily를 쓰면 리렌더가 준다"는 **"일부만 바뀔 때"**라는 전제를 달고 있는 말입니다. 그 전제를 안 보고 도입하면 코드만 복잡해지고 숫자는 안 바뀝니다. 실제로 1.0배(전혀 안 줄어듦)였고, 이동 비율만 10%로 바꾸니 9.2배였습니다. 코드는 한 글자도 안 고쳤습니다.
2. 재현성은 정확성의 증거가 아니다.
가상화 효과를 네 번 재서 매번 "프레임당 5.0행"을 얻었습니다. 다른 머신에서도 5.0이었습니다. 완벽히 재현됐지만 다섯 번 다 틀린 값이었습니다 — 레이아웃이 없는 환경에서 clientHeight가 0이라 "화면에 아무것도 안 보이는" 상태를 재고 있었습니다. 같은 잘못된 전제 위에서 재면 잘못된 값이 완벽하게 재현됩니다.
3. 측정할 수 없는 것은 주장하지 않는다.
부모 리렌더가 사라지므로 리렌더 1회의 크기는 줄었을 것이라고 추론할 수 있습니다. 문제는 그게 시간으로만 드러나고, 이 환경의 시간은 5.2배 흔들린다는 것입니다. 1회 측정만 보고 "절반이 됐다"고 썼다가 3회에서 정반대가 나와 철회했습니다.
4. 측정 대상을 두 개 두면 원인이 갈린다.
커밋 시간만 보면 "느리다"까지밖에 못 말합니다. 행 렌더 횟수를 같이 세니 "1,000개가 다시 실행돼서 느리다"가 됐고, 그러자 해법이 자명해졌습니다.
5. 재현 가능한 측정은 도구가 아니라 구조에서 나온다.
WebSocket 연결을 React 바깥에 두고 전송과 이벤트 버스를 분리한 덕에, 벤치마크가 소켓 없이 같은 경로로 스냅샷을 흘릴 수 있었습니다. 측정하려고 코드를 비튼 게 아니라, 경계를 제대로 그었더니 측정이 따라온 것에 가깝습니다.
6. 측정 도구가 측정을 바꾼다.
StrictMode를 켜면 행 렌더가 정확히 2배가 됩니다. 처음엔 원인을 중복 연결로 잘못 짚었다가, 커밋 횟수는 그대로고 커밋당 렌더만 2배인 걸 보고 알았습니다.
📎 코드·재현 스크립트: One-HyeWon/fleet-ops#3
cd frontend
pnpm bench # 위 표를 그대로 재현합니다 (행 렌더 열이 같게 나오면 성공)
pnpm test # 회귀 테스트 (1.7초)
1. 작업 내용
백엔드가 100ms마다 로봇 1,000대의 위치를 WebSocket으로 밀어넣습니다. 이번 주는 그걸 React가 어떻게 받아서 그리느냐입니다.
로봇 1,000대 × 초당 10 tick = 초당 1만 건의 상태 변화. 이걸 React에 그대로 흘리면 어떻게 되는지가 출발점이었습니다.
pnpm bench하나로 재현)useState에 로봇 배열 통째로 — LLM에게 시키면 나오는 코드atomFamily— 로봇당 원자 분리WebSocket 연결은 **React 바깥(모듈 수준)**에 뒀습니다. 변형을 바꿀 때마다 컴포넌트가 언마운트되는데, 그때마다 재연결되면 재연결 직후의 첫 프레임 지연이 측정에 섞입니다. 그리고 전송(WebSocket)과 이벤트 버스를 분리했습니다 — 벤치마크가 소켓 없이 같은 경로로 스냅샷을 흘릴 수 있어야 재현 가능한 측정이 되기 때문입니다. 측정을 위해 코드를 비튼 게 아니라 원래 있어야 할 경계입니다.
2. Deep Dive 해본 것
가설: "리렌더 폭발은 상태를 원자 단위로 쪼개면 풀린다"
useState에 배열을 통째로 넣으면 로봇 1대가 움직여도 전체가 리렌더됩니다. JotaiatomFamily로 로봇당 원자를 만들면 바뀐 로봇만 리렌더될 것이다 — 이게 Jotai를 쓰는 표준적인 이유고, 저도 그렇게 예상했습니다.측정 방법 — 두 가지를 따로 잰다
React DevTools Profiler는 손으로 켜고 끄고 캡처해야 합니다. 3회 중앙값을 재려면 사람이 30번 클릭해야 하고, 스크린샷은 다른 사람이 재현할 수 없습니다.
<Profiler>콜백으로 같은 데이터를 모으면 명령 하나로 끝납니다.<Profiler>)앞의 것만 보면 "커밋 100번, 총 2.7초"인데 그게 1,000개 행 때문인지 1개 행이 느려서인지 모릅니다. 뒤의 것이 있어야 원인이 갈립니다.
결과 ① — 우리 도메인(전원이 매 tick 이동)
로봇 1,000대 × 100프레임(=10초분) · 100% 이동
useState배열atomFamilyB의 행 렌더가 A와 똑같습니다. 100,000 → 100,000.
그리고 D와 C도 똑같습니다. 2,700 → 2,700. D는 가상화만 넣은 것이고 C는
거기에 atomFamily를 얹은 것인데, 차이가 없습니다. 37배는 전부 가상화
몫이었습니다.
처음엔 C(B+가상화)만 재고 "37배"라고 했는데, 그 37배가 가상화 덕인지
atomFamily 덕인지 구분하지 못한 상태였습니다. 변수를 하나씩만 바꿔야
원인이 갈립니다. 그래서 D를 따로 만들었습니다.
· A → D = 가상화의 순수 효과 → 37배
· D → C = atomFamily의 순수 효과 → 1.0배 (없음)
가설이 틀렸습니다. 이유는 도메인에 있습니다 — FleetOps는 모든 로봇이 매 tick 움직입니다. 원자 1,000개가 전부 매번 바뀌니, 원자를 쪼개도 리렌더되는 컴포넌트 수는 하나도 안 줄어듭니다.
B가 아낀 게 하나 있기는 합니다. 부모가 리렌더되지 않습니다. A는 매 tick 리스트 컨테이너가 리렌더되며
<Row>element 1,000개를 새로 만들고 이전 것과 비교(reconciliation)합니다. B는 컨테이너가 manifest(안 바뀜)만 읽으므로 그 비용이 사라집니다. 다만 이게 얼마나 이득인지는 이번 측정으로 말할 수 없습니다 — 아래 「시간은 못 쟀다」 참고.결과 ② — 조건을 바꾸면 가설이 살아납니다
코드는 한 글자도 안 고치고, 데이터만 "10%만 움직임"으로 바꿨습니다.
useState배열atomFamily1.0배가 9.2배가 됐습니다. 바뀐 건 코드가 아니라 데이터의 성질뿐입니다.
채팅 읽음 표시, 알림 뱃지, 폼 필드처럼 일부만 자주 바뀌는 화면이면 atomFamily가 정확히 기대한 만큼 값을 합니다. 우리처럼 전원이 매 tick 바뀌면 아닙니다.
결과 ③ — 실제 승부는 가상화에서 났습니다
37배. 행 렌더가 프레임당 1000 → 27입니다.
화면에 보이는 게 27행뿐이라(600px ÷ 28px + 여유분) 나머지 973행은 만들 이유가 없습니다. 그리고 만들지 않으면:
마지막 항목이 중요합니다. 가상화는 렌더만 줄이는 게 아니라 구독 자체를 줄입니다. B와 C의 차이가 여기서 벌어집니다.
그리고 로봇이 5,000대로 늘어도 27행은 그대로입니다. 화면 크기가 정하지 데이터 크기가 정하지 않으니까요. A는 5,000행이 됩니다.
흔한 오해는 "화면 밖 행을
display:none으로 숨기는 것"인데, 그러면 아무것도 안 아낍니다. DOM도 렌더도 그대로입니다. 아예 만들지 않아야 합니다.결과 ④ — 재현되는 숫자와 옳은 숫자는 다르다
가상화 효과를 처음엔 200배(프레임당 5행)로 냈습니다. 네 번 돌려서 매번 정확히 5.0이 나왔고, 다른 머신에서도 5.0이 나왔습니다. 완벽히 재현됐는데 전부 틀린 값이었습니다.
원인은
VirtualList가 뷰포트 높이를el.clientHeight로 구한다는 것이었습니다. 벤치가 도는 happy-dom에는 레이아웃 엔진이 없어서clientHeight가 항상 0입니다.즉 "화면에 아무것도 안 보이는 상태"를 재고 있었습니다. 에러도 경고도 없었고, 표는 멀쩡히 나왔고, 숫자만 5배 컸습니다.
뷰포트 높이를 명시적으로 주입하도록 고치고 다시 재서 나온 게 위의 37배입니다.
여기서 나온 게 이번 주 가장 큰 교훈입니다.
그리고 왜 A와 B는 안 틀렸는지가 판단 기준을 줍니다. A·B가 재는 것은 "매 tick 몇 개 컴포넌트가 실행되나"로 레이아웃과 무관합니다. 반면 가상화는 정의상 화면 크기에 의존합니다. 측정값이 환경에 안 좌우되려면 측정 대상 자체가 환경에 안 좌우되어야 하는데, 그걸 구분하지 못한 것이 이번 실수였습니다.
결과 ⑤ — 측정 도구가 측정을 바꾼다
측정을 끝내고 "이거 StrictMode를 켠 채로 잰 것 아닌가"라는 의문이 들었습니다. 코드를 보면 "안 켰다"까지는 알 수 있지만, 켰을 때 얼마나 달라지는지는 재봐야 압니다.
구독 해제를 제대로 해도 정확히 2배가 됩니다.
원인은 중복 구독이 아니라 StrictMode가 렌더 함수 자체를 두 번 호출하는 것이었습니다(순수하지 않은 렌더를 드러내려고). 커밋 횟수가 100으로 동일한 게 증거입니다 — 커밋이 2배가 아니라 커밋당 렌더가 2배입니다.
이중 렌더는 개발 모드 전용이라 프로덕션에선 안 일어납니다. 켠 채로 재면 실제 사용자가 겪지 않는 2배를 재게 되므로 벤치에서만 뺍니다. 앱의 StrictMode는 그대로 둡니다 — 버그 탐지기를 끄는 건 문제를 없애는 게 아니라 안 보이게 하는 것이니까요.
그리고
A′(구독 해제 누락)가A와 같은 숫자가 나와서 누수 재현에 실패했습니다. 마운트가 한 번뿐이고, 리스너가 2개여도setState두 번이 한 커밋으로 배칭되기 때문입니다. 실패한 실험이지만 지우지 않았습니다 — 왜 재현이 안 됐는지가 배칭을 이해하는 지점이라서요.시간은 못 쟀다 — 이 환경에서는
같은 코드, 같은 설정(A · 100% 이동)을 네 번 돌렸습니다.
이 정도 편차면 시간으로는 어떤 주장도 못 합니다. 실제로 A와 B의 시간 순위가 실행마다 뒤집혔습니다.
처음엔 1회 결과만 보고 **"atomFamily가 리렌더 1회의 크기를 절반으로 줄였다"**고 썼다가, 3회에서 정반대가 나와 철회했습니다. 부모 리렌더가 사라지므로 크기가 줄었을 것이라는 추론은 여전히 그럴듯합니다. 다만 추론이 그럴듯하다고 측정이 되는 건 아닙니다.
지금 말할 수 있는 건 구조적인 것뿐입니다 — A는 프레임당 1,000개 컴포넌트를 실행하고, C는 27개를 실행한다. "그래서 몇 fps냐"는 브라우저에서 재야 합니다.
조용히 무너지는 최적화는 테스트로 고정했습니다
B가 10% 케이스에서 9.2배를 내는 건
TelemetryBridge의 비교 한 줄 덕분입니다.Jotai는 참조가 바뀌면 구독자를 깨우므로, 이 비교 없이 매번 새 객체를 넣으면 안 변한 로봇까지 리렌더됩니다. 그런데 이 줄을 지워도 타입 에러도 런타임 에러도 안 나고 화면도 똑같습니다. 리렌더만 10배가 됩니다.
실제로 지우고 돌려서
100 → 1,000으로 실패하는 걸 확인했습니다. 실패할 수 있음을 확인해야 의미 있는 테스트입니다. 반대 방향(과하게 걸러내서 아무것도 안 그림)도 두 번째 테스트로 막았습니다 — 첫 테스트만 있으면 "항상 0회 렌더"로도 통과하니까요.3. 알게된 것
1. 최적화 기법에는 전제가 있고, 그 전제는 도메인이 정한다.
"atomFamily를 쓰면 리렌더가 준다"는 **"일부만 바뀔 때"**라는 전제를 달고 있는 말입니다. 그 전제를 안 보고 도입하면 코드만 복잡해지고 숫자는 안 바뀝니다. 실제로 1.0배(전혀 안 줄어듦)였고, 이동 비율만 10%로 바꾸니 9.2배였습니다. 코드는 한 글자도 안 고쳤습니다.
2. 재현성은 정확성의 증거가 아니다.
가상화 효과를 네 번 재서 매번 "프레임당 5.0행"을 얻었습니다. 다른 머신에서도 5.0이었습니다. 완벽히 재현됐지만 다섯 번 다 틀린 값이었습니다 — 레이아웃이 없는 환경에서
clientHeight가 0이라 "화면에 아무것도 안 보이는" 상태를 재고 있었습니다. 같은 잘못된 전제 위에서 재면 잘못된 값이 완벽하게 재현됩니다.3. 측정할 수 없는 것은 주장하지 않는다.
부모 리렌더가 사라지므로 리렌더 1회의 크기는 줄었을 것이라고 추론할 수 있습니다. 문제는 그게 시간으로만 드러나고, 이 환경의 시간은 5.2배 흔들린다는 것입니다. 1회 측정만 보고 "절반이 됐다"고 썼다가 3회에서 정반대가 나와 철회했습니다.
4. 측정 대상을 두 개 두면 원인이 갈린다.
커밋 시간만 보면 "느리다"까지밖에 못 말합니다. 행 렌더 횟수를 같이 세니 "1,000개가 다시 실행돼서 느리다"가 됐고, 그러자 해법이 자명해졌습니다.
5. 재현 가능한 측정은 도구가 아니라 구조에서 나온다.
WebSocket 연결을 React 바깥에 두고 전송과 이벤트 버스를 분리한 덕에, 벤치마크가 소켓 없이 같은 경로로 스냅샷을 흘릴 수 있었습니다. 측정하려고 코드를 비튼 게 아니라, 경계를 제대로 그었더니 측정이 따라온 것에 가깝습니다.
6. 측정 도구가 측정을 바꾼다.
StrictMode를 켜면 행 렌더가 정확히 2배가 됩니다. 처음엔 원인을 중복 연결로 잘못 짚었다가, 커밋 횟수는 그대로고 커밋당 렌더만 2배인 걸 보고 알았습니다.
📎 코드·재현 스크립트: One-HyeWon/fleet-ops#3