Search
🌐

gRPC와 protobuf의 특징과 web worker를 사용한 대용량데이터 멀티스레드 작업

Date
2023/07/29
Authors
Published
Slug
frontend-grpc-protobuf

gRPC와 protobuf

현재 회사에서는 REST API 대신 gRPC를 이용해 통신을 주고 받고 있다. gRPC는 HTTP 2.0 기반으로 통신을 주고 받고, protobuf 기반의 압축 된 메세지를 주고 받기 때문에 여러가지 성능적인 이점을 누릴 수 있다.
HTTP/2.0 기반으로 하기에
한 번의 connection으로 여러 개의 메세지를 주고 받을 수 있음
header를 압축하여 중복 제거 후 전달
protobuf를 사용하여
바이너리 형태로 직렬화 된 데이터를 전송하기에 효율적
데이터를 주고받는 형식이 강제되기 때문에 자연스럽게 API에 대한 가이드가 제공
위와 같은 다양한 장점을 누릴 수 있다.

게임 도메인의 특성과 문제 상황

다른 프론트엔드 개발자들과 flex의 최적화 관련한 이야기를 하면서, 어드민 최초 접속 시에 performance 탭을 확인하게 되는 일이 있었다. 거기에서 눈에 띄는 것이 binary 데이터를 객체형태로 파싱해주는 함수의 실행시간이였다. 그래프 상에서 차지하는 비율이 적지 않았기에 살펴보니, 게임에서 사용하는 dataId가 직렬화되어 있어 역직렬화하는 과정에서 시간이 소요되고 있었다.
이 문제에 대해 약간의 설명을 덧붙이자면, 게임 도메인에서 어드민 개발을 시작하면서 가장 놀라웠던 점이 대용량의 데이터였다. 유저의 입장에서는 볼 때 쿠키런 시리즈는 친근한 그래픽을 가진 캐주얼 게임이라는 인식이었다. 하지만 내부에서 게임을 살펴보면 게임이라는 하나의 세계를 구성하기 위해 수많은 기반 데이터가 존재하는 것이 신기하게 느껴졌다.
위에서 언급한 dataId의 경우 게임의 모든 개념이 대상이 된다. 다이아 등의 재화나 나무 같은 아이템은 물론이고 OOO의 탑 1층, OOO의 탑 2층 … OOO의 탑 200층 같은 던전이나 각각의 게임화면도 dataId를 가지고 있다.(웹페이지의 주소처럼 특정 액션 시 페이지로 이동하기 위해서) dataId가 유니크하기 때문에 다양한 언어로 번역할 수 있고, 특정적인 연출이나 편의기능을 제공하기에도 용이한 것이다. 그렇기에 하나의 게임을 구성하기 위해, 시간이 지나며 수만 개의 dataId가 필요하게 된다.
앞서 설명한 gRPC의 직렬화 방식과 게임 도메인 특유의 많은 데이터가 결합하면서 어드민에서도 부담이 더해지게 되었다. binary의 형태로 압축되어서 전달되는 dataId들이 갈수록 쌓이면서 싱글스레드 방식으로 처리되는 어드민에서는 블락현상이 일어나고 있던 것이다.

ServiceWorker

문제의 원인을 파악한 후 Next.js 스터디에서 party town 내용 중 발표했던 멀티스레딩으로 해결하면 좋겠다는 생각이 들었다 web worker를 이용한 연산 오프로딩을 통해 사이트의 성능을 향상시키는 원리이다.
MDN의 worker 문서를 보면 dedicated workers, shared workers, service workers 의 세가지 유형에 대해 다루고 있다. 조금 더 찾아보니 나처럼 오프로딩을 원하는 사람들은 web worker vs service worker 중에 선택하는 것으로 보였다.
공통적으로 메인스레드와 독립 된 스레드에서 실행되고, 메인스레드의 window, document 전역 객체에는 접근할 수 없다는 특징이 있었다. 차이점으로는 web worker는 여러 개를 등록하여 원하는 만큼의 멀티스레드 구성이 가능하지만, service worker는 도메인 당 하나만 등록할 수 있다. 대신에 service worker는 활성화 된 다른 탭에 영향을 줄 수도 있다. 또한, web worker는 탭과 수명주기가 동일하지만, service worker는 백그라운드에서 별도의 생명주기를 가지기 때문에 push 알람과 같은 기능도 가능하다 (그래서 PWA에서 사용된다고 한다) MDN의 service worker 문서web worker 문서
나의 경우에는 여러 개의 worker를 사용하기보다는 메인 스레드와의 분리정도만 생각하고 있었기 때문에 선택에 대해 고민이 되었다. 또한, service worker의 탭 간 공유와 백그라운드 실행 부분도 어드민의 특성을 고려했을 때 기능의 확장성적인 측면에서 매력적으로 느껴졌다. 하지만, 기존 쿠키런: 킹덤 어드민 개발을 진행하던 프론트엔드 개발자분들과 논의하고, 결정적으로 아래의 아티클을 읽고나서 web worker로 가는 것이 바람직하다는 확신이 들었다.
이 아티클은 정확히 나와 같은 고민을 하는 사람들을 위해 오프로딩을 위해 어떤 선택을 할지에 대해 다룬 내용으로 출처가 web.dev인 만큼 가장 내용도 믿음직하였다. 아티클의 내용을 보면 service worker는 시간이 많이 소요될 경우 종료될 수 있다는 언급이 있다. 물론 이를 방지할 수 있는 API 도 소개하고 있지만, service worker는 백그라운드에 독립적인 생명주기를 가지고 존재한다는 점과 더불어 괜한 사이드이펙트가 있을 수 있다는 생각이 들었다.
그래서, 기존의 데이터 역직렬화 과정이 web worker를 사용하도록 코드를 수정하였다. 몇 가지 핵심 로직 위주로 내용을 정리해보았다. (가독성과 보안을 위해 함수명과 타입 등 부가적인 부분은 변경 또는 생략하였다.)
생성자를 이용해 새로운 worker를 생성하는 함수이다. 워커 로직은 반드시 별도의 파일에 존재해야하기에 별도 파일에 작성 후 경로를 명시해주어야 한다.
function getWorker() { workerInstance = new Worker('워커파일의 경로'); return workerInstance; }
JavaScript
복사
buffer 객체를 받아 통신을 위한 port를 만들고, worker에 payload로 전달하는 함수이다. worker와의 통신을 위해서는 메인스레드가 수신하는 port1과 송신하는 port2를 분리하여야 한다. port1에서는 onmessage 핸들러를 통해 응답을 받도록 설정한다.
async function decodeWithWorker(buffer){ const worker = getWorker(); return new Promise((resolve, reject) => { const channel = new MessageChannel(); channel.port1.onmessage = (event) => { // ... }; // 웹 워커에 'decodeBulkUltimate' 액션과 payload를 전달 worker.postMessage({ payload: buffer }, [channel.port2]); }); }
JavaScript
복사
워커는 self.onmessage 핸들러를 통해 메인 스레드로부터 전달된 메시지를 수신한다. 메인스레드의 수신포트에 postMessage 메서드로 응답을 보내준다. 전달된 메시지(event.data)에서 action과 payload를 추출한 후, 처리 결과를 메인 스레드로 보내기 위해 메인 스레드가 전달한 MessageChannel의 포트(여기서는 port)를 사용하여 postMessage() 메서드로 응답을 보내준다.
// 워커파일.js self.onmessage = async function(event) { const { action, payload } = event.data; // ... port.postMessage({ result: object }); };
JavaScript
복사

결과

로컬 실행 기준(맥북 m1 pro) 약 400ms 정도의 작업이 오프로딩을 통해 별도의 스레드에서 이루어지고 있다 앞으로의 업데이트를 통해 dataId 항목이 늘어난다면 더 유의미한 결과를 기대할 수 있을 것 같다. (오프로딩을 하지 않았을 때에 비해 100ms 정도의 시간이 더 걸린 것으로 보이는데, 별도의 스레드를 사용하면서 발생하는 오버헤드로 생각 된다.)
추가로, serviceWorker 사용 시에도 오프로딩에 걸리는 시간은 비슷했다. worker의 종류의 따른 성능이나 통신에 드는 딜레이는 대략 비슷한 것으로 보인다.

레퍼런스

grpc의 특징
일반 JSON과 protobuf로 압축 된 응답의 효율차이
web 관점에서의 gRPC의 장단점