Web Worker 실무 적용기

image

들어가며

실무에서 TensorFlow.js 기반 발 인식 모델을 실행하기 위해 Web Worker를 활용한 경험이 있었다. 이 과정에서 언제, 왜, 어떻게 Web Worker를 사용해야 하는지를 깊이 고민하게 되었고, 그 내용을 정리해 두면 앞으로 유사한 프로젝트를 진행할 때 큰 도움이 될 것 같아 글로 남긴다.

1. Web Worker란?

Web Worker는 자바스크립트 자체 기능이 아닌, 브라우저가 제공하는 백그라운드 스레드 실행 도구다. 자바스크립트는 싱글 스레드 언어로, 한 번에 하나의 작업만 처리한다. 따라서 메인 스레드에서 무거운 연산을 수행하면 UI가 멈추거나 버벅거림이 발생한다.

Web Worker를 사용하면 이런 연산을 메인 스레드와 분리된 백그라운드 스레드에서 실행해 병렬 처리가 가능해진다.

1-1. 웹워커 사용 시 고려해야할 점

  • DOM에 접근이 안된다.
    web worker는 백그라운드 스레드에서 동작하기에 DOM에 접근이 안되는 특징을 가지고 있기에 DOM 객체를 보내면 안된다. 메시지 데이터는 전송 가능한 형태로 보내는게 중요하다.

  • Web Worker의 메시지 주고받기는 동시에(동기식) 되는 게 아니라 비동기적으로 처리된다.

    🔁 postMessage는 메시지를 비동기 큐에 넣는 것이다

    ⸰ 메인 스레드와 워커는 서로 독립된 스레드에서 동작함
    ⸰ 수신 측은 이벤트 루프에서 그 메시지를 나중에 처리

// main.js
worker.postMessage('hello')
console.log('메인 스레드는 멈추지 않고 계속 실행됨')

// worker.js
self.onmessage = (e) => {
  console.log('워커에서 메시지 받음:', e.data)
}

2. 언제 사용하면 좋을까?

그럼 web worker는 언제 사용하면 좋을까?
언제 사용해야 유용하게 사용되는걸까?

web worker는 CPU부하가 큰 연산이나 ML 모델 추론, 이미지/영상 처리 등에 유용하다고 한다.

우리 팀이 web worker를 사용했던 이유는 tensorFlow.js로 개발된 발인식 모델을 사용해야했기에 이 부분을 백그라운드 스레드에 동작하게 해주는 것이 필요하다고 판단했었다.

tensorFlow.js로 개발된 발인식 모델의 경우 실시간 추론을 해야했기에 GPU를 사용해 연산을 되게 개발이 되어있었다.
이 경우 아무래도 무거운 연산을 메인스레드에 돌리는 것보다 웹워커를 사용해 백그라운드 스레드에서 처리될 수 있는게 효율적이기에 설계단계부터 분리를 결정했다.

3. Web Worker 사용법

3-1. 워커 생성하기

const footDetectorWorker = new Worker(
  new URL('features/paperless/utils/footDetectionWorker', import.meta.url)
)
  • ES Module 기반 환경에서는 경로 해석이 엄격하고, worker 파일도 번들링 대상이라 이렇게 써야한다. new URL('상대경로',import.meta.url)

3-2. 메시지 통신하기

메인 스레드와 워커 간에는 postMessage와 message 이벤트 리스너를 통해 양방향 비동기 통신을 구현한다.

<메인 스레드 → worker로 요청할 때>

  1. 초기화 요청할 때
        footDetectorWorker.postMessage({
            key: 'initialize',
            body: {},
        } );
  1. 추론 요청할 때
  • tensorFlow.js로 웹캠 캔버스 이미지를 전처리한 데이터를 worker에 body로 보내며 추론 요청을 한다.
      footDetectorWorker.postMessage({
                    key: 'inference',
                    body: { imageTensorData },
                } as WorkerRequestMessage);

<worker가 응답받을 때>

워커는 이 이벤트를 addEventListeneronmessage로 받을 수 있는데

이벤트리스너를 통해 메인으로부터 데이터를 받았다.

초기화 시 에러가 나면, 에러가 난 내용을 다시 postMessage로 메인에 전달했고, 추론에 필요한 데이터를 받아서 detectFrame함수에 이 데이터를 보낸다. 에러가 나면 postMessage로 메인에 전달한다.

  • self를 쓰는 이유? 웹워커에는 window 객체가 없다. 워커 환경임을 명시하기 위해서이다.
self.addEventListener('message', (e: MessageEvent<WorkerRequestMessage>) => {
    // e.data에 메인 스레드에서 보낸 데이터가 들어있음
    switch (e.data.key) {
}

<worker → 메인스레드로 요청 보낼 때>

웹 워커에서 메인스레드로 데이터를 보내기 위해 이미지 데이터 받은걸 tensorflow.js 텐서 객체로 변환하고 TypedArray로 변환하는 과정을 거친다. 이렇게 해야 일반 배열처럼 접근 가능해서 메인스레드로 발인식 감지 결과 값을 보낼 수 있다

postMessage({ key: 'result', body: { detections } } as WorkerResponseMessage);
// 워커 내부에서 호출하면 메인 스레드로 데이터가 전달 된다.
postMessage({ key: 'ready', body: {} } as WorkerResponseMessage);

<메인스레드에서 응답 받을 때>

// 이벤트 리스너 안에서 값을 받는다.
       const listener = (e: MessageEvent<WorkerResponseMessage>) => {
            if (e.data.key === 'error') {
                console.log("e",e.data.body.error)
                return;

   footDetectorWorker.addEventListener('message', listener);

실제 동작과정 예시 코드

// 메인 스레드에서
worker.postMessage({
  key: 'inference',
  body: { imageTensorData: tensorData },
})

// ↓ 브라우저 내부에서 이런 일이 일어남 (개념적으로)
workerContext.dispatchEvent(
  new MessageEvent('message', {
    data: { key: 'inference', body: { imageTensorData: tensorData } },
  })
)

// ↓ 워커에서 이벤트 수신
self.addEventListener('message', (e) => {
  // e.data = { key: 'inference', body: { imageTensorData: tensorData } }
  console.log(e.data.key) // 'inference'
})

웹 워커 통신의 핵심

메인 스레드에서 worker.postMessage(data)를 호출하면:

  1. 브라우저가 워커 컨텍스트에 message 이벤트를 발생시킴
  2. 워커는 이 이벤트를 addEventListeneronmessage로 받을 수 있음

3-3 종료: worker.terminate()

메인에서 worker.terminate()을 하거나, worker에서 self.close()를 해주는게 좋다.

아니면 아래처럼 메시지로 제어할 수도 있다.

// 메인 스레드에서
worker.postMessage({ type: 'shutdown' })

// 워커에서
self.addEventListener('message', (e) => {
  if (e.data.type === 'shutdown') {
    self.close() // 정리 작업 후 종료
  }
})

명시적으로 종료하지 않으면 페이지를 벗어나기 전까지 메모리를 계속 차지할 수 있으므로, 종료 처리를 습관화하는 것이 좋다.

4. 어떻게 디버깅하는가? (디버깅 팁)

✅ 1. Sources → Workers 패널 확인하는 방법이 있다.

  1. DevTools 열기 (F12 또는 Cmd+Opt+I / Ctrl+Shift+I)
  2. 상단 탭 중 Sources 클릭
  3. 왼쪽 사이드바에서 Threads 혹은 Workers 섹션 확인
    • main 외에 worker.js 등 추가로 보이면 Web Worker가 로딩된 것
    • 클릭하면 해당 워커의 스크립트 코드를 볼 수 있고, 브레이크포인트 설정이 가능하다.

아래 이미지처럼 콘솔에 브레이크 포인트를 설정하면 어떤 데이터가 오는지 볼 수 있음

image

✅ 2. Console에서 로그 확인

image
  • 웹 워커에서도 console 객체가 존재하기에 콘솔을 찍어 바로 바로 확인하는 방법이 가장 편했다.

✅ 3. worker에서 에러가 나면 메인스레드로 다시 postMessage를 한다

  • 메인 스레드는 워커 안에서 정확히 무슨 작업 중에 에러가 났는지, 어떤 메시지를 보내서 뭘 하려다가 실패했는지 알고 대처할 수 있다.
  • 메인 스레드가 "어떤 key 작업 중에 발생한 에러인지"를 알고 대처할 수 있다.
self.addEventListener('message', (e: MessageEvent<WorkerRequestMessage>) => {
  console.log('EEEEE', e)

  // e.data에 메인 스레드에서 보낸 데이터가 들어있음
  switch (e.data.key) {
    case 'initialize':
      footDetector
        .initialize()
        .catch((error) => postMessage({ key: 'error', body: { error } }))
      break
    case 'inference':
      footDetector
        .detectFrame(e.data.body.imageTensorData)
        .catch((error) => postMessage({ key: 'error', body: { error } }))
      break
  }
})

5. 개발하며 겪은 문제와 해결 방법

5-1. 아래와 같이 하면 Uncaught SyntaxError: Unexpected token '<' 이 에러가 난다

const footDetectorWorker = new Worker(
  'features/paperless/utils/footDetectionWorker'
)

웹 워커를 로딩하려고 했는데 HTML 파일이 로딩된 경우에 자주 발생하는 에러이다.

웹 워커를 로드할 때 실제로는 JS 파일을 불러와야 하는데, 잘못된 경로나 설정으로 인해 HTML 파일을 불러오게 되었고, 그 결과 < (HTML 문서의 시작 태그)를 만나서 파싱 에러가 난 것

해결방법은? new URL('파일 경로', import.meta.url) 로 만들어진 경로를 넣으면 구동할 수 있다.

다만 이건 Webpack 5부터이고, 이전이면 worker-loader가 필요하다.

const footDetectorWorker = new Worker(
  new URL('features/paperless/utils/footDetectionWorker', import.meta.url)
)

5-2 비동기 처리로 인한 메시지 순서 문제

메시지 순서 문제: 메인 스레드와 워커 간 postMessage()로 여러 메시지를 빠르게 보낼 때, 비동기로 처리되기에 처리 순서가 보장되지 않을 수 있다.

// 메인 스레드에서 빠르게 연속 전송
worker.postMessage({ key: 'initialize', body: {} });
worker.postMessage({ key: 'inference', body: { imageTensorData: [...] } });

문제 발생 과정:

  1. initialize 메시지가 워커에 도착
  2. 하지만 initialize() 함수는 비동기이고 시간이 오래 걸림 (모델 로딩 준비)
  3. inference 메시지가 곧바로 도착해서 처리 시작
  4. detectFrame() 실행 시 this.detectionModel이 아직 undefined
  5. 에러 발생: "[detectFrame] detectionModel is not initialized"

initialize를 먼저 보냈지만 inference가 먼저 처리되려고 시도하면서 에러가 발생하게 되었다..!

5-3 out of memory 이슈

사용 도중 브라우저가 멈추는 문제가 간혹 있는데, 메모리 문제(out of memory error)가 발생하는 경우가 있었다.

모바일 기기의 제한된 메모리

*// iOS 17 미만에서 비활성화하는 이유 중 하나*
if (iosVersion !== false && iosVersion < 17) {
    this.disabled = true; *// 메모리 부족으로 인한 크래시 방지*
}

TensorFlow.js 텐서 누수

원본 코드는 tf.tidy()startScope/endScope를 사용하고 있어서 대체로 안전했었지만, 예외 상황에서 누수가 생길 수 있다.

마치며

image

메인스레드와 web worker의 통신으로 나온 결과물!
초록색 상자는 실시간 유저의 발을 감지한다. 이 기능 추가로 사용자 경험 개선에 긍정적인 영향을 주었다.

Web Worker는 평소에는 필요성을 느끼기 어려울 수 있지만, 연산량이 큰 작업을 메인 스레드에서 분리해 UI 성능과 사용자 경험을 지키는 강력한 도구임을 실무에서 체감했다.

ML 모델, 이미지 처리, 복잡한 연산이 필요한 웹 애플리케이션을 만든다면 초기 설계 단계부터 Web Worker 활용 여부를 고려하는 것이 좋다.


Written by@chloee
기록하는 것을 좋아하는 프론트엔드 개발자👩🏻‍💻

GitHubLinkedIn