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

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

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

그 결과 기본 스레드가 엄청나게 과부하되었습니다. 웹 앱의 복잡성이 증가함에 따라 기본 스레드가 성능의 중요한 병목 현상이 됩니다. 게다가 기기 기능이 성능에 큰 영향을 미치기 때문에 특정 사용자의 기본 스레드에서 코드를 실행하는 데 걸리는 시간은 거의 완전히 예측할 수 없습니다. 사용자가 매우 제한적인 피처폰부터 고성능 고주사율 플래그십 기기에 이르기까지 점점 더 다양한 기기에서 웹에 액세스함에 따라 이러한 예측 불가능성은 더욱 커질 것입니다.

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

웹 작업자를 사용하는 이유

JavaScript는 기본적으로 기본 스레드에서 작업을 실행하는 단일 스레드 언어입니다. 하지만 웹 작업자는 개발자가 기본 스레드에서 작업을 처리하는 별도의 스레드를 만들 수 있도록 하여 기본 스레드에서 벗어날 수 있는 일종의 비상구를 제공합니다. 웹 워커의 범위는 제한적이며 DOM에 직접 액세스할 수 없지만, 그렇지 않으면 기본 스레드를 압도할 수 있는 상당한 작업이 필요한 경우 매우 유용할 수 있습니다.

Core Web Vitals의 경우 기본 스레드 외부에서 작업을 실행하는 것이 유용할 수 있습니다. 특히 기본 스레드에서 웹 작업자로 작업을 오프로드하면 기본 스레드의 경합을 줄일 수 있으므로 페이지의 다음 페인트까지의 상호작용 (INP) 반응성 측정항목을 개선할 수 있습니다. 기본 스레드에서 처리해야 하는 작업이 적을수록 사용자 상호작용에 더 빠르게 응답할 수 있습니다.

특히 시작 중에 메인 스레드 작업이 적으면 긴 작업이 줄어들어 최대 콘텐츠 렌더링 시간 (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);
});

이 접근 방식은 다소 제한적입니다. 이전에는 웹 작업자가 주로 기본 스레드에서 하나의 무거운 작업을 이동하는 데 사용되었습니다. 단일 웹 작업자로 여러 작업을 처리하려고 하면 금방 다루기 어려워집니다. 매개변수뿐만 아니라 메시지의 작업도 인코딩해야 하고 요청에 응답을 일치시키기 위해 장부를 작성해야 합니다. 이러한 복잡성 때문에 웹 작업자가 더 널리 채택되지 않은 것 같습니다.

하지만 기본 스레드와 웹 워커 간의 통신 어려움을 일부 제거할 수 있다면 이 모델은 다양한 사용 사례에 적합할 수 있습니다. 다행히도 이 작업을 수행하는 라이브러리가 있습니다.

ComlinkpostMessage의 세부정보를 생각하지 않고도 웹 워커를 사용할 수 있도록 하는 라이브러리입니다. Comlink를 사용하면 스레딩을 지원하는 다른 프로그래밍 언어와 거의 마찬가지로 웹 워커와 기본 스레드 간에 변수를 공유할 수 있습니다.

웹 워커에서 Comlink를 가져오고 기본 스레드에 노출할 함수 집합을 정의하여 Comlink를 설정합니다. 그런 다음 기본 스레드에서 Comlink를 가져오고, 작업자를 래핑하고, 노출된 함수에 액세스합니다.

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 및 WebUSB, WebRTC, Web Audio와 같은 여러 API에 액세스할 수 없으므로 이러한 액세스를 사용하는 앱 부분을 작업자에 넣을 수 없습니다. 하지만 워커로 이동한 작은 코드 조각은 사용자 인터페이스 업데이트와 같이 기본 스레드에 있어야 하는 항목을 위한 여유 공간을 더 확보해 줍니다.

웹 개발자의 한 가지 문제는 대부분의 웹 앱이 Vue 또는 React와 같은 UI 프레임워크를 사용하여 앱의 모든 것을 오케스트레이션한다는 것입니다. 모든 것이 프레임워크의 구성요소이므로 기본적으로 DOM에 연결됩니다. 이로 인해 OMT 아키텍처로 마이그레이션하기가 어려워 보입니다.

하지만 UI 문제가 상태 관리와 같은 다른 문제와 분리된 모델로 전환하면 프레임워크 기반 앱에서도 웹 워커가 매우 유용할 수 있습니다. PROXX에서 사용한 접근 방식이 바로 이 접근 방식입니다.

PROXX: OMT 사례 연구

Google Chrome팀은 오프라인으로 작동하고 매력적인 사용자 환경을 제공하는 등 프로그레시브 웹 앱 요구사항을 충족하는 지뢰 찾기 클론으로 PROXX를 개발했습니다. 불행히도 초기 버전의 게임은 피처폰과 같은 제한된 기기에서 성능이 좋지 않았고, 이로 인해 팀은 기본 스레드가 병목 현상이라는 것을 깨달았습니다.

팀은 웹 워커를 사용하여 게임의 시각적 상태를 논리에서 분리하기로 했습니다.

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

OMT는 PROXX의 피처폰 성능에 흥미로운 영향을 미쳤습니다. OMT가 아닌 버전에서는 사용자가 UI와 상호작용한 후 6초 동안 UI가 고정됩니다. 피드백이 없으며 사용자는 다른 작업을 하기 전에 6초를 완전히 기다려야 합니다.

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

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

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

이는 의도적인 절충안입니다. 고급 기기 사용자를 불이익을 주지 않으면서 제약이 있는 기기 사용자에게 더 나은 느낌을 주는 환경을 제공합니다.

OMT 아키텍처의 영향

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

  • 작업을 줄이는 것이 아니라 기본 스레드에서 작업을 이동하는 것뿐입니다.
  • 웹 작업자와 기본 스레드 간의 추가 통신 오버헤드로 인해 속도가 약간 느려질 수 있습니다.

영향 고려하기

JavaScript가 실행되는 동안 기본 스레드에서 스크롤과 같은 사용자 상호작용을 자유롭게 처리할 수 있으므로 총 대기 시간이 약간 길어질 수 있지만 프레임이 삭제되는 경우는 적습니다. 사용자가 대기 시간을 인식하기 전까지 수백 밀리초가 걸리지만 프레임이 삭제되는 데는 밀리초가 걸리므로 프레임을 삭제하는 것보다 사용자를 약간 기다리게 하는 것이 좋습니다.

기기 간 성능의 예측 불가능성으로 인해 OMT 아키텍처의 목표는 병렬화의 성능 이점이 아니라 위험 감소, 즉 매우 가변적인 런타임 조건에서 앱의 안정성을 높이는 것입니다. 복원력 향상과 UX 개선은 속도에서 약간의 절충이 있더라도 충분히 가치가 있습니다.

도구 관련 참고사항

웹 워커는 아직 주류가 아니므로 webpack, Rollup과 같은 대부분의 모듈 도구는 기본적으로 지원하지 않습니다. (Parcel은 지원됩니다.) 다행히도 webpack 및 Rollup과 함께 웹 작업자를 작업할 수 있는 플러그인이 있습니다.

요약

특히 점점 더 글로벌화되는 시장에서 앱이 최대한 안정적이고 액세스 가능하도록 하려면 제한된 기기를 지원해야 합니다. 대부분의 사용자가 전 세계에서 웹에 액세스하는 방식이기 때문입니다. OMT는 고급 기기 사용자에게 부정적인 영향을 주지 않으면서 이러한 기기의 성능을 향상할 수 있는 유망한 방법을 제공합니다.

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

웹 워커가 무서운 존재는 아닙니다. Comlink와 같은 도구는 작업자에서 작업을 제거하여 다양한 웹 애플리케이션에 적합한 선택사항으로 만듭니다.