[js] 웹워커 입문하기 편

개요top

현재 브라우저는 웹워커를 대부분 탑재하기 때문에 멀티코어의 장점을 브라우저에서 최대한 살릴 수 있는 환경입니다.
특히 브라우저 위의 웹워커는 단지 웹워커뿐만 아니라 다양한 브라우저용 API과 결합하여 훨씬 편리하게 사용할 수 있습니다.

사실 웹브라우저 환경을 제외한 일반적인 앱용 클라이언트 개발 시에는 강제적으로 백그라운드쓰레드를 사용하여 메인 쓰레드의 작업을 최대한 회피하도록 하고 있으며 이는 웹에도 당연히 적용되어야 할 일입니다. 이에 따라 폴리필을 포함한 전과정이 부드럽게 진행되면서도 백그라운드 쓰레드를 편리하게 사용할 수 있게 웹워커를 감싸보면서 공부해보도록 하겠습니다.

  • 코드는 es6이후의 문법으로 진행됩니다.
  • 본 내용에 나오는 많은 개념은 코드스피츠85의 마지막 회차 강의에서도 어느정도 다뤘으니 영상이 도움이 되실 수 있습니다.

웹워커를 사용하는 기본적인 방법top

입문하기 편 답게 가장 간단한 웹워커의 사용법과 흐름을 살펴보겠습니다.

우선 메인쓰레드에서는 웹워커를 생성하고 웹워커에게 어떠한 작업을 postMessage를 통해 의뢰합니다.

//main.js

const worker = new Worker("worker.js");
worker.postMessage("hello");
  1. 그럼 브라우저는 내부적으로 백그라운드 쓰레드를 생성하고 이 컨텍스트에서 worker.js를 로딩하여 실행하게 됩니다.
  2. 백그라운드에서 실행되고 있는 자바스크립트가 메인쓰레드가 postMessage로 요청한 메세지를 수신하려면 이벤트 리스너를 구현해야 합니다.
  3. 또한 다시 메인쓰레드에게 보고 싶다면 마찬가지로 postMessage를 통해 보고하게 됩니다.
//worker.js

onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};
  1. 위의 코드는 메인쓰레드에서 받은 데이터를 event.data로 부터 얻어 콘솔에 표시합니다. 콘솔은 백그라운드 쓰레드에서도 자유롭게 사용할 수 있죠.
  2. 그리고 나서 메인쓰레드에게는 world라는 데이터를 돌려줍니다.

그럼 메인쓰레드도 이 데이터를 수신해야하니 이벤트 리스너를 구현해야겠죠. 앞에 있던 main.js의 내용에 다음을 추가합니다.

//main.js

const worker = new Worker("worker.js");
worker.postMessage("hello");
worker.onmessage = ({data})=>console.log(data);

마지막에 추가된 worker의 이벤트 리스너가 백그라운드로부터 보내진 메세지를 수신하게 되는거죠.

사실 워커에 대한 사용법은 이게 전부 입니다. 그 외에 깊이 학습해야하는 내용은 오히려 백그라운드 쓰레드 환경에서 실행되는 것에 대한 내용입니다.

당연하게도 백그라운드 쓰레드엔 UI도 없고 DOM도 없습니다. 그것만봐도 메인쓰레드와는 다른 환경이 주어진다는 것을 알 수 있죠.

그럼 예를들어 백그라운드 쓰레드에서 ajax같은 통신은 할 수 있을까요? 같은 질문에 답하려면 백그라운드 쓰레드에서 지원되는 객체와 기능에 대해 살펴볼 필요가 있습니다.

우선 이 때 제공되는 글로벌 객체는 dedicatedWrokerGlobalScrope를 따릅니다. 여기에 대한 자세한 내용은 MDN링크를 참고하면 됩니다.
즉 제가 worker.js코드에 썼던 onmessage라던가 postMessage는 바로 이 워커용 글로벌 객체의 기능인 것이죠.

하지만 이것과 별도로 백그라운드 쓰레드에서 사용할 수 있는 수 많은 객체가 있습니다. 이것도 MDN링크를 참고해보면 됩니다.

생각보다 상당히 많은 것들을 할 수 있다는 걸 알 수 있습니다. 사실 상 실무적인 측면에서는 UI조작 외엔 거의 다 할 수 있죠.

여기까지 간단하게 웹워커와 사용법 및 기능에 대해서 살펴보았습니다. 그런데 왜 다들 안쓰고 있는 걸까요?

웹워커를 사용하지 않는 이유 3종 특선top

첫 번째는 아마도 폴리필 문제겠죠. 특히 외부 파일(worker.js)에 코드를 기술하는 방식이라 폴리필이 깨질 경우 똑같은 코드를 main.js에도 적어줘야하는 상황이 되어버립니다. 다른 폴리필과는 차원이 다른 귀찮음입니다. 아무리 최신 브라우저 대부분이 웹워커를 지원한다곤 해도 여전히 우리에겐..

두 번째 이유는 외부 파일을 분리해서 기술해야 한다는 점이겠죠. 헌데 오히려 이 점은 모듈형으로 파일을 나눠 개발하고 패킹하는 게 대세인 현 시점에 그렇게까지 단점인가 싶긴 합니다. 파일을 나누는 게 단점이라기보단 단일 컨텍스트에서 편리하게 흐름대로 작성하던 코드의 연속성이 깨지는게 싱글쓰레드 개발에 익숙한 사람에게 더 장벽으로 작용할 것 같다는 생각도 듭니다.

세 번째로 성능 상의 의구심입니다. 이미 싱글쓰레드에 비동기만 잘 맞춰줘도 화면이 충분히 부드럽게 도는데 뭘 더 해야해라는 거죠. 그런거 안해도 잘 돌고 있고 우리 서비스가 하드코어한 CPU연산을 시킬 것도 아닌데 굳이 해야하나라는 생각은 거의 대부분의 프론트엔드 개발자가 하고 있을 듯 합니다. 약간 관점을 바꿔보면 아무리 사소한 작업도 UI업데이트가 아닌 이상 전부 백그라운드로 빼는 건 다른 클라이언트 개발에선 너무나 당연한 기본이라는 것입니다. 이 기본을 지킬 수 없었던 환경이야말로 변태같은 프론트엔드 노하우를 만들어내는 현장이 되었던 것이죠 ^^
불편해지는게 아니라 오히려 아무런 생각없이 최적화 노하우도 없이 부드러운 UI를 구현할 수 있는 방법론이라고 생각해도 괜찮을 듯합니다.

그래서 세 번째는 개개인마다 와닿는 부분이 다르겠지만, 귀찮은 게 주 이유인 1, 2번은 코드 상으로 감싸서 어느 정도 보완할 수 있습니다. 이 포스팅의 목적 중 하나이기도 하죠.

외부 파일에 코드를 적지 않기top

웹워커는 생성 시 인자로 파일경로를 받습니다. 근데 아주 정확하게는 URL을 받고 있는 것입니다. 동일출처 제약은 물론 걸려있기 때문에 도메인을 아무거나 적는다고 작동하지는 않지만 우리가 “worker.js”라고 적으면 현재 도메인을 기준으로 상대경로를 파악한 URL로 인식해 로딩하는 것이죠.
따라서 이걸 제거하려면

  1. 무려 임의의 파일을 만들어내서
  2. 가상의 URL을 만들어낸 뒤
  3. 게다가 이 가상의 URL이 같은 도메인이어야한 상태로..
  4. 웹워커에게 전달한다

를 달성해야 합니다. 근데 말이죠…. 이게 됩니다 ^^
이 패턴이 굉장히 흔하게 사용되기 때문에 아예 Embedded workers라는 패턴이름으로 불릴 뿐 아니라 MDN에도 구체적인 예제가 나와있습니다.

근데 이 포스팅에서는 여기서 한 걸음 더 나아가 ES6이후에 적용된 Function.prototype.toString 스펙도 같이 이용해볼 생각입니다.
이에 맞춰 단계별로 전개해보도록 하죠. 여전히 위에 등장했던 간단한 worker.js 군이 수고를 계속하겠습니다.

worker.js의 내용을 함수로 만들자top

말 그대로입니다. 우선 함수를 만들어 worker.js의 내용을 밀어 넣습니다.

//main.js

const workerJS =_=>onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};

ES6는 함수의 toString이 반드시 그 함수 전체의 코드를 다 표현하도록 표준으로 규정하고 있습니다. 따라서 우리는 저 함수를 정의해두면 문자열을 얻을 수 있죠. 문제는 함수의 문자열을 얻어서 어떻게 파일을 만들어내냐는 것입니다.

파일이 뭘까 좀 더 고민해보면, 사실 SSD에 저장되어있냐 메모리에 저장되어있냐는 위치는 그렇게 중요하지 않다는 사실을 알 수 있습니다. 그냥 어떤 데이터를 묶어서 파일이라는 하나의 단위로 본다는 것이죠. 이 때의 데이터는 언어 내부에서 조작되는 변수나 객체같은게 아니라 순수한 바이너리 덩어리로서 내부를 신경쓰지 않고 담아둔 그릇이라 할 수 있습니다.
이렇게 어떤 텍스트나 바이너리로부터 덩어리진 데이터를 만들어내는 객체가 바로 Blob이라는 녀석입니다.
따라서 텍스트를 Blob으로 환원하면 일종의 파일과 같은 상태가 됩니다. Blob의 상세한 API는 위의 링크를 참고하시고 여기서는 어떤 함수를 받아들여 그 함수를 Blob로 바꾸는 함수를 하나 만들어보죠.

//main.js

const workerJS =_=>onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};

const process = f=>{
  const blob = new Blob([f.toString()], {type:'text/javascript'});
};

위에 추가된 process함수는 f인자로 함수를 받아 이를 Blob객체로 만들어주는데 두 번째 인자는 만들어질 데이터덩어리가 어떤 데이터인지 마인타입으로 표현하는 것입니다.

이제 덩어리진 파일 비스무리한 데이터를 메모리내에 생성하긴 했는데 이걸 어떻게 URL로 만드냐는 것이죠.
이것도 다 제공이 됩니다 ^^ 브라우저가 제공하는 URL객체는 URL.createObjectURL메소드를 통해 특정 메모리를 같은 도메인 내의 URL로 만들어냅니다(자세한건 MDN으로 미루죠 ^^)

따라서 process함수를 좀 더 개선하면 인자로 받은 함수를 URL로 바꿀 수 있게 되는 것입니다.

//main.js

const workerJS =_=>onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};

const process = f=>{
  const blob = new Blob([f.toString()], {type:'text/javascript'});
  const url = URL.createObjectURL(blob);
};

참 쉽죠. 좋은 세상입니다만 모든 브라우저가 이것을 지원하는 것은 아닙니다. 웹워커의 폴리필은 간단하게도 워커를 실행하지 않고 직접 함수를 실행하는 것이니까 이를 아예 코드에 반영해보죠.

//main.js

const workerJS =_=>onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};

const okWorker = Blob && URL && URL.createObjectURL;
const process = f=>{
  if(okWorker){
    const blob = new Blob([f.toString()], {type:'text/javascript'});
    const url = URL.createObjectURL(blob);
    //이후 웹워커로 처리된다.
  }else{
    //걍 f함수 호출로 버틸 예정
  }
};

머 정말 엄청나게 정교한 디텍팅도 생각해볼 수 있겠지만 원하는 스펙은 대충 저 정도라 간단히 폴리필을 구현할 수 있습니다.

아직 워커를 발동하진 않았지만 상당히 귀찮았던 1, 2번의 이유를 해소하는데 까지는 성공했네요. 이제 평범하게 함수를 만들어서 넘기면 백그라운드에서 작동하는 함수의 기본틀이 완성되었습니다.

Promise인터페이스로 연결하기top

헌데 요즘 세상에 웹워커의 postMessage + onmessage는 너무 구닥다리 아닌가요. 이래서야 프라미스로 대동단결된 기존 코드에 삽입되기는 무척이나 어려울 것입니다.
그래서 함수의 내용을 프라미스로 반환할 수 있는 구조를 생각해볼건데요, 그 이전에 구체적으로 우리는 worker.js를 대신할 함수란 과연 무엇인가 생각해볼 필요가 있습니다.

지금까지 등장했던 worker.js를 그대로 옮긴 함수는 다음과 같습니다.

//main.js

const workerJS =_=>onmessage = ({data})=>{
  console.log(data);
  postMessage("world");
};

하지만 실제로 우리가 백그라운드 쓰레드에 저런 식으로 요청하는 코드를 짠다는 건 불편하기 그지 없습니다. 그저 단순히 반환하는 함수가 훨씬 유리하죠. 예를 들어 단순히 인자로 data를 받아들이고 그저 “world”를 반환하게 고쳐봅시다.

//main.js

const workerJS = data=>{
  console.log(data);
  return "world";
};

이제 웹워커 전용 함수가 아니라 우리가 일반적으로 사용하는 함수와 같은 모양이 되었습니다. 그럼 이 함수를 감싸서 postMessage가 작동하도록 해야할 것입니다.

//main.js

const workerJS = data=>{
  console.log(data);
  return "world";
};

const okWorker = Blob && URL && URL.createObjectURL;
const process = f=>{
  if(okWorker){
    const blob = new Blob([`onmessage=({data})=>postMessage((${f})(data));`], {type:'text/javascript'});
    const url = URL.createObjectURL(blob);
    //이후 웹워커로 처리된다.
  }else{
    //걍 f함수 호출로 버틸 예정
  }
};

아까와 달라진 점은 우선 workerJS함수가 일반적인 함수모양이 되었다는 점이고 두번째는 blob을 만들 때 단순히 f.toString()이 아니라 템플릿 문자열을 통해 만들었다는 것입니다.

`onmessage=({data})=>postMessage((${f})(data));`

이 부분입니다. 만약 f인자로 workerJS가 전달되었다면 ${f}에 의해 다음과 같은 문자열이 될 것입니다.

`onmessage=({data})=>postMessage((data=>{
  console.log(data);
  return "world";
})(data));`

이게 문자열이 아니라 그냥 코드라면

onmessage=({data})=>postMessage((data=>{
  console.log(data);
  return "world";
})(data));

이렇게 되어 해석해보면

  1. onmessage에 이벤트가 들어오면
  2. 괄호 안의 함수에게 event.data를 넘겨
  3. 그 결과를 즉시 postMessage로 보내준다.

라는 뜻이 됩니다. 여기까지 완성되면 드디어 process 함수가 실제 worker를 만드는데 까지 전진 시킬 수 있죠.

//main.js

const workerJS = data=>{
  console.log(data);
  return "world";
};

const okWorker = Blob && URL && URL.createObjectURL;
const process = f=>{
  if(okWorker){
    const blob = new Blob([`onmessage=({data})=>postMessage((${f})(data));`], {type:'text/javascript'});
    const url = URL.createObjectURL(blob);
    const worker = new Worker(url);
    worker.onmessage =_=>{}; //아직 뭐할 지 미정
    worker.onerror =_=>{};   //아직 뭐할 지 미정
    
  }else{
    //걍 f함수 호출로 버틸 예정
  }
};

위의 코드에서 드디어 워커를 만들고 이벤트리스너를 걸었습니다. 근데 워커는 언제 postMessage를 보내야하는 걸까요?

이 과정을 통해서 인자로 들어온 f함수가 백그라운드에서 워커로 호출될 수 있다는 점은 좋지만 1회성이 아니라 제네레이터처럼 여러번 호출할 수 있는 함수를 반환하는 고차함수로 만드는 편이 훨씬 유리합니다. 즉 아래와 같이 사용할 수 있게 하는거죠.

const helloWorker = process(helloJS);
helloWorker("abc1");
helloWorker("abc2");

const worldWorker = process(worldJS);
worldWorker("abc3");
worldWorker("abc4");

이러한 고차함수에서는 반환된 함수가 호출되는 시점에 postMessage가 발동될 것이고 그때 보낸 인자가 바로 data가 될 것입니다.
이에 맞춰 process함수의 나머지를 짜보죠.

//main.js

const okWorker = Blob && URL && URL.createObjectURL;
const process = f=>{
  if(okWorker){
    const blob = new Blob([`onmessage=({data})=>postMessage((${f})(data));`], {type:'text/javascript'});
    const url = URL.createObjectURL(blob);
    const worker = new Worker(url);
    worker.onmessage =_=>{}; //아직 뭐할 지 미정
    worker.onerror =_=>{};   //아직 뭐할 지 미정

    return data=>worker.postMessage(data);
  }else{
    return data=>f(data);
  }
};

이제 process는 함수를 반환하는 고차함수가 되었습니다. 특히 워커가 지원되지 않을 때는 무심한듯 시크하게 f함수를 그냥 호출하여 결과를 반환하도록 폴리필 됩니다.
직접 사용해보면

const test = process(v=>v+2);
test(1);
test(2);

이렇게 사용되겠죠. 근데 말이죠. 이상하잖아요. 웹워커는 비동기적인 작동이라서 함수의 반환값을 줄 수 없고 onmessage로 받아야합니다. 이를 함수로서 감추려면?
그렇습니다. 프라미스를 반환하는 함수로 만들면 됩니다. 프라미스의 resolve시점을 onmessage로 reject시점을 onerror로 매핑하면 아름다워질 것입니다.
프라미스를 반환하는 함수가 되도록 개조해봅시다.

//main.js

const okWorker = Blob && URL && URL.createObjectURL;
const process = f=>{
  if(okWorker){
    const blob = new Blob([`onmessage=({data})=>postMessage((${f})(data));`], {type:'text/javascript'});
    const url = URL.createObjectURL(blob);
    const worker = new Worker(url);
    worker.onmessage = ({data})=>resolve(data);
    worker.onerror = ({data})=>reject(data);
    let resolve, reject;
    return data=>new Promise((res, rej)=>{
      resolve = res;
      reject = rej;
      worker.postMessage(data);
    });
  }else{
    return data=>Promise.resolve(f(data));
  }
};

개조된 부분부터 살펴보면
1. 반환될 함수는 호출될 때마다 프라미스를 생성해 리졸브와 리젝트 함수가 만들어지지만
2. 스코프 상의 워커는 하나로 동작하기 때문에 이 둘을 매핑하려면 이 둘 사이를 보간하는 자유변수가 필요하게 됩니다.
3. 이를 코드에서는 let resolve, reject로 정의했습니다.
4. 함수의 호출로 실제 프라미스가 생성되면 인자로 들어온 res, rej를 이 보간해주는 변수에 할당한 뒤 postMessage를 호출합니다.
5. 그 결과 onmessage나 onerror 시점에 프라미스가 해소되는 것이죠.

이에 맞춰 폴리필쪽도 프라미스의 resolve로 반환하여 인터페이스를 동일하게 해줍니다.
다시 사용해보면 다음과 같이 쓸 수 있게 됩니다.

const test = process(v=>v + 2);
(async()=>console.log(await test(5)))(); //7 출력

결론top

이제 막 모던 웹워커에 입문하신 걸 축하드립니다. 외부에는 거의 노출도 되지 않으면서 심리스하게 프라미스로 노출되는 평범한 로직을 함수로 작성할 수 있게 되었습니다.
이 단계만 되어도 상당히 많은 로직을 기본적으로 백그라운드로 손쉽게 옮길 수 있죠.

하지만 지금 구현체에서도 주의할 점이 없는 것은 아닙니다.

  1. process에 전달되는 함수는 결국 문자열로 치환되므로 자유변수등을 사용하면 안되고 스코프 영향이 없이 완전한 함수여야 한다.
  2. 동시성 접근문제를 해결해야 한다.

1번은 예를 들어 다음과 같은 함수는 제대로 작동하지 않을 것이라는 겁니다.

const base = 16;
const test = process(v=>base + v);

위에서 process에 넘긴 함수는 자유변수 base를 참조하고 있습니다. 하지만 실제로는 문자열로 환원되어 백그라운드에서 실행되기 때문에 base라는 변수는 존재하지 않게 되는 거죠.

2번은 무슨 얘기일까요.
기본적으로 웹워커에 전달하는 데이터는 무조건 복사본이 넘어가게 됩니다. 하지만 SharedArrayBuffer라는 표준 구현체를 이용하면 복사본이 아니라 참조본을 넘길 수도 있습니다.
이러한 참조는 메인쓰레드와 백그라운드 쓰레드가 동시에 접근하여 수정할 수 있기 때문에 크리티컬섹션이라 알려진 동시 접근 문제가 발생하게 됩니다.
따라서 이를 주의해서 작성해야만 안전하게 사용할 수 있죠.

이러한 내용은 추후 심화편에서 다뤄보기로 하고 이번에는 웹워커를 사용할 수 있는 토대를 익히는 정도에서 마무리할까합니다.

그럼 여러분 UI업데이트 말고는 사소한 작업이라도 백그라운드에 웹워커로 보내는 코드로 바꿔봅시다.

부록: 코틀린JS버전 ^^

코틀린JS에서도 거의 비슷하게 사용할 수 있는데 간단한 컨버팅으로 가능합니다.

import ein.core.core.elazy
import org.w3c.dom.Worker
import org.w3c.files.Blob
import kotlin.js.Promise

object eWorker{
    private val okWorker by elazy {
        js("Blob && URL && URL.createObjectURL") != undefined
    }
    private val map = mutableMapOf<String, (Any)->Promise<Any>>()
    operator fun set(k:String, block:(Any)->Any){
        if(!okWorker) map[k] = {Promise.resolve(block(it))}
        else{
            val worker = Worker("${js(
            """URL.createObjectURL(new Blob(["onmessage=e=>postMessage((" + block + ")(e.data));"], {type:'text/javascript'}))"""
            )}")
            var resolve = {it:Any->Unit}
            var reject = {it:Throwable->Unit}
            worker.onmessage = {resolve(it.data!!)}
            worker.onerror = {reject(throw Throwable("$it"))}
            map[k] = {Promise{res, rej->
                resolve = res
                reject = rej
                worker.postMessage(it)
            }}
        }
    }
    operator fun invoke(k:String, v:Any) = map[k]?.invoke(v)
}

인터페이스상 함수를 만드는 과정을 set으로 처리해서 내부에 map으로 잡았고, 사용시엔 등록시 부여한 키를 이용하도록 개조했을 뿐입니다.

실제 사용은 다음과 같이 확인해보면 됩니다.

class WorkerTest{
    @Test
    fun step1() = Promise<Boolean> { resolve, _ ->
        eWorker["test"] = {(it as Int) + 5}
        eWorker("test", 10)?.then {
            assertEquals(15, it)
            resolve(15 == it)
        }
    }
}

15가 잘 나오는 것을 확인할 수 있습니다.