기본 스레드 외부 아키텍처는 앱의 안정성과 사용자 환경을 크게 개선할 수 있습니다.
지난 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);
});
물론 이 접근 방식에는 어느 정도 한계가 있습니다. 과거에는 웹 작업자가 주로 기본 스레드에서 하나의 무거운 작업을 이동하는 데 사용되었습니다. 단일 웹 작업자로 여러 작업을 처리하려고 하면 금방 다루기 힘들어집니다. 매개변수뿐만 아니라 메시지의 작업도 인코딩해야 하며 요청에 대한 응답을 일치시키기 위해 기록을 유지해야 합니다. 이러한 복잡성 때문에 웹 작업자가 더 널리 채택되지 않았을 수 있습니다.
하지만 기본 스레드와 웹 작업자 간의 통신에서 발생하는 어려움을 일부 제거할 수 있다면 이 모델은 많은 사용 사례에 적합할 수 있습니다. 다행히도 이 작업을 수행하는 라이브러리가 있습니다.
Comlink: 웹 작업자 작업 줄이기
Comlink는 postMessage의 세부정보를 고려하지 않고도 웹 작업자를 사용할 수 있도록 하는 것을 목표로 하는 라이브러리입니다. 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초를 기다려야 합니다.
하지만 OMT 버전에서는 게임이 UI 업데이트를 완료하는 데 12초가 걸립니다. 성능 저하처럼 보이지만 실제로는 사용자에게 더 많은 피드백을 제공합니다. 앱이 프레임을 전혀 전송하지 않는 비 OMT 버전보다 더 많은 프레임을 전송하기 때문에 속도 저하가 발생합니다. 따라서 사용자는 무언가 진행되고 있음을 알 수 있고 UI가 업데이트되는 동안 계속 플레이할 수 있으므로 게임이 훨씬 더 좋게 느껴집니다.
이는 의도적인 절충입니다. 제한된 기기의 사용자에게는 고급 기기 사용자에게 불이익을 주지 않으면서 더 좋은 환경을 제공합니다.
OMT 아키텍처의 영향
PROXX 예에서 볼 수 있듯이 OMT를 사용하면 앱이 더 광범위한 기기에서 안정적으로 실행되지만 앱이 더 빨라지지는 않습니다.
- 작업을 줄이는 것이 아니라 기본 스레드에서 작업을 이동하는 것입니다.
- 웹 작업자와 기본 스레드 간의 추가 통신 오버헤드로 인해 작업이 약간 느려질 수 있습니다.
장단점 고려하기
JavaScript가 실행되는 동안 기본 스레드가 스크롤과 같은 사용자 상호작용을 자유롭게 처리할 수 있으므로 총 대기 시간이 약간 길어지더라도 프레임 드롭이 줄어듭니다. 사용자를 약간 기다리게 하는 것이 프레임을 삭제하는 것보다 낫습니다. 프레임 삭제의 오차 범위가 더 작기 때문입니다. 프레임 삭제는 밀리초 단위로 발생하지만 사용자가 대기 시간을 인식하기까지는 수백 밀리초가 걸립니다.
기기 전반의 성능을 예측할 수 없기 때문에 OMT 아키텍처의 목표는 병렬화의 성능 이점이 아니라 위험을 줄이는 것, 즉 가변성이 높은 런타임 조건에서 앱을 더 강력하게 만드는 것입니다. 복원력 향상과 UX 개선은 속도 저하라는 작은 단점을 상쇄하고도 남습니다.
도구 관련 참고사항
웹 작업자는 아직 주류가 아니므로 webpack 및 Rollup과 같은 대부분의 모듈 도구는 기본적으로 웹 작업자를 지원하지 않습니다. (Parcel은 지원합니다.) 다행히 웹 작업자를 Webpack 및 Rollup과 함께 작동하게 만드는 플러그인이 있습니다.
- webpack용 worker-plugin
- Rollup용 rollup-plugin-off-main-thread
요약
특히 점점 더 세계화되는 시장에서 앱의 안정성과 접근성을 최대한 높이려면 제한된 기기를 지원해야 합니다. 전 세계 대부분의 사용자가 이러한 기기를 통해 웹에 액세스하기 때문입니다. OMT는 고급형 기기 사용자에게 부정적인 영향을 주지 않으면서 이러한 기기의 성능을 높일 수 있는 유망한 방법을 제공합니다.
또한 OMT에는 다음과 같은 부차적인 이점이 있습니다.
- JavaScript 실행 비용을 별도의 스레드로 이동합니다.
- 파싱 비용이 이동하므로 UI가 더 빠르게 부팅될 수 있습니다. 이렇게 하면 첫 콘텐츠 페인트 또는 상호작용까지의 시간이 줄어들어 Lighthouse 점수가 높아질 수 있습니다.
웹 작업자는 무서워할 필요가 없습니다. Comlink와 같은 도구는 작업자에서 작업을 가져와 다양한 웹 애플리케이션에 적합한 선택으로 만듭니다.