웹 성능 최적화를 위해 브라우저의 메인 스레드(Main Thread) 부담을 줄이는 것은 매우 중요합니다. 이번 글에서는 웹 초보자도 이해하기 쉽게 Web Worker, OffscreenCanvas, Comlink를 활용해 메인 스레드 작업을 분산하고 성능을 향상시키는 방법을 소개합니다.
Web Worker란? 왜 메인 스레드 작업을 분산해야 하나?
Web Worker는 브라우저에서 메인 스레드와 별도로 동작하는 백그라운드 스레드입니다. 쉽게 말해 웹 페이지의 UI와 독립적으로 실행되는 자바스크립트 환경입니다. Web Worker를 사용하면 메인 스레드가 해야 할 무거운 작업을 워커로 위임할 수 있어요
이로써 메인 스레드는 보다 중요한 UI 렌더링이나 사용자 입력 처리 등에 집중하고, 무거운 연산으로 인한 프리즈(멈춤) 현상을 줄일 수 있습니다.
웹 성능이 저하되는 흔한 이유는 메인 스레드가 과부하되어 UI가 응답하지 않는 상태가 되는 것입니다. 예를 들어 복잡한 계산이나 큰 이미지 처리 등을 메인 스레드에서 하면, 그동안 버튼 클릭이나 화면 렌더링이 멈출 수 있죠. Web Worker로 이러한 작업을 옮기면 브라우저가 다중 스레드처럼 작업하여 UI를 매끄럽게 유지할 수 있습니다. 요약하면, Web Worker는 웹 앱에 멀티스레딩 효과를 주어 성능을 높이는 도구입니다.
** 성능상 우월한 방법이 아닙니다! 병렬 처리를 위할때 유용합니다.
OffscreenCanvas: 워커에서 캔버스를 그리기 위한 비밀병기
Canvas를 활용한 그래픽 연산은 웹에서 많이 사용되지만, 기존에는 이 <canvas> 요소가 메인 스레드의 DOM과 연결되어 있어 워커에서 직접 조작할 수 없었습니다. OffscreenCanvas(오프스크린 캔버스)는 이런 제약을 해결해 주는 기술입니다. OffscreenCanvas는 화면에 보이지 않는 캔버스를 의미하며, 캔버스를 DOM과 분리하여 화면 밖(off-screen)에서 렌더링할 수 있게 해줍니다. 덕분에 Web Worker 내부에서도 캔버스에 그림을 그릴 수 있죠.
오프스크린 캔버스를 쓰는 이유는 간단합니다. 메인 스레드의 부담을 줄이기 위해서입니다. 예를 들어 복잡한 애니메이션이나 물리 시뮬레이션을 Canvas로 구현한다면, 메인 스레드에서 매 프레임 그림을 그리느라 다른 작업을 못할 수 있습니다.
OffscreenCanvas를 사용하면 이러한 캔버스 그리기 연산을 워커로 넘겨 병렬 처리할 수 있습니다. 메인 스레드는 UI 업데이트나 이벤트 처리에 집중하고, 워커는 OffscreenCanvas에 그림을 그린 다음 그 결과만 메인 스레드로 보내 화면에 표시하는 식이죠. 이렇게 하면 캔버스 애니메이션도 부드럽게 돌리고, UI도 끊김 없이 반응하도록 만들 수 있습니다.
Comlink: Web Worker와의 통신을 쉽게 해주는 라이브러리
Web Worker를 직접 사용하다 보면, postMessage와 onmessage로 메시지를 주고받는 코드가 다소 번거롭습니다.
Comlink(컴링크)는 이를 간단하게 만들어주는 경량 라이브러리입니다. Comlink를 쓰면 마치 메인 스레드에서 워커 내부 함수나 변수를 직접 호출하는 것처럼 프로그래밍할 수 있어요.
내부적으로는 postMessage를 추상화하여 자동으로 메시지를 전달해주기 때문에, 개발자는 복잡한 메시지 핸들러 대신 평범한 함수 호출 형태로 워커와 소통할 수 있습니다
예를 들어 Comlink 없이라면:
- 메인 스레드에서 worker.postMessage(data)로 데이터를 보내고,
- 워커 내부에서 self.onmessage로 이벤트를 받아 처리한 뒤,
- 다시 self.postMessage(result)로 결과를 메인에 보내고,
- 메인에서는 worker.onmessage로 결과를 받는
일련의 과정을 코딩해야 합니다.
Comlink를 쓰면 Web Worker와의 통신을 프록시 객체로 감싸서(일종의 RPC), 개발자가 쉽게 비동기 함수 호출처럼 다루도록 도와주는 도구입니다.
예제로 보는 Web Worker + OffscreenCanvas 활용 (Matter.js 물리 시뮬레이션)
이제 간단한 예시로 위 개념들을 연결해보겠습니다. Matter.js라는 자바스크립트 2D 물리 엔진을 이용해 공 몇 개가 튕기는 물리 시뮬레이션을 만든다고 가정해봅시다. 이 시뮬레이션은 계산량이 많으니 Web Worker에서 실행하고, 메인 스레드에서는 현재 프레임 수나 객체 개수 등의 count 상태만 화면에 표시하도록 해볼게요.
Main Thread
const canvas = document.getElementById('simCanvas');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('physics-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
// UI에서 1초마다 count 증가 표시 (워커와는 별개로 동작)
let count = 0;
setInterval(() => {
document.getElementById('counter').innerText = `Count: ${count++}`;
}, 1000);
- 메인 스레드(main.js): 메인 HTML에 <canvas id="simCanvas"></canvas>와 <div id="counter"></div>가 있다고 합시다. 메인에서는 워커를 생성하고 캔버스를 OffscreenCanvas로 넘깁니다.
- 위 코드에서 transferControlToOffscreen()은 DOM 캔버스를 OffscreenCanvas 객체로 변환하여 워커로 소유권 이전(transfer) 하는 역할을 합니다. 이렇게 하면 이후부터는 워커가 해당 캔버스에 직접 그림을 그릴 수 있어요. 또한 메인에서는 1초마다 단순 카운트 숫자를 올려서 #counter 영역에 표시합니다. 이 count 증가 로직은 메인 스레드에서 별도로 돌기 때문에, 물리 시뮬레이션이 돌아가더라도 UI 카운터가 멈추지 않고 계속 업데이트 됩니다.
Worker Thread
importScripts('matter.min.js'); // 워커에서 Matter.js 라이브러리 로드
let engine, render;
onmessage = (event) => {
const offscreenCanvas = event.data.canvas;
const ctx = offscreenCanvas.getContext('2d');
// Matter.js 엔진 초기화 (중력, 세계 설정 등)
engine = Matter.Engine.create();
// Matter.js에서는 render를 직접 사용하지 않고 OffscreenCanvas의 ctx로 그림
function update() {
Matter.Engine.update(engine, 16); // 물리 세계 한 스텝 진행 (approx 60fps)
drawScene(ctx, engine.world); // 사용자 정의: world의 객체들을 ctx로 그리기
requestAnimationFrame(update); // 다음 프레임 업데이트 예약
}
update(); // 시뮬레이션 시작
};
- 워커 스레드(physics-worker.js): 워커 쪽에서는 메인으로부터 메시지를 받아 OffscreenCanvas 객체를 얻습니다. 그리고 Matter.js 엔진을 초기화하고, 주기적으로 물리 시뮬레이션 업데이트 + 캔버스 렌더링을 합니다.
- 위 예시는 단순화를 위해 의사코드 형태로 나타냈지만, 핵심은 워커에서 주기적으로 Matter.js 엔진을 업데이트하고 OffscreenCanvas의 2D 컨텍스트에 결과를 그림입니다.
- requestAnimationFrame도 워커 환경에서 사용할 수 있는데, 이 경우 OffscreenCanvas에 그리는 것이므로 메인 화면에 바로 반영됩니다. 메인 스레드는 이 과정에 관여하지 않으므로, 물리 계산과 캔버스 렌더링으로 인한 메인 스레드 블로킹이 발생하지 않습니다. 메인은 그저 앞서 설정한 setInterval로 UI 카운트만 올리고 있을 뿐이죠.
- 메인에서의 결과: 실제로 실행해보면, <canvas>에는 워커가 그린 Matter.js 물리 시뮬레이션 (예: 공들이 튕기는 애니메이션)이 매끄럽게 표시되고, 동시에 <div id="counter">에는 1, 2, 3... 하는 카운트 숫자가 끊김 없이 증가하는 것을 볼 수 있습니다. 만약 워커를 사용하지 않고 모든 작업을 메인에서 했다면, 물리 연산이나 그리기로 인해 카운트 업데이트가 지연되거나 멈췄을 겁니다. 이처럼 워커+오프스크린 캔버스 구조는 무거운 연산을 백그라운드에서 처리하면서도 메인 UI를 부드럽게 유지시킵니다.
맺으며
정리하면, Web Worker는 무거운 작업을 메인 스레드에서 떼어내어 비동기로 처리할 수 있게 해주고, OffscreenCanvas는 그런 워커에서 그래픽을 그릴 수 있도록 도와주며, Comlink는 메인-워커 간 통신을 개발자 친화적으로 만들어 줍니다. 이 세 가지를 조합하면, 초기 단계의 웹 개발자도 비교적 쉽게 메인 UI의 성능을 최적화하는 구조를 설계할 수 있습니다.
** 다시 적지만, 애니메이션이 좀 더 매끄러워지는거 같은 성능상의 이점은 없습니다
다만 "무거운 연산"을 메인 쓰레드에서 분리 가능합니다.
reference
https://web.dev/articles/offscreen-canvas
'Front-end > 브라우저' 카테고리의 다른 글
다국어 지원을 위하여 tailwind에서 logical property로 overwrite하기 (3) | 2025.03.04 |
---|---|
Cross-Origin Read Blocking(CORB) (3) | 2022.02.04 |
크롬 브라우저의 이벤트 핸들링 처리 (0) | 2022.02.01 |
브라우저의 렌더링 과정 (feat GPU) (0) | 2022.01.30 |
[운영체제] 크롬 웹 브라우저의 구조 (프로세스 & 쓰레드) (0) | 2022.01.30 |