초당 수백 건이라고 썼던 시세 메시지를 세어보니 평균 36건이었다
거래소 WebSocket 글에서 rAF로 렌더링을 묶었다고 썼는데 숫자가 하나도 없었다. 실제 시세를 녹화해서 다시 흘려보내며 재보니 평소에는 차이가 거의 없었고, 느린 폰과 바쁜 장이 겹칠 때 7.6fps와 37.6fps로 갈렸다.
#성능최적화 #WebSocket #React #requestAnimationFrame #Playwright
거래소 WebSocket 두 개를 붙였다가 화면이 죽은 글 을 다시 읽다가 좀 민망해졌다. 업비트 시세가 초당 수백 건씩 들어온다고 써놨고, 메시지마다 dispatch하던 걸 requestAnimationFrame으로 묶어서 해결했다고 써놨다. 근데 글 어디에도 잰 숫자가 없다. 초당 몇 건이 오는지도, 묶고 나서 얼마나 나아졌는지도 없다. 요즘 다른 글에서는 고치기 전에 먼저 재라고 계속 써왔는데, 정작 이 글은 그렇게 안 했던 거다. 그래서 이번에 처음부터 다시 재봤다. 결과부터 말하면 초당 수백 건은 틀린 말이었고, rAF로 묶은 효과도 생각했던 것과 모양이 달랐다. 메시지가 실제로 몇 건 오는지부터 업비트의 원화 마켓 전체를 구독해놓고 60초 동안 초 단위로 메시지 수를 셌다. 원화 마켓은 잰 날 기준으로 295개였다. markets 295 seconds 59 avg/s 35.8 median 33 max 79 min 15 max msgs in one 16ms window 175 top codes [ [ 'KRW-RSR', 301 ], [ 'KRW-USDT', 153 ], [ 'KRW-VVV', 105 ], [ 'KRW-BTC', 91 ], [ 'KRW-W', 85 ] ] 평균 35.8건, 제일 많은 1초에도 79건이었다. 초당 수백 건과는 거리가 멀다. 평일 낮에 잰 거라 장이 크게 움직이는 날은 더 많겠지만, 평소 기준으로는 한 자릿수가 틀렸던 셈이다. 그리고 메시지가 고르게 오지도 않았다. 한 종목이 혼자 301건을 보냈고 비트코인은 91건이었다. 근데 이상한 숫자가 하나 있었다. 16ms 한 칸에 175건이 몰린 순간이 있다. 초당 평균이 36건인데 한 프레임에 175건이 온 거다. 같은 방식으로 60초를 한 번 더 받아서 이번에는 메시지마다 도착 시각을 같이 저장해봤다. recorded 2242 msgs first100ms 296 first1s 318 distinct 295 처음 100ms 안에 296건이 왔다. 종목이 295개니까 구독하자마자 모든 종목의 현재가를 한 번씩 몰아서 보내주는 것 이다. 그다음부터는 초당 30건 남짓으로 잠잠해진다. 77번 글에 쏟아진다고 썼던 게 이 첫 순간이었을 수도 있다. 페이지를 열거나 재연결할 때마다 이 한 번이 반복된다. 재는 방법 진짜 거래소에 붙어서 재면 매번 시세가 달라서 비교가 안 된다. 그래서 위에서 받은 60초짜리 녹화본을 그대로 쓰기로 했다. 메시지 2,242건을 녹화된 시각에 맞춰 다시 흘려보내고, 받는 쪽 코드만 두 가지로 바꿔서 같은 조건에서 비교하는 거다. 하나는 예전 방식인 메시지 하나에 dispatch 하나고, 다른 하나는 지금 방식인 Map 큐에 쌓아두고 프레임마다 한 번만 dispatch하는 것이다. 화면은 따로 흉내 내지 않고 실제 사이트의 스토어, 정렬 훅, 시세 표 컴포넌트를 그대로 가져다 295행을 그리게 했다. // coinSite/bench/main.tsx const receive = (m: Msg) = { const coin = toKrw(m); if (mode === 'each') { store.dispatch(syncKRWPrice2({ code: coin.krwSymbol, updatedCoin: coin })); return; } queue.set(coin.krwSymbol, coin); if (!scheduled) { scheduled = true; requestAnimationFrame(flush); } }; 재생할 때 신경 쓴 게 하나 있다. 실제 WebSocket은 메시지마다 별도의 이벤트로 들어온다. 재생 루프 안에서 밀린 메시지를 한 번에 몰아 처리해버리면 예전 방식도 자연스럽게 묶여버려서 비교가 안 된다. 그래서 메시지마다 MessageChannel로 따로 보내서 각자 다른 태스크로 처리되게 했다. setTimeout으로 하면 브라우저가 중첩 타이머를 4ms 단위로 늦추기 때문에 그것도 피했다. 그리고 렌더링 횟수와 시간을 세려고 React Profiler를 붙였는데, 처음에 전부 0이 나왔다. 운영 빌드의 React는 Profiler를 아예 꺼버린다. vite 설정에서 react-dom/client 를 react-dom/profiling 으로 바꿔 끼워서 해결했다. 브라우저는 playwright로 띄웠다. 화면 크기는 390 x 844로 맞췄고, 폰을 흉내 내려고 CDP로 CPU를 4배 느리게 거는 경우를 따로 돌렸다. 장이 바쁜 날을 가정해서 녹화본을 5배속으로 재생하는 경우도 넣었다. 조건마다 두 번씩 돌려서 평균을 냈다. await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); await page.goto(`http://localhost:${port}/?mode=${mode} speed=${speed}`); await page.waitForFunction(() = window.__result); 결과 CPU 배속 방식 fps p95 프레임 50ms 넘은 프레임 렌더 횟수 렌더 시간 1x 1x each 58.6 16.8ms 5 2242 4,593ms 1x 1x raf 58.0 16.8ms 6 967 2,751ms 1x 5x each 56.6 16.8ms 6 2242 2,217ms 1x 5x raf 55.6 33.3ms 2 436 802ms 4x 1x each 54.6 16.8ms 34 2242 6,946ms 4x 1x raf 54.1 33.3ms 14 917 3,322ms 4x 5x each 7.6 292ms 98 2242 6,704ms 4x 5x raf 37.6 33.4ms 14 259 1,078ms 먼저 보이는 건 평소에는 차이가 거의 없다 는 거다. 노트북 CPU 그대로에 실제 속도로 재생하면 예전 방식이 58.6fps, 지금 방식이 58.0fps다. 렌더링 횟수는 2,242번에서 967번으로, 렌더링에 쓴 시간도 4.6초에서 2.8초로 줄었지만 화면에서 느낄 수 있는 차이는 아니다. 초당 36건이면 예전 방식으로도 충분히 버텼다는 뜻이다. 차이가 벌어지는 건 CPU를 4배 느리게 걸었을 때부터다. 실제 속도에서도 50ms 넘게 걸린 프레임이 34개에서 14개로 줄었다. 그리고 느린 CPU에 5배속이 겹치면 완전히 갈린다. 예전 방식은 7.6fps까지 떨어졌고, 12.3초짜리 녹화본을 재생하는 데 14.8초가 걸렸다. 메시지를 처리하는 속도가 들어오는 속도를 못 따라가서 뒤로 밀린 거다. 시세가 2초 넘게 늦게 보인다는 뜻이다. 지금 방식은 같은 조건에서 37.6fps였고 재생도 제시간에 끝났다. 그러니까 77번 글에 쓴 화면이 죽었다는 건 느린 기기에서 장이 바쁠 때는 맞는 말이었다. 다만 늘 그런 건 아니었고, 평소에 rAF가 해주는 일은 화면을 살리는 것보다 CPU를 덜 쓰게 하는 쪽에 가까웠다. 예상 못 한 숫자 하나 표를 보다가 걸리는 게 하나 있었다. p95 프레임 시간이 지금 방식에서 오히려 나빠졌다. 예전 방식은 대부분 16.8ms인데, 지금 방식은 5배속이나 느린 CPU에서 33.3ms가 된다. 프레임 100개 중 5개 이상이 한 프레임을 놓쳤다는 뜻이다. 이유를 생각해보면 당연하다. 예전 방식은 메시지가 올 때마다 조금씩 렌더링하니까, 그 일이 프레임과 프레임 사이 빈 시간에 흩어져서 처리된다. 지금 방식은 그 일을 모아서 화면을 그리기 직전의 rAF 콜백 안에서 한 번에 한다. 총량은 줄었는데, 하필 제일 바쁜 순간에 몰아서 하는 거다. 그 한 번이 16ms 안에 안 끝나면 그 프레임은 그대로 밀린다. 그래서 배치가 공짜는 아니었다. 메시지가 적을 때는 흩어서 처리하는 게 프레임 하나하나에는 더 부드럽고, 메시지가 많아질수록 묶는 쪽이 압도적으로 유리하다. 이 사이트는 느린 폰에서 바쁜 장을 보는 경우를 버텨야 하니까 지금 방식을 유지하는 게 맞다. 다만 77번 글의 설명처럼 무조건 좋아지는 변경은 아니었다. 그래서 다음은 77번 글 마지막에 코인 200개를 한 화면에 띄우면 rAF로 묶어도 프레임이 밀린다고 쓰고 다음은 가상 스크롤이라고 적어뒀다. 그때는 감이었는데 이번에 숫자로 확인했다. 느린 CPU에 5배속에서 지금 방식도 37.6fps다. 묶어서 렌더링 횟수를 줄이는 건 이미 할 만큼 했고, 남은 건 한 번 렌더링할 때 그리는 양이다. 지금은 화면에 보이는 건 그중 일부인데도 295행을 전부 그린다. 이번에 만든 벤치마크는 coinSite/bench 에 남겨뒀다. 녹화본과 실행 스크립트가 같이 있어서 npm run bench 만 돌리면 같은 표가 다시 나온다. 가상 스크롤을 붙이고 나면 같은 녹화본으로 다시 재서 이 표 옆에 나란히 놓을 생각이다. 성능을 고쳤다고 써둔 글이 있다면, 그때 정말 숫자를 쟀는지 한 번 다시 보길 추천한다. 나는 고친 방향은 맞았는데, 고친 이유로 들었던 숫자가 열 배쯤 부풀려져 있었다. 그리고 고치기 전과 후를 비교할 때는 실제 데이터를 녹화해서 같은 입력으로 돌리는 게 제일 확실했다. 매번 다른 시세로 재면 무엇이 달라져서 숫자가 바뀐 건지 알 수가 없다.