TypeScript에서의 `SharedArrayBuffer`

TypeScript에서의 `SharedArrayBuffer`

이 글은 TypeScript에서 SharedArrayBuffer에 대해 설명합니다.

실용적인 예제를 통해 TypeScript에서 SharedArrayBuffer를 설명하겠습니다.

YouTube Video

TypeScript에서의 SharedArrayBuffer

SharedArrayBufferWeb Workers와 같은 여러 스레드 간에 동일한 메모리 공간을 공유하기 위한 메커니즘입니다. Atomics와 결합하면 데이터 경쟁을 관리하고 저지연 공유 메모리 연산을 수행할 수 있습니다.

전제 조건 및 참고 사항

브라우저에서 SharedArrayBuffer를 사용할 때는 교차 출처 격리(Cross-Origin Isolation) 보안 요구 사항을 충족하기 위해 COOP 및 COEP 헤더가 필요합니다. Node.js에서는 worker_threads를 사용하여 공유 메모리를 비교적 쉽게 다룰 수 있습니다.

기본 개념

SharedArrayBuffer는 고정 길이의 바이트 시퀀스를 나타내는 객체이며, TypedArray 등 뷰를 사용하여 숫자를 읽고 쓸 수 있습니다. 단순한 읽기/쓰기 작업은 동기화되지 않으므로, Atomics API를 사용하여 원자적 연산을 보장하고, waitnotify 메커니즘으로 조정할 수 있습니다.

간단한 카운터 (브라우저 버전)

이 예제에서는 메인 스레드가 SharedArrayBuffer를 생성해 Web Worker에 전달하고, 양쪽에서 공유된 카운터를 증가시킵니다. 이 코드는 최소 예시 패턴을 보여줍니다: Atomics.add로 원자적 덧셈, Atomics.load로 읽기.

main.ts (브라우저 측)

이 예제는 메인 스레드에서 SharedArrayBuffer를 생성하고 워커와 공유하여 멀티스레드 접근을 구현하는 방법을 보여줍니다.

 1// main.ts
 2// Create a 4-byte (Int32) shared buffer for one counter
 3const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1);
 4const counter = new Int32Array(sab); // view over the buffer
 5
 6// Create worker and transfer the SharedArrayBuffer
 7const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
 8worker.postMessage({ sab });
 9
10// Increase counter in main thread every 200ms
11setInterval(() => {
12  const newVal = Atomics.add(counter, 0, 1) + 1; // Atomically increment
13  console.log('Main incremented ->', newVal);
14}, 200);
15
16// Listen for messages (optional)
17worker.onmessage = (e) => {
18  console.log('From worker:', e.data);
19};
  • 이 코드에서는 메인 스레드가 Atomics.add로 값을 원자적으로 증가시킵니다. worker.ts 측에서도 동일한 SharedArrayBuffer에 접근하고 조작할 수 있습니다.

worker.ts (브라우저 워커)

이 예시는 워커가 동일한 공유 버퍼를 받아 주기적으로 값을 감소시키거나 조작하는 예제입니다.

 1// worker.ts
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const counter = new Int32Array(sab);
 5
 6  // Every 350ms, atomically add 2 (demonstration)
 7  setInterval(() => {
 8    const newVal = Atomics.add(counter, 0, 2) + 2;
 9    // Optionally notify main thread (postMessage)
10    self.postMessage(`Worker added 2 -> ${newVal}`);
11  }, 350);
12};
  • 워커 역시 Int32Array로 동일한 메모리를 다루며, Atomics 덕분에 경쟁 상태 없이 업데이트됩니다.

wait/notify를 사용한 동기화

Atomics.waitAtomics.notify를 사용하면 특정 조건이 충족될 때까지 스레드를 일시 중단시킬 수 있어, 워커 내에서 이벤트 기반 동기화를 구현할 수 있습니다. 브라우저에서는 워커 내부에서만 Atomics.wait를 사용하는 것이 가장 안전합니다.

producer.ts (브라우저 프로듀서 워커)

프로듀서가 데이터를 쓰고 notify로 컨슈머에게 알립니다.

 1// producer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab); // state[0] will be 0=empty,1=filled
 5
 6  // produce every 500ms
 7  setInterval(() => {
 8    // produce: set state to 1 (filled)
 9    Atomics.store(state, 0, 1);
10    Atomics.notify(state, 0, 1); // wake one waiter
11    self.postMessage('produced');
12  }, 500);
13};

consumer.ts (브라우저 컨슈머 워커)

컨슈머는 Atomics.wait로 대기하며, 프로듀서의 알림이 오면 처리를 재개합니다.

 1// consumer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab);
 5
 6  // consumer loop
 7  (async function consumeLoop() {
 8    while (true) {
 9      // if state is 0 => wait until notified (value changes)
10      while (Atomics.load(state, 0) === 0) {
11        // Blocks this worker until notified or timeout
12        Atomics.wait(state, 0, 0);
13      }
14      // consume (reset to 0)
15      // (processing simulated)
16      // read data from another shared buffer in real scenarios
17      Atomics.store(state, 0, 0);
18      // continue loop
19      self.postMessage('consumed');
20    }
21  })();
22};
  • 이 패턴에서는 프로듀서가 Atomics.notify로 컨슈머에게 알리고, 컨슈머는 Atomics.wait로 효율적으로 대기합니다. 브라우저의 메인 스레드에서는 Atomics.wait를 호출할 수 없습니다. UI가 멈추는 것을 방지하기 위해, Atomics.wait는 워커 내부에서만 사용할 수 있습니다.

Node.js (worker_threads)를 활용한 실용 예제

Node.js 환경에서는 worker_threads를 사용하여 SharedArrayBuffer 기능을 다룰 수 있습니다. 아래는 Node.js용 타입이 명시된 TypeScript 샘플입니다.

main-node.ts

메인 스레드가 버퍼를 생성해 워커에 전달합니다.

 1// main-node.ts
 2import { Worker } from 'worker_threads';
 3import path from 'path';
 4
 5// Create SharedArrayBuffer for one 32-bit integer
 6const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
 7const counter = new Int32Array(sab);
 8
 9// Spawn worker and pass sab via workerData
10const worker = new Worker(path.resolve(__dirname, 'worker-node.js'), {
11  workerData: { sab }
12});
13
14// Log messages from worker
15worker.on('message', (msg) => console.log('Worker:', msg));
16
17// Increment in main thread periodically
18setInterval(() => {
19  const val = Atomics.add(counter, 0, 1) + 1;
20  console.log('Main increment ->', val);
21}, 300);

worker-node.ts

워커 측에서는 worker_threadsparentPortworkerData를 사용합니다.

 1// worker-node.ts
 2import { parentPort, workerData } from 'worker_threads';
 3
 4const sab = workerData.sab as SharedArrayBuffer;
 5const counter = new Int32Array(sab);
 6
 7// Periodically add 5
 8setInterval(() => {
 9  const val = Atomics.add(counter, 0, 5) + 5;
10  parentPort?.postMessage(`Added 5 -> ${val}`);
11}, 700);
  • Node.js에서는 브라우저와 달리 COOP/COEP 제한이 없으므로, worker_threads만으로 공유 메모리를 편리하게 다룰 수 있습니다. TypeScript 사용 시, CommonJS 또는 ESM 설정 중 어떤 것으로 빌드하는지 주의하세요.

TypeScript 타입 지정 포인트

SharedArrayBufferAtomics는 표준 DOM 및 라이브러리 타입 정의에 포함되어 있어, TypeScript에서 바로 사용할 수 있습니다. 워커와 메시지를 교환할 때는 인터페이스를 정의하고 타입을 명확히 지정하는 것이 더 안전합니다.

1// Example: typed message
2type WorkerMessage = { type: 'init'; sab: SharedArrayBuffer } | { type: 'ping' };
  • 타입을 명확하게 정의하면 postMessageonmessage 처리가 더 안전해지고 타입 검사도 가능합니다.

실제 활용 사례

SharedArrayBuffer가 항상 필요한 것은 아니지만, 공유 메모리의 속도 이점이 요구되는 상황에서는 매우 효과적입니다. 어떤 상황에서 효과적인지 이해하는 것이 적절한 기술 선택으로 이어집니다.

  • 고속 공유 버퍼가 필요한 저지연 처리에 적합하며, 실시간 오디오/비디오 처리나 게임 물리 연산 등에 활용할 수 있습니다.
  • 간단한 데이터 교환이나 대용량 데이터 전송에는 SharedArrayBuffer보다 Transferable ArrayBufferpostMessage가 더 쉽습니다.

제한 사항 및 보안

브라우저에서 SharedArrayBuffer를 사용하려면 교차 출처 격리가 필요합니다: COOP는 same-origin-allow-popups, COEP는 require-corp로 설정해야 합니다. 이 요건을 충족하지 않으면 SharedArrayBuffer가 비활성화됩니다.

성능 및 디버깅 팁

원자적 연산(Atomics)은 빠르지만, 잦은 대기나 과도한 동기화는 지연을 증가시킬 수 있습니다.

공유 메모리를 안전하고 효율적으로 다루려면 아래 사항을 점검하세요.

  • Int32Array와 같은 뷰는 올바른 바이트 정렬로 다루어야 합니다.
  • 동일한 공유 버퍼의 어떤 인덱스를 어느 프로세스가 사용하는지 명확하게 하고, 코드 내에서 일관된 규칙을 유지해야 합니다.
  • SharedArrayBuffer를 일반 ArrayBuffer처럼 다루면 데이터 경쟁이 발생합니다. Atomics를 사용하지 않고 여러 스레드가 동일한 인덱스에 쓰는 것은 매우 위험합니다.

요약

SharedArrayBufferAtomics를 적절히 활용하면 TypeScript에서도 안전하고 빠른 공유 메모리 연산을 구현할 수 있습니다. 카운터나 신호 같은 단순한 동기화 패턴부터 시작하면 이해하기 쉽고, 정확한 동기화와 인덱스 관리를 철저히 하면 저지연 환경에서도 효과적으로 사용할 수 있습니다.

위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video