xnb.js 개발일지(3)

Lybell·2022년 7월 14일
0

xnb.js 개발일지

목록 보기
3/4

개발 상황

현재 Stardew Dressup의 xnb 임포팅을 구현하고 있는데, 본인이 개발했던 xnb.js가 파일 읽고 쓰는 부분만 비동기지 사실 코어 부분(blob data를 xnb가 담고 있는 데이터로 변환)은 동기적 프로그래밍에 가까워서, 어떻게 하면 해당 부분을 비동기로 구현할 수 있을까를 고민하던 중이었다.

자바스크립트는 어떻게 비동기를 처리하는가?

그 전에, 자바스크립트는 어떻게 비동기 함수를 처리하는지를 잠깐 복습해 보았다.

자바스크립트는 싱글 스레드 언어다. 싱글 스레드 언어란, 쉽게 말해 한 번에 하나의 작업만 수행할 수 있다는 뜻이다. 그러니까, 하나의 명령문이 끝나면 그 뒤의 명령문이 실행될 수 있는 구조다.

function 엄청_느린_함수()
{
	for(let i=0; i<2147483647; i++)
    {
      	//task
    }
	console.log("끝났다!");
}

엄청_느린_함수();
console.log("next");

대충 위의 예시를 들어보면, 엄청_느린_함수()가 전부 실행된 뒤에야 next가 출력되는 것을 볼 수 있을 것이다.

앞서 자바스크립트는 하나에 한 개의 작업만 처리할 수 있다고 했는데, 그렇다면 비동기는 어떻게 구현되는 것일까? 그것은 바로 브라우저나 node.js 환경에서 구현된 이벤트 루프 때문이다.

모든 함수는 호출 시점에 콜 스택이라는 데에 저장되고, 완료되면 콜 스택에서 빠져나간다. 비동기 함수 역시 콜 스택에 저장되는 것은 똑같지만, 브라우저나 node.js 환경에 작업을 위임한 뒤 바로 콜 스택에서 제거된다. 참고로 이 때 콜백 함수 역시 같이 등록된다. 위임된 작업이 끝나면 비동기 함수의 콜백 함수는 태스크 큐라는 데에 저장된다. 이벤트 루프는 콜 스택이 비었는지, 태스크 큐가 차 있는지를 주기적으로 확인하며, 콜 스택이 비어 있고 태스크 큐에 자리가 있으면 태스크 큐에서 함수를 빼 와 콜 스택에 집어넣는다.

function foo()
{
  	console.log("foo!");
	bar();
}
function bar()
{
	console.log("bar!");
}
function slow_func()
{
	for(let i=0; i<2147483647; i++)
    {
      	//task
    }
	console.log("too slow!");
}
function asyncFunc()
{
  	Promise.resolve().then(()=>console.log("yes!"));
}
asyncFunc();
foo();
slow_func();

다음의 예시를 들어보겠다. (전역에서 실행되는 명령문의 경우, 가상의 익명함수로 감싸져 있다고 생각하는 게 좋다.)

  1. 전역 익명함수가 콜 스택에 추가된 뒤, asyncFunc가 콜 스택에 추가된 뒤 실행 환경에 작업을 위임하고 콜 스택에서 제거된다.
  2. 이 asyncFunc는 즉시 실행 완료되는 비동기 함수이므로, ()=>console.log("yes!")가 태스크 큐에 추가된다. 전역 익명함수가 콜 스택에 남아 있으므로, yes!는 출력되지 않는다.
  3. foo 함수가 콜 스택에 추가된다. foo!가 출력된다.
  4. bar 함수가 콜 스택에 추가된다. bar!가 출력된다. 완료된 bar 함수가 콜 스택에서 제거된다.
  5. 완료된 foo 함수가 콜 스택에서 제거된다.
  6. slow_func 함수가 콜 스택에 추가된 뒤, 매우 긴 연산을 거쳐, too slow!를 출력한다. 완료된 slow_func 함수는 콜 스택에서 제거된다.
  7. 전역 익명함수가 콜 스택에서 제거된다.
  8. 이벤트 루프는 콜 스택에 아무것도 없고, 태스크 큐에 작업이 있는 것을 확인한 뒤, ()=>console.log("yes!")를 콜 스택에 추가한다. yes!가 출력된다.

보는 바와 같이, 엄청 느린 함수가 먼저 실행된 뒤 비동기 함수의 콜백 함수가 나중에 실행되고 있다. 따라서, 엄청 느린 함수가 실행되면 다음에 이어지는 동기적 명령문뿐만 아니라, 작업을 완료 후 대기 중인 비동기 함수 역시 엄청 느린 함수의 실행이 완료되기 전까지 실행이 되지 않는다.

해당 문제를 해결하기 위해, "엄청 느린 함수"의 연산을 어떻게 브라우저나 node.js 환경에 위임할 수 있을지를 고민하였고, Worker API를 찾게 되었다.

Worker

워커는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술이다. 이를 이용하면 매우 긴 연산을 자바스크립트의 주 스레드가 아닌 다른 스레드로 넘김으로써, 주 스레드에서의 사용자 동작을 끊김 없이 처리할 수 있다.

워커는 브라우저와 node.js에서 모두 지원하지만, 그 사용 방식에 차이가 있다. 워커를 이용해 무거운 동작을 수행해야 하지만, 브라우저와 node.js에서 차이점이 발생해서 라이브러리를 만드는 나에게는 골치아픈 일이 아닐 수가 없었다.

워커 스레드 플로우

메인 스레드와 워커 스레드는 다음의 플로우를 따르며 통신한다.

브라우저에서 Worker 사용 방법

// main.js
const myWorker = new Worker("worker.js");
myWorker.postMessage("this is data");
myWorker.onmessage = function(e) {
  console.log(`received! ${e.data}`);
  myWorker.terminate();
}
myWorker.onerror = function(e){
  console.log("ERROR!");
}
  • new Worker(경로, 옵션) : 새로운 Worker 객체를 생성하면서, 백그라운드 스레드를 연다. 이 때 매개변수로 들어가는 경로는 이 함수를 실행한 경로를 기준으로 한 상대경로가 아니라, 호스팅되는 페이지 기준의 상대경로를 의미한다. 예를 들어, project/src/module/main.js에서 project/src/module/worker.js를 워커로서 호출할 때, new Worker("./worker.js")를 호출하는 것이 아니라 new Worker("/src/module/main.js")를 호출해야 한다는 것이다.(index.html은 project 폴더에 있다고 가정)
    옵션에서는 type, credentials, name을 key로 갖는 객체가 들어갈 수 있는데, {type:"module"}을 사용하면 해당 워커 스크립트에 ES6 모듈을 사용할 수 있다.
  • myWorker.postMessage(전달할 값) : 워커 스레드에 값을 전달한다. 전달한 값은 worker.js의 onmessage 함수의 인자로 전달된 e.data에 저장되고, onmessage 함수를 호출한다.
  • myWorker.onmessage : 함수를 대입하여 사용한다. 워커 스레드에서 값을 보내면, 해당 함수가 실행된다. e.data로 메시지의 값을 참조할 수 있다. myWorker.addEventListener("message", func)와 동일하다.
  • myWorker.onerror : 함수를 대입하여 사용한다. 워커 스레드에서 에러를 반환했을 때, 해당 함수가 실행된다. 인자로 받는 e는 ErrorEvent 객체이다. myWorker.addEventListener("error", func)와 동일하다.
  • myWorker.terminate() : 워커 스레드를 종료시킨다. 더 이상 워커가 필요없을 때 사용한다.
// worker.js
onmessage = function(e) {
  console.log(`worker received! ${e.data}`);
  postMessage("to Main thread");
}
  • onmessage : 해당 워커 스레드가 메시지를 받았을 때 실행된다. e.data로 메인 스레드에서 받은 값을 읽을 수 있다. addEventListener("message", func)와 동일하다.
  • postMessage(전달할 값) : 메인 스레드로 값을 전달한다.

추가로, postMessage로 전달하는 값은 자바스크립트 내부에서 JSON 객체 형태로 변환된다. typedArray 형태의 객체는 유지된다. 즉, 생성자 등으로 생성한 객체의 메소드 등은 소실된다.
worker.js 내부에 전역적으로 onmessage, postMessage가 존재하는 형태를 띄는데, 이는 worker.js의 전역 스코프로 DedicatedWorkerGlobalScope라는 객체가 암시적으로 지정되어 있기 때문이다.

Node.js에서 Worker 사용 방법

Node.js에서도 Worker를 지원한다. 하지만, 브라우저에서 Worker 클래스가 호스트 객체로 내장되어 있어서 별도의 라이브러리 설치 없이 이용할 수 있는 것과는 다르게, Node.js에서는 내장 모듈인 worker_threads 모듈을 임포트해야 한다.

// main.js
import {Worker} from "worker_threads";

const myWorker = new Worker("./worker.js");
myWorker.postMessage("this is data");
myWorker.on("message", function(e) {
  console.log(`received! ${e}`);
  myWorker.terminate();
});
myWorker.on("error", function(e){
  console.log("ERROR!");
});
  • new Worker(경로, 옵션) : 브라우저의 그것과 동일하나, options에 들어가는 값에서 세세한 차이가 있다.
  • myWorker.postMessage(전달할 값) : 브라우저의 그것과 동일하다.
  • myWorker.on("message", func) : 2번째 인자로 함수를 넣어서 사용한다. 워커 스레드에서 값을 보내면, 해당 함수가 실행된다. e로 메시지의 값을 참조할 수 있다.
  • myWorker.on("error", func) : 2번째 인자로 함수를 넣어서 사용한다. 워커 스레드에서 값을 보내면, 해당 함수가 실행된다. 인자로 받는 e는 Error 객체이다. myWorker.addEventListener("error", func)와 동일하다.
  • myWorker.terminate() : 브라우저의 그것과 동일하다.
// worker.js
import {parentPort} from "worker_threads";

parentPort.on("message", function(e) {
  console.log(`worker received! ${e}`);
  parentPort.postMessage("to Main thread");
});

Node.js에서는 브라우저와는 다르게 부모 스레드와 통신하기 위해서는 parentPort 객체를 임포트해줘야 한다.

  • parentPort.on("message", func) : 해당 워커 스레드가 메시지를 받았을 때 실행된다. e로 메인 스레드에서 받은 값을 읽을 수 있다.
  • parentPort.postMessage(전달할 값) : 메인 스레드로 값을 전달한다.

Worker를 Promise에 싸서 드셔보세요

Worker는 워커 스레드와 통신할 때, 콜백 함수를 이용하여 통신한다. 하지만, 이 방법은 요새 자주 쓰이는 Promise를 이용한 비동기 처리와 거리가 있으므로, Worker 스레드와의 통신을 Promise 형식으로 바꿔보는 작업을 하도록 하겠다.

// 브라우저 기준
function workerPromise(worker, sendData)
{
	return new Promise( (resolve, reject)=>{
		worker.postMessage(sendData);
		worker.addEventListener("message", (e)=>resolve(e.data));
		worker.addEventListener("error", (e)=>reject(e.message));
	});
}

다음과 같이, worker와 보낼 데이터를 인자로 받아 Promise를 반환하는 간단한 workerPromise 함수를 만들어 보았다. Promise 생성자의 인자로 받는 함수는 resolve 함수가 호출되면 상태가 fulfill이 되고, reject 함수가 호출되면 상태가 reject가 된다. 이를 worker에 적용하면, worker에서 메시지를 받았을 때 resolve 함수를 호출하고, 에러가 났을 때 reject 함수를 호출하면 된다.

rollup과 Worker

자바스크립트로 개발을 할 때면 webpack이나 rollup과 같은 번들러를 써서 개발을 하게 된다. 하지만, Worker는 인자로 파일 경로를 받기 때문에, 파일이 분리되어야 한다는 문제가 있다. rollup에서는 Web Worker를 메인 스레드와 같이 하나의 파일로 번들링해주는 플러그인, rollup-plugin-web-worker-loader가 존재한다.

사용 방법은 여타 rollup의 플러그인을 쓰는 때와 동일하다.

//rollup.config.js
import webWorkerLoader from 'rollup-plugin-web-worker-loader';

export default {
	input: 'src/index.js',
	output: {
		file: 'dist/index.js',
		format: 'esm'
	},
	plugins: [
		webWorkerLoader()
	]
};

우선 rollup.config.js에 webWorkerLoader를 임포트하고, plugins에 webWorkerLoader()를 추가한다.

//main.js
import Worker from "web-worker:./worker.js";

임포트할 워커 소스코드를 불러올 때 앞에 web-worder:를 붙여주면 된다. 내부적으로 worker 내부 소스코드는 번들링할 때 Base64로 인코딩되어 저장되고, 실행 환경이 브라우저면 URL.createObjectURL을 이용해서, Node.js면 Base64에서 디코딩된 문자열을 이용해서 Worker 객체를 생성한다. 하지만, 브라우저와 Node.js의 Worker 문법은 차이가 있기에, 바로 크로스플랫폼은 불가능하다.

브라우저와 Node.js에서 호환되는 Worker를 만들자

앞서 말했듯, 브라우저와 Node.js에서 사용되는 Worker는 그 문법에서 차이가 있다. 그래서, 브라우저와 Node.js 환경을 모두 대응할 수 있는 래퍼 함수를 만들고자 한다.
브라우저와 Node.js를 구분하는 방법은 여러가지가 있으나, 오늘은 rollup-plugin-web-worker-loader에서 쓰고 있는 방법을 그대로 사용하기로 했다.

var kIsNodeJS = Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]';

원리는 Node.js 환경에 존재하는 process 객체의 유무와, 해당 객체가 적절한 Node.js의 객체인지를 체크하는 것으로 보인다. 간혹 브라우저 환경에서 process 식별자에 다른 값을 집어넣거나, 심지어는 process.toString()의 값이 "[object process]"가 나오도록 할 수도 있는데, Object.prototype.toString.call을 이용하면 이를 방지할 수 있다고 한다.

worker-thread 모듈을 불러오는 것도 rollup-plugin-web-worker-loader의 그것을 가져왔다.

const WorkerThreads =
	typeof module !== 'undefined' && typeof module.require === 'function' && module.require('worker_threads') ||
	typeof __non_webpack_require__ === 'function' && __non_webpack_require__('worker_threads') ||
	typeof require === 'function' && require('worker_threads');

참고로 브라우저 환경에서 WorkerThreads 변수를 불러올 일은 없으므로 신경쓰지 않아도 된다.

function workerMaker(workerFunc)
{
	// node-js environment
	if(isNodeJS())
	{
		const {parentPort} = WorkerThreads;
		parentPort.on('message', (e)=>workerFunc(e, parentPort));
	}
	// browser environment
	else
	{
		onmessage = function(e){
			workerFunc(e.data, this);
		}
	}
}

workerMaker 함수다. Node.js 환경이면 parentPort.on 메소드를, 브라우저 환경이면 onmessage 함수를 실행하도록 하였다. workerFunc의 첫 번째 인자로는 메인 스레드에서 받은 메시지를, 두 번째 인자로는 postMessage를 사용하기 위한 객체를 집어넣는다. 브라우저 환경에서는 (전역 객체).postMessage 형태로 실행되어야 하므로, this를 넣어주었다. this를 넣어 준 이유는 onmessage가 실행 될 때 this는 워커의 전역 객체를 가리키기 때문이다.(자바스크립트에서 일반 함수 내의 this는 일반 함수로 실행될 때 전역 객체를, 메소드로 실행될 때는 메소드를 호출한 주체를 반환한다.)

function workerPromise(worker, sendData)
{
	return new Promise( (resolve, reject)=>{
		worker.postMessage(sendData);
		if(isNodeJS()) 
		{
			worker.on("message", (e)=>resolve(e));
			worker.on("error", (e)=>reject(e));
		}
		else
		{
			worker.addEventListener("message", (e)=>resolve(e.data));
			worker.addEventListener("error", (e)=>reject(e.message));
		}
	});
}

workerPromise 함수다. 환경이 node.js 환경이면 worker.on을, 브라우저 환경이면 worker.addEventListener를 호출하도록 했다.

여담

자바스크립트의 이벤트 루프를 공부하면서, 혹시 나중에 부스트캠프에서 이벤트 루프 직접 구현하라는 거 아니냐는 불안감이 엄습해오기 시작했다. 아마도 setInterval과 제너레이터 함수를 이용해서 잘 하면 구현할 수 있을지도 모르겠지만...

P.S.(220714-23:38 추가)

끔찍한 생각이었다. 플러그인 적용 기능이 망가졌다!
rollup으로 번들링된 worker 내부의 코드와 외부의 코드는 같은 모듈의 객체를 참조하고 있어도 다른 객체로 취급된다. 설상가상으로 xnb.js의 플러그인은 함수기 때문에 함수를 워커로 전송할 수 없는 상황.

레퍼런스

자바스크립트와 이벤트 루프 - TOAST
이벤트 루프와 태스크 큐 (마이크로 태스크, 매크로 태스크)
Web Worker API
Node.js Worker Thread API

profile
홍익인간이 되고 싶은 꿈꾸는 방랑자

0개의 댓글