거래소 WebSocket 두 개를 동시에 붙였더니 화면이 죽었다. dispatch를 requestAnimationFrame으로 묶은 이야기
김치프리미엄을 보려면 국내 시세랑 해외 시세가 동시에 필요하다. 업비트에서 원화 가격을 받고, 바이낸스에서 달러 가격을 받아서 그 차이를 계산하는 거다. 그래서 WebSocket을 두 개 동시에 열었다.
붙이는 것 자체는 쉬웠다. 문제는 붙이고 나서였다. 업비트 ticker 는 체결이 날 때마다 메시지를 보내주는데, 코인 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월 22일이었다. 그날 커밋 메시지가 socket, test, error, error message, socket 순서로 다섯 개 남아 있다. Socket.IO 로 채팅을 붙인 이야기를 쓸 때도 이 로그를 한 번 꺼냈는데, 시세 쪽 소켓도 같은 날 시작이었다.
메시지가 test 랑 error 인 걸 보면 뭘 하고 있었는지 지금 봐도 모르겠다. 그때는 연결만 되면 된다고 생각했고, 화면이 버티는지는 신경도 안 썼다. 그래서 당시엔 소켓을 걷어내고 REST 폴링으로 돌아갔다.
그러다 2025년 11월 12일에 delete: fetching api and change to socket 이라는 커밋으로 폴링을 다시 걷어냈고, 거기서부터 이 글의 문제가 시작됐다. 메시지가 실제로 쏟아지기 시작한 게 그때였으니까. 1년 4개월 만에 같은 자리로 돌아온 셈인데, 이번엔 연결이 되느냐가 아니라 화면이 버티느냐가 문제였다.
지금도 완전히 끝난 건 아니다. 코인 200개를 한 화면에 띄우면 rAF로 묶어도 프레임이 밀린다. 다음은 가상 스크롤이다. 화면에 실제로 보이는 행만 그리면 코인이 몇 개로 늘어나든 한 프레임에 그리는 양이 일정해진다.