브라우저의 Worker가 Node에 왔다: v26.9.0에서 그대로 믿으면 안 되는 다섯 경계

Node.js 26.9.0에 브라우저식 Worker가 들어왔습니다. new Worker()로 스레드를 만들고 addEventListener('message')postMessage()로 통신하는 모양은 웹 코드와 꽤 비슷합니다. 워커 안에서는 self, name, location, navigator, close(), importScripts()도 쓸 수 있습니다.

하지만 "브라우저의 Web Worker를 Node가 그대로 구현했다"고 받아들이면 곤란합니다. 이 API는 아직 실험 단계라 플래그가 필요하고, 네트워크에서 워커 스크립트를 가져오지 않으며, SharedWorker도 없습니다. 워커 안에서 process, Buffer, require() 같은 Node 전역도 계속 보입니다.

이번 글은 새 API가 편해진 지점과 호환성의 경계를 나눠 봅니다. Node 26.9.0 공식 macOS arm64 바이너리의 SHA-256을 공식 체크섬과 대조한 뒤, 간단한 메시지 왕복과 URL 실패 조건도 직접 실행했습니다.

새 이름이 붙었지만 실행 기반은 그대로다

Node의 Web Worker는 새로운 스레드 엔진이 아닙니다. 공식 문서는 이 API가 node:worker_threads 위에 구현됐다고 명시합니다. 실제 스레드, structured clone, transferable 객체의 전달 의미도 기존 worker_threads와 공유합니다. 바뀐 부분은 개발자가 마주하는 공개 인터페이스입니다.

기존 Node 코드는 보통 node:worker_threads에서 Worker, parentPort, workerData를 가져옵니다. 새 API에서는 브라우저처럼 전역 Worker를 만들고, 부모와 자식이 DOM 스타일 이벤트로 메시지를 주고받습니다. 웹과 서버에서 비슷한 메시지 처리 코드를 유지하려는 라이브러리에는 반가운 변화입니다.

Node 애플리케이션 코드의 브라우저식 Worker API가 DedicatedWorkerGlobalScope를 거쳐 node worker_threads 실행 기반에 연결되는 구조

그림 1. Node.js의 Web Worker는 브라우저식 인터페이스를 기존 worker_threads 실행 기반 위에 제공한다. 출처: nodejs.org/download/release/v26.9.0/docs/api/globals.html#web-workers-and-nodeworker_threads.

단, 이 편의가 기본값은 아닙니다. Node 26.9.0을 플래그 없이 실행했을 때 typeof Workerundefined였습니다. --experimental-web-worker를 붙인 뒤에야 function이 됐습니다. 운영 코드가 이 API에 의존한다면 런타임 버전뿐 아니라 시작 옵션도 배포 계약에 포함해야 합니다.

브라우저와 가장 다른 부분은 스크립트 로딩이다

브라우저의 워커는 URL과 origin을 중심으로 동작합니다. Node 구현은 로컬 파일 시스템이나 메모리에서 스크립트를 동기적으로 읽습니다. 그래서 new Worker()importScripts()가 받아들이는 스킴은 file:, data:, blob:으로 제한됩니다. https:를 넘기면 지원하지 않는다는 예외가 납니다.

공식 바이너리로 new Worker('https://example.com/worker.js')를 실행하자 실제로 NotSupportedError가 발생했습니다. 반면 존재하지 않는 상대 파일은 생성자에서 바로 던져지지 않고, 워커 객체의 error 이벤트로 ENOENT가 전달됐습니다. 같은 "스크립트를 못 읽음"이라도 호출 형태에 따라 실패를 잡는 위치가 다릅니다.

상대 경로의 기준도 눈여겨봐야 합니다. 메인 스레드에서는 문서의 base URL이 없으므로 현재 작업 디렉터리를 기준으로 해석합니다. 워커 안의 상대 URL은 그 워커 자신의 URL을 기준으로 합니다. 실행 디렉터리가 바뀌는 서비스나 CLI라면 브라우저에서 잘 돌아간 상대 경로를 그대로 복사하기보다 new URL(..., import.meta.url)처럼 기준을 코드에 드러내는 편이 안전합니다.

이름은 Web Worker지만 브라우저 격리 환경은 아니다

data: URL로 만든 워커에 메시지를 보내고 내부 전역을 확인했습니다. self는 객체였고 name에는 생성할 때 준 vpl-probe가 들어왔습니다. 동시에 typeof processobject였습니다. location.origin은 문자열 null이었습니다. 모두 공식 문서가 설명한 범위와 일치했습니다.

Node 26.9.0 Web Worker의 실험 플래그, Dedicated Worker, SharedWorker, 지원 URL, Node 전역 노출을 비교한 표

그림 2. 브라우저와 비슷한 API를 제공하지만 SharedWorker, 네트워크 URL, origin과 전역 환경은 다르다. 출처: nodejs.org/download/release/v26.9.0/docs/api/globals.html#differences-from-the-html-standard.

이 차이는 보안 판단에 직접 영향을 줍니다. 브라우저 Worker처럼 보인다는 이유로 신뢰하지 않는 코드를 격리하는 샌드박스로 사용하면 안 됩니다. Node 전역과 런타임 기능이 남아 있고, 브라우저의 same-origin과 cross-origin 구분도 존재하지 않습니다. API 모양의 호환성과 권한 경계는 별개의 문제입니다.

SharedWorker가 빠진 이유도 같은 맥락입니다. SharedWorker의 수명과 공유 범위는 origin과 browsing context에 기대는데, Node에는 그 모델이 없습니다. 26.9.0에서 플래그를 켜도 typeof SharedWorkerundefined였습니다. 여러 요청이나 프로세스가 공유하는 장기 실행 워커가 필요하다면 이 API가 그 역할을 대신해 주지 않습니다.

종료와 오류 처리도 웹의 감각만 믿으면 안 된다

Node의 close()는 HTML 표준의 closing flag 절차를 따르지 않고 워커를 즉시 종료합니다. 현재 작업에 남은 코드도 실행되지 않습니다. 브라우저용 코드가 close() 뒤의 정리 동작에 기대고 있다면 Node로 옮길 때 순서가 달라질 수 있습니다. 정리해야 할 데이터는 종료 호출 전에 명시적으로 처리해야 합니다.

오류 이벤트가 제공하는 정보도 줄어듭니다. message와 실제 error는 오지만 filename, lineno, colno는 각각 빈 문자열과 0입니다. 처리되지 않은 워커 오류는 스레드를 끝내지만 부모 전역으로 다시 전파되거나 프로세스 종료 코드를 자동으로 바꾸지 않습니다. 워커 객체의 error 이벤트를 반드시 수집하고, 실패를 작업 상태나 프로세스 정책에 반영하는 코드를 따로 둬야 합니다.

웹의 online, offline, languagechange 이벤트도 발생하지 않습니다. unhandledrejectionrejectionhandled 역시 WorkerGlobalScope 이벤트로 전달되지 않습니다. 표면에 핸들러 속성이 보인다고 실제 이벤트까지 지원한다고 가정하면 장애를 놓치기 쉽습니다.

Web Worker와 worker_threads 중 무엇을 고를까

새 API는 worker_threads의 상위 호환이 아닙니다. 웹과 비슷한 이벤트 코드를 공유하고 name, location, importScripts()가 필요하다면 Web Worker가 읽기 쉽습니다. 반대로 Node 전용 제어가 중요하면 기존 API가 낫습니다.

공식 문서는 workerData, 사용자 지정 envexecArgv, resourceLimits, 표준 입출력 리디렉션, onlineexit 이벤트, threadId가 필요할 때 node:worker_threads를 직접 쓰라고 권합니다. 새 Worker가 받는 옵션은 name, type, credentials뿐입니다. 그중 credentials는 호환성을 위해 검증하지만 네트워크 요청이 없어서 실제 효과가 없습니다.

브라우저식 Web Worker와 node worker_threads를 코드 이식성 및 Node 전용 제어 요구에 따라 선택하는 기준

그림 3. 웹 호환 메시지 API와 Node 전용 스레드 제어는 같은 기반 위의 서로 다른 공개 계약이다. 출처: nodejs.org/download/release/v26.9.0/docs/api/globals.html#web-workers-and-nodeworker_threads.

마이그레이션은 API 이름을 바꾸는 작업보다 요구사항을 분류하는 작업에 가깝습니다. 코드 이식성이 목적인지, Node 런타임 제어가 목적인지 먼저 정해야 합니다. 같은 스레드 기반을 쓰더라도 공개 계약과 관찰 가능한 실패 방식이 다릅니다.

지금 적용한다면 좁게 시작해야 한다

배포할 때는 기능 탐지와 플래그를 한 묶음으로 관리하는 게 좋습니다. typeof Worker 확인만 남기면 시작 옵션 누락의 원인을 설명하기 어렵습니다. 배포 manifest나 실행 스크립트에 Node 버전과 --experimental-web-worker를 같이 고정해야 재현이 됩니다.

테스트에는 정상 file: 또는 data: 케이스만 넣지 마세요. https: 거부와 누락 파일의 비동기 error 이벤트를 같은 묶음에 넣어야 로딩 경계가 보입니다. 워커 경로도 실행 디렉터리에 암묵적으로 기대기보다 절대 기준으로 만드는 편이 안전합니다.

그리고 이 API를 권한 격리 수단으로 쓰면 안 됩니다. CPU 작업을 메인 이벤트 루프에서 떼어 내는 것과, 신뢰하지 않는 코드를 샌드박스에 넣는 것은 전혀 다른 일입니다. process와 Node 전역이 보인다는 사실을 코드 리뷰와 위협 모델에 남겨야 합니다.

오류의 최종 책임은 부모가 져야 합니다. 워커가 조용히 끝나도 전체 프로세스가 성공으로 남을 수 있습니다. 작업 ID와 워커 오류를 연결하고, 어느 실패가 프로세스나 큐의 실패로 이어지는지 부모 코드에서 결정해야 합니다.

Node 26.9.0의 Web Worker는 웹과 서버의 메시지 코드를 가까이 가져온 흥미로운 실험입니다. 다만 이름 때문에 실제 경계까지 같아진 듯 보이는 게 함정입니다. 지금은 "브라우저 Worker가 Node에 왔다"보다 "Node 스레드에 브라우저식 인터페이스가 추가됐다"고 이해하는 편이 정확합니다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다