November 01, 2022
지난 글에서는 웹워커의 종류와 특징들을 찍먹하는 내용을 작성했는데요 이번에는 웹워커가 실제로 메인 쓰레드와 어떻게 데이터를 주고 받는지에 대해 작성하겠습니다. 제가 처음 웹워커를 개발했을 때 저만의 규칙을 만들어서 메인 쓰레드와 웹워커 간의 메시지 패싱을 구현했는데 구조도 별로고 다른 사람이 보기에도 어렵더라구요. 무엇보다 찝찝한 건 저만의 규칙이라는 것이 제일 컸습니다. 그래서 정형화된 패턴을 가지고 적용하면 어떨까 생각이 들어 찾아보고 정리해봤습니다.
관련된 책을 읽던 중 RPC 패턴을 발견했습니다! RPC(Remote Procedure Call) 패턴이란 함수와 인자로 된 표현 양식을 직렬화하여 외부에 전달하여 실행시키는 방법이라고 하는데요, 아래와 같은 구조로 소통한다고 합니다.
squareSum(4) → square_sum|num:4
예시를 보니까 어지럽네요; 다행히 이를 확장시켜 JSON-RPC 라는 메시지 통신 방식이 존재합니다. 요청과 응답을 객체에 담아 우리에게 익숙한 JSON 형식으로 표현하는 방식인데요 예시를 JSON-RPC 버전으로 바꾸면 아래와 같습니다.
squareSum(4) → { jsonrpc: ‘2.0’, method: ‘square_sum’, params: [4], id: 1 }
위와같이 표현한다면 id를 기준으로 요청과 응답이 어떻게 연결되는 지 명확하게 알 수 있습니다!
하지만 새롭게 추가된 jsonrpc 필드는 뜬금없이 무엇일까요? 해당 필드는 JSON-RPC 버전을 가리키는데 네트워크 설정 시 활용되는 값입니다. *Web3.js 에서도 JSON-RPC 형식을 따릅니다.
웹 워커의 경우 Structured Clone Algorithm 을 사용하여 깊은 복사된 객체를 넘겨주기 때문에 JSON 직렬화/역직렬화 과정을 거칠 필요가 없습니다. 따라서 jsonrpc 필드는 브라우저의 웹 워커에서는 큰 의미가 없다고 판단하여 구성하지 않았습니다.
JSON-RPC 패턴으로 요청하지만 응답을 받는 주체에서는 어떤 코드를 실행할지 막막합니다.
명령 분배 패턴을 적용한다면, 직렬화된 명령어를 받아서 실행해야 할 함수를 찾을 수 있습니다.
// 명령 분배 패턴 예시
const commands = {
squareSum(max) {},
fibonacci(limit) {},
}
const dispatch = (method, args) => {
if (commands.hasOwnProperty(method)) {
return commands[method](...args)
}
throw new TypeError(`Command ${method} not defined!`)
}
hasOwnProperty
를 사용해 method 속성을 찾으러 proto 객체까지 탐색하는 일을 막아줍니다.그렇다면 이제 JSON-RPC 패턴과 명령 분배 패턴을 이용하여 간단하게 구현해보겠습니다.
project
|- index.html. // 브라우저에서 워커 실행을 위한 html 파일입니다.
|- main.js. // 프로그램의 엔트리 파일입니다.
|- worker.js. // 워커 코드를 작성한 파일입니다.
|- rpc-worker.js // RpcWorker 클래스를 정의한 파일입니다.
|- commands.js. // 워커가 처리할 명령을 작성한 파일입니다.
<html>
<head>
<meta charset="UTF-8" />
<title>Worker Patterns</title>
<script src="main.js" type="module"></script>
</head>
</html>
const sleep = ms => new Promise(res => setTimeout(res, ms))
const commands = {
async square_sum(max) {
await sleep(Math.random() * 5000)
let sum = 0
for (let i = 0; i < max; i++) {
sum += Math.sqrt(i)
}
return sum
},
async fibonacci(limit) {
await sleep(Math.random() * 1000)
let prev = 1n,
next = 0n,
swap
while (limit) {
swap = prev
prev = prev + next
next = swap
limit--
}
return String(next)
},
async bad() {
await sleep(Math.random() * 3000)
throw new Error('oh no')
},
}
export default commands
import commands from './commands.js'
self.onmessage = async msg => {
const { method, params, id } = msg.data
let data
if (commands.hasOwnProperty(method)) {
try {
const result = await commands[method](...params)
data = { id, result }
} catch (err) {
data = { id, error: { code: -32000, message: err.message } }
}
} else {
data = {
id,
error: {
code: -32601,
message: `method ${method} not found`,
},
}
}
postMessage(data)
}
import { RpcWorker } from './rpc-worker.js'
const worker = new RpcWorker('worker.js')
Promise.allSettled([
worker.exec('square_sum', 1_000_000),
worker.exec('fibonacci', 1_000),
worker.exec('fake_method'),
worker.exec('bad'),
]).then(([square_sum, fibonacci, fake, bad]) => {
console.log('square sum: ' + square_sum.value)
console.log('fibonacci: ' + fibonacci.value)
console.log('fake: ' + fake.reason.message)
console.log('bad: ' + bad.reason.message)
})
export class RpcWorker {
constructor(path) {
this.next_comand_id = 0
this.in_flight_commands = new Map()
this.worker = new Worker(path, { type: 'module' })
this.worker.onmessage = this.onMessageHandler.bind(this)
}
onMessageHandler(msg) {
const { result, error, id } = msg.data
const { resolve, reject } = this.in_flight_commands.get(id)
this.in_flight_commands.delete(id)
if (error) {
reject(error)
} else {
resolve(result)
}
}
exec(method, ...args) {
const id = ++this.next_comand_id
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
this.in_flight_commands.set(id, { resolve, reject })
this.worker.postMessage({ method, params: args, id })
return promise
}
}
멀티스레드 기반 자바스크립트 책을 참고하여 JSON-RPC 패턴과 명령 분배 패턴을 이용해서 간단한 구현을 해봤는데요 저는 무엇보다도 exec 메서드에서 클로저를 활용하여 워커의 비동기 처리를 관리하는 부분이 인상 깊었습니다. 앞으로 여기서 적용한 두 패턴을 이용하여 실무에 적용 후 내용을 공유하려합니다.