85 거침없는 자바스크립트 6회차

이누의 벨로그·2022년 3월 23일
3

코드스피츠 85 거침없는 자바스크립트 - 6회차

이번 시간에는 Shared Array Buffer와 Atomics에 대한 내용을 들여다 볼 것이다. 정확하게는 Atomics가 표준이 된 자바스크립트 직전까지의 멀티 스레드 환경에 대해서 알아볼 것이다. 자바스크립트에 Web Worker가 들어온 것은 HTML5 부터이며, 이후 Web Worker와의 통신을 위해 ES 스펙으로 Shared Array Buffer가 도입되었다.

따라서 Shared Array Buffer는 멀티 스레드 환경에서의 공유메모리를 위해 도입된 스펙이라고 할 수 있다. Web Worker가 브라우저 상과 NodeJS의 최신버전에서 모두 지원됨으로써, 자바스크립트의 멀티 스레드 환경이 갖춰지게 되었다. 하지만 Web Worker 초기에는 메모리 공유 Shared Memory 의 문제를 피하기 위해 쓰레드간에 오직 복사본만을 보내도록 제한되었다. 복사본만을 보냄으로써 쓰레드 간의 경합을 피하고 Shared Memory 문제를 피할 수 있었다. 하지만 데이터의 크기가 커지면 커질 수록 데이터의 복사본을 생성하는 데 드는 비용이 기하급수적으로 늘어나면서 메인스레드(foreground) 에서 처리하는 비용보다 백그라운드 쓰레드(background) 와 데이터 복사본을 주고받는 비용이 더 커지게 됐다. foreground에서 처리하기에는 크기가 큰 데이터를 background에서 처리하기 위해 도입된 것이 Web Worker 환경이라는 점을 생각해보면 아이러니가 아닐 수 없다.

이를 해결하기 위해 나온 공유 메모리 객체가 Shared Array Buffer이다. Shared Array Buffer를 활용하면 복사본을 보낼 필요없이 공유 메모리 객체를 그대로 주고받게 되어 Web Worker의 사용성이 증가하게 되었다. 하지만 Shared Memory Buffer가 ES2017에 발표되었을 때, 인텔 CPU의 스펙터 게이트가 발생하면서, 해당 보안 허점을 해결하기 위해 도입이 지연되면서 이후 크롬 64에서 버그 픽스가 되긴 했지만 개발자들이 아직까지도 해당 스펙에 익숙하지 못하게 된 원인이 되었다. 거기에 계속해서 발생하는 인텔 CPU의 공유 메모리 버그 때문에 Shared Memory Buffer 기능에 대한 완전한 지원이 언제쯤 브라우저 표준으로 자리 잡을 지는 미지수인 상황이다. 현재는 크롬과 파이어폭스만이 해당 기능을 지원하고 있다.(강의 날짜 기준)

Worker와 Blob URL

메인 스레드에서 백그라운드 스레드 환경을 제공해주는 Worker에 대해서 알아보자. 다음과 같이 Worker를 생성할 수 잇다.

const worker = new Worker("a.js");

Worker 인스턴스는 인자로 url을 받고, 이를 내부에 있는 네트워크 로더가 로딩한 후 데이터를 자바스크립트로 환원한다. 이 순간에 바로 백그라운드 스레드가 만들어지는 것은 아니고, 우선 실행 전에 대기를 하게 된다. 즉 인스턴스가 생성되는 순간, 백그라운드 스레드는 해당 url의 다운로드를 끝내고 언제라도 내용을 로딩할 수 있도록 준비를 완료한다. 백그라운드 스레드가 실행되는 순간은 최초로 postmessage를 실행하는 순간이다.

worker.postMessage('hello');

이 코드가 실행되면 처음으로 백그라운드 스레드가 활성화된다. postMessage는 Worker 스펙이 아닌, 브라우저의 글로벌 객체 window의 메서드이며, 브라우저의 window는 하나의 프로세스가 되기 때문에 postmessage는 사실은 프로세스간 통신을 위한 기능이며,Worker 클래스가 그대로 상속받아 스레드간 통신에 사용한다. 따라서 postmessage는 window간이나 스레드 간의 통신이나 동일한 이벤트 타입을 사용한다. 백그라운드 스레드에는 해당 스레드를 위한 컨텍스트인 Web Worker 컨텍스트가 존재한다. 이는 브라우저와는 다른 Web Worker 전용 컨텍스트로, onmessage라는 이벤트리스너를 가지고 있다.

//Background Thread
onmessage=({data})=>{
	console.log(data);
}

이벤트 리스너의 인자로 들어오는 이벤트 객체는 data를 가지고 있어 postmessage로 보낸 인자를 그대로 받는다. console.log만 하므로 지금은 아무일도 안하는 것 처럼 보이지만, 사실 console.log 자체가 이미 스레드 간의 동시성 문제를 해결한, 생각보다 많은 일을 하는 스펙이다.

그렇다면 스레드 간의 통신이 발생하면 어떠한 일이 일어나는지 살펴보자. 우선, 앞으로 메인 스레드를 foreground, 백그라운드 스레드를 background 이라고 지칭할 것이다. foreground가 postmessage를 보낸 순간에, foreground는 즉시 blocking이 해제되어 non-blocking이 된다. 즉 postMessage 이후의 코드가 바로 실행되기 시작한다. 이후 postmessage의 내용이 바로 백그라운드로 옮겨져 onmessage 이벤트 리스너 콜백이 실행되기 시작한다. 따라서 postmessage 이후에 foreground와 background는 동시에 움직이기 시작한다.

//Background Thread
onmessage=({data})=>{
	console.log(data);
	postMessage("world");
}

이 후 백그라운드가 postMessage를 사용하면 다시 포그라운드에 메세지가 전달된다. 단 이 때 백그라운드는 포그라운드로만 통신할 수 있고 백그라운드 스레드 간의 통신은 불가능하다. 백그라운드 간의 채널은 지원되지 않고 있다.

따라서 우리는 포그라운드에서 해당 메세지를 worker를 통해 수신해야 한다.

worker.postMessage('hello');
onmessage= ({data})=>{console.log(data);}

간단하다. 하지만 동시에 귀찮다. 귀차니즘을 유발하게 하는 원인은 다음과 같다. worker를 사용하기 위해서 a.js 와 같이 별도의 url을 가지는 파일을 만들어야 한다. 간단한 함수 하나를 백그라운드로 실행하려고 할 때마다 파일을 만들어야 한다면 참으로 귀찮은 일이 아닐 수 없다. 이를 위해 HTML5 표준에서 메모리 객체에 url를 부여하는 URL 객체 및 메모리에 바이너리 파일을 생성할 수 있는 Blob 객체를 지원하고 있다. Blob 객체는 여러가지 형식의 데이터를 넣어서 하나의 바이너리 데이터로 참조할 수 있게 하며, 이를 파일 객체로 변환할 수도 있다. 이렇게 Blob 객체로 만든 바이너리 데이터를 URL 객체로 감싸면 메모리 상의 바이너리 데이터에 대한 URL 주소를 얻을 수 있다.

그렇다면 우리가 방금 백그라운드 스레드에서 만든 a.js 파일에 해당하는 자바스크립트 코드를 파일 객체로 만들어보자. 우선 코드를 문자열로 만들고, 이를 Blob 객체로 만든 다음 Url 주소를 얻을 것이다.


//Background Thread
new Blob([`onmessage=({data})=>{
	console.log(data);
	postMessage("world");
}`], {type:'text/javascript'})

Blob 객체는 첫번째 인자로 배열을 받고, 두번째 인자로는 이를 인식할 MIME 타입을 지정한다. 윈도우 운영체제가 파일 확장자로 파일 타입을 인식한다면, 웹이나 이메일에서의 표준은 MIME 타입이다. 배열안에 전달한 데이터를 한번에 묶어서 하나의 바이너리 덩어리로 참조한 다음 전달한 MIME 타입으로 바이너리 데이터를 로딩하게 된다. 즉 배열에 넣는 내용은 하나로 모두 합쳐버리기 때문에, 변환할 데이터가 하나라면 여럿으로 나눌 필요가 없다. 이렇게 만든 Blob 객체는 우리가 아까 작성한 코드로 이루어진 하나의 데이터 객체를 생성한다. 이 객체를 File 객체로 감싸면 파일을 얻을 수 있고, Url 객체로 감싸면 url을 얻을 수 있다. 우리는 url을 얻을 것이다. 얻는 방법은 다음과 같다.

//Background Thread
URL.createObjectURL(new Blob([`onmessage=({data})=>{
	console.log(data);
	postMessage("world");
}`], {type:'text/javascript'}))

URL의 createObjectURL 메서드의 인자로 객체를 넘겨주면 해당 객체를 Url로 환원시킨다. url로 환원된 결과는 다음과 같은 형태의 텍스트이다.

blob:http://localhost:1234/28ff8746-94eb-4dbe-9d6c-2443b581dd30

이 텍스트는 메모리 상에 위치한 Blob 객체를 주소화 시킨 것이다. 이제 이 url을 사용해서 img의 src나 css의 background url, 캔버스에서도 전부 사용할 수 있다. 즉 우리는 원하는 파일이 서버에서 존재하지 않아도 우리가 메모리에 직접 데이터 객체를 생성하여 이를 url로 할당할 수 있다. 코드로 직접 이미지나 폰트, 바이너리 파일을 만들고 주소로 참조할 수 있는 것이다. Blob Url 기법은 굉장히 유용한 기법이다. 다만 브라우저마다 Blob url로 만든 주소를 브라우저의 모든 요소가 참조할 수 있는 것은 아니고 지원범위가 조금씩 다르니 주의해야 한다. 또한 Blob Url은 재사용할 수가 없고 1회성이므로 이 또한 주의하기 바란다.

이제 Worker가 참조할 수 있는 url이 생겼으니 우리에게 더 이상 실제 a.js 파일 따위는 필요없다. 다음과 같이 사용하자.

const worker = new Worker(URL.createObjectURL(new Blob([`onmessage=({data})=>{
	console.log(data);
	postMessage("world");
}`], {type:'text/javascript'})));

간단하지 않은가? 이제 우리는 코드를 Blob 객체로 만들어 url을 할당하기만 하면 실제 파일을 만드는 행위 없이도 해당 주소의 자바스크립트 파일을 로드하는 쓰레드를 생성할 수 있다. 문자열로 자바스크립트 코드를 짜는 부분이 불편하겠지만 우선 지금은 넘어가도록 하자.

workerPromise

지난시간까지 Promise를 통해 할 수 있는 많은 것들을 알아봤었다. 가장 중요한 특징은 함수실행의 시점을 분리함으로써 반제어역전을 이룬다는 것이었고 이를 통해 async iterator나 여러가지 구조와 패턴의 비동기 작업을 할 수 있었다.

그렇다면 다른 스레드에 작업을 넘기고 non-blocking을 달성하는 worker를 그냥 사용하는 것과 Promise를 같이 사용했을 때의 차이를 알아보자.

우선 자바스크립트는 언어차원에서 Promise를 기반으로 모든 문법을 지원한다는 것을 알아야 한다. async, await 부터 async iterator, async generator 까지 언어차원에서 Promise라는 표준을 지원하기 때문에 Promise로 랩핑하는 것과 랩핑하지 않는 것은 이러한 다양한 문법을 활용할 수 있느냐의 중대한 차이를 가진다.

우선 고정적으로 사용하는 MIME 타입을 미리 정의하자

const mime = {js:{type:'text/javascript'}}

이제 Worker 스레드를 사용하며 Promise를 리턴할 WorkerPromise라는 함수를 생각해보자. WorkerPromise 함수는 함수를 받아서 Promise를 리턴해주는 함수 를 반환하는 고차함수가 될 것이다.

const WorkerPromise = (f) => { 
  return (data) =>
    new Promise((res, rej) => {
   ....
    });
};

그렇다면 우리가 전달할 함수 f는 Worker 스레드가 참조할 자바스크립트 코드가 될 것이다. 또한 우리의 리턴함수가 리턴할 Promise는 worker 스레드를 동작시킬 것이다.

const WorkerPromise = (f) => {  
  const worker = Object.assign(
    new Worker(
      URL.createObjectURL(
        new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mime.js) //ES6표준은 함수를 문자열화 시켰을 때 함수내용 그대로를 문자열화함(네이티브 함수가 아닌 이상)
      )
    ), { onmessage: (e) => ..., onerror: (e) => ... } 
		return (data) =>
    new Promise((res, rej) => {
   ....
		worker.postMesssage(data);
    });
  );

worker의 코드는 메세지를 받으면 이를 인자로받은 f함수가 처리하여 그대로 다시 포어그라운드로 보내는 코드이다. ES6 표준에서는 자바스크립트 함수를 문자열화 시켰을 때 자바스크립트 네이티브 함수(내장 객체 프로토타입 함수)가 아닌 이상 함수 내용 그대로 문자열로 변환되도록 정의되어 있다. 즉 Object.toString() 이나 본체가 비어있는 함수가 나올 우려 없이 함수가 그대로 문자열화 될 것으로 믿고 사용할 수 있다. 자세히 살펴보면 함수를 괄호로 감싸고 인자를 전달함으로써 함수를 즉시실행했으니, 곧 함수실행의 반환값을 포어그라운드로 보낼 것을 알 수 있다. 이를 토대로 f함수의 시그니쳐를 쉽게 미루어 알 수 있다. 포어그라운드에서 전달하는 데이터를 받아서 백그라운드에서 실행한 뒤 다시 포어그라운드로 리턴값을 보내는 함수이다.

그럼 우리는 worker에게 백그라운드에서 f함수가 처리하고 받은 데이터를 받을 이벤트리스너를 달아줘야 한다. 이벤트리스너는 이벤트를 성공적으로 수신할 시 onmessage, 에러가 발생했을 시 onerror의 두가지 리스너가 있다. 이를 worker 객체에 Object.assign으로 그대로 할당해주면 된다.

그렇다면 우리가 리턴할 Promise는 바로 백그라운드에게 메세지를 보내는 과정을 랩핑하면 된다. 우리가 반환할 함수는 인자로 보낼 데이터를 받고, Promise는 백그라운드에 그 데이터를 보내는 과정을 감싸는 것은 확정되었다고 할 수 있다.

그렇다면 Promise를 리턴할 함수를 호출했을 때 전달되는 Promise에 then을 했을 때 일어나는 일에 대해 생각해볼 필요가 있다. 우리는 백그라운드로 메세지를 보내는 과정을 Promise로 감쌌고, worker의 2개의 이벤트리스너는 백그라운드로 보낸 그 메세지가 처리된 결과를 수신할 것이므로, 우리는 이벤트 수신 결과를 Promise가 then으로 해소되었을 때의 반환값으로 전달해주기만 하면 된다. 두개의 서로 다른 객체의 스코프를 연결해주기 위해서 이를 외부에서 클로져로 잡아주자.

const WorkerPromise = (f) => {
  let resolve, reject;
  const worker = Object.assign(
    new Worker(
      URL.createObjectURL(
        new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mine.js) 
      )
    ),
    { onmessage: (e) => resolve(e.data), onerror: (e) => reject(e.data) } 
  );
  return (data) =>
    new Promise((res, rej) => {
      resolve = res;
      reject = rej;
      worker.postMessage(data); 
    });
};

고차함수와 스코프 때문에 헷갈릴 수 있겠지만 사실 단순하다(맹 대표님의 코멘트입니다). WorkerPromise 함수는 호출될 때마다 매번 새로운 백그라운드 처리함수를 가지는 백그라운드 스레드를 생성하고, 호출 결과로 메세지를 인자로 보내면 백그라운드에 메세지를 전송하는 행위를 Promise로 담아 리턴하는 함수를 리턴한다. 해당 메세지가 백그라운드에서 정상적으로 처리됐다면 처리된 결과를 then의 인자로 전달할 것이고, 에러가 발생했다면 catch에 에러가 전달될 것이다.

Promise의 반제어역전이란 이렇게 복잡하고 어려운 제어를 가능하게 하는 것이다. Promise는 제어 자체를 일급객체인 함수로 바꿨기 때문에, 이 함수를 어디에 전달하느냐에 따라 완전히 다른 곳에서 Promise가 소비될 수 있다. 여기서는 그것을 worker 객체에 전달하기 위해 고차함수를 만들어 함수객체의 인자를 클로져 컨텍스트로 만드는 방법을 사용했다. 우리는 Promise가 래핑한 함수객체를 전달할 수 있는 곳이면 어디에서든지 Promise를 소비할 수 있다는 사실에 유의해야 한다. 이러한 반제어역전은 Promise만의 특성이 아닌, 비슷한 스펙을 사용하는 모든 언어에서 공통적으로 나타나는 특성이며, 우리는 이러한 반제어역전을 통해 우리가 원하는 형태로 then을 일으킬 수 있다.

이렇게 만든 고차함수가 리턴할 함수는 매번 새로운 Promise를 리턴하고 resolve 객체는 매번 새로운 값을 반환한다. 따라서 우리는 백그라운드 처리함수에 따라 서로 다른 스레드를 생성하고, 스레드에 메세지를 보낼 때마다 매번 새로운 Promise 객체를 리턴하는 재사용 가능한 함수를 얻게 됐다. 이제 실제 사용코드를 보자.

const addWorld = WorkerPromise(str=>str+"world");
addWorld("hello").then(console.log); //"hello world"

addWorld는 백그라운드에서 포어그라운드 메세지에 응답할 콜백을 안고 태어난 함수 이다. addWolrd의 인자로 전달한 메세지 스트링은 해당 콜백에 따라 처리되어 결과값을 Promise로 감싸 리턴하므로 then, 혹은 catch로 Promise를 해소하면 된다.

지금까지의 코드는 지난시간 강의까지의 엄격한 OOP 스타일 보다는 자바스크립트 원래의 스코프나 클로져를 사용하는 코드를 주로 사용하고 있다. 자바스크립트는 일급객체와 스코드 등의 특성이 합쳐저 굉장이 유연한 언어의 특성 때문에 키워드를 사용함에도 불구하고 상당히 짧고 간결하게 표현되는 특성이 있다. 이렇게 우리는 백그라운드 작업을 위한 workerPromise를 만들었다.

GrayScale

이제 우리가 만든 workerPromise 함수를 이용해서 grayscale 작업을 해보자. grayscale 작업은 시간이 상당히 오래걸리는 일이다. 왜냐하면 모든 픽셀을 전부 greyscale값으로 바꿔줘야 하는데 이미지 데이터의 크기는 일반 배열과는 비교도 안되게 크기 때문이다. 단적인 예로 FullHD의 픽셀 배열은 198010804(RGBA) 이니 배열의 크기가 쉽게 mb 사이즈를 넘어가고, 이로 인해 포어그라운드 블로킹이 유발되고 타임아웃이 발생한다. 따라서 이미지 작업은 백그라운드로 보내야 포어그라운드의 blocking을 막을 수 있다. 이미지 작업을 백그라운드로 보내는 일은 다른 언어라면 일반적인 일이다. 자바스크립트에도 이를 위한 수단과 방법이 없는 것이 아니니, 우리는 이미지 작업을 위해서 이를 학습하고 백그라운드작업은 백그라운드 작업에 맞는 수단과 방법을 쓸 수 있도록 해야한다.

그레이스케일 작업을 위한 백그라운드 함수를 정의해보자.

const grayscale = WorkerPromise((imgData) => {
  for (let i = 0; i < imgData.length; i += 4) {
    const v = 0.34 * imgData[i] + 0.5 * imgData[i + 1] + 0.16 * imgData[i + 2];
    imgData[i] = imgData[i + 1] = imgData[i + 2] = v;
  }
  return imgData;
});

그레이스케일이 받아들이는 데이터는 픽셀의 rgba값이 들어있는 픽셀 배열이다. 실제로 캔버스에게 요청했을 시 받을 수 있는 데이터가 이러한 전체 픽셀에 대한 RGBA 값의 데이터이다. 이를 백그라운드에서 루프를 돌며 grayscale로 변환할 것이다. 하나의 rgba인덱스인 4칸씩 루프를 돌며 grayscale의 비율에 따라 각각의 다른 비율을 혼합한 후 하나로 통일해주면 흑백으로 변환된 greyscale이 된다. 앞서 Web Worker는 복사본만을 보내도록 제한되었다고 했으니 인자로 주고받는 배열을 직접 변경하더라도 원본은 변하지 않는다.

그럼 이렇게 만든 grayscale 함수를 사용해보자.

img.onload=({target})=>{
	const {width,height} = target;
	const ctx = Object.assign(canvas,{width,height}).getContext('2d');
	ctx.drawImage(target,0,0);
	const imgData = ctx.getImageData(0,0,width,height).data;
	greyscale(imgData).then(v=>ctx.putImageData(new ImageData(v,width,height),0,0));
}

이미지의 load 이벤트 발생시 캔버스에 이를 똑같이 그려준 뒤 캔버스의 getImageData 메서드를 사용하면 ImageData 객체를 얻어온다. ImageData 객체는 data,width,height의 3가지 속성을 가지고 있고 data속성이 바로 rgba 배열을 가지고 있다. 이를 grayscale 함수로 보내 백그라운드에서 그레이스케일 처리를 완료한뒤 then으로 응답을 받아오면 이를 그대로 캔버스에 putImageData로 그려주기만 하면 된다. 요점은 이 작업은 백그라운드에서 일어나기 때문에 포어그라운드를 블로킹하거나 타임아웃을 일으키지 않는다는 점이다.

ArrayBuffer

ArrayBuffer은 원래 WebGL 표준 스펙이다. WebGL은 IE11이상, 안드로이드 진저브레드 이상이면 전부 지원되는 상당히 표준적인 스펙이다. 그런데 ES6 기준으로 자바스크립트에도 ArrayBuffer 스펙이 들어오게 되면서, WebGL을 지원하지 않는 브라우저에서도 ES6만 지원한다면 ArrayBuffer 기능을 사용할 수 있게 되었다.

ArrayBuffer란 무엇인가? 그 이전에, 자바스크립트 Array는 사실은 배열이 아니라 연결 리스트라는 사실을 알아야 한다. 배열은 고정된 메모리 인덱스와 크기를 할당받고 크기를 변경하는 것이 불가능하지만, 자바스크립트의 배열은 추가와 삭제가 자유로운 것을 보면 알 수 있다. 그러나 WebGL에서 GPU에 전달할 수 있는 데이터의 형태가 오직 배열형태만 가능하기 때문에, 자바스크립트에도 기존의 연결리스트 타입의 배열 내장객체 외에 실제 배열타입의 객체를 차후 추가하게 되었다 .

보통 타 언어에서 고정 배열 타입은 배열 한칸의 데이터 타입/크기에 따른 타입별 배열을 선언한다. int 배열은 한 칸의 크기가 4바이트이고 char 배열은 한 칸이 1바이트인 식이다. 하지만 자바스크립트는 이와 다르다. 어떤 방법을 사용하는지 알아보자.

new ArrayBuffer(12);

ArrayBuffer 객체는 할당할 바이트 크기를 인자로 받아서 생성된다. 따라서 ArrayBuffer는 기본적으로 바이트 Array이다. 위 ArrayBuffer 객체는 12바이트의 메모리 공간을 확보하게 된다. 하지만 이대로는 ArrayBuffer를 사용할 수 없다. ArrayBuffer를 읽고 쓰기 위해서는 View 객체가 필요하다. 마찬가지로, View 객체를 생성하기 위해서는 View가 쓰여질 원본 메모리 버퍼인 ArrayBuffer가 필요하므로, 이 2개는 정확하게 서로 같이 사용된다.

const intView = new Int32Array(new ArrayBuffer(12));
intView[0] = 10;
intView[1] = 20;
intView[2] = 30;

Int32Array 객체는 부호가 있는 4바이트 정수형 타입을 나타내는 View 객체이다. 12바이트의 원본 메모리 버퍼를 4바이트 정수형 타입 뷰 객체와 생성했으니, 이 뷰 객체는 총 3칸의 버퍼 메모리에 정수형 데이터를 읽고 쓸 수 있는 것이다.

다른 뷰 객체도 사용해보자.

const utiny= new Uint8ClampedArray(new ArrayBuffer(12))
utiny[0]=10;
utiny[1]=20;
....
utiney[11] = 100; 

Uint8ClampedArray는 부호가 없는 1바이트 정수형 타입을 나타내는 View 객체이다. 따라서 우리는 1바이트 양수 정수 데이터인 0~255까지의 숫자를 읽고 쓸 수 있는 12칸 짜리 고정 배열을 얻게 되었다. 즉 View 객체는 어떠한 고정된 바이트 메모리 버퍼에 읽고 쓸 수 있는 타입을 지정해주는 역할을 그대로 하고 있다. 다만 다른 언어에서처럼 이를 타입으로 취급하는 것이 아니라 각기 다른 객체로 선언할 뿐이다.

다음과 같이 하나의 바이트 메모리 버퍼를 서로 다른 뷰가 공유하여 사용하는 것도 가능하다.

const buffer = new ArrayBuffer(12);
const intView = new Int32Array(buffer);
intView[0] = 10;
intView[1] = 20;
intView[2] = 30;
const utiny = new Uint8ClampedArray(buffer);
utiny[0]=10;
utiny[1]=20;
utiny[2] = 30;

이 2개의 뷰는 충돌하지 않는다. Int32Array는 4바이트 타입이며, 뷰 객체는 엔디안을 따로 지정하지 않으면 기본적으로 빅 엔디안 방식으로 저장하기 때문에 1바이트짜리 utiny 배열 3칸 이후 4번째 메모리 주소에 intView의 숫자가 저장된다. 만약에 같은 위치에 값을 쓰더라도 해당 메모리 버퍼에 그대로 값이 덮어쓰여지게 된다. 따라서 우리는 하나의 바이트 메모리 버퍼에 다양한 타입에 데이터를 쓸 수 있는 것이다. 우리가 브라우저에서 문자열을 인코딩하고 받는 결과물도 바로 Utf8 1바이트 문자열 뷰 객체로 값이 쓰여진 바이트 메모리 버퍼이며, 이를 브라우저 텍스트 디코더에게 보내면 그대로 Utf8 뷰 객체로 읽어들여 문자열로 변환된다. 문자열이든 숫자든 타입에 상관 없이 거의 모든 타입의 뷰 객체로 읽고 쓸 수 있기 때문에, 다른 언어 코드를 컴파일한 데이터를 메모리 버퍼에 쓴 다음 이 메모리 버퍼를 다시 해당하는 뷰객체로 읽어 실행할 수가 있는데 이를 웹 어셈블리라고 한다. 변수를 가상메모리에 맵핑하는 v-table 을 실제 메모리 주소로 맵핑하고 뷰 객체 배열의 인덱스로 접근하기 때문에, 상수시간에 접근할 수 있을 뿐더러 자바스크립트에는 원래 존재하지 않는 타입을 디코딩 하는 뷰 객체에 지정된 타입에 따라서 결정할 수 있게 된다. 이것이 바로 웹 어셈블리의 속도가 매우 빠른 요인이다.

Shared Array Buffer 객체는 바로 이러한 ArrayBuffer 객체로만 사용할 수 있다. 앞서 Shared Array Buffer 외의 다른 객체는 포어그라운드와 백그라운드 간의 통신에서 전부 복사본을 주고 받도록 제한된다고 했었다. 자바스크립트가 메모리에 대한 참조를 백그라운드와 공유할 수 있는 유일한 객체가 바로 Shared Array Buffer이다. 고정된 메모리 버퍼의 데이터를 인코딩하고 디코딩하는 행위는 다른 언어에서는 매우 일반적인 일이며, 자바스크립트라는 언어가 이를 전부 대신해주기 때문에 우리에게 익숙하지 않을 뿐이라는 것을 알아야 한다.

그렇다면 공유 메모리 버퍼를 이용해서 앞서 했던 작업을 똑같이 해보자.

img.onload = ({ target }) => {
  //onload 이벤트
  const { width, height } = target;
  const ctx = Object.assign(canvas, { width, height }).getContext("2d");
  ctx.drawImage(target, 0, 0);
  const sObj = new SharedArrayBuffer(width * height * 4); 
  const u8c = new Uint8ClampedArray(sObj); 
  const imgData = ctx.getImageData(0, 0, width, height).data; 
  u8c.set(imgData); 
};

SharedArrayBuffer 객체는 ArrayBuffer 객체와 마찬가지로 바이트 크기를 인자로 생성한다. 우리에게 필요한 바이트 사이즈는 높이넓이4(rgba) 이다. rgba는 32비트 컬러 체계이기 때문에 하나의 픽셀 당 4바이트를 할당하여 전체 픽셀 수만큼의 rgba 배열을 얻을 수 있다. 이를 rgba 의 타입과 같은 0~255까지의 양의 정수 타입인 Uint8 뷰 객체로 씌운다. ClampedArray라는 타입은 잘못된 데이터를 씌우지 못하도록 보다 안정성을 강화한 뷰 객체이다. 최신 버전의 캔버스를 내장한 브라우저에서 이를 지원한다. 앞서 이미지로부터 data, width, height의 3가지 속성을 반환해주는 canvas의 getImageData의 메서드를 소개했었다. 이 중 data 속성은 rgba 배열을 가지고 있는데, 최신버전의 캔버스는 이를 자바스크립트 배열로 반환하지 않고 Uint8ClampedArray 뷰 객체로 감싼 ArrayBuffer 객체로 반환한다. ArrayBuffer와 ArrayBuffer 객체 간의 복사는 배열의 복사이므로 연결리스트에 비해서 훨씬 속도가 빠르며, 모든 뷰 객체는 set메소드를 통해 똑같은 크기의 ArrayBuffer 객체를 가지고 있는 뷰를 매우 빠른 속도로 복사할 수 있다. 따라서 우리는 새로 만든 Uint8 뷰에 캔버스에서 가져온 rgba 배열 뷰 객체를 바로 복사해줄 수 있다. 이렇게 복사해준 객체를 바로 grayscale 함수에 넘겨주는 것이 아니라, 이 뷰 객체가 가지고 있는 원본 Shared Array Buffer 객체를 넘겨주면 우리는 백그라운드 작업은 그대로 포어그라운드의 공유 메모리 객체에 반영된다.

따라서 이제 우리는 동시성의 문제에 직면하게 되었다. greyscale에 보낸 Shared Array Buffer 객체는 포어그라운드와 백그라운드에서 동시에 조작이 가능한 공유 객체이기 때문이다. 자바스크립트에 새로 도입된 atomics라는 도구를 이용해서 이를 해결할 수도 있지만 이번 강의에서는 이를 다른 방법으로 해결해볼 것이다.

우선 이제 Shared Array Buffer를 백그라운드로 넘길 것이므로 기존의 복사본을 넘기던 grescale 함수를 그에 맞게 수정해 준다.

const greyscale1 = WorkerPromise((sObj) => {
  const v = new Uint8ClampedArray(sObj);
  for (let i = 0; i < v.byteLength; i += 4) {
    const v = 0.34 * v[i] + 0.5 * v[i + 1] + 0.16 * v[i + 2];
    v[i] = v[i + 1] = v[i + 2] = v;
  }
  return sObj;
});
greyscale(sObj).then((_) => {
  const r = new Uint8ClampedArray(u8c.byteLength);
  r.set(u8c); 
  ctx.putImageData(new ImageData(r, width, height), 0, 0);
});

Shared Array Buffer를 받았으므로 이를 rgba 데이터 타입으로 읽고 쓸 수 있도록 Uint8ClampedArray 뷰 객체로 다시 한번 감싸준 뒤 greyscale 작업을 처리하고 포어그라운드로 보낸다. 즉 포어그라운드에서 보낸 공유 메모리 객체를 백그라운드에서 새롭게 작업한 greyscale 값으로 덮어씌운다.

이 때 주의할 점은 포어그라운드에서도 마찬가지로 새로운 뷰 객체를 비어 있는 ArrayBuffer 로부터 생성해서 기존에 공유했던 Shared Array Buffer의 원본 뷰 객체 u8c 를 복사한 뒤 이를 캔버스로 읽어들여야 한다. 그 이유는 캔버스는 Shared Array Buffer 상의 데이터에 대한 조작이 불가능하기 때문이다. 공유 메모리의 상태가 바뀌어 캔버스 데이터가 바뀌는 일이 발생할 가능성 때문에 캔버스 표준은 Shared Array Buffer가 아닌 일반 ArrayBuffer에서만 값을 읽는 것을 허용하고 있다. 또한 뷰 객체에는 기본적으로 byteLength와 length의 두가지 프로퍼티가 존재하는데, length는 뷰객체로 읽었을 때의 버퍼 크기를, byteLength는 뷰객체가 감싼 메모리 버퍼의 크기를 반환한다. 이 함수에서 사용하는 뷰객체는 Uint8로 실제 바이트 크기와 뷰가 읽는 크기가 동일하므로 두 프로퍼티의 값이 같다. (8비트==1바이트)

이렇게 우리는 더 이상 메모리 버퍼를 매번 복사할 필요없이 하나의 공유 메모리로 백그라운드에서 greyscale을 처리할 수 있다.

비슷하게 밝기 조절을 백그라운드에서 처리하는 brightness 함수를 생각해보자.

const brightness = WorkerPromise(({ rate, sObj }) => {
  const v = new Uint8ClampedArray(sObj); 
  for (let i = 0; i < v.byteLength; i++) {
    v[i] = v[i] * (1 + rate);
    v[i + 1] = v[i + 1] * (1 + rate);
    v[i + 2] = v[i + 2] * (1 + rate);
  }
  return sObj;
});
consy copy = () => {
    const r = new Uint8ClampedArray(u8c.byteLength); //캔버스에서 사용하기 위해서는 Uint8ClampedArray 뷰로 빈 어레이에 복사해줘야함.
    r.set(u8c); 
    ctx.putImageData(new ImageData(r, width, height), 0, 0);
});

brightness 함수는 밝기와 공유 메모리 객체를 하나의 객체로 감싼 인자를 받을 것이다. 이렇게 일반 자바스크립트 객체 안에 공유 메모리 객체를 감싸서 보내면 객체 자체는 복사되지만 키로 전달되는 공유 메모리 자체는 오직 참조만 주고 받는다. 앞서와 마찬가지로 새로운 뷰객체를 생성해서 공유 메모리를 설정할 밝기로 새롭게 덮어쓰면 된다.

앞서 공유 메모리 위에 백그라운드에서 처리한 작업을 포어그라운드에서 다시 뷰객체를 만들어 캔버스로 읽어들이는 함수를 재활용 할 수 있도록 copy라는 함수로 분리했다. 다음과 같이 백그라운드에서 처리가 끝날 때마다 이를 캔버스로 읽어들일 수 있다.

greyscale(sObj).then(_=>{
	copy();
	return bright({rate:-1, sObj})
}).then(copy)

그레이스케일 작업과 브라이트니스 작업을 순차적으로 수행하고 이를 순차적으로 캔버스로 읽어들였다.

그런데 다음과 같이 동시에 백그라운드로 공유 메모리를 넘기면 어떤 일이 발생할까?

greyscale(sObj).then(copy);
brightness({ rate: -0.1, sObj }).then(copy); 

바로 이 때 동시성 문제가 발생한다. 포어 그라운드는 백그라운드로 작업을 넘기자마자 blocking이 풀리고 바로 다음 코드를 실행하기 때문에 greyscale과 brightness는 동시에 서로 다른 백그라운드 스레드를 생성하고 공유 메모리 객체를 전달한다. 따라서 rgba 값에 대한 greyscale과 brightness 작업이 동시에 일어나게 되고 이에 따라 우리가 곱셈과 덧셈 연산이 순서가 뒤섞이고 바뀌어 우리가 원하던 결과가 나오지 않게 되어버린다. 자바스크립트에 전례없던 이러한 문제를 해결하기 위해서는 여러가지 멀티스레드에서의 수단과 기법을 활용할 필요가 있다. 이번 시간에는 그 중 대표적으로 Scheduled Queue 기법을 배워볼 것이다.

Scheduled Queue

Scheduled Queue는 여러가지 형식의 큐 중 링크드 리스트 형태의 큐를 말한다. 링크드 리스트 큐는 start와 end라는 포인터를 갖고 있으며 큐에 추가할 때는 end 노드에 연결하고, 삭제할 때는 start 노드부터 삭제한다. 우리는 스케쥴 큐를 WorkerPromise 함수 내부에 상태로 만들어 순차적인 Promise 처리를 보장할 것이다.

앞서 함수는 invocation 시점과 execution 시점을 분리할 수 있다고 했다. 우리는 이를 Promise를 생성하고 이를 클로져로 백그라운드 작업이 포어그라운드로 전달되는 이벤트 리스너에서 resolve 해줌으로써 재현해보았다. 즉 Promise를 생성 시점에 invoke 된 함수를 resolve, reject라는 클로져에 기록한 뒤, 이를 백그라운드의 처리 결과에 따라 execution되도록 위임한 것이다. 쉬운 비유로, 카페에서 우리가 커피를 주문하는 것을 invocation, 카페 직원이 주문을 전부 다 처리하고 나서 알람 벨을 울리는 것을 execution이라고 할 수 있다. execution을 위해서 카페 직원은 우리는 우리의 주문 내용과 번호(순서)가 적힌 주문서를 기록 하는 것이 필요하다. 만약 이 기록이 없다면, 카페 직원은 주문 받은 음료를 언제, 몇 번 손님에게 전달해야 하는지 전혀 모르게 될 것이다. Promise에서 이 기록 의 역할을 하는 것이 바로 resolve와 reject 객체이다. Promise가 랩핑한 함수의 내용을 그대로 보관하는 것이 바로 resolve와 reject의 역할이라고 할 수 있다. 이는 객체지향적으로 보면 커맨드 패턴에 해당된다.

이 원리만 기억하면 아까의 WorkerPromise 함수에 스케쥴 큐를 추가하는 작업을 어렵지 않게 구현할 수 있다. 우선 큐의 start와 end라는 포인터를 필드로 추가해보자. 가장 첫번째로 생성된 Promise가 소비되는 과정은 앞의 함수와 동일할 것이다.

const WorkerPromise1 = (f) => {
  let resolve, reject, start, end; //큐를 만든다.

  const worker = Object.assign(
    new Worker(
      URL.createObjectURL(
        new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mine.js)
      )
    ),
    {
      onmessage: (e) => (resolve(e.data)),
      onerror: (e) => (reject(e.data))
    }
  );
return (data) => new Promise((res,rej)=>{
		const v = {data, resolve:res, reject:rej};
		if(end) end=end.next=v;
		else{
			start=end=v;
      resolve = res;
      reject = rej;
      worker.postMessage(data); //Foreground에서 백그라운드로 메시지 보내기 worker.onmessage, worker.onerror는 메인 쓰레드 이벤트 리스너
    }
});
};
}

아까에 함수에서 상태를 캡쳐하는 객체 v를 하나 더 추가하였다. 이를 앞으로 Promise의 상태라고 바라보자. 첫번째 Promise는 큐의 start와 end 포인터가 모두 동일한 상태를 가리킬 것이다. 이 때 v의 resolve나 reject가 아직 해소되지 않은 상황에서 함수가 한번 더 실행되어서 Promise가 한번 더 생성된다고 해보자. 이 때는 end가 이미 이전 상태 v를 바라보고 있는 상황이므로 새로운 상태 v는 end의 next로 연결해줄 것이다. 할당식의 결과에 대해서도 주의해야 된다. 할당식은 자바스크립트에서 유일하게 오른쪽에서 왼쪽으로 파싱되는 식이다. 따라서 v를 할당하는 end=end.next=v 의 결과로 end가 end.next가 되며 end.next는 v를 바라본다. 즉 큐에 하나라도 이전 상태가 들어있는 경우에는 아래 코드의 if문이, 하나도 없이 모두 소비된 상태일 때는 else문이 실행될 것이다.

그러면 이제, 이벤트리스너에서 첫번째 Promise가 resolve되면 이벤트리스너에서 큐의 포인터를 다음으로 옮겨줘야 한다. 따라서 worker의 이벤트 리스너를 다음과 같이 수정하자

const WorkerPromise1 = (f) => {
  let resolve, reject, start, end; //큐를 만든다.

  const worker = Object.assign(
    new Worker(
      URL.createObjectURL(
        new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mine.js)
      )
    ),
    {
      onmessage: (e) => (resolve(e.data), next()),
      onerror: (e) => (reject(e.data), next())
    }
  );

아직 정확한 코드는 구현하지 않았지만 우선, 첫번째 promise가 resolve 된 이후에 큐 포인터를 옮겨줄 next()라는 함수를 호출한다고 해보자. 지연평가는 comma로 이루어진 구문을 순서대로 전부 실행하여 평가하고, 결과값으로는 뒤쪾에 오는 구문만을 반환한다. 따라서 이벤트 리스너는 순차적으로 promise를 resolve 한 후 next()를 호출할 것이다. 그럼 이제 실제로 큐를 소비하여 이동시켜주는 next함수를 정의해보자.


const WorkerPromise1 = (f) => {
  let resolve, reject, start, end, data;

  const worker = Object.assign(
    new Worker(
      URL.createObjectURL(
        new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mine.js)
      )
    ),
    {
      onmessage: (e) => (resolve(e.data), next()),
      onerror: (e) => (reject(e.data), next())
    }
  );
``

const next = () => {
    if (!start.next) {
			start = null;
			return //소비할 start가 없으면 리턴
		}
    ({ data, worker, resolve, reject } = start.next); 
    start = start.next;
    worker.postMessage(data);
  };
	...
}

next가 호출되기 전에 무슨 일이 일어났는지 다시 보자. 우리가 첫번째로 생성한 Promise의 상태를 start와 end가 동시에 바라보게 했고, 이를 worker의 이벤트 리스너에서 소비한 뒤에 next()가 호출되었다. 따라서 큐의 가장 초입을 가리키는 포인터 start는 next 호출 전에 항상 소비되고 없는 상태이므로 우리는 start의 다음 포인터가 바라보는 또다른 Promise 상태가 있는지 확인해서 없다면 next를 리턴시킬 것이다. 또한 큐에있는 모든 작업을 비운 뒤 다시 큐에 첫 Promise 상태를 인서트하려면 분기문에 따라 end 포인터가 null 이여야 하므로 start를 null로 바꿔준다.

만약 다음 Promise상태가 next 포인터로 존재한다면 우리는 포인터가 가리키는 Promise 상태를 해체 할당 할 것이다. 자바스크립트의 destructuring은 그 자체로 연산자이다. 따라서 이를 할당하면 우리가 초기에 클로져로 캡쳐했던 resolve와 reject는 let이므로 재할당 된다. 주의할 점은 문법상으로 자바스크립트 parser의 모순을 방지 하기 위해서 객체의 해체는 (const가 아니라면) 항상 소괄호 ()로 묶어줘야 한다.

우리의 worker는 항상 start 포인터가 가리키는 Promise 상태 v의 data와 resolve, reject만을 소비한다. 따라서 우리는 worker가 소비할 상태를 그 다음 포인터의 상태로 옮겨줘야 하는 것이다. 기존에 이미 클로져로 이벤트리스너에 연결해주었던 resolve와 reject는 그대로 재할당해주기만하면 worker의 다음 이벤트리스너가 다음 포인터의 resolve와 reject를 소비할 것이고, 캡쳐되어 있던 data는 다시 worker에게 넘겨 백그라운드 작업을 시작하게 할 것이다. 주의할 점은, 앞서 우리가 Promise 내에서 만들었던 if-else 분기문에 의해서, 큐가 비어있는 상태일 때와 큐가 차있을 때의 백그라운드 워커가 동작하는 시점이 다르다는 것이다. 비어있는 상태일 때는 else문에 해당되며 then으로 Promise가 해소되자 마자 worker가 동작하기 시작하지만, 큐가 차있는 경우에는 아무리 then을 해도 이전 큐의 작업이 끝나서 next()가 호출되기 전에는 worker가 동작하지 않는다. 즉 우리는 큐가 차있을 때는 worker의 동작을 비동기적으로 then 시점에 동작하도록 프로그래머가 결정하게 되지만, 이를 제외한 나머지 큐가 차 있는 경우는 계속해서 동기적으로 이벤트 리스너의 발동에 따라서 백그라운드 작업을 끊이지 않고 계속해서 수행하게 된다.

링크드 리스트의 장점이 여기서 나타나는데, 바로 자가 완결성 self description 이다. 스스로 스스로의 상태를 결정하는 성질을 가지고 있다.

let i=10;
	while(i--)brightness({rate:-.1,sObj}).then(copy)

그러면 이제 이런 동기적 코드를 작성할 수 있다. 동기적으로 백그라운드 요청을 여러차례 날려도 모두 큐에 스케줄되어 순차적으로 실행될 것이다. 그런데 여전히 문제가 하나 있다. 지금은 동일한 백그라운드 쓰레드와 큐를 안고 태어난 함수를 호출했기 때문에 큐에 스케쥴링이 가능했는데, 만약에 서로 다른 백그라운드와 큐를 안고 태어난 greyscale이나 brightness에 대해 동일한 공유 메모리를 넘겨주고 동기 실행을 하면 어떻게 될까? 물론 여전히 공유 메모리에 대한 동시성 문제가 발생할 것이다. 따라서 우리는 큐를 확장해서 여러 개의 스레드 작업이 동기적으로 호출되어도 동일한 큐를 사용하도록 코드를 조금 변경할 필요가 있다. 방법은 간단하다. 바로 즉시 실행함수를 이용하면 된다.

const SerialWorkerPromise = (() => {
  //여러 다른 쓰레드가 공통된 큐 사용
  let resolve, reject, start, end; 
  const next = () => {
    if (!start.next) return; 
    ({ data, worker, resolve, reject } = start.next); //메인쓰레드가 통제하는 통제변수
    //메인쓰레드가 싱글쓰레드라는 점을 이용해서 베타제어 시스템을 메인쓰레드에 구축하고, 멀티쓰레드가 메인쓰레드에 통제변수(스케줄 큐)에 따라 순차 실행되는 형태
    //즉 메인쓰레드만 로직을 변경하기 때문에 멀티 쓰레드가 로직에 변경을 가할 가능성은 0이라고 확정짓고 제어가 가능한 것
    start = start.next;
    worker.postMessage(data);
  }; //공통 큐를 스코프로 해결
  return (f) => {
    const worker = Object.assign(
      new Worker(
        URL.createObjectURL(
          new Blob([`onmessage=e=>postMessage((${f})(e.data));`], mine.js)
        )
      ),
      {
        onmessage: (e) => (resolve(e.data), next()), 
        onerror: (e) => (reject(e.data), next()),
      }
    );
    return (data) =>
      new Promise((res, rej) => {
        const v = { data, worker, resolve: res, reject: rej };
        if (end) {
          end = end.next = v;
        } else {
          start = end = v;
          resolve = res;
          reject = rej;

          worker.postMessage(data);
        }
      });
  };
})();

아까와 모두 동일한 함수지만 단지 클로저 변수들과 큐의 포인터들이 전부 즉시 실행함수의 내부 private 변수로 변경되었다. 즉, 앞으로 SerialWorkerPromise로 생성되는 고차함수들은 전부 동일한 큐를 이용한다. 여러 쓰레드가 동일한 큐를 사용하지만 메모리 공유의 문제가 일어나지 않는 것은, 메인 쓰레드에서 직접 worker의 postMessage 메서드를 호출하는 제어를 전부 담당하기 때문에, postMessage 이후 백그라운드 쓰레드에서 이루어지는 작업이 메인쓰레드에서 클로져 및 큐에 저장한 상태와 일치되며, 그 중간에 다른 스레드가 개입하여 resolve 객체를 바꿔치거나 할 염려가 없다. 그 이유는 자바스크립트의 메인쓰레드는 싱글 쓰레드이며 다른 쓰레드가 개입하지 않는, 이른바 자바의 synchronize 키워드를 사용한 것과 같은 동기화가 보장되기 때문이다. 따라서 우리는 메인쓰레드의 제어 변수의 순서나 유일성이 그대로 보장되면서, 나머지 백그라운드 쓰레드 들은 이러한 제어변수의 통제를 받을 것이라고 확신할 수 있는 것이다.

다만 이 때, worker 변수의 스코프가 리턴 함수 밖에는 존재하지 않기 때문에 우리는 캡쳐할 Promise의 상태에 백그라운드 작업을 요청할 worker도 같이 포함시켜야 한다.

이제 이렇게 SerialWorkerPromise로 만든 함수들은 서로 다른 스레드 워커를 사용하더라도 동일한 큐에 스케줄링 될 것이다. 이렇게 만든 백그라운드 처리함수들을 사용하면 공유 메모리 객체를 사용하면서도 동시성의 문제가 없이, 여러 다른 작업을 워커 스레드로 수행할 수 있다. mb 크기가 넘는 이미지들도 blocking 없이 이미지 처리를 할 수 있다. 강의에서 실제로 이미지 처리를 시연한 영상을 보면, 매우 크기가 큰 이미지에 대한 brightness 및 grayscale 작업을 동기적으로 10회 이상 호출할 때도 처리가 완료되는데 1초 정도의 시간밖에 소요되지 않는 것을 확인할 수 있다.

profile
inudevlog.com으로 이전해용

0개의 댓글