웹 작업자를 사용하여 브라우저의 기본 스레드에서 자바스크립트 실행

기본 스레드가 아닌 아키텍처는 앱의 안정성과 사용자 환경을 크게 개선할 수 있습니다.

수르마
수마

지난 20년 동안 웹은 몇 가지 스타일과 이미지를 사용하는 정적 문서에서 복잡한 동적 애플리케이션으로 크게 진화했습니다. 하지만 한 가지는 크게 변하지 않았습니다. 브라우저 탭당 하나의 스레드 (일부 예외는 있음)로 사이트를 렌더링하고 JavaScript를 실행하는 작업을 수행합니다.

그 결과, 기본 스레드가 엄청나게 과부하 상태가 되었습니다. 그리고 웹 앱의 복잡성이 증가함에 따라 기본 스레드는 성능에서 심각한 병목 현상을 유발합니다. 게다가 기기 기능은 성능에 막대한 영향을 미치기 때문에 특정 사용자의 기본 스레드에서 코드를 실행하는 데 걸리는 시간을 거의 완전히 예측할 수 없습니다. 이러한 예측 불가능성은 사용자가 극도로 제한된 피처폰에서 고성능 및 새로고침 빈도의 플래그십 기기에 이르기까지 점점 더 다양한 기기 집합에서 웹에 액세스함에 따라 더욱 커질 것입니다.

정교한 웹 앱이 인간의 인식과 심리에 관한 경험적 데이터를 기반으로 하는 코어 웹 바이탈과 같은 성능 가이드라인을 안정적으로 충족하도록 하려면 코드를 기본 스레드 (OMT)에서 실행할 방법이 필요합니다.

웹 작업자를 사용해야 하는 이유

자바스크립트는 기본적으로 기본 스레드에서 작업을 실행하는 단일 스레드 언어입니다. 그러나 웹 작업자는 개발자가 별도의 스레드를 가동하여 기본 스레드에서 벗어나 작업을 처리할 수 있도록 함으로써 기본 스레드에서 일종의 이스케이프 해치를 제공합니다. 웹 작업자의 범위가 제한적이고 DOM에 대한 직접적인 액세스를 제공하지는 않지만, 기본 스레드에 부담을 주는 상당한 작업이 이루어져야 하는 경우 상당한 도움이 될 수 있습니다.

코어 웹 바이탈의 경우 기본 스레드 외부에서 작업을 실행하는 것이 유용할 수 있습니다. 특히 작업을 기본 스레드에서 웹 작업자로 오프로드하면 기본 스레드의 경합을 줄일 수 있으므로 다음 페인트와의 상호작용 (INP)첫 입력 지연 (FID)과 같은 중요한 응답성 측정항목을 개선할 수 있습니다. 기본 스레드는 처리할 작업이 적을 때 사용자 상호작용에 더 빠르게 응답할 수 있습니다.

특히 시작 시 기본 스레드 작업이 줄어들면 긴 작업을 줄여 최대 콘텐츠 렌더링 시간 (LCP)을 줄일 수 있습니다. LCP 요소를 렌더링하려면 자주 발생하는 일반적인 LCP 요소인 텍스트나 이미지를 렌더링하는 데 기본 스레드 시간이 필요하며, 전반적인 기본 스레드 작업을 줄이면 웹 작업자가 대신 처리할 수 있는 값비싼 작업으로 인해 페이지의 LCP 요소가 차단될 가능성을 줄일 수 있습니다.

웹 작업자를 사용한 스레딩

일반적으로 다른 플랫폼에서는 스레드에 함수를 제공하여 병렬 작업을 지원합니다. 이 함수는 프로그램의 나머지 부분과 병렬로 실행됩니다. 두 스레드에서 동일한 변수에 액세스할 수 있으며 이러한 공유 리소스에 대한 액세스를 뮤텍스 및 세마포어와 동기화하여 경합 상태를 방지할 수 있습니다.

JavaScript에서는 웹 워커에서 유사한 기능을 얻을 수 있습니다. 웹 워커는 2007년부터 사용해왔고 2012년부터 모든 주요 브라우저에서 지원되고 있습니다. 웹 작업자는 기본 스레드와 동시에 실행되지만 OS 스레딩과 달리 변수를 공유할 수 없습니다.

웹 작업자를 만들려면 작업자 생성자에 파일을 전달합니다. 그러면 작업자 생성자가 별도의 스레드에서 파일 실행을 시작합니다.

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

postMessage API를 통해 메시지를 전송하여 웹 작업자와 통신합니다. postMessage 호출에서 메시지 값을 매개변수로 전달한 후 메시지 이벤트 리스너를 작업자에 추가합니다.

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

기본 스레드로 메시지를 다시 보내려면 웹 작업자에서 동일한 postMessage API를 사용하고 기본 스레드에서 이벤트 리스너를 설정합니다.

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

그러나 이러한 접근 방식은 다소 제한적입니다. 지금까지 웹 작업자는 주로 복잡한 작업 하나를 기본 스레드에서 이동하는 데 사용되었습니다. 단일 웹 작업자로 여러 작업을 처리하려고 하면 관리하기 어려워집니다. 즉, 매개변수뿐만 아니라 메시지의 작업까지 인코딩해야 하고 요청에 대한 응답을 일치시키기 위해 부기 작업을 수행해야 합니다. 이러한 복잡성으로 인해 웹 작업자가 더 광범위하게 채택되지 않았을 가능성이 높습니다.

하지만 기본 스레드와 웹 작업자 간의 통신 어려움을 완화할 수 있다면 많은 사용 사례에 이 모델이 잘 맞을 것입니다. 다행히도 이러한 작업을 하는 라이브러리가 있습니다.

Comlink는 개발자가 postMessage의 세부정보를 고려할 필요 없이 웹 작업자를 사용할 수 있도록 하는 것이 목표인 라이브러리입니다. Comlink를 사용하면 스레딩을 지원하는 다른 프로그래밍 언어와 거의 마찬가지로 웹 작업자와 기본 스레드 간에 변수를 공유할 수 있습니다.

Comlink는 웹 워커로 가져오고 기본 스레드에 노출할 함수 세트를 정의하여 설정합니다. 그런 다음 기본 스레드에서 Comlink를 가져오고 worker를 래핑하고 노출된 함수에 액세스합니다.

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

기본 스레드의 api 변수는 웹 작업자의 변수와 동일하게 작동하지만 모든 함수가 값 자체가 아닌 값의 프로미스를 반환한다는 점이 다릅니다.

웹 작업자로 어떤 코드를 옮겨야 하나요?

웹 작업자는 DOM 및 여러 API(예: WebUSB, WebRTC, 웹 오디오)에 액세스할 수 없으므로 이러한 액세스에 의존하는 앱 부분을 작업자에 배치할 수 없습니다. 그래도 작업자로 이동하는 작은 코드 조각이 있을 때마다 사용자 인터페이스 업데이트와 같이 있어야 하는 작업을 위해 기본 스레드에서 더 많은 헤드룸을 구매합니다.

웹 개발자에게 한 가지 문제는 대부분의 웹 앱이 Vue나 React와 같은 UI 프레임워크를 사용하여 앱의 모든 항목을 조정한다는 점입니다. 모든 것이 프레임워크의 구성요소이므로 본질적으로 DOM과 연결되어 있습니다. 따라서 OMT 아키텍처로 마이그레이션하기가 어려워 보일 수 있습니다.

그러나 UI 문제가 상태 관리와 같이 다른 문제와 분리된 모델로 전환하면 웹 워커는 프레임워크 기반 앱에서도 상당히 유용할 수 있습니다. 이것이 바로 PROXX에서 채택한 접근 방식입니다.

PROXX: OMT 우수사례

Chrome팀은 오프라인 작업, 몰입도 높은 사용자 환경 등 프로그레시브 웹 앱 요구사항을 충족하는 지뢰찾기 클론으로 PROXX를 개발했습니다. 안타깝게도 게임의 초기 버전은 피처폰과 같이 제한된 기기에서 제대로 작동하지 않아서 팀은 기본 스레드가 병목 현상이라는 사실을 깨달았습니다.

팀은 웹 작업자를 사용하여 게임의 시각적 상태를 로직과 구분하기로 했습니다.

  • 기본 스레드는 애니메이션과 전환의 렌더링을 처리합니다.
  • 웹 작업자는 전적으로 계산적인 게임 로직을 처리합니다.

OMT는 PROXX의 피처폰 실적에 흥미로운 영향을 미쳤습니다. OMT가 아닌 버전에서는 사용자가 상호작용한 후 6초 동안 UI가 정지됩니다. 피드백이 없으며 사용자는 6초 동안 기다려야 다른 작업을 할 수 있습니다.

PROXX OMT 외 버전의 UI 응답 시간입니다.

하지만 OMT 버전에서는 게임이 UI 업데이트를 완료하는 데 12초가 걸립니다. 성능 손실처럼 보이지만 실제로는 사용자에게 더 많은 피드백으로 이어집니다. 앱이 프레임을 전혀 전달하지 않는 비 OMT 버전보다 더 많은 프레임을 제공하기 때문에 속도가 느려집니다. 따라서 사용자는 무언가가 진행되고 있음을 알고 UI가 업데이트됨에 따라 계속 플레이할 수 있으므로 게임이 훨씬 더 나은 느낌을 줍니다.

PROXX OMT 버전의 UI 응답 시간입니다.

이는 의식적인 절충점입니다. 즉, 고급형 기기 사용자에게 불이익을 주지 않으면서 제한된 기기의 사용자에게 더 나은 사용 환경을 제공합니다.

OMT 아키텍처의 의미

PROXX 예에서 볼 수 있듯이 OMT를 사용하면 앱이 더욱 다양한 기기에서 안정적으로 실행되지만 앱이 더 빨라지지는 않습니다.

  • 기본 스레드에서 작업을 이동하는 것뿐이며 작업이 감소하지 않습니다.
  • 웹 작업자와 기본 스레드 간의 추가 통신 오버헤드로 인해 작업이 약간 느려질 수 있습니다.

장단점 고려

기본 스레드는 JavaScript가 실행되는 동안 스크롤과 같은 사용자 상호작용을 자유롭게 처리할 수 있으므로 총 대기 시간이 약간 더 길더라도 드롭된 프레임이 적습니다. 드롭된 프레임의 경우 오차 범위가 작기 때문에 사용자가 약간 기다리게 하는 것이 바람직합니다. 프레임 삭제는 밀리초 단위로 발생하는 반면 사용자가 대기 시간을 인지하기까지 수백 밀리초의 시간이 남게 됩니다.

여러 기기 간에 성능을 예측할 수 없기 때문에 OMT 아키텍처의 목표는 사실 병렬 처리의 성능상 이점을 누리는 것이 아니라 위험을 줄이는 것입니다. 복원력이 향상되고 UX가 개선됨에 따라 속도를 조금씩 떨어뜨릴 만한 가치가 충분합니다.

도구 관련 참고사항

웹 작업자는 아직 주류가 아니므로 webpackRollup과 같은 대부분의 모듈 도구는 기본적으로 이를 지원하지 않습니다. Parcel은 제공합니다. 다행히 웹 작업자가 webpack 및 Rollup과 함께 작동하도록 하는 플러그인이 있습니다.

요약

특히 점점 글로벌화되는 마켓플레이스에서 최대한 안정적이고 접근성이 높은 앱을 만들기 위해서는 제한된 기기, 즉 대부분의 사용자가 전 세계에서 웹에 액세스하는 방식을 지원해야 합니다. OMT는 고급형 기기 사용자에게 부정적인 영향을 주지 않으면서 이러한 기기에서 성능을 향상시킬 수 있는 좋은 방법을 제공합니다.

또한 OMT에는 다음과 같은 부수적인 이점이 있습니다.

웹 작업자를 두려워할 필요는 없습니다. Comlink와 같은 도구는 작업자의 업무를 줄이고 다양한 웹 애플리케이션에 대한 실용적인 선택이 되고 있습니다.

James PeacockUnsplash의 히어로 이미지