September 11, 2025
실무에서 TensorFlow.js 기반 발 인식 모델을 실행하기 위해 Web Worker를 활용한 경험이 있었다. 이 과정에서 언제, 왜, 어떻게 Web Worker를 사용해야 하는지를 깊이 고민하게 되었고, 그 내용을 정리해 두면 앞으로 유사한 프로젝트를 진행할 때 큰 도움이 될 것 같아 글로 남긴다.
Web Worker는 자바스크립트 자체 기능이 아닌, 브라우저가 제공하는 백그라운드 스레드 실행 도구다. 자바스크립트는 싱글 스레드 언어로, 한 번에 하나의 작업만 처리한다. 따라서 메인 스레드에서 무거운 연산을 수행하면 UI가 멈추거나 버벅거림이 발생한다.
Web Worker를 사용하면 이런 연산을 메인 스레드와 분리된 백그라운드 스레드에서 실행해 병렬 처리가 가능해진다.
DOM에 접근이 안된다.
web worker는 백그라운드 스레드에서 동작하기에 DOM에 접근이 안되는 특징을 가지고 있기에 DOM 객체를 보내면 안된다. 메시지 데이터는 전송 가능한 형태로 보내는게 중요하다.
Web Worker의 메시지 주고받기는 동시에(동기식) 되는 게 아니라 비동기적으로 처리된다.
🔁 postMessage
는 메시지를 비동기 큐에 넣는 것이다
⸰ 메인 스레드와 워커는 서로 독립된 스레드에서 동작함
⸰ 수신 측은 이벤트 루프에서 그 메시지를 나중에 처리함
// main.js
worker.postMessage('hello')
console.log('메인 스레드는 멈추지 않고 계속 실행됨')
// worker.js
self.onmessage = (e) => {
console.log('워커에서 메시지 받음:', e.data)
}
그럼 web worker는 언제 사용하면 좋을까?
언제 사용해야 유용하게 사용되는걸까?
web worker는 CPU부하가 큰 연산이나 ML 모델 추론, 이미지/영상 처리 등에 유용하다고 한다.
우리 팀이 web worker를 사용했던 이유는 tensorFlow.js로 개발된 발인식 모델을 사용해야했기에 이 부분을 백그라운드 스레드에 동작하게 해주는 것이 필요하다고 판단했었다.
tensorFlow.js로 개발된 발인식 모델의 경우 실시간 추론을 해야했기에 GPU
를 사용해 연산을 되게 개발이 되어있었다.
이 경우 아무래도 무거운 연산을 메인스레드에 돌리는 것보다 웹워커를 사용해 백그라운드 스레드에서 처리될 수 있는게 효율적이기에 설계단계부터 분리를 결정했다.
const footDetectorWorker = new Worker(
new URL('features/paperless/utils/footDetectionWorker', import.meta.url)
)
new URL('상대경로',import.meta.url)
메인 스레드와 워커 간에는 postMessage와 message 이벤트 리스너를 통해 양방향 비동기 통신을 구현한다.
<메인 스레드 → worker로 요청할 때>
footDetectorWorker.postMessage({
key: 'initialize',
body: {},
} );
footDetectorWorker.postMessage({
key: 'inference',
body: { imageTensorData },
} as WorkerRequestMessage);
<worker가 응답받을 때>
워커는 이 이벤트를 addEventListener
나 onmessage
로 받을 수 있는데
이벤트리스너를 통해 메인으로부터 데이터를 받았다.
초기화 시 에러가 나면, 에러가 난 내용을 다시 postMessage로 메인에 전달했고, 추론에 필요한 데이터를 받아서 detectFrame함수에 이 데이터를 보낸다. 에러가 나면 postMessage로 메인에 전달한다.
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)
를 호출하면:
message
이벤트를 발생시킴addEventListener
나 onmessage
로 받을 수 있음worker.terminate()
메인에서 worker.terminate()을 하거나, worker에서 self.close()를 해주는게 좋다.
아니면 아래처럼 메시지로 제어할 수도 있다.
// 메인 스레드에서
worker.postMessage({ type: 'shutdown' })
// 워커에서
self.addEventListener('message', (e) => {
if (e.data.type === 'shutdown') {
self.close() // 정리 작업 후 종료
}
})
명시적으로 종료하지 않으면 페이지를 벗어나기 전까지 메모리를 계속 차지할 수 있으므로, 종료 처리를 습관화하는 것이 좋다.
F12
또는 Cmd+Opt+I
/ Ctrl+Shift+I
)Sources
클릭Threads
혹은 Workers
섹션 확인
main
외에 worker.js
등 추가로 보이면 Web Worker가 로딩된 것아래 이미지처럼 콘솔에 브레이크 포인트를 설정하면 어떤 데이터가 오는지 볼 수 있음
"어떤 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
}
})
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)
)
메시지 순서 문제: 메인 스레드와 워커 간 postMessage()
로 여러 메시지를 빠르게 보낼 때, 비동기로 처리되기에 처리 순서가 보장되지 않을 수 있다.
// 메인 스레드에서 빠르게 연속 전송
worker.postMessage({ key: 'initialize', body: {} });
worker.postMessage({ key: 'inference', body: { imageTensorData: [...] } });
문제 발생 과정:
initialize
메시지가 워커에 도착initialize()
함수는 비동기이고 시간이 오래 걸림 (모델 로딩 준비)inference
메시지가 곧바로 도착해서 처리 시작detectFrame()
실행 시 this.detectionModel
이 아직 undefined
"[detectFrame] detectionModel is not initialized"
initialize
를 먼저 보냈지만 inference
가 먼저 처리되려고 시도하면서 에러가 발생하게 되었다..!
사용 도중 브라우저가 멈추는 문제가 간혹 있는데, 메모리 문제(out of memory error)가 발생하는 경우가 있었다.
*// iOS 17 미만에서 비활성화하는 이유 중 하나*
if (iosVersion !== false && iosVersion < 17) {
this.disabled = true; *// 메모리 부족으로 인한 크래시 방지*
}
원본 코드는 tf.tidy()
와 startScope/endScope
를 사용하고 있어서 대체로 안전했었지만, 예외 상황에서 누수가 생길 수 있다.
메인스레드와 web worker의 통신으로 나온 결과물!
초록색 상자는 실시간 유저의 발을 감지한다. 이 기능 추가로 사용자 경험 개선에 긍정적인 영향을 주었다.
Web Worker는 평소에는 필요성을 느끼기 어려울 수 있지만, 연산량이 큰 작업을 메인 스레드에서 분리해 UI 성능과 사용자 경험을 지키는 강력한 도구임을 실무에서 체감했다.
ML 모델, 이미지 처리, 복잡한 연산이 필요한 웹 애플리케이션을 만든다면 초기 설계 단계부터 Web Worker 활용 여부를 고려하는 것이 좋다.