최적화 - buffer를 활용하여 상태 갱신 줄이기

😥 두나무의 요청으로 클론 사이트 배포를 중단하였습니다...
💁‍♂️ 레포 링크

최적화의 중요성

업비트 클론 프로젝트를 진행하기 전엔 최적화에 쓰는 기술은 React.memo나 useCallback 정도를 많이 사용했다.

그리고 최적화를 하면서도 최적화를 하나 안하나 웹 성능 향상이 크게 체감되지 않아서 성능이 좋아졌겠거니 하며 사용했었다.

그런데 업비트 클론을 진행하면서... 최적화를 안하면 웹이 터지거나 성능이 매우 느려질 수 있다는걸 눈으로 확인하게 됐다.

WebSocket에 1초에 150개가 넘는 데이터가 넘어오고, 매번 상태를 변경하면 어떻게 될까?
(사실 아무리 자주 상태를 바꾸더라도 16ms마다 한 번씩 몰아서 업데이트 된다)

React.memo를 사용하지 않아서 매번 모든 엘리먼트를 리랜더링 한다면?

당연히 사이트가 터진다.

리액트를 공부하면서 처음으로 사이트가 터지는걸 보았다.

이 포스트에선 WebSocket으로 넘어오는 데이터가 너무 많을 때 상태 갱신을 줄이는 방법을 적어보겠다.


1차 시도 - Thunk

1초에 150개의 데이터가 넘어온다는 것을 몰랐을 때는 아래와 같이 웹소켓을 만들었었다.

const createConnectSocketThunk = (type, connectType, dataMaker) => {
  const SUCCESS = `${type}_SUCCESS`;
  const ERROR = `${type}_ERROR`;

  return (action = {}) => (dispatch, getState) => {
    const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
    client.binaryType = "arraybuffer";

    client.onopen = () => {
      client.send(
        JSON.stringify([
          { ticket: "downbit-clone" },
          { type: connectType, codes: action.payload },
        ])
      );
    };

    client.onmessage = (evt) => {
      const enc = new TextDecoder("utf-8");
      const arr = new Uint8Array(evt.data);
      const data = JSON.parse(enc.decode(arr));
      const state = getState();

      dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
    };

    client.onerror = (e) => {
      dispatch({ type: ERROR, payload: e });
    };
  };
};

redux-thunk로 만들었는데 Push 방식의 웹소켓을 사용하면서 getState라는 함수로 항상 최신 상태를 가져올 수 있어 immutable을 유지하기 쉽기 때문에 사용했다.

이 방식의 문제점은 웹 소켓에 너무 많은 데이터가 넘어오면 한 번의 데이터 수신마다 매번 상태를 변경한다는 것이다.


2차 시도 - Throttle

그래서 가장 먼저 생각한 것은 throttle을 이용하는 것이었다.

throttle을 이용하면 일정 시간동안 하나의 이벤트만 처리할 수 있을 것이고 한동안 상태 갱신을 막을 수 있을 것이다. 그 코드는 다음과 같다.

import { throttle } from "lodash";

const createConnectSocketThrottleThunk = (type, connectType, dataMaker) => {
  const SUCCESS = `${type}_SUCCESS`;
  const ERROR = `${type}_ERROR`;
  const throttleDispatch = throttle((dispatch, state, data) => {
    dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
  }, 500);

  return (action = {}) => (dispatch, getState) => {
    const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
    client.binaryType = "arraybuffer";

    client.onopen = () => {
      client.send(
        JSON.stringify([
          { ticket: "downbit-clone" },
          { type: connectType, codes: action.payload },
        ])
      );
    };

    client.onmessage = (evt) => {
      const enc = new TextDecoder("utf-8");
      const arr = new Uint8Array(evt.data);
      const data = JSON.parse(enc.decode(arr));
      const state = getState();

      // dispatch({ type: SUCCESS, payload: dataMaker(data, state) });
      throttleDispatch(dispatch, state, data);
    };

    client.onerror = (e) => {
      dispatch({ type: ERROR, payload: e });
    };
  };
};

lodash의 throttle을 이용하여 만들었으나 이 방식에도 문제가 있다.

웹소켓으로 항상 같은 코인 정보만 넘어오는 것이 아니라 여러 코인 정보가 같이 들어오기 때문에 마지막 데이터만 처리할 시 몇몇 코인의 최신 데이터가 누락된다.

쉽게 해결할 수 있을줄 알았는데 전혀 아니었다. 머리가 아파왔다.

결국 throttle로 처리하지 말고, 일정 시간동안 수신된 데이터를 buffer에 쌓아두고 중복되는 데이터를 제거 해야했다.

그렇다면 buffer를 직접 만들어서 처리해야 할까?

다행히도 redux-saga로 WebSocket을 다루면 이 문제를 해결할 수 있다.


3차시도 - Redux-Saga buffers, eventChannel

import { call, put, select, flush, delay } from "redux-saga/effects";
import { buffers, eventChannel } from "redux-saga";

// 소켓 만들기
const createSocket = () => {
  const client = new W3CWebSocket("wss://api.upbit.com/websocket/v1");
  client.binaryType = "arraybuffer";

  return client;
};

// 소켓 연결용
const connectSocekt = (socket, connectType, action, buffer) => {
  return eventChannel((emit) => {
    socket.onopen = () => {
      socket.send(
        JSON.stringify([
          { ticket: "downbit-clone" },
          { type: connectType, codes: action.payload },
        ])
      );
    };

    socket.onmessage = (evt) => {
      const enc = new TextDecoder("utf-8");
      const arr = new Uint8Array(evt.data);
      const data = JSON.parse(enc.decode(arr));

      emit(data);
    };

    socket.onerror = (evt) => {
      emit(evt);
    };

    const unsubscribe = () => {
      socket.close();
    };

    return unsubscribe;
  }, buffer || buffers.none());
};

// 웹소켓 연결용 사가
const createConnectSocketSaga = (type, connectType, dataMaker) => {
  const SUCCESS = `${type}_SUCCESS`;
  const ERROR = `${type}_ERROR`;

  return function* (action = {}) {
    const client = yield call(createSocket);
    const clientChannel = yield call(
      connectSocekt,
      client,
      connectType,
      action,
      buffers.expanding(500)
    );

    while (true) {
      try {
        const datas = yield flush(clientChannel); // 버퍼 데이터 가져오기
        const state = yield select();

        if (datas.length) {
          const sortedObj = {};
          datas.forEach((data) => {
            if (sortedObj[data.code]) {
              // 버퍼에 있는 데이터중 시간이 가장 최근인 데이터만 남김
              sortedObj[data.code] =
                sortedObj[data.code].timestamp > data.timestamp
                  ? sortedObj[data.code]
                  : data;
            } else {
              sortedObj[data.code] = data; // 새로운 데이터면 그냥 넣음
            }
          });

          const sortedData = Object.keys(sortedObj).map(
            (data) => sortedObj[data]
          );

          yield put({ type: SUCCESS, payload: dataMaker(sortedData, state) });
        }
        yield delay(500); // 500ms 동안 대기
      } catch (e) {
        yield put({ type: ERROR, payload: e });
      }
    }
  };
};

소스코드가 훨씬 길어졌지만 요약하면 다음과 같다.

Push 방식의 WebSocket을 Pull 방식으로 바꿔 사용한다!!

.on 메세지로 넘어오는 데이터를 바로 dispatch하는 것이 아니라 emit()으로 buffer에 쌓아두고

일정 시간이 지날때 마다 buffer에 쌓인 데이터들을 가져와서 처리하는 것이다.

이 방법으로 데이터 수신을 할 때마다 매번 상태를 갱신하여 랜더링하던 문제를 해결할 수 있었다.

좋은 웹페이지 즐겨찾기