Skip to content

소켓이벤트 전송 최적화를 위한 batching web worker 환경 구축(with react)

Myeongseong Choe edited this page Dec 11, 2022 · 2 revisions

문제 상황

현재 boocrum에서 socket에 이벤트를 보내는 방식은 크게 2가지가 있다.

  1. object가 생성, 삭제되거나 상태가 최종 업데이트 되었을 때 보내는 -ed 방식의 이벤트
  2. mouse의 움직임, object의 움직임이나 스케일링과 같은 상태가 업데이트되는 동안 보내는 -ing 방식의 이벤트

후자의 경우 현재 너무 많은 이벤트를 발생시킨다는 문제점이 있다.

image

move pointer 발생 주기

부하테스트 결과, 일정시간동안 많은 양의 소켓 이벤트가 발생할 경우 지연으로 인한 timeout 현상이 발생함을 확인할 수 있다. 이는 지연현상 뿐만 아니라 사용자간 다른 화면을 보게되는 치명적인 오류를 발생할 수 있다. 또한 너무 많은 요청이 소켓을 타서 캔버스에 도착할 경우 한 프레임안에 이 요청들을 다 처리하지 못해 캔버스가 버벅거리는 현상이 발생할 수 있다. 이는 사용자 경험을 크게 저하시킨다.

따라서 클라이언트 → 서버로 보내는 이벤트 수를 최적화할 필요가 있다

개선 목표 선정

  1. 서버로부터 보내는 요청수를 절감한다.
  2. ~ing 이벤트가 다른 사용자가 볼 떄 끊겨서 보이는 사용자 경험의 저하가 발생해서는 안된다.
  3. 모든 사용자는 같은 상태의 화면을 공유해야한다.

즉 서버로 보내는 요청수를 절감하면서도 이것이 부드럽게 움직이는 사용자 경험에 저하를 일으켜서는 안되며 상태의 손실이 일어나 사용자간 다른 화면을 공유해서도 안된다.

개선 방법

debounce vs throtte

이벤트나 함수 들의 실행되는 빈도를 줄이기 위해 가장 먼저 생각해볼법한 방법은 debounce와 throttling이 있다.

둘의 차이는 “특정 시간”를 설정했을 때 다음과 같다

  1. throttling: 특정 시간 동안 요청을 막고 한번만 실행
  2. debounce: 이벤트 발생 시간 이후에 특정 시간 동안 만을 기다리고 기다리는 도중에 이벤트가 발생하면 다시 특정시간 기다렸다가 이벤트가 발생되지 않으면 실행한다.

쉽게 throtte는 이벤트를 일정 시간 동안, 한번만 실행 되도록 만들고

debounce 는 여러번 발생하는 이벤트에서, 가장 마지막 이벤트 만을 실행 되도록 만든다.

필자는 2가지 방법 모두 적합하지 않다고 판단했다

먼저 debounce는 가장 마지막 이벤트만을 실행되도록 하기 때문에 부드러운 움직임을 구현하기에 적합하지 않다.

throtte는 간단하게 요청을 제한할 수 있지만 특정 상태를 손실할 수 있는 위험이 있다. 이는 모든 사용자가 같은 상태의 화면을 보지 못하는 결과를 초래할 위험이 있다.

batch

그래서 buffer에 이벤트 메시지를 저장해놨다가 batch, 일괄 처리 방법을 선택했다.

image

batch 도입 전 아키텍처

image

batch 도입 후 아키텍처

queue(buffer)에 발생한 ~ing 이벤트를 저장하고 일정 주기마다 일괄처리하여 병합된 메시지만 서버로 보내는 방식으로 변경했다.

새로운 문제점

batch는 앞선 debounce와 throttle보다 연산이 훨씬 많아 비싸다. batch로 소켓 이벤트를 효과적으로 줄였으나 batch를 위한 일괄처리 연산이 추가되었다.

브라우저는 이미 canvas렌더링과 server로부터 오는 데이터를 연산하는데 이미 정신이 없는 상태다. 여기에 배치 연산까지 주기적으로 작동하게되면 오히려 렌더링 과정에서 프레임드랍이 일어나 사용자 경험이 저하될 수 있다.

Web worker

batch를 위한 연산의 부담을 덜어줄 방법이 필요했고 고민한 끝에 web worker 도입을 결심했다.

batch를 위한 연산을 web worker를 통해 worker thread에서 진행하면 이벤트수를 줄이면서도 연산에 대한 부담을 줄여 사용자 경험도 유지할 수 있다고 생각했다.

image

web worker 도입 후 아키텍처

web worker는 병합처리 로직에 따라 object를 처리하는 worker와 mouse move를 처리하는 worker로 분리하였고 이에 따라 buffer(queue)도 구분하였다.

이벤트가 발생하면 web worker에 이벤트를 전송하고 web worker는 받은 이벤트를 버퍼에 저장해놓다가 web worker에서 특정 주기마다 일괄 처리하여 병합된 이벤트를 다시 main thread로 보내고 main thread를 서버로 전송하도록 했다.

구현(with react)

이제 구현을 해보자 참고로 필자는 react 18.2.0 version을 사용하고 있다.

worker script

// object.worker.ts
import { ObjectDataToServer } from '@pages/workspace/whiteboard-canvas/types';

export default () => {
	let waitQueue: ObjectDataToServer[] = [];

	self.onmessage = ({ data }: { data: ObjectDataToServer }) => {
		waitQueue.push(data);
	};

	setInterval(() => {
		const data = batchQueue();
		if (data) {
			Object.keys(data).forEach((key) => {
				self.postMessage(data[key]);
			});
		}
	}, 33.4);

	const batchQueue = () => {
		if (waitQueue.length === 0) return;
		const taskQueue = waitQueue;
		waitQueue = [];

		const dataByObject: {
			[key: string]: ObjectDataToServer;
		} = {};
		taskQueue.forEach((item) => {
			if (dataByObject.hasOwnProperty(item.objectId)) {
				Object.assign(dataByObject[item.objectId], item);
			} else {
				dataByObject[item.objectId] = item;
			}
		});
		return dataByObject;
	};
};

먼저 worker 스크립트를 작성해준다. 이벤트를 받으면 waitQueue에 저장하도록 했고 33.4ms(30fps)마다 배치 로직을 수행 후 전송하도록 구현했다.

react

import useObjectWorker from './useObjectWorker';
const code = useObjectWorker.toString();
const blob = new Blob([`(${code})()`]);
const worker = new Worker(URL.createObjectURL(blob));

react에서 web worker를 사용하기 위해서는 work script를 import한 후 위와 같은 작업을 해줘야한다. 그냥 스크립트를 불러와서 생성하는 방법도 있으나 여러 worker를 사용할꺼면 위를 모듈화해서 사용하면 재사용 가능하다.

// useObjectWorker.ts
import { useRef, useEffect } from 'react';
import { isUndefined } from '@utils/type.utils';
import { ServerToClientEvents, ClientToServerEvents, ObjectDataToServer } from './types';
import { Socket } from 'socket.io-client';

function useObjectWorker(
	workerModule: () => void,
	socket: React.MutableRefObject<Socket<ServerToClientEvents, ClientToServerEvents> | null>
) {
	const worker = useRef<Worker>();

	const initWorker = () => {
		const code = workerModule.toString();
		const blob = new Blob([`(${code})()`]);
		return new Worker(URL.createObjectURL(blob));
	};

	useEffect(() => {
		worker.current = initWorker();
		worker.current.onmessage = ({ data }: { data: ObjectDataToServer }) => {
			socket.current?.emit('updating_object', data);
		};

		worker.current.onerror = (error) => {
			console.log(error);
		};

		return () => {
			if (isUndefined(worker.current)) return;
			worker.current.terminate();
		};
	}, []);

	return {
		worker,
	};
}

export default useObjectWorker;

object를 처리하는 worker를 관리하는 useObjectWorker hook을 만들었다. 원래는 useWorker로 추상화하려고 했으나 일단 만들고 추상화하자는 생각으로 만들었다.

// useCanvasToSocket.ts
if (fabricObject.type in ObjectType) {
	const message = formatScaleObjectEventToSocket(fabricObject as fabric.Group);
	objectWorker.current?.postMessage(message);
	return;
}

이벤트 발생시 message를 worker에 넘겨주도록 설정하면 끝이다.

결과

image

솔직히 어제 적용해서 얼마나 개선되었는지 테스트해보지는 못했다. 그래도 perfomace 측정을 해보니 worker가 잘 작동하고 있다.

개선 점

image

솔직히 써보기 전까지는 batch 처리에 얼마나 시간이 걸릴지 몰라서 좀 조심스럽게 단순한 role만 일단 줘봤는데 실행시간 단위가 micro seconds다

안심하고 더 많은 role을 worker에게 부여할 계획이다.

역시 직접 써봐야 감이 오는 것 같다

📚 그라운드 룰

✏️ 컨벤션

🧑‍🏫 멘토링

📁 애자일 프로세스

기획
데일리 스크럼
스프린트 리뷰
스프린트 회고
트러블 슈팅
기타 산출물

📖 기술문서

Week2
Week3
Week4
Week5

🗂 참고문서

Clone this wiki locally