거래소 WebSocket 두 개를 동시에 붙였더니 화면이 죽었다 — dispatch를 requestAnimationFrame으로 묶은 이야기
초당 수백 건 들어오는 시세를 Map 큐와 rAF로 배치 처리한 과정, 그리고 안 골랐던 대안들
#WebSocket #React #Redux #성능최적화 #requestAnimationFrame
거래소 WebSocket 두 개를 동시에 붙였더니 화면이 죽었다 — dispatch를 requestAnimationFrame으로 묶은 이야기 김치프리미엄을 보려면 국내 시세랑 해외 시세가 동시에 필요하다. 업비트에서 원화 가격을 받고, 바이낸스에서 달러 가격을 받아서 그 차이를 계산하는 거다. 그래서 WebSocket을 두 개 동시에 열었다 . 붙이는 것 자체는 쉬웠다. 문제는 붙이고 나서였다. 코인 200개쯤 구독해두면 메시지가 초당 수백 건씩 들어온다. 처음 만든 대로 두니까 스크롤이 끊기고, 노트북 팬이 돌기 시작했다. 이 글은 그걸 고친 과정이다. 결론부터 말하면 메시지를 받을 때마다 렌더링하지 않고, 한 프레임에 한 번만 몰아서 렌더링하도록 바꿨다. 처음 코드 — 메시지 하나에 dispatch 하나 제일 자연스러운 코드는 이거다. 메시지가 오면 파싱해서 스토어에 넣는다. ws.onmessage = (e) = { const coin = JSON.parse(e.data); dispatch(syncKRWPrice2({ code: coin.code, updatedCoin: coin })); }; reducer도 딱 한 줄이라 문제 없어 보인다. syncKRWPrice2: (state, action) = { const { code, updatedCoin } = action.payload; state.coins[code] = updatedCoin; }, 그런데 이게 초당 300번 실행되면 얘기가 달라진다. dispatch 한 번은 리듀서 실행 + 스토어 갱신 + 구독한 컴포넌트 리렌더까지를 다 부른다. 초당 300번이면 리렌더도 초당 300번이다. 화면은 초당 60번밖에 못 그린다. 나머지 240번은 아무도 보지 못하는 중간 상태를 계산하느라 CPU를 쓴 것 이다. 그것도 메인 스레드에서. 생각해본 방법 세 가지 고칠 방법을 세 개 놓고 비교했다. 장점 단점 1. setInterval 스로틀 구현이 제일 쉽다 화면 주기와 안 맞음 (100ms마다 flush) 간격을 내가 정할 수 있다 탭이 숨어도 계속 돈다 2. 상태를 컴포넌트로 Redux 부하가 사라짐 컴포넌트가 200개면 내리기 구독 단위가 잘게 쪼개짐 구독도 200개 3. Map 큐 + rAF 화면 주기랑 정확히 맞음 코드가 한 겹 늘어남 (선택) 탭 숨으면 자동으로 멈춤 같은 심볼 자동 중복 제거 1번은 제일 만만한데, 100ms가 화면 갱신 주기(약 16.7ms)랑 아무 상관이 없다. 어떤 프레임은 두 번 갱신되고 어떤 프레임은 안 되는 식으로 어긋난다. 무엇보다 사용자가 다른 탭을 보고 있어도 계속 돈다 . 시세 화면은 켜두고 딴짓하는 경우가 많은데, 그동안 배터리를 계속 먹는다. 2번은 근본적이긴 한데 구조를 다 뒤집어야 해서 접었다. 3번을 골랐다. requestAnimationFrame은 브라우저가 실제로 화면을 그리기 직전에 딱 한 번 불러준다 . 그리고 탭이 백그라운드로 가면 브라우저가 알아서 호출을 멈춘다. 내가 신경 쓸 게 없다. 고친 코드 — 큐에 쌓고 프레임마다 한 번만 비운다 메시지가 오면 스토어에 넣지 않고 Map에 쌓아만 둔다 . 그리고 다음 프레임에 한 번에 비운다. const krwUpdateQueue = new Map(); let rafScheduled = false; const flushUpdates = () = { if (krwUpdateQueue.size 0) { const batch = {}; krwUpdateQueue.forEach((updatedCoin, code) = { batch[code] = updatedCoin; }); dispatch(syncKRWPriceBatch(batch)); // dispatch 한 번 krwUpdateQueue.clear(); } rafScheduled = false; }; const scheduleUpdate = () = { if (!rafScheduled) { rafScheduled = true; requestAnimationFrame(flushUpdates); } }; 핵심은 rafScheduled 플래그 다. 이게 없으면 메시지 300개에 rAF 예약을 300번 건다. 플래그로 한 프레임에 예약은 딱 하나만 걸리게 막았다. 메시지 핸들러는 이제 큐에 넣고 예약만 건다. krwUpdateQueue.set(coinSymbol, { krwSymbol: coinSymbol, krwprice: updatedCoins.trade_price, prevPrice: updatedCoins.prev_closing_price, change: updatedCoins.change, changePercent: updatedCoins.change_rate * 100, ... }); scheduleUpdate(); reducer도 하나씩이 아니라 통째로 받도록 바꿨다. syncKRWPriceBatch: (state, action) = { const updates = action.payload; for (const code in updates) { state.coins[code] = updates[code]; } }, 배열이 아니라 Map을 쓴 이유 큐를 배열로 만들었으면 이렇게 됐을 거다. // 배열이면 — 같은 코인이 한 프레임에 5번 오면 5개가 다 쌓인다 [BTC(9,700만), BTC(9,701만), BTC(9,700만), BTC(9,702만), BTC(9,701만)] → 앞의 4개는 화면에 한 번도 안 보이고 버려질 값이다 Map에 심볼을 키로 넣으면 같은 코인이 몇 번 오든 마지막 값 하나만 남는다 . 중복 제거를 자료구조가 알아서 해준다. 비트코인처럼 거래가 활발한 종목은 한 프레임에 여러 번 오는 게 흔해서, 이것만으로도 dispatch에 실리는 데이터가 줄었다. 덤으로 붙은 문제 — 연결이 조용히 죽는다 렌더링을 고치고 나니 다른 게 보였다. 한참 켜두면 시세가 그냥 멈춰 있었다 . 에러도 안 나고 그냥 안 움직인다. 원인은 두 개였다. 업비트는 약 120초 동안 아무것도 안 주고받으면 연결을 끊는다 . 그리고 거래소는 같은 IP에서 짧은 시간에 연결이 몰리면(새로고침 연타 같은 것) 일시적으로 거부한다. 그래서 재연결을 하는 소켓 래퍼를 따로 뺐다. const scheduleReconnect = () = { if (disposed || retryTimer !== null) return; // 1s, 2s, 4s ... 최대 30s. 절반은 고정, 절반은 지터로 동시 재접속을 흩는다. const base = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(failures - 1, 5)); const delay = base / 2 + Math.random() * (base / 2); retryTimer = setTimeout(() = { retryTimer = null; open(); }, delay); }; 지터를 섞은 건 여러 탭이 동시에 끊겼을 때 똑같은 타이밍에 재접속하면 또 거부당하기 때문 이다. 고정 간격으로 재시도하면 거부 상황을 스스로 악화시킨다. 유휴 끊김은 PING으로 막았다. const upbitConn = createReconnectingSocket({ url: 'wss://api.upbit.com/websocket/v1', onOpen: (ws) = { // 재연결될 때마다 구독을 다시 걸어야 시세가 다시 흐른다 ws.send(JSON.stringify([ { ticket: 'kimchi-premium-check' }, { type: 'ticker', codes: coinNames }, { format: 'DEFAULT' }, ])); }, pingIntervalMs: 60_000, pingPayload: 'PING', }); 여기서 제일 많이 헤맨 게 onOpen에서 구독을 다시 보내는 것 이었다. 처음엔 연결할 때 한 번만 구독을 보냈는데, 재연결되면 소켓은 살아있는데 구독이 없어서 아무것도 안 왔다. 연결은 됐는데 데이터가 안 오는 상태가 제일 디버깅하기 나쁘다. 에러를 바로 보여주지 않은 것 처음엔 연결이 끊기면 곧바로 배너를 띄웠다. 그랬더니 지하철에서 배너가 계속 깜빡였다 . 잠깐 끊겼다 붙는 건 흔한 일인데 그때마다 알리니까 오히려 불안해 보였다. // 연결이 잠깐 끊기는 건 흔하다. 몇 번 연속 실패했을 때만 사용자에게 알린다. const FAILURES_BEFORE_WARNING = 3; const handleStatus = (label) = (connected, failures) = { if (connected) { onError(''); } else if (failures = FAILURES_BEFORE_WARNING) { onError(`${label} 실시간 시세 연결이 불안정합니다. 자동으로 다시 연결하는 중입니다...`); } }; 그리고 바이낸스 소켓은 아예 배너를 안 띄우게 했다. 바이낸스는 김프 계산용 달러 시세에만 쓰이니까, 잠깐 끊겨도 사용자가 알아야 할 이유가 없다. 끊긴 걸 다 알리는 게 친절한 게 아니었다 . 사실 이게 처음이 아니었다 이 글을 쓰면서 git 로그를 뒤져봤는데, 소켓을 처음 붙인 게 2024년 7월이었다. 그날 커밋이 이렇게 남아 있다. 2024-07-22 socket 2024-07-22 test 2024-07-22 error 2024-07-22 error message 2024-07-22 socket 커밋 메시지가 test, error다. 뭘 하고 있었는지 지금 봐도 모르겠다. 그때는 연결만 되면 된다고 생각했고, 화면이 버티는지는 신경도 안 썼다. 그러다 2025년 11월 12일에 delete: fetching api and change to socket 이라는 커밋으로 폴링을 걷어냈고, 거기서부터 이 글의 문제가 시작됐다. 메시지가 실제로 쏟아지기 시작한 게 그때였으니까. 지금도 완전히 끝난 건 아니다. 코인 200개를 한 화면에 띄우면 rAF로 묶어도 프레임이 밀린다. 가상 스크롤로 보이는 행만 그리는 걸 붙여야 하는데 아직 안 했다.