오프 메인 스레드 아키텍처를 사용하면 앱의 안정성과 사용자 환경을 크게 개선할 수 있습니다.
지난 20년간 웹은 몇 가지 스타일과 이미지를 가진 정적 문서에서 복잡하고 동적인 애플리케이션으로 크게 진화했습니다. 하지만 한 가지는 크게 변경되지 않았습니다. 사이트를 렌더링하고 JavaScript를 실행하는 작업을 하는 스레드는 브라우저 탭당 하나뿐입니다 (일부 예외 있음).
그 결과 기본 스레드가 과도하게 사용되었습니다. 웹 앱이 복잡해짐에 따라 기본 스레드가 성능의 주요 병목 현상이 됩니다. 더 큰 문제는 기기 기능이 성능에 큰 영향을 미치므로 특정 사용자의 기본 스레드에서 코드를 실행하는 데 걸리는 시간이 거의 완전히 예측할 수 없다는 것입니다. 이러한 예측 불가능성은 사용자가 극도로 제약된 피처폰부터 고성능, 고주사율 플래그십 머신에 이르기까지 점점 더 다양한 기기에서 웹에 액세스함에 따라 더욱 커질 것입니다.
정교한 웹 앱이 인간의 인지와 심리학에 관한 경험적 데이터를 기반으로 하는 코어 웹 바이탈과 같은 성능 가이드라인을 안정적으로 충족하도록 하려면 기본 스레드 (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를 가져오고, 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 및 WebUSB, WebRTC, Web Audio와 같은 여러 API에 액세스할 수 없으므로 이러한 액세스를 사용하는 앱 부분을 워커에 배치할 수 없습니다. 그럼에도 불구하고 작업자로 옮겨진 모든 작은 코드 조각은 사용자 인터페이스 업데이트와 같이 존재해야 하는 작업을 위해 기본 스레드에서 더 많은 헤드룸을 구매합니다.
웹 개발자에게는 한 가지 문제가 있습니다. 대부분의 웹 앱은 Vue 또는 React와 같은 UI 프레임워크를 사용하여 앱의 모든 것을 조정합니다. 모든 것이 프레임워크의 구성요소이므로 본질적으로 DOM에 연결되어 있습니다. 따라서 OMT 아키텍처로 이전하기가 어려울 것 같습니다.
그러나 UI 문제와 상태 관리와 같은 다른 문제가 분리된 모델로 전환하면 프레임워크 기반 앱에서도 웹 워커가 매우 유용할 수 있습니다. 이것이 PROXX에서 취한 접근 방식입니다.
PROXX: OMT 우수사례
Chrome팀은 오프라인 작업, 흥미로운 사용자 환경 등 프로그레시브 웹 앱 요구사항을 충족하는 지뢰찾기 클론으로 PROXX를 개발했습니다. 안타깝게도 게임의 초기 버전은 피처폰과 같이 제약된 기기에서 실적이 저조하여 팀은 기본 스레드가 병목 현상이라는 것을 깨달았습니다.
팀은 웹 작업자를 사용하여 게임의 시각적 상태를 로직과 분리하기로 결정했습니다.
- 기본 스레드는 애니메이션 및 전환의 렌더링을 처리합니다.
- 웹 워커는 순수하게 계산적인 게임 로직을 처리합니다.
OMT는 PROXX의 피처폰 성능에 흥미로운 영향을 미쳤습니다. OMT가 아닌 버전에서는 사용자가 UI와 상호작용한 후 6초 동안 UI가 정지됩니다. 아무런 피드백이 없으며 사용자는 다른 작업을 수행하려면 6초 동안 기다려야 합니다.
그러나 OMT 버전에서는 게임이 UI 업데이트를 완료하는 데 12초가 걸립니다. 성능 저하처럼 보이지만 실제로는 사용자에게 더 많은 피드백으로 이어집니다. 속도가 느려지는 이유는 앱이 프레임을 전송하지 않는 비 OMT 버전보다 더 많은 프레임을 전송하기 때문입니다. 따라서 사용자는 무언가가 진행 중임을 알고 UI가 업데이트됨에 따라 계속 플레이할 수 있으므로 게임이 훨씬 더 나아집니다.
Google은 고급형 기기 사용자에게 불이익을 주지 않으면서 제한된 기기 사용자에게 더 나은 느낌을 선사합니다.
OMT 아키텍처의 영향
PROXX 예에서 볼 수 있듯이 OMT를 사용하면 더 다양한 기기에서 앱을 안정적으로 실행할 수 있지만 앱 속도는 빨라지지 않습니다.
- 작업을 줄이는 것이 아니라 기본 스레드에서 작업을 이동하는 것입니다.
- 웹 작업자와 기본 스레드 간의 추가 통신 오버헤드로 인해 속도가 약간 느려질 수 있습니다.
장단점 고려하기
JavaScript가 실행되는 동안 스크롤과 같은 사용자 상호작용을 기본 스레드에서 자유롭게 처리할 수 있으므로 총 대기 시간은 약간 더 길더라도 누락된 프레임이 적습니다. 사용자가 잠시 기다리게 하는 것이 프레임을 삭제하는 것보다 낫습니다. 드롭된 프레임의 오차 범위는 더 작기 때문입니다. 프레임 삭제는 밀리초 단위로 발생하지만 사용자가 대기 시간을 인지하기까지 수백 밀리초가 걸립니다.
기기 전반에 걸쳐 성능을 예측할 수 없기 때문에, OMT 아키텍처의 목표는 병렬화의 성능상의 이점이 아닌, 가시적인 런타임 조건 속에서도 앱을 더욱 견고하게 만드는 위험을 감소하는 것입니다. 복원력 향상과 UX 개선은 속도 저하의 단점을 훨씬 상쇄합니다.
도구 관련 참고사항
웹 워커는 아직 주류가 아니므로 webpack 및 Rollup과 같은 대부분의 모듈 도구는 기본적으로 이를 지원하지 않습니다. (Parcel은 지원합니다.) 다행히 웹워커가 webpack 및 Rollup과 작동하도록 하는 플러그인이 있습니다.
- webpack용 worker-plugin
- 롤업용 rollup-plugin-off-main-thread
요약
특히 점점 더 글로벌화되는 시장에서 앱이 최대한 안정적이고 접근하기 쉬워지도록 하려면 제약된 기기를 지원해야 합니다. 제약된 기기는 전 세계 대부분의 사용자가 웹에 액세스하는 방식이기 때문입니다. OMT는 고급 기기 사용자에게 부정적인 영향을 주지 않으면서도 이러한 기기의 성능을 높이는 유망한 방법을 제공합니다.
또한 OMT에는 다음과 같은 부수적인 이점이 있습니다.
- JavaScript 실행 비용을 별도의 스레드로 이동합니다.
- 파싱 비용이 이동하므로 UI가 더 빠르게 부팅될 수 있습니다. 이렇게 하면 콘텐츠가 포함된 첫 페인트 또는 Time to Interactive가 줄어들어 Lighthouse 점수가 높아질 수 있습니다.
웹 작업자는 겁을 먹지 않아도 됩니다. Comlink와 같은 도구는 작업 부담을 덜어주고 다양한 웹 애플리케이션에 적용할 수 있는 실용적인 선택을 하고 있습니다.