한국어

자바스크립트에서 진정한 멀티스레딩을 구현하세요. 이 종합 가이드는 SharedArrayBuffer, Atomics, 웹 워커, 그리고 고성능 웹 애플리케이션을 위한 보안 요구사항을 다룹니다.

JavaScript SharedArrayBuffer: 웹에서의 동시성 프로그래밍 심층 분석

수십 년 동안 자바스크립트의 싱글 스레드 특성은 단순함의 원천인 동시에 심각한 성능 병목 현상의 원인이었습니다. 이벤트 루프 모델은 대부분의 UI 중심 작업에 훌륭하게 작동하지만, 계산 집약적인 작업을 처리할 때는 어려움을 겪습니다. 오래 실행되는 계산은 브라우저를 멈추게 하여 사용자에게 불편한 경험을 줍니다. 웹 워커가 스크립트를 백그라운드에서 실행할 수 있게 하여 부분적인 해결책을 제시했지만, 비효율적인 데이터 통신이라는 자체적인 주요 한계를 가지고 있었습니다.

웹에서 스레드 간에 진정한 저수준 메모리 공유를 도입하여 근본적으로 게임의 판도를 바꾸는 강력한 기능인 SharedArrayBuffer(SAB)를 소개합니다. Atomics 객체와 함께 SAB는 브라우저에서 직접 고성능 동시성 애플리케이션의 새로운 시대를 엽니다. 하지만 큰 힘에는 큰 책임과 복잡성이 따릅니다.

이 가이드는 자바스크립트의 동시성 프로그래밍 세계로 여러분을 깊이 안내할 것입니다. 우리는 왜 동시성 프로그래밍이 필요한지, SharedArrayBufferAtomics가 어떻게 작동하는지, 반드시 해결해야 할 중요한 보안 고려 사항은 무엇인지, 그리고 시작하는 데 도움이 될 실용적인 예제들을 탐구할 것입니다.

과거의 방식: 자바스크립트의 싱글 스레드 모델과 그 한계

해결책을 제대로 이해하기 전에, 우리는 먼저 문제를 완전히 이해해야 합니다. 브라우저에서의 자바스크립트 실행은 전통적으로 "메인 스레드" 또는 "UI 스레드"라고 불리는 단일 스레드에서 일어납니다.

이벤트 루프

메인 스레드는 자바스크립트 코드 실행, 페이지 렌더링, 사용자 상호작용(클릭 및 스크롤 등)에 대한 응답, CSS 애니메이션 실행 등 모든 것을 담당합니다. 이 작업들은 메시지(작업) 큐를 지속적으로 처리하는 이벤트 루프를 사용하여 관리됩니다. 만약 어떤 작업이 완료되는 데 오랜 시간이 걸리면, 전체 큐가 막히게 됩니다. 다른 어떤 것도 일어날 수 없으며, UI는 멈추고 애니메이션은 버벅거리며 페이지는 응답하지 않게 됩니다.

웹 워커: 올바른 방향으로의 한 걸음

웹 워커는 이 문제를 완화하기 위해 도입되었습니다. 웹 워커는 본질적으로 별도의 백그라운드 스레드에서 실행되는 스크립트입니다. 무거운 계산을 워커에 위임하여 메인 스레드가 사용자 인터페이스를 자유롭게 처리하도록 할 수 있습니다.

메인 스레드와 워커 간의 통신은 postMessage() API를 통해 이루어집니다. 데이터를 보낼 때, 이는 구조화된 복제 알고리즘(structured clone algorithm)에 의해 처리됩니다. 즉, 데이터는 직렬화되고, 복사된 후, 워커의 컨텍스트에서 역직렬화됩니다. 이 방식은 효과적이지만, 대용량 데이터셋의 경우 다음과 같은 상당한 단점을 가집니다:

브라우저에서 비디오 편집기를 상상해 보세요. 전체 비디오 프레임(수 메가바이트가 될 수 있음)을 초당 60번씩 처리를 위해 워커와 주고받는 것은 엄청나게 비싼 작업일 것입니다. 이것이 바로 SharedArrayBuffer가 해결하기 위해 설계된 문제입니다.

게임 체인저: SharedArrayBuffer의 등장

SharedArrayBufferArrayBuffer와 유사한 고정 길이의 원시 이진 데이터 버퍼입니다. 결정적인 차이점은 SharedArrayBuffer는 여러 스레드(예: 메인 스레드와 하나 이상의 웹 워커) 간에 공유될 수 있다는 것입니다. postMessage()를 사용하여 SharedArrayBuffer를 "보낼" 때, 당신은 복사본을 보내는 것이 아니라 동일한 메모리 블록에 대한 참조를 보내는 것입니다.

이는 한 스레드에서 버퍼의 데이터에 가한 변경 사항이 해당 버퍼에 대한 참조를 가진 다른 모든 스레드에 즉시 보인다는 것을 의미합니다. 이로 인해 비용이 많이 드는 복사 및 직렬화 단계가 제거되어 거의 즉각적인 데이터 공유가 가능해집니다.

다음과 같이 생각해 볼 수 있습니다:

공유 메모리의 위험: 경쟁 상태(Race Conditions)

즉각적인 메모리 공유는 강력하지만, 동시성 프로그래밍 세계의 고전적인 문제인 경쟁 상태(race conditions)를 야기합니다.

경쟁 상태는 여러 스레드가 동시에 동일한 공유 데이터에 접근하고 수정하려고 할 때 발생하며, 최종 결과는 예측할 수 없는 실행 순서에 따라 달라집니다. SharedArrayBuffer에 저장된 간단한 카운터를 생각해 보세요. 메인 스레드와 워커 모두 이 값을 증가시키고 싶어 합니다.

  1. 스레드 A가 현재 값인 5를 읽습니다.
  2. 스레드 A가 새 값을 쓰기 전에, 운영 체제가 스레드 A를 잠시 멈추고 스레드 B로 전환합니다.
  3. 스레드 B가 현재 값인 5를 읽습니다.
  4. 스레드 B가 새 값(6)을 계산하고 메모리에 다시 씁니다.
  5. 시스템이 다시 스레드 A로 전환합니다. 스레드 A는 스레드 B가 무언가를 했다는 사실을 모릅니다. 중단됐던 지점부터 다시 시작하여, 자신의 새 값(5 + 1 = 6)을 계산하고 6을 메모리에 다시 씁니다.

카운터가 두 번 증가되었음에도 불구하고 최종 값은 7이 아닌 6입니다. 이 연산들은 원자적(atomic)이지 않았습니다. 즉, 중단될 수 있었고, 이로 인해 데이터가 손실되었습니다. 이것이 바로 SharedArrayBuffer를 그것의 중요한 파트너인 Atomics 객체 없이는 사용할 수 없는 이유입니다.

공유 메모리의 수호자: Atomics 객체

Atomics 객체는 SharedArrayBuffer 객체에 대해 원자적 연산을 수행하기 위한 정적 메서드 집합을 제공합니다. 원자적 연산은 다른 어떤 연산에도 방해받지 않고 전체가 완전히 수행되는 것이 보장됩니다. 완전히 일어나거나 아예 일어나지 않거나 둘 중 하나입니다.

Atomics를 사용하면 공유 메모리에 대한 읽기-수정-쓰기 연산이 안전하게 수행되도록 보장하여 경쟁 상태를 방지할 수 있습니다.

주요 Atomics 메서드

Atomics가 제공하는 가장 중요한 메서드 몇 가지를 살펴보겠습니다.

동기화: 단순한 연산을 넘어서

때로는 안전한 읽기와 쓰기 이상의 것이 필요합니다. 스레드들이 서로 협력하고 기다려야 할 때가 있습니다. 흔한 안티패턴은 "바쁜 대기(busy-waiting)"로, 스레드가 좁은 루프 안에서 계속해서 메모리 위치의 변화를 확인하는 것입니다. 이것은 CPU 사이클을 낭비하고 배터리 수명을 소모합니다.

Atomicswait()notify()를 통해 훨씬 더 효율적인 해결책을 제공합니다.

전체 내용 종합하기: 실용 가이드

이제 이론을 이해했으니, SharedArrayBuffer를 사용하여 솔루션을 구현하는 단계를 살펴보겠습니다.

1단계: 보안 전제 조건 - 교차 출처 격리(Cross-Origin Isolation)

이것은 개발자들이 가장 흔하게 부딪히는 장애물입니다. 보안상의 이유로, SharedArrayBuffer교차 출처 격리(cross-origin isolated) 상태인 페이지에서만 사용할 수 있습니다. 이는 스펙터(Spectre)와 같은 추측 실행 취약점을 완화하기 위한 보안 조치로, 이러한 취약점은 공유 메모리를 통해 가능해진 고해상도 타이머를 사용하여 출처 간에 데이터를 유출할 수 있습니다.

교차 출처 격리를 활성화하려면, 웹 서버가 주 문서에 대해 두 가지 특정 HTTP 헤더를 보내도록 구성해야 합니다:


Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

이 설정은 특히 필요한 헤더를 제공하지 않는 제3자 스크립트나 리소스에 의존하는 경우 까다로울 수 있습니다. 서버를 구성한 후, 브라우저 콘솔에서 self.crossOriginIsolated 속성을 확인하여 페이지가 격리되었는지 확인할 수 있습니다. 이 값은 true여야 합니다.

2단계: 버퍼 생성 및 공유하기

메인 스크립트에서 SharedArrayBuffer를 생성하고 Int32Array와 같은 TypedArray를 사용하여 그 위에 "뷰"를 만듭니다.

main.js:


// 먼저 교차 출처 격리 상태인지 확인합니다!
if (!self.crossOriginIsolated) {
  console.error("이 페이지는 교차 출처 격리 상태가 아닙니다. SharedArrayBuffer를 사용할 수 없습니다.");
} else {
  // 32비트 정수 하나를 위한 공유 버퍼를 생성합니다.
  const buffer = new SharedArrayBuffer(4);

  // 버퍼에 대한 뷰를 생성합니다. 모든 원자적 연산은 이 뷰에서 일어납니다.
  const int32Array = new Int32Array(buffer);

  // 인덱스 0의 값을 초기화합니다.
  int32Array[0] = 0;

  // 새로운 워커를 생성합니다.
  const worker = new Worker('worker.js');

  // 공유 버퍼를 워커에게 보냅니다. 이것은 복사가 아닌 참조 전달입니다.
  worker.postMessage({ buffer });

  // 워커로부터의 메시지를 수신합니다.
  worker.onmessage = (event) => {
    console.log(`워커가 완료를 보고했습니다. 최종 값: ${Atomics.load(int32Array, 0)}`);
  };
}

3단계: 워커에서 원자적 연산 수행하기

워커는 버퍼를 받고 이제 그 위에서 원자적 연산을 수행할 수 있습니다.

worker.js:


self.onmessage = (event) => {
  const { buffer } = event.data;
  const int32Array = new Int32Array(buffer);

  console.log("워커가 공유 버퍼를 받았습니다.");

  // 원자적 연산을 수행해 봅시다.
  for (let i = 0; i < 1000000; i++) {
    // 공유된 값을 안전하게 증가시킵니다.
    Atomics.add(int32Array, 0, 1);
  }

  console.log("워커가 값 증가를 마쳤습니다.");

  // 작업이 끝났음을 메인 스레드에 알립니다.
  self.postMessage({ done: true });
};

4단계: 더 고급 예제 - 동기화를 사용한 병렬 합산

여러 워커를 사용하여 매우 큰 숫자 배열을 합산하는 더 현실적인 문제를 다뤄보겠습니다. 효율적인 동기화를 위해 Atomics.wait()Atomics.notify()를 사용할 것입니다.

우리의 공유 버퍼는 세 부분으로 구성됩니다:

main.js:


if (self.crossOriginIsolated) {
  const NUM_WORKERS = 4;
  const DATA_SIZE = 10_000_000;

  // [status, workers_finished, result]
  // 큰 합계의 오버플로우를 방지하기 위해 결과에 32비트 정수 두 개를 사용할 수 있지만, 여기서는 단순하게 하나만 사용합니다.
  const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4개의 정수
  const sharedArray = new Int32Array(sharedBuffer);

  // 처리할 임의의 데이터 생성
  const data = new Uint8Array(DATA_SIZE);
  for (let i = 0; i < DATA_SIZE; i++) {
    data[i] = Math.floor(Math.random() * 10);
  }

  const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);

  for (let i = 0; i < NUM_WORKERS; i++) {
    const worker = new Worker('sum_worker.js');
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, DATA_SIZE);
    
    // 워커의 데이터 청크에 대한 비공유 뷰 생성
    const dataChunk = data.subarray(start, end);

    worker.postMessage({ 
      sharedBuffer,
      dataChunk // 이것은 복사됩니다
    });
  }

  console.log('메인 스레드가 워커들이 끝나기를 기다립니다...');

  // 인덱스 0의 상태 플래그가 1이 될 때까지 기다립니다
  // 이것은 while 루프보다 훨씬 낫습니다!
  Atomics.wait(sharedArray, 0, 0); // sharedArray[0]이 0이면 대기

  console.log('메인 스레드가 깨어났습니다!');
  const finalSum = Atomics.load(sharedArray, 2);
  console.log(`최종 병렬 합계는: ${finalSum}`);

} else {
  console.error('페이지가 교차 출처 격리 상태가 아닙니다.');
}

sum_worker.js:


self.onmessage = ({ data }) => {
  const { sharedBuffer, dataChunk } = data;
  const sharedArray = new Int32Array(sharedBuffer);

  // 이 워커의 청크에 대한 합계 계산
  let localSum = 0;
  for (let i = 0; i < dataChunk.length; i++) {
    localSum += dataChunk[i];
  }

  // 지역 합계를 공유 총계에 원자적으로 더함
  Atomics.add(sharedArray, 2, localSum);

  // '완료된 워커' 카운터를 원자적으로 증가시킴
  const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;

  // 이 워커가 마지막으로 끝나는 경우...
  const NUM_WORKERS = 4; // 실제 앱에서는 전달되어야 함
  if (finishedCount === NUM_WORKERS) {
    console.log('마지막 워커가 작업을 마쳤습니다. 메인 스레드에 알립니다.');

    // 1. 상태 플래그를 1(완료)로 설정
    Atomics.store(sharedArray, 0, 1);

    // 2. 인덱스 0에서 대기 중인 메인 스레드에 알림
    Atomics.notify(sharedArray, 0, 1);
  }
};

실제 사용 사례 및 응용 분야

이 강력하지만 복잡한 기술이 실제로 차이를 만드는 곳은 어디일까요? 대규모 데이터셋에 대한 무겁고 병렬화 가능한 계산이 필요한 애플리케이션에서 뛰어난 성능을 발휘합니다.

과제 및 최종 고려사항

SharedArrayBuffer는 혁신적이지만, 만병통치약은 아닙니다. 신중한 처리가 필요한 저수준 도구입니다.

  1. 복잡성: 동시성 프로그래밍은 악명 높게 어렵습니다. 경쟁 상태와 교착 상태(데드락)를 디버깅하는 것은 매우 어려울 수 있습니다. 애플리케이션 상태가 어떻게 관리되는지에 대해 다르게 생각해야 합니다.
  2. 교착 상태(Deadlocks): 교착 상태는 둘 이상의 스레드가 서로가 자원을 해제하기를 기다리며 영원히 차단될 때 발생합니다. 복잡한 잠금 메커니즘을 잘못 구현하면 이런 일이 발생할 수 있습니다.
  3. 보안 오버헤드: 교차 출처 격리 요구 사항은 상당한 장애물입니다. 제3자 서비스, 광고, 결제 게이트웨이가 필요한 CORS/CORP 헤더를 지원하지 않으면 통합이 깨질 수 있습니다.
  4. 모든 문제에 대한 해결책은 아님: 간단한 백그라운드 작업이나 I/O 작업의 경우, postMessage()를 사용하는 전통적인 웹 워커 모델이 종종 더 간단하고 충분합니다. SharedArrayBuffer는 대용량 데이터를 포함하는 명확한 CPU 병목 현상이 있을 때만 사용해야 합니다.

결론

SharedArrayBufferAtomics 및 웹 워커와 함께 웹 개발의 패러다임 전환을 의미합니다. 이는 싱글 스레드 모델의 경계를 허물고, 강력하고 성능이 뛰어나며 복잡한 새로운 종류의 애플리케이션을 브라우저로 초대합니다. 이는 계산 집약적인 작업에 대해 웹 플랫폼을 네이티브 애플리케이션 개발과 동등한 위치에 놓습니다.

동시성 자바스크립트로의 여정은 도전적이며, 상태 관리, 동기화 및 보안에 대한 엄격한 접근을 요구합니다. 그러나 실시간 오디오 합성에서 복잡한 3D 렌더링 및 과학 컴퓨팅에 이르기까지 웹에서 가능한 것의 한계를 뛰어넘으려는 개발자에게 SharedArrayBuffer를 마스터하는 것은 더 이상 선택이 아니라 차세대 웹 애플리케이션을 구축하기 위한 필수 기술입니다.