TypeScript의 `Shared Worker`
이 글에서는 TypeScript의 Shared Worker
를 설명합니다.
Shared Worker의 동작 방식과 실무에서의 활용 방법을 TypeScript 코드 예제와 함께 자세히 설명합니다.
YouTube Video
TypeScript의 Shared Worker
Shared Worker
는 동일 오리진의 여러 탭, 창, iframe에서 공유되는 단일 워커 프로세스입니다. 이를 사용하면 여러 브라우저 탭에 걸친 공유 상태와 리소스를 다룰 수 있습니다.
예를 들어, 공유 WebSocket 연결, 탭 간 동기화된 캐시와 큐 처리, **상호 배제(뮤텍스)**를 효율적으로 구현할 수 있습니다.
Dedicated Worker
와 달리, Shared Worker
는 onconnect
이벤트를 통해 여러 MessagePort
를 받아 여러 클라이언트와의 통신을 다중화할 수 있습니다.
Shared Worker
를 선택해야 하는 경우
다음과 같은 경우에는 Shared Worker
를 사용하는 것이 적절합니다.
- 탭 간 공유 상태나 상호 배제가 필요할 때
- 하나의 WebSocket 연결 또는 IndexedDB 접근을 공유하고 싶을 때
- 모든 탭에 알림이 필요할 때(브로드캐스트)
- 리소스 절약을 위해 무거운 처리를 한곳으로 모으고 싶을 때
반대로, 다음과 같은 경우에는 다른 접근이 더 적합합니다.
- 캐시 제어나 오프라인 지원이 필요할 때는
Service Worker
를 사용할 수 있습니다. - 단일 탭에 한정된 무거운 처리를 위해서는
Dedicated Worker
를 사용할 수 있습니다.
Shared Worker
구현 단계
여기서는 TypeScript로 다음을 단계별으로 구현해 보겠습니다.
- 타입 안전한 메시지 프로토콜
- Promise 기반 요청/응답(RPC)
- 모든 탭으로의 브로드캐스트
- 하트비트와 클라이언트 정리
환경 설정
Shared Worker
를 사용하는 각 소스 파일을 컴파일하기 위한 설정을 만듭니다.
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "dom", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["src"]
13}
tsconfig-src.json
에서 DOM 및 Web Worker 타입 정의를 활성화하여 코드를 안전하게 컴파일할 수 있도록 합니다.
메시지 프로토콜 정의
통신의 기반은 타입이 명시된 메시지 계약입니다. 이를 미리 정의해 두면 이후 통신을 안전하게 하고 확장도 쉬워집니다.
1// worker-protocol.ts
2// Define discriminated unions for structured messages
3
4export type RequestAction =
5 | { type: 'ping' }
6 | { type: 'echo'; payload: string }
7 | { type: 'getTime' }
8 | { type: 'set'; key: string; value: unknown }
9 | { type: 'get'; key: string }
10 | { type: 'broadcast'; channel: string; payload: unknown }
11 | { type: 'lock.acquire'; key: string; timeoutMs?: number }
12 | { type: 'lock.release'; key: string };
RequestAction
은 워커(worker)에 전송되는 요청의 유형을 나타내며,ping
,get
,broadcast
와 같은 연산을 정의하는 판별 유니온(태그된 유니온)입니다.
1export interface RequestMessage {
2 kind: 'request';
3 id: string;
4 from: string;
5 action: RequestAction;
6}
RequestMessage
는 클라이언트에서 워커로 보내는 요청 메시지의 구조를 정의합니다.
1export interface ResponseMessage {
2 kind: 'response';
3 id: string;
4 ok: boolean;
5 result?: unknown;
6 error?: string;
7}
ResponseMessage
는 워커에서 클라이언트로 반환되는 응답 메시지의 구조를 정의합니다.
1export interface BroadcastMessage {
2 kind: 'broadcast';
3 channel: string;
4 payload: unknown;
5 from: string;
6}
BroadcastMessage
는 워커가 다른 클라이언트에게 보내는 브로드캐스트 메시지의 구조를 정의합니다.
1export type WorkerInMessage =
2 | RequestMessage
3 | { kind: 'heartbeat'; from: string }
4 | { kind: 'bye'; from: string };
WorkerInMessage
는 요청, 하트비트, 연결 해제 알림 등 워커가 수신하는 모든 메시지를 나타내는 타입입니다.
1export type WorkerOutMessage = ResponseMessage | BroadcastMessage;
WorkerOutMessage
는 워커가 클라이언트로 보내는 응답 또는 브로드캐스트 메시지를 나타내는 타입입니다.
1export const randomId = () => Math.random().toString(36).slice(2);
randomId
는 메시지 ID 등으로 사용할 무작위 영숫자 문자열을 생성하는 함수입니다.
Shared Worker
구현
shared-worker.ts
에서 onconnect
이벤트로 연결되는 탭을 등록하고 메시지를 처리합니다.
1// shared-worker.ts
2/// <reference lib="webworker" />
- 이 지시문은 TypeScript에 Web Worker용 타입 정의를 로드하라고 지시합니다.
1import {
2 WorkerInMessage,
3 WorkerOutMessage,
4 RequestMessage,
5 ResponseMessage,
6} from './worker-protocol.js';
- 워커 통신에 사용되는 타입 정의를 임포트합니다.
1export default {};
2declare const self: SharedWorkerGlobalScope;
self
가Shared Worker
의 전역 스코프임을 명시적으로 선언합니다.
1type Client = {
2 id: string;
3 port: MessagePort;
4 lastBeat: number;
5};
Client
는 각 클라이언트의 식별자, 통신 포트, 마지막 하트비트 타임스탬프를 나타내는 타입입니다.
1const clients = new Map<string, Client>();
2const kv = new Map<string, unknown>();
3const locks = new Map<string, string>();
4const HEARTBEAT_TIMEOUT = 30_000;
- 연결된 클라이언트 목록, 키-값 저장소, 락 상태, 타임아웃 기간을 관리합니다.
1function send(port: MessagePort, msg: WorkerOutMessage) {
2 port.postMessage(msg);
3}
send
는 지정된 포트로 메시지를 보내는 유틸리티 함수입니다.
1function respond(req: RequestMessage, ok: boolean, result?: unknown, error?: string): ResponseMessage {
2 return { kind: 'response', id: req.id, ok, result, error };
3}
respond
는 요청에 대한 응답 메시지를 생성합니다.
1function broadcast(from: string, channel: string, payload: unknown) {
2 for (const [id, c] of clients) {
3 send(c.port, { kind: 'broadcast', channel, payload, from });
4 }
5}
broadcast
는 지정된 채널로 모든 클라이언트에게 메시지를 보냅니다.
1function handleRequest(clientId: string, port: MessagePort, req: RequestMessage) {
handleRequest
는 들어온 요청을 유형별로 처리하고 결과를 클라이언트에 반환합니다.
1 const { action } = req;
2 try {
3 switch (action.type) {
4 case 'ping':
5 send(port, respond(req, true, 'pong'));
6 break;
7 case 'echo':
8 send(port, respond(req, true, action.payload));
9 break;
10 case 'getTime':
11 send(port, respond(req, true, new Date().toISOString()));
12 break;
13 case 'set':
14 kv.set(action.key, action.value);
15 send(port, respond(req, true, true));
16 break;
17 case 'get':
18 send(port, respond(req, true, kv.get(action.key)));
19 break;
20 case 'broadcast':
21 broadcast(clientId, action.channel, action.payload);
22 send(port, respond(req, true, true));
23 break;
- 이 코드는 수신한 요청 유형에 따라 메시지 송수신, 데이터 조회 및 저장, 브로드캐스트를 처리합니다.
1 case 'lock.acquire': {
2 const owner = locks.get(action.key);
3 if (!owner) {
4 locks.set(action.key, clientId);
5 send(port, respond(req, true, { owner: clientId }));
6 } else if (owner === clientId) {
7 send(port, respond(req, true, { owner }));
8 } else {
9 const start = Date.now();
10 const tryWait = () => {
11 const current = locks.get(action.key);
12 if (!current) {
13 locks.set(action.key, clientId);
14 send(port, respond(req, true, { owner: clientId }));
15 } else if ((action.timeoutMs ?? 5000) < Date.now() - start) {
16 send(port, respond(req, false, undefined, 'lock-timeout'));
17 } else {
18 setTimeout(tryWait, 25);
19 }
20 };
21 tryWait();
22 }
23 break;
24 }
- 이 코드는 지정된 키에 대해 클라이언트가 잠금을 획득하는 절차를 구현합니다. 잠금이 이미 보유되어 있지 않다면 즉시 획득되며, 동일한 클라이언트가 다시 요청하는 경우에도 성공한 것으로 처리됩니다. 다른 클라이언트가 이미 잠금을 보유하고 있는 경우 잠금이 해제될 때까지 25밀리초마다 재시도하며, 지정된 타임아웃(기본값 5초)을 초과하면 오류로 응답합니다.
1 case 'lock.release':
2 if (locks.get(action.key) === clientId) {
3 locks.delete(action.key);
4 send(port, respond(req, true, true));
5 } else {
6 send(port, respond(req, false, undefined, 'not-owner'));
7 }
8 break;
9 default:
10 send(port, respond(req, false, undefined, 'unknown-action'));
11 }
12 } catch (e: any) {
13 send(port, respond(req, false, undefined, e?.message ?? 'error'));
14 }
15}
- 이 코드는 클라이언트가 보유한 잠금을 해제하고, 클라이언트에 권한이 없거나 동작이 알 수 없는 경우 오류 응답을 반환합니다.
1function handleInMessage(c: Client, msg: WorkerInMessage) {
2 if (msg.kind === 'heartbeat') {
3 c.lastBeat = Date.now();
4 } else if (msg.kind === 'bye') {
5 cleanupClient(msg.from);
6 } else if (msg.kind === 'request') {
7 handleRequest(c.id, c.port, msg);
8 }
9}
handleInMessage
는 클라이언트로부터 수신한 메시지를 파싱하고 요청과 하트비트를 처리합니다.
1function cleanupClient(clientId: string) {
2 for (const [key, owner] of locks) {
3 if (owner === clientId) locks.delete(key);
4 }
5 clients.delete(clientId);
6}
cleanupClient
는 레지스트리와 락 상태에서 연결이 끊긴 클라이언트를 제거합니다.
1setInterval(() => {
2 const now = Date.now();
3 for (const [id, c] of clients) {
4 if (now - c.lastBeat > HEARTBEAT_TIMEOUT) cleanupClient(id);
5 }
6}, 10_000);
setInterval
을 사용해 모든 클라이언트의 하트비트를 주기적으로 확인하고 타임아웃된 연결을 정리합니다.
1self.onconnect = (e: MessageEvent) => {
2 const port = (e.ports && e.ports[0]) as MessagePort;
3 const clientId = crypto.randomUUID?.() ?? Math.random().toString(36).slice(2);
4 const client: Client = { id: clientId, port, lastBeat: Date.now() };
5 clients.set(clientId, client);
6
7 port.addEventListener('message', (ev) => handleInMessage(client, ev.data as WorkerInMessage));
8 send(port, { kind: 'broadcast', channel: 'system', payload: { hello: true, clientId }, from: 'worker' });
9 port.start();
10};
-
onconnect
는 새 탭이나 페이지가Shared Worker
에 연결될 때 호출되며, 클라이언트를 등록하고 통신을 시작합니다. -
이 파일 전반에 걸쳐, 여러 브라우저 탭 간에 상태 공유와 통신을 가능하게 하는
Shared Worker
의 기본 메커니즘이 구현되어 있습니다.
클라이언트 래퍼(RPC)
다음으로 Promise 기반 RPC 클라이언트를 만듭니다.
1// shared-worker-client.ts
2import {
3 RequestAction,
4 RequestMessage,
5 WorkerOutMessage,
6 randomId
7} from './worker-protocol.js';
- 워커 통신에 사용되는 타입 정의와 유틸리티 함수를 임포트합니다.
1export type BroadcastHandler = (msg: {
2 channel: string;
3 payload: unknown;
4 from: string
5}) => void;
- 여기서는 브로드캐스트 메시지를 수신했을 때 실행되는 콜백 함수의 타입을 정의합니다.
1export class SharedWorkerClient {
SharedWorkerClient
는Shared Worker
와 통신하며 요청을 보내고 응답을 처리하는 클라이언트 클래스입니다.
1 private worker: SharedWorker;
2 private port: MessagePort;
3 private pending = new Map<string, {
4 resolve: (v: any) => void;
5 reject: (e: any) => void
6 }>();
- 이 변수들은 워커 인스턴스, 워커와의 통신 포트, 응답 대기 중인 요청을 추적하는 맵입니다.
1 private clientId = randomId();
2 private heartbeatTimer?: number;
3 private onBroadcast?: BroadcastHandler;
- 이 변수들은 클라이언트 식별자, 하트비트 전송용 타이머, 브로드캐스트 수신 핸들러를 보관합니다.
1 constructor(url: URL, name = 'app-shared', onBroadcast?: BroadcastHandler) {
2 this.worker = new SharedWorker(url, { name, type: 'module' as any });
3 this.port = this.worker.port;
4 this.onBroadcast = onBroadcast;
- 생성자에서
Shared Worker
와의 연결을 초기화하고 메시지 리스너와 하트비트 전송을 설정합니다.
1 this.port.addEventListener('message', (ev) => {
2 const msg = ev.data as WorkerOutMessage;
3 if (msg.kind === 'response') {
4 const p = this.pending.get(msg.id);
5 if (p) {
6 this.pending.delete(msg.id);
7 msg.ok ? p.resolve(msg.result) : p.reject(new Error(msg.error || 'error'));
8 }
9 } else if (msg.kind === 'broadcast') {
10 this.onBroadcast?.(msg);
11 }
12 });
13 this.port.start();
- 여기서는 워커로부터 메시지를 받아 응답 또는 브로드캐스트를 처리합니다.
1 this.heartbeatTimer = window.setInterval(() => {
2 this.port.postMessage({ kind: 'heartbeat', from: this.clientId });
3 }, 10_000);
- 연결을 유지하기 위해 하트비트 메시지를 주기적으로 전송합니다.
1 window.addEventListener('beforeunload', () => {
2 try {
3 this.port.postMessage({ kind: 'bye', from: this.clientId });
4 } catch {}
5 if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
6 });
7 }
- 창이 닫히기 전에 워커에 연결 해제 알림을 보냅니다.
1 request<T = unknown>(action: RequestAction): Promise<T> {
2 const id = randomId();
3 return new Promise<T>((resolve, reject) => {
4 this.pending.set(id, { resolve, reject });
5 this.port.postMessage({ kind: 'request', id, from: this.clientId, action });
6 });
7 }
request
메서드는 지정된 액션을 워커에 보내고 결과를Promise
로 받습니다.
1 ping() {
2 return this.request<string>({ type: 'ping' });
3 }
4 echo(payload: string) {
5 return this.request<string>({ type: 'echo', payload });
6 }
7 getTime() {
8 return this.request<string>({ type: 'getTime' });
9 }
- 이는 기본 통신 테스트와 현재 시간 조회를 위한 유틸리티 메서드들입니다.
1 set(key: string, value: unknown) {
2 return this.request<boolean>({ type: 'set', key, value });
3 }
4 get<T = unknown>(key: string) {
5 return this.request<T>({ type: 'get', key });
6 }
- 이는 키-값 쌍을 저장하고 조회하는 메서드들입니다.
1 broadcast(channel: string, payload: unknown) {
2 return this.request<boolean>({ type: 'broadcast', channel, payload });
3 }
- 이는 워커를 통해 다른 클라이언트에게 브로드캐스트 메시지를 보내는 메서드입니다.
1 lockAcquire(key: string, timeoutMs?: number) {
2 return this.request<{ owner: string }>({ type: 'lock.acquire', key, timeoutMs });
3 }
4 lockRelease(key: string) {
5 return this.request<boolean>({ type: 'lock.release', key });
6 }
7}
- 이는 공유 자원에 대한 상호 배제를 달성하기 위해 락을 획득하고 해제하는 메서드들입니다.
- 이 파일 전반에 걸쳐, 각 브라우저 탭에서 Shared Worker로의 안전한 비동기 통신을 위한 클라이언트 API가 구현되어 있습니다.
사용 예
demo.ts
에서 앞서 생성한 SharedWorkerClient
클래스를 사용하여 동작을 검증합니다. 통신 테스트, 데이터 읽기/쓰기, 브로드캐스트, 잠금 처리 등을 포함한 일련의 기능을 순차적으로 실행합니다.
1// demo.ts
2import { SharedWorkerClient } from './shared-worker-client.js';
3
4const client = new SharedWorkerClient(new URL('./shared-worker.js', import.meta.url), 'app-shared', (b) => {
5 console.log('[BROADCAST]', b.channel, JSON.stringify(b.payload));
6});
7
8async function demo() {
9 console.log('ping:', await client.ping());
10 console.log('echo:', await client.echo('hello'));
11 console.log('time:', await client.getTime());
12
13 // Counter update
14 await client.set('counter', (await client.get<number>('counter')) ?? 0);
15 const c1 = await client.get<number>('counter');
16 await client.set('counter', (c1 ?? 0) + 1);
17 console.log('counter:', await client.get<number>('counter'));
18
19 // Broadcast test
20 await client.broadcast('notify', { msg: 'Counter updated' });
21
22 // Lock test
23 const key = 'critical';
24 console.log(`[lock] Trying to acquire lock: "${key}"`);
25 const lockResult = await client.lockAcquire(key, 2000);
26 console.log(`[lock] Lock acquired for key "${key}":`, lockResult);
27
28 try {
29 console.log(`[lock] Simulating critical section for key "${key}"...`);
30 await new Promise((r) => setTimeout(r, 250));
31 console.log(`[lock] Critical section completed for key "${key}"`);
32 } finally {
33 console.log(`[lock] Releasing lock: "${key}"`);
34 const releaseResult = await client.lockRelease(key);
35 console.log(`[lock] Lock released for key "${key}":`, releaseResult);
36 }
37}
38
39demo().catch(console.error);
- 이 코드는
Shared Worker
를 사용하여 여러 브라우저 탭 간에 데이터와 상태를 공유하고 동기화하는 데모입니다. 메시지 기반 통신을 사용하면 느슨한 결합으로 안전하게 비동기 메시지를 주고받을 수 있어, 서로 다른 컨텍스트 간의 통신을 더 쉽게 관리할 수 있습니다. 또한 RPC를 사용하면 워커와의 통신을 직관적인 메서드 호출 방식으로 추상화하여 유지보수성과 가독성을 높일 수 있습니다.
HTML에서 테스트하기
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Shared Worker Demo</title>
7</head>
8<body>
9 <h1>SharedWorker Demo</h1>
10 <p>Open the browser console to see the output.</p>
11
12 <script type="module">
13 import './demo.js';
14 </script>
15</body>
16</html>
설계 및 운영 시 고려사항
설계 및 운영 시 다음 사항을 염두에 두면 보다 견고하고 확장 가능한 시스템을 구축하는 데 도움이 됩니다.
kind
또는type
에 따라 분기할 수 있도록 하는 **판별 유니온(태그된 유니온)**을 채택할 수 있습니다.- 요청과 응답을 올바르게 매칭하기 위해 상관 ID를 사용합니다.
- 하트비트와 자동 정리는 방치된 락을 방지할 수 있습니다.
- 향후 프로토콜 변경에 유연하게 대응할 수 있도록 버저닝을 구현합니다.
- 명확한 오류 코드를 정의하면 UI 측 처리와 디버깅이 더 쉬워집니다.
요약
Shared Worker
는 여러 브라우저 탭 간에 데이터와 상태를 공유하기 위한 핵심 메커니즘입니다.
여기서 소개한 구조는 타입 안전한 RPC 통신, 하트비트를 통한 생존 모니터링, 락 메커니즘을 제공하여, 그대로 프로덕션에서 사용할 수 있는 견고한 설계입니다.
이 메커니즘 위에 다음과 같은 응용을 구현할 수도 있습니다.
- IndexedDB 접근 직렬화
- WebSocket 연결의 통합 및 공유
- 여러 탭 간 작업 큐 구축
- 스로틀링 및 진행 상황 알림 전달
보시다시피 Shared Worker
를 활용하면 여러 탭에 걸쳐 데이터와 처리를 안전하고 효율적으로 공유할 수 있습니다.
위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.