Web Workers는 Web API입니다. 그 말은 즉, 주로 클라이언트 브라우저에서 동작한다는 말! 덕분에 서버로 동작하는 Next.js에서 Web Worker를 쓰기 위해서는 조금 돌아가야 해요. 이번 포스팅은 Worker에 대해서 다뤄볼까 합니다.
Javascript는 싱글 스레드(Single Thread)로 동작하는 언어(Blocking 언어)입니다. 그렇기에 병렬로 무언가를 처리할 수 없고, 하나가 처리될 때까지 기다려야 합니다.
하나가 처리될 때까지 기다리게 된다면 효율이 떨어지게 되겠죠? 그래서 생각해낸 것이 바로 비동기 작업을 만들고, 해당 작업을 런타임 환경에 위임해주는 거에요. 이에 관한 내용은 제가 예전에 작성한 이벤트 루프 포스트를 참고해보세요.
요는 런타임 환경에 비동기 작업을 위임하고 JS는 결과값만 받아오는 식으로 처리한다는 것입니다. 그리하여 JS는 본인의 동작에만 신경을 쓰면 됐어요. 하지만, 이 비동기 작업을 처리하는 데에 알아두어야 할 사실이 있는데요, 사실 런타임 환경에서 비동기 작업은 약속된 작업, 다시 말해서 런타임 환경에서 구현이 되어있는 작업을 뜻해요.
예를 들어, 네트워크를 요청하는 fetch()라는 함수는 Web API이므로 대부분의 모던 브라우저에서는 동작하지만, NodeJS 환경에서는 해당 함수가 존재하지 않아요. (Next.js가 Native fetch() API를 확장했다는 이유가 바로 이것, fetch()를 서버 환경에서도 동작하게 만듦) 이러한 이유의 연장선으로, 그러므로 내가 원하는 작업이 런타임 환경에 구현되어 있지 않다면 작업을 위임하기 힘들어요.
또한 Web API와 JS(Main Thread) 사이에서 작동하는 Event Loop는 우선순위에 따라 작업을 순차적으로 처리해요. 문제는 사용자 입력 처리나 데이터 처리 등의 작업이 많아질수록 메인 스레드가 병목 현상을 겪게 된다는 것이고, 이는 화면 렌더링을 지연시키는 이유가 되므로 UX를 떨어뜨리게 돼요.
위의 문제를 정리해보면 (1) 정해진 비동기 작업만 수행할 수 있고 (2) 병목 현상이 일어날 수 있다 정도겠네요. 그리고 두 가지의 문제를 바로 Web Worker가 해결해 줄 수 있다는 것!
위키피디아의 Web Worker에 대한 정의는 다음과 같아요.
A web worker, as defined by the World Wide Web Consortium (W3C) and the Web Hypertext Application Technology Working Group (WHATWG), is a JavaScript script executed from an HTML page that runs in the background, independently of scripts that may also have been executed from the same HTML page.[1] Web workers are often able to utilize multi-core CPUs more effectively.[2]
같은 페이지 내에서 실행되는 스크립트와 독립적으로, 백그라운드에서 실행되는 스크립트라고 하네요. 그렇다면 정해진 작업 말고 저희가 원하는 동작을 직접 지정할 수도 있겠어요!
... The W3C and WHATWG envision web workers as long-running scripts that are not interrupted by scripts that respond to clicks or other user interactions. Keeping such workers from being interrupted by user activities should allow Web pages to remain responsive at the same time as they are running long tasks in the background ...
Web Worker는 클릭 이벤트라든가 유저의 상호작용에 반응하는 스크립트에 방해받지 않고, 그럼으로써 웹 페이지가 백그라운드에서 무거운 작업을 하더라도 반응성을 유지할 수 있게끔 만든다고 해요.
싱글 스레드에서 블로킹 되지 않고 실행할 순 없으니, Web Worker는 새로운 Thread를 만든다는 것을 알 수 있어요. 새로운 Thread를 만들었다는 것은 병목 현상이 일어나지 않지 않는다는 것! (아까의 맥락에서 이어보자면)
정리해보자면, Web Worker는 같은 HTML 페이지에서 독립적으로 실행되는 스크립트로서, 웹 페이지의 반응성을 보장할 수 있게끔 무거운 작업을 따로 쓰레드로 만들어 처리하는 역할이라고 볼 수 있겠네요.
싱글 스레드 바깥에서 새로운 Thread를 만들어서 동작하는 녀석이라니! 생각만 해도 성능이 높아질 것 같지 않나요? 그렇지만 무언가가 되게 좋아보인다면 항상 의심을 해봐야 하는 습관을 길러야 합니다.
Web Worker는 수명이 길고 시작 성능 비용이 높으며 인스턴스당 메모리 비용이 높아요. 메모리 비용이 높다는 것은 상대적으로 무겁다는 이야기가 되겠고, 무겁다면 Worker를 대량으로 사용하기엔 적합하지 않다는 점이 될 것 같아요.
위에서 언급했듯, 같은 HTML 페이지에서 독립적으로 실행되는 스크립트에요. 이 말은 HTML 문서의 스크립트 컨텍스트 외부에서 실행된다는 뜻입니다. 그렇기에 DOM의 정보에 접근할 수 없어요.
위와 같은 이유를 고려해본 결과, 저는 타이머를 Worker로 만드는 게 적절하다고 판단했어요. setInterval() 함수는 브라우저가 비활성화 되면 정상적으로 동작하지 않기에, 계속 백드라운드에서 Tick을 해줄 필요가 있다고 생각했어요. 또, DOM을 조작하는 일도 없고 다른 무거운 작업들에 비해 Tick만 알려주는 일이라 부담이 적을 것이라 생각했어요.
본격적으로 Web Worker를 Next.js에서 써보도록 할게요. 위에서 주구장창 써놓았듯, 이름에서 알 수 있듯, 웹에서만 쓸 수 있는 녀석이기 때문에 RCC로 만들어 줘야 해요.

시퀀스 다이어그램 더 이쁘게 못하나 이거
Worker는 '메시지 이벤트'를 중심으로 데이터를 주고 받아요. 호출부와 구현부 둘 다 상대방에게 온 메시지를 받아볼 수 있는 Listener를 달아줘야 해요. 이때 이벤트의 이름은 미리 정의된 'message'로 지정해줘야 합니다.
메시지 이벤트를 일으키는 방법은 Worker에서 제공하는 postMessage() 호출하면 되는데요, 이 방법은 window.postMessage()를 사용해서 팝업이나 iframe과 통신하는 방법과 비슷한 것 같아요.
각 메시지 이벤트 핸들러에서 원하는 로직을 작성하고 하면 기본적인 통신 구조는 끝!
전체적인 흐름은 위를 참고하며 아래의 코드를 살펴보면 될 것 같아요. 중요한 부분은 아래에서 다시 짚어볼게요.
Page.tsx
"use client" export default function Page() { useEffect(() => { const testWorker = new Worker(new URL('./\_testWorker.ts', import.meta.url), { type: 'module' }); const handleMessage = (event: MessageEvent) => { console.log(`${event.data} >> [Client] Received from Worker!`); }; testWorker.addEventListener('message', handleMessage); testWorker.postMessage('[Client] Send from Client!'); return () => { testWorker.terminate(); }; }, []); return <></>; }
Worker는 재밌게도 addEventListener가 존재해요. 해당 메소드의 첫 번째 인자는 message, error, messageerror만 받을 수 있어요. 메시지를 받는 부분은 message 이벤트가 되겠죠?
위에서 언급했듯 postMessage()로 해당 메소드로 Worker에게 메시지를 전달할 수 있어요. 이때, 전송할 수 있는 타입에 제한이 있어요. Worker에서는 내부적으로 구조화된 복제 알고리즘을 호출하는데, 이때 IndexedDB나 다른 API들을 위해 복제본을 남겨두기 위함이라고 하네요. 자세한 건 MDN 링크로!
사이드 이펙트를 관리하기 위해서인데, 렌더 시 최초 한 번만 호출되면 되므로 의존성 없이 호출하였어요. 간단한 예제라 useEffect()를 사용했지만, 모듈 스코프로 올려서 Worker를 불러와도 좋을 것 같아요. 그리고 당연하게도 Re-rendering이 발생하지 않는 페이지여야 해요. 만약 발생한다면 useRef()로 감싸든가 해서 하나의 인스턴스를 유지해야 할 거에요.
Next.js에서는 일관성을 위해 되로록 상대 경로보다 절대 경로를 쓰길 권장하고 있기 때문이에요. (일관성 문제도 있지만, Webpack에서 번들링하는 과정 중에 이슈가 생기기도 해서)
{ type: module }가 필요한가요?크게 상관 없는데, 사실 이건 Turbopack 환경에서 자꾸 오류나서 이것저것 해본 잔여물입니다(...)
그리고 알게된 사실: Turbopack은 아직 Worker를 지원하지 않는다.

그 다음으론 worker 파일을 살펴볼게요.
_testWorker.ts
const handleMessage = (event: MessageEvent) => { console.log(`${event.data} >> [Worker] Received from Client!`); }; typeof self === 'object' && self.addEventListener('message', handleMessage);
생각보다 간단하죠? worker 파일 자체가 Worker의 Context를 가지고 있기 때문에 단순히 이벤트 등록만 진행해주면 된답니다.
일단 self인 이유부터 밝히자면, 지금까지 말해왔듯 다른 컨텍스트이기 때문이에요. window와는 다른 global context이기 때문에, 이를 가리키는 self 키워드를 썼습니다.
A worker is an object created using a constructor (e.g. Worker()) that runs a named JavaScript file — this file contains the code that will run in the worker thread; workers run in another global context that is different from the current window. Thus, using the window shortcut to get the current global scope (instead of self) within a Worker will return an error.
먼저 Worker() 생성자를 사용하여 worker 객체를 만듭니다. 이 객체는 별도의 스레드에서 실행될 JavaScript 파일을 지정합니다.
worker 스레드 내에서 실행되는 코드는 현재 창(window)과는 별도의 전역 컨텍스트에서 실행됩니다. 따라서 worker 내에서 window를 사용하여 전역 스코프에 접근하려고 하면 에러가 발생합니다. 대신 self를 사용하여 해당 worker의 전역 스코프에 접근할 수 있습니다.
그리고,, self의 타입을 검사하는 이유는 Next.js 때문입니다. use client directive를 선언했다고 해도 Next.js에서 일단 웹팩에서 로더를 통해 번들링 과정을 걸치고 내보내는 것이기 때문에, self가 없는 NodeJS에선 ReferrenceError를 내뿜으며 장렬하게 전사합니다(...) 그렇기에 안전하게 타입을 검사해주는 것이 인지상정!

Worker로부터 메시지가 잘 온 것을 확인할 수 있었습니다.
이제 해당 구조를 기반으로 타이머를 구현해볼 차례입니다. 간단하게 카운트를 하는 예제를 만들어볼 텐데요, 구현해보기 전에 Worker의 역할을 정의하고 갈 필요가 있습니다.
Worker를 (1) 카운트를 해주는 타이머로 가져갈지, (2) Tick만 해주는 타이머로 가져갈지에 따라 구현하는 방식이 좀 달라지게 돼요. (큰 틀은 변화없지만)
+여담) 개발을 하다보면 확장성을 생각해서 개발하는 것이 늘 언제나 '맞다'라는 판단을 내리게 되는데, 물론 나중에 유지보수를 생각한다면 확장성을 생각하는 것이 맞지만, 현재 상황과 리소스를 생각하며 판단해야 해요. 확장성을 생각하다보면 생각보다 더 유연하게 짜려고 할 때가 많아서, 오버 엔지니어링이 될 확률이 높거든요. 내가 그랬음.
이번에는 (2)의 방식으로 한번 개발해볼게요.

testWorker.ts
let \_timerId: NodeJS.Timeout | null = null; const handleMessage = (event: MessageEvent<TimerEvent\>) => { const { data: timerEvent } = event; const wouldStartTimer = timerEvent.type === TIMER_ACTION.START; const wouldEndTimer = timerEvent.type === TIMER_ACTION.STOP; if (wouldStartTimer && isNull(\_timerId)) { const { interval } = timerEvent.payload; \_timerId = setInterval(() => { postMessage(null); }, interval); } if (wouldEndTimer && isNotNull(\_timerId)) { clearInterval(\_timerId); \_timerId = null; } }; typeof self === 'object' && self.addEventListener('message', handleMessage);
메시지 이벤트 기반은 모든 액션이 하나의 핸들러로 흘러가기 때문에, 메시지 안에서 어떤 액션인지 구분을 해주어야 합니다. 그것을 위해서 TimerEvent를 작성해주었어요.
TimerEvent
export enum TIMER_ACTION { START = 'START', STOP = 'STOP', } export type TimerPayload = { interval: number; }; export type TimerEvent = | { type: TIMER_ACTION.START; payload: TimerPayload; } | { type: TIMER_ACTION.STOP; payload: null; };
메시지로 들어오는 녀석들은 기본적으로 { type, payload }의 구조를 가지고 와요. type에 따라 타이머를 시작하거나 중지하고, 각 타입에 필요한 payload를 따로 정의해주었어요.
저렇게 각 타입에 따라 타입을 따로 지정해주면 나중에 type이 특정 되었을 때 payload도 같이 타입 가드가 되어서 편리하더라구요^~^
저는 개인적으로 조건문에 대해서 명시적으로 적어주는 걸 선호해요. wouldStartTimer 처럼요. 그런데 조건에 따라 타입이 여러 개 가지고 있는 녀석들을 구분해주기 위해서는 이런 방식이 먹히지 않아요.

(1)의 방식으로는 타입 가드가 일어나지 않고, (2)와 (3)의 방식으로만 일어나는 것을 확인할 수 있어요. 그래서 어쩔 수 없이(😇) (3)의 방식으로 접근했어요.
if (wouldEndTimer && isNotNull(\_timerId)) { clearInterval(\_timerId); \_timerId = null; }
이게 좀 중요한데, 타이머 종료가 곧 'Worker'의 종료를 의미하는 건 아니에요. Worker Instance는 계속 살아있으나, setInterval Instance가 사라지는 거죠. 그래야 재시작이 가능합니다.
Worker를 종료하기 위해선 직접 terminate()를 호출해주어야 해요. Unmounted 될 때 등이 되겠죠?
test/Page.tsx
"use client" export default function Page() { const testWorkerRef = useRef<Worker | null>(null); const [currentCount, setCurrentCount] = useState<number\>(10); const currentCountRef = useRef(currentCount); const isDoneCounting = currentCount === 0; const isCounting = currentCount !== 0; useEffect(() => { testWorkerRef.current = new Worker(new URL('./\_testWorker.ts', import.meta.url), { type: 'module' }); const updateCount = () => { if (currentCountRef.current === 0) { const stopEvent: TimerEvent = { type: TIMER_ACTION.STOP, payload: null, }; testWorkerRef.current!.postMessage(stopEvent); return; } setCurrentCount((count) => count - 1); }; testWorkerRef.current.addEventListener('message', updateCount); const startTimerEvent: TimerEvent = { type: TIMER_ACTION.START, payload: { interval: 1000, }, }; testWorkerRef.current.postMessage(startTimerEvent); return () => { testWorkerRef.current!.terminate(); }; }, []); useEffect(() => { currentCountRef.current = currentCount; }, [currentCount]); return ( <> {isCounting && <p>{currentCount}초 남았습니다.</p>} {isDoneCounting && ( <> <p>카운트 끝!</p> <button type="button" onClick={() => { setCurrentCount(10); testWorkerRef.current!.postMessage({ type: TIMER_ACTION.START, payload: { interval: 1000 } } as TimerEvent); }}> 다시 세기 </button> </> )} </> ); }
먼저 해당 Page는 RCC인 점을 인지하고 가자구요! 코드가 더러운 것도요!
현재 Worker를 useRef를 선언할 때 초기값으로 넣어주지 않고 useEffect() 안에서 지정해주고 있어요. 위에서 언급했든 use client directive가 있더라도 Next.js에서 한 번 걸치기 때문에, 초기값으로 Worker를 생성해주게 되면 NodeJS에는 Worker 객체가 없기 때문에 오류가 일어나게 된답니다. 때문에 처음에는 null을 넣어주었어요.
const [currentCount, setCurrentCount] = useState<number\>(10); const currentCountRef = useRef(currentCount); useEffect(() => { currentCountRef.current = currentCount; }, [currentCount]);
쓰윽 봤을 땐 꽤 열받고 굳이 이러는 의도가 뭐야?! 라는 생각이 들지만,, 다 이유가 있답니다.
Worker에 message 이벤트 리스너를 등록할 때 요기에 등록된 콜백 함수는 등록당할 당시의 Context를 가지고 있어요. 그러므로 해당 콜백 함수는 새로 업데이트된 상태를 받지 못하고, 당시의 상태만 가지고 있게 된답니다. currentCount를 아무리 호출해도 10만 뜨는 것이죠.
이를 해결하기 위해서 updateCount의 bind를 this로 지정해서 넘겨주는 방법도 있는데~ 안타깝게도 함수형 컴포넌트에서는 this가 쓰이지 않아요. class component에서나 쓰이지 ..
함수형 컴포넌트에서는 대신 다른 방법으로 처리를 해야하는데, 그 중 하나가 위의 코드처럼 useRef를 사용하는 것입니다. useRef를 사용해서 최신 상태 값을 유지하는 레퍼런스를 생성하는 거죠.
사실 useRef를 쓰지 않아도 다음과 같이 처리할 수 있어요.
상태 Updator 이용하기
setCurrentCount((count) => { if (count === 0) { testWorkerRef.current!.postMessage({ type: TIMER_ACTION.STOP, payload: null } as TimerEvent); return 0; } return count - 1; });
!주의! 이 코드는 되도록! 정말! 피해야 하는 코드입니다. 상태 업데이트 함수는 'Pure' 해야 해요. 사이드 이펙트를 일으키지 않아야 하죠. 하지만 해당 코드는 상태 업데이트 함수 내에서 사이드 이펙트를 일으키고 있어요. React가 싫어합니다(ㅋㅋ)
() => { setCurrentCount(10); testWorkerRef.current!.postMessage({ type: TIMER_ACTION.START, payload: { interval: 1000 } } as TimerEvent); }
JSX 내부에 버튼 이벤트만 가져왔어요. 카운트에 대한 초기화를 해주고 다시 START 이벤트를 호출해줬어요. 다시 호출할 수 있는 이유는, 위에서 말했듯 Worker가 종료되는 것이 아니라 setInterval Instance가 종료된 것이기 때문!
지금은 서비스 내에서 Tick이 필요해서 Tick 용도로만 쓰고 있지만, 써보니 꽤 괜찮아서 나중에는 더 무거운 작업을 위임해보고 싶다!