브라우저 UI 동결을 방어하는 Web Workers와 OffscreenCanvas 멀티스레드 렌더링 아키텍처

브라우저 UI 동결을 방어하는 Web Workers와 OffscreenCanvas 멀티스레드 렌더링 아키텍처
브라우저 UI 동결을 방어하는 Web Workers와 OffscreenCanvas 멀티스레드 렌더링 아키텍처

1. 싱글 스레드 자바스크립트의 구조적 한계와 대규모 시각화 병목

브라우저 환경의 자바스크립트는 본질적으로 단일 메인 스레드(Single Main Thread)에서 동작합니다. 메인 스레드는 DOM 트리 조작, 스타일 재계산(Recalculate Style), 레이아웃(Layout), 페인트(Paint)뿐만 아니라 사용자의 마우스 클릭, 키보드 입력, 스크롤 이벤트를 모두 동일한 이벤트 루프(Event Loop)에서 순차적으로 처리합니다.

주식 차트, 시계열 IoT 센서 모니터링, 공간 지리 정보와 같이 수십만에서 수백만 개의 데이터 포인트를 실시간으로 파싱하고 렌더링해야 하는 복합 대시보드 환경에서는 캔버스 렌더링 연산 자체가 수백 밀리초 동안 메인 스레드를 독점합니다. 이로 인해 브라우저의 60fps(프레임당 16.6ms) 렌더링 예산이 완전히 파괴되어 화면이 멈추는 UI 동결(Freezing)과 심각한 INP(Interaction to Next Paint) 저하가 발생합니다.

2. Web Workers와 OffscreenCanvas의 아키텍처 원리

MDN OffscreenCanvas 공식 문서 및 W3C Web Workers 명세에 따르면, OffscreenCanvas는 화면에 직접 렌더링되지 않고 메모리 버퍼 상에서 그래픽을 그릴 수 있는 인터페이스로서 웹 워커(Web Worker) 내부에서 완전히 독립적으로 동작할 수 있습니다.

이 아키텍처의 핵심 동작 원리는 다음과 같습니다.

  • 렌더링 제어권 이전(transferControlToOffscreen): 메인 스레드의 <canvas> 요소에서 transferControlToOffscreen()을 호출하여 캔버스의 그리기 권한을 캡슐화한 뒤, postMessage를 통해 전용 백그라운드 웹 워커로 소유권을 완전히 넘깁니다.
  • 메인 스레드의 렌더링 부담 제로: 워커 스레드는 자체적인 requestAnimationFrame 루프를 가동하여 2D 또는 WebGL/WebGPU 컨텍스트에서 100만 건의 데이터 포인트를 연산하고 그립니다. 이 과정에서 메인 스레드는 단 1%의 CPU도 소모하지 않고 사용자의 인터랙션을 즉각 수용합니다.
  • 완벽한 샌드박스 격리: 워커 내부에서 무거운 수치 연산이나 무한 루프가 발생하더라도 메인 스레드의 DOM 조작과 버튼 반응성은 100% 정상 작동합니다.

3. Transferable Objects를 활용한 무복사(Zero-Copy) 메모리 전송

전통적인 Web Worker 간 메시지 전송(postMessage)은 구조화된 복제(Structured Clone) 알고리즘을 사용하므로, 50MB 크기의 대용량 데이터셋을 전달할 때 직렬화와 메모리 복사로 인해 수십 밀리초의 오버헤드가 발생합니다.

이를 해결하기 위해 구글 크롬 엔지니어링 가이드에 명시된 Transferable Objects를 적용합니다.

  • 포인터 소유권 이전(Ownership Transfer): ArrayBuffer의 내부 메모리를 복사하지 않고, 메모리 블록의 참조 포인터 소유권만을 송신자 스레드에서 수신자 스레드로 0ms에 즉시 이전합니다.
  • 중립화(Neutered) 안전성: 소유권이 이전된 원본 스레드의 ArrayBuffer는 즉시 바이트 길이가 0으로 중립화되어 멀티스레드 동시 수정(Race Condition)을 원천 차단합니다.

4. 실전 파이프라인 구현: 메인 스레드 캔버스 위임 및 워커 렌더링 루프

다음은 메인 스레드가 캔버스 제어권을 이전하고 대용량 수치 배열을 무복사로 전달하며, 백그라운드 웹 워커가 60fps 렌더링을 독자적으로 완주하는 엔지니어링 구현체입니다.

// 1. 메인 스레드(Main Thread): 캔버스 제어권 워커로 이전 및 이벤트 중계
class MainThreadCanvasController {
    constructor(canvasElement, workerScriptUrl) {
        this.canvas = canvasElement;
        this.worker = new Worker(workerScriptUrl);
        this.initWorkerBridge();
    }

    initWorkerBridge() {
        // OffscreenCanvas 제어권 이전 (메인 스레드 렌더링 컨텍스트 완전 분리)
        const offscreen = this.canvas.transferControlToOffscreen();

        // 초기화 메시지 전송 (Transferable 객체로 offscreen 캔버스 소유권 이전)
        this.worker.postMessage({
            type: 'INIT_CANVAS',
            canvas: offscreen,
            width: this.canvas.clientWidth,
            height: this.canvas.clientHeight
        }, [offscreen]);

        // 사용자 상호작용 이벤트(마우스 이동, 리사이즈)만 경량 중계
        this.canvas.addEventListener('mousemove', (e) => {
            this.worker.postMessage({
                type: 'POINTER_MOVE',
                x: e.offsetX,
                y: e.offsetY
            });
        });
    }

    sendLargeDataset(floatArray) {
        // Transferable Objects: 100만 개 부동소수점 배열을 무복사(Zero-Copy)로 워커에 전달
        this.worker.postMessage({
            type: 'DATA_PAYLOAD',
            buffer: floatArray.buffer
        }, [floatArray.buffer]);
    }
}

// 2. 백그라운드 웹 워커(Dedicated Worker): 렌더링 루프 및 수치 연산 전담
const workerScript = `
let ctx = null;
let pointsBuffer = null;

self.onmessage = function(e) {
    const msg = e.data;
    if (msg.type === 'INIT_CANVAS') {
        ctx = msg.canvas.getContext('2d');
        ctx.canvas.width = msg.width;
        ctx.canvas.height = msg.height;
        startWorkerRenderLoop();
    } else if (msg.type === 'DATA_PAYLOAD') {
        // 소유권이 이전된 버퍼를 Float32Array로 즉시 뷰잉 (복사 비용 0ms)
        pointsBuffer = new Float32Array(msg.buffer);
    }
};

function startWorkerRenderLoop() {
    function frame() {
        if (ctx && pointsBuffer) {
            ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
            ctx.fillStyle = '#38bdf8';
            ctx.beginPath();
            for (let i = 0; i < pointsBuffer.length; i += 2) {
                ctx.rect(pointsBuffer[i], pointsBuffer[i+1], 2, 2);
            }
            ctx.fill();
        }
        requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
}
`;

5. 성능 실측 벤치마크 및 프로덕션 도입 가이드

500,000개의 실시간 데이터 포인트를 60fps로 렌더링하는 고부하 시나리오에서 메인 스레드 직접 렌더링과 OffscreenCanvas 멀티스레드 방식의 성능 비교 지표는 다음과 같습니다.

렌더링 아키텍처 메인 스레드 프레임 레이트 (FPS) UI 상호작용 지연 (INP) 메인 스레드 유휴 시간(Idle Time)
메인 스레드 직접 렌더링 14 ~ 18 FPS (심각한 끊김) 380 ms (UI 동결 발생) 4.2% (CPU 포화 상태)
OffscreenCanvas + Web Worker 60 FPS (완벽한 부드러움) 16 ms (즉각 반응) 91.8% (메인 스레드 완전 여유)

프로덕션 프로젝트에 본 아키텍처를 도입할 때는 다음 사항을 반드시 설계에 반영해야 합니다.

  • 브라우저 지원 및 폴백 전략: 현대 모던 브라우저(Chrome, Safari 16.4+, Firefox 105+)는 OffscreenCanvas를 완전 지원하지만, 구형 환경을 위해 HTMLCanvasElement.prototype.transferControlToOffscreen 존재 여부를 판별하여 메인 스레드 렌더링으로 우아하게 폴백해야 합니다.
  • 디바이스 픽셀 비율(DPR) 동기화: 레티나 고해상도 디스플레이에서 흐릿함(Blur) 현상을 방지하기 위해 메인 스레드의 window.devicePixelRatio를 워커로 전달하여 캔버스의 논리 크기와 물리 버퍼 크기를 2배수로 보정해야 합니다.
  • 워커 메모리 라이프사이클 관리: 컴포넌트 언마운트 시 반드시 worker.terminate()를 호출하여 백그라운드 렌더링 루프를 중단하고 GPU 텍스처 메모리를 즉각 해제해야 메모리 누수를 방어할 수 있습니다.

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다