거래소 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} 실시간 시세 연결이 불안정합니다. 자동으로 다시 연결하는 중입니다...`);
  }
};

그리고 바이낸스 소켓은 아예 배너를 안 띄우게 했다. 바이낸스는 김프 계산용 달러 시세에만 쓰이니까, 잠깐 끊겨도 사용자가 알아야 할 이유가 없다. 끊긴 걸 다 알리는 게 친절한 게 아니었다.

고치고 난 지금 화면이다. 업비트와 바이낸스 두 소켓이 동시에 붙어 있고, 우측 하단에 두 거래소 연결 상태가 표시된다. 김프 수치는 실시간으로 바뀐다.

거래소 WebSocket 두 개를 동시에 붙였더니 화면이 죽었다. dispatch를 requestAnimationFrame으로 묶은 이야기 이미지 1

사실 이게 처음이 아니었다

이 글을 쓰면서 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로 묶어도 프레임이 밀린다. 다음은 가상 스크롤이다. 화면에 실제로 보이는 행만 그리면 코인이 몇 개로 늘어나든 한 프레임에 그리는 양이 일정해진다.