자바스크립트에서 진정한 멀티스레딩을 구현하세요. 이 종합 가이드는 SharedArrayBuffer, Atomics, 웹 워커, 그리고 고성능 웹 애플리케이션을 위한 보안 요구사항을 다룹니다.
JavaScript SharedArrayBuffer: 웹에서의 동시성 프로그래밍 심층 분석
수십 년 동안 자바스크립트의 싱글 스레드 특성은 단순함의 원천인 동시에 심각한 성능 병목 현상의 원인이었습니다. 이벤트 루프 모델은 대부분의 UI 중심 작업에 훌륭하게 작동하지만, 계산 집약적인 작업을 처리할 때는 어려움을 겪습니다. 오래 실행되는 계산은 브라우저를 멈추게 하여 사용자에게 불편한 경험을 줍니다. 웹 워커가 스크립트를 백그라운드에서 실행할 수 있게 하여 부분적인 해결책을 제시했지만, 비효율적인 데이터 통신이라는 자체적인 주요 한계를 가지고 있었습니다.
웹에서 스레드 간에 진정한 저수준 메모리 공유를 도입하여 근본적으로 게임의 판도를 바꾸는 강력한 기능인 SharedArrayBuffer
(SAB)를 소개합니다. Atomics
객체와 함께 SAB는 브라우저에서 직접 고성능 동시성 애플리케이션의 새로운 시대를 엽니다. 하지만 큰 힘에는 큰 책임과 복잡성이 따릅니다.
이 가이드는 자바스크립트의 동시성 프로그래밍 세계로 여러분을 깊이 안내할 것입니다. 우리는 왜 동시성 프로그래밍이 필요한지, SharedArrayBuffer
와 Atomics
가 어떻게 작동하는지, 반드시 해결해야 할 중요한 보안 고려 사항은 무엇인지, 그리고 시작하는 데 도움이 될 실용적인 예제들을 탐구할 것입니다.
과거의 방식: 자바스크립트의 싱글 스레드 모델과 그 한계
해결책을 제대로 이해하기 전에, 우리는 먼저 문제를 완전히 이해해야 합니다. 브라우저에서의 자바스크립트 실행은 전통적으로 "메인 스레드" 또는 "UI 스레드"라고 불리는 단일 스레드에서 일어납니다.
이벤트 루프
메인 스레드는 자바스크립트 코드 실행, 페이지 렌더링, 사용자 상호작용(클릭 및 스크롤 등)에 대한 응답, CSS 애니메이션 실행 등 모든 것을 담당합니다. 이 작업들은 메시지(작업) 큐를 지속적으로 처리하는 이벤트 루프를 사용하여 관리됩니다. 만약 어떤 작업이 완료되는 데 오랜 시간이 걸리면, 전체 큐가 막히게 됩니다. 다른 어떤 것도 일어날 수 없으며, UI는 멈추고 애니메이션은 버벅거리며 페이지는 응답하지 않게 됩니다.
웹 워커: 올바른 방향으로의 한 걸음
웹 워커는 이 문제를 완화하기 위해 도입되었습니다. 웹 워커는 본질적으로 별도의 백그라운드 스레드에서 실행되는 스크립트입니다. 무거운 계산을 워커에 위임하여 메인 스레드가 사용자 인터페이스를 자유롭게 처리하도록 할 수 있습니다.
메인 스레드와 워커 간의 통신은 postMessage()
API를 통해 이루어집니다. 데이터를 보낼 때, 이는 구조화된 복제 알고리즘(structured clone algorithm)에 의해 처리됩니다. 즉, 데이터는 직렬화되고, 복사된 후, 워커의 컨텍스트에서 역직렬화됩니다. 이 방식은 효과적이지만, 대용량 데이터셋의 경우 다음과 같은 상당한 단점을 가집니다:
- 성능 오버헤드: 스레드 간에 메가바이트 또는 기가바이트의 데이터를 복사하는 것은 느리고 CPU 집약적인 작업입니다.
- 메모리 소비: 메모리에 데이터의 복사본을 생성하므로, 메모리가 제한된 장치에서는 큰 문제가 될 수 있습니다.
브라우저에서 비디오 편집기를 상상해 보세요. 전체 비디오 프레임(수 메가바이트가 될 수 있음)을 초당 60번씩 처리를 위해 워커와 주고받는 것은 엄청나게 비싼 작업일 것입니다. 이것이 바로 SharedArrayBuffer
가 해결하기 위해 설계된 문제입니다.
게임 체인저: SharedArrayBuffer
의 등장
SharedArrayBuffer
는 ArrayBuffer
와 유사한 고정 길이의 원시 이진 데이터 버퍼입니다. 결정적인 차이점은 SharedArrayBuffer
는 여러 스레드(예: 메인 스레드와 하나 이상의 웹 워커) 간에 공유될 수 있다는 것입니다. postMessage()
를 사용하여 SharedArrayBuffer
를 "보낼" 때, 당신은 복사본을 보내는 것이 아니라 동일한 메모리 블록에 대한 참조를 보내는 것입니다.
이는 한 스레드에서 버퍼의 데이터에 가한 변경 사항이 해당 버퍼에 대한 참조를 가진 다른 모든 스레드에 즉시 보인다는 것을 의미합니다. 이로 인해 비용이 많이 드는 복사 및 직렬화 단계가 제거되어 거의 즉각적인 데이터 공유가 가능해집니다.
다음과 같이 생각해 볼 수 있습니다:
postMessage()
를 사용하는 웹 워커: 이것은 두 동료가 이메일로 문서 사본을 주고받으며 작업하는 것과 같습니다. 변경이 있을 때마다 새로운 전체 사본을 보내야 합니다.SharedArrayBuffer
를 사용하는 웹 워커: 이것은 두 동료가 공유 온라인 편집기(예: Google Docs)에서 같은 문서를 작업하는 것과 같습니다. 변경 사항이 양쪽 모두에게 실시간으로 보입니다.
공유 메모리의 위험: 경쟁 상태(Race Conditions)
즉각적인 메모리 공유는 강력하지만, 동시성 프로그래밍 세계의 고전적인 문제인 경쟁 상태(race conditions)를 야기합니다.
경쟁 상태는 여러 스레드가 동시에 동일한 공유 데이터에 접근하고 수정하려고 할 때 발생하며, 최종 결과는 예측할 수 없는 실행 순서에 따라 달라집니다. SharedArrayBuffer
에 저장된 간단한 카운터를 생각해 보세요. 메인 스레드와 워커 모두 이 값을 증가시키고 싶어 합니다.
- 스레드 A가 현재 값인 5를 읽습니다.
- 스레드 A가 새 값을 쓰기 전에, 운영 체제가 스레드 A를 잠시 멈추고 스레드 B로 전환합니다.
- 스레드 B가 현재 값인 5를 읽습니다.
- 스레드 B가 새 값(6)을 계산하고 메모리에 다시 씁니다.
- 시스템이 다시 스레드 A로 전환합니다. 스레드 A는 스레드 B가 무언가를 했다는 사실을 모릅니다. 중단됐던 지점부터 다시 시작하여, 자신의 새 값(5 + 1 = 6)을 계산하고 6을 메모리에 다시 씁니다.
카운터가 두 번 증가되었음에도 불구하고 최종 값은 7이 아닌 6입니다. 이 연산들은 원자적(atomic)이지 않았습니다. 즉, 중단될 수 있었고, 이로 인해 데이터가 손실되었습니다. 이것이 바로 SharedArrayBuffer
를 그것의 중요한 파트너인 Atomics
객체 없이는 사용할 수 없는 이유입니다.
공유 메모리의 수호자: Atomics
객체
Atomics
객체는 SharedArrayBuffer
객체에 대해 원자적 연산을 수행하기 위한 정적 메서드 집합을 제공합니다. 원자적 연산은 다른 어떤 연산에도 방해받지 않고 전체가 완전히 수행되는 것이 보장됩니다. 완전히 일어나거나 아예 일어나지 않거나 둘 중 하나입니다.
Atomics
를 사용하면 공유 메모리에 대한 읽기-수정-쓰기 연산이 안전하게 수행되도록 보장하여 경쟁 상태를 방지할 수 있습니다.
주요 Atomics
메서드
Atomics
가 제공하는 가장 중요한 메서드 몇 가지를 살펴보겠습니다.
Atomics.load(typedArray, index)
: 주어진 인덱스의 값을 원자적으로 읽고 반환합니다. 이를 통해 완전하고 손상되지 않은 값을 읽을 수 있습니다.Atomics.store(typedArray, index, value)
: 주어진 인덱스에 값을 원자적으로 저장하고 그 값을 반환합니다. 이를 통해 쓰기 작업이 중단되지 않도록 보장합니다.Atomics.add(typedArray, index, value)
: 주어진 인덱스의 값에 특정 값을 원자적으로 더합니다. 해당 위치의 원래 값을 반환합니다. 이것은x += value
의 원자적 버전입니다.Atomics.sub(typedArray, index, value)
: 주어진 인덱스의 값에서 특정 값을 원자적으로 뺍니다.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: 강력한 조건부 쓰기 기능입니다.index
의 값이expectedValue
와 같은지 확인합니다. 만약 같다면, 그 값을replacementValue
로 바꾸고 원래의expectedValue
를 반환합니다. 그렇지 않다면, 아무것도 하지 않고 현재 값을 반환합니다. 이것은 락(lock)과 같은 더 복잡한 동기화 기본 요소를 구현하는 데 있어 근본적인 구성 요소입니다.
동기화: 단순한 연산을 넘어서
때로는 안전한 읽기와 쓰기 이상의 것이 필요합니다. 스레드들이 서로 협력하고 기다려야 할 때가 있습니다. 흔한 안티패턴은 "바쁜 대기(busy-waiting)"로, 스레드가 좁은 루프 안에서 계속해서 메모리 위치의 변화를 확인하는 것입니다. 이것은 CPU 사이클을 낭비하고 배터리 수명을 소모합니다.
Atomics
는 wait()
와 notify()
를 통해 훨씬 더 효율적인 해결책을 제공합니다.
Atomics.wait(typedArray, index, value, timeout)
: 스레드를 잠들게 합니다.index
의 값이 여전히value
인지 확인합니다. 만약 그렇다면, 스레드는Atomics.notify()
에 의해 깨워지거나 선택적timeout
(밀리초 단위)에 도달할 때까지 잠듭니다. 만약index
의 값이 이미 변경되었다면, 즉시 반환됩니다. 잠든 스레드는 CPU 자원을 거의 소비하지 않으므로 매우 효율적입니다.Atomics.notify(typedArray, index, count)
:Atomics.wait()
를 통해 특정 메모리 위치에서 잠들어 있는 스레드를 깨우는 데 사용됩니다. 최대count
개의 대기 중인 스레드를 깨웁니다(count
가 제공되지 않거나Infinity
인 경우 모두 깨움).
전체 내용 종합하기: 실용 가이드
이제 이론을 이해했으니, SharedArrayBuffer
를 사용하여 솔루션을 구현하는 단계를 살펴보겠습니다.
1단계: 보안 전제 조건 - 교차 출처 격리(Cross-Origin Isolation)
이것은 개발자들이 가장 흔하게 부딪히는 장애물입니다. 보안상의 이유로, SharedArrayBuffer
는 교차 출처 격리(cross-origin isolated) 상태인 페이지에서만 사용할 수 있습니다. 이는 스펙터(Spectre)와 같은 추측 실행 취약점을 완화하기 위한 보안 조치로, 이러한 취약점은 공유 메모리를 통해 가능해진 고해상도 타이머를 사용하여 출처 간에 데이터를 유출할 수 있습니다.
교차 출처 격리를 활성화하려면, 웹 서버가 주 문서에 대해 두 가지 특정 HTTP 헤더를 보내도록 구성해야 합니다:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): 문서의 브라우징 컨텍스트를 다른 문서로부터 격리하여, 다른 문서가 당신의 window 객체와 직접 상호작용하는 것을 방지합니다.Cross-Origin-Embedder-Policy: require-corp
(COEP): 페이지에 의해 로드되는 모든 하위 리소스(이미지, 스크립트, iframe 등)는 동일한 출처에서 오거나Cross-Origin-Resource-Policy
헤더 또는 CORS를 통해 명시적으로 교차 출처 로드가 가능하도록 표시되어야 합니다.
이 설정은 특히 필요한 헤더를 제공하지 않는 제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()
를 사용할 것입니다.
우리의 공유 버퍼는 세 부분으로 구성됩니다:
- 인덱스 0: 상태 플래그 (0 = 처리 중, 1 = 완료).
- 인덱스 1: 작업을 마친 워커의 수를 세는 카운터.
- 인덱스 2: 최종 합계.
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);
}
};
실제 사용 사례 및 응용 분야
이 강력하지만 복잡한 기술이 실제로 차이를 만드는 곳은 어디일까요? 대규모 데이터셋에 대한 무겁고 병렬화 가능한 계산이 필요한 애플리케이션에서 뛰어난 성능을 발휘합니다.
- 웹어셈블리(WebAssembly, Wasm): 이것이 가장 핵심적인 사용 사례입니다. C++, Rust, Go와 같은 언어들은 멀티스레딩을 완벽하게 지원합니다. Wasm은 개발자들이 기존의 고성능, 멀티스레드 애플리케이션(게임 엔진, CAD 소프트웨어, 과학 모델 등)을 컴파일하여 브라우저에서 실행할 수 있게 하며, 이때
SharedArrayBuffer
를 스레드 통신의 기본 메커니즘으로 사용합니다. - 브라우저 내 데이터 처리: 대규모 데이터 시각화, 클라이언트 측 머신러닝 모델 추론, 방대한 양의 데이터를 처리하는 과학 시뮬레이션 등을 크게 가속화할 수 있습니다.
- 미디어 편집: 고해상도 이미지에 필터를 적용하거나 사운드 파일에 오디오 처리를 수행하는 작업을 여러 조각으로 나누어 다수의 워커가 병렬로 처리함으로써 사용자에게 실시간 피드백을 제공할 수 있습니다.
- 고성능 게이밍: 현대 게임 엔진은 물리, AI, 애셋 로딩 등을 위해 멀티스레딩에 크게 의존합니다.
SharedArrayBuffer
는 브라우저에서 완전히 실행되는 콘솔 품질의 게임을 구축하는 것을 가능하게 합니다.
과제 및 최종 고려사항
SharedArrayBuffer
는 혁신적이지만, 만병통치약은 아닙니다. 신중한 처리가 필요한 저수준 도구입니다.
- 복잡성: 동시성 프로그래밍은 악명 높게 어렵습니다. 경쟁 상태와 교착 상태(데드락)를 디버깅하는 것은 매우 어려울 수 있습니다. 애플리케이션 상태가 어떻게 관리되는지에 대해 다르게 생각해야 합니다.
- 교착 상태(Deadlocks): 교착 상태는 둘 이상의 스레드가 서로가 자원을 해제하기를 기다리며 영원히 차단될 때 발생합니다. 복잡한 잠금 메커니즘을 잘못 구현하면 이런 일이 발생할 수 있습니다.
- 보안 오버헤드: 교차 출처 격리 요구 사항은 상당한 장애물입니다. 제3자 서비스, 광고, 결제 게이트웨이가 필요한 CORS/CORP 헤더를 지원하지 않으면 통합이 깨질 수 있습니다.
- 모든 문제에 대한 해결책은 아님: 간단한 백그라운드 작업이나 I/O 작업의 경우,
postMessage()
를 사용하는 전통적인 웹 워커 모델이 종종 더 간단하고 충분합니다.SharedArrayBuffer
는 대용량 데이터를 포함하는 명확한 CPU 병목 현상이 있을 때만 사용해야 합니다.
결론
SharedArrayBuffer
는 Atomics
및 웹 워커와 함께 웹 개발의 패러다임 전환을 의미합니다. 이는 싱글 스레드 모델의 경계를 허물고, 강력하고 성능이 뛰어나며 복잡한 새로운 종류의 애플리케이션을 브라우저로 초대합니다. 이는 계산 집약적인 작업에 대해 웹 플랫폼을 네이티브 애플리케이션 개발과 동등한 위치에 놓습니다.
동시성 자바스크립트로의 여정은 도전적이며, 상태 관리, 동기화 및 보안에 대한 엄격한 접근을 요구합니다. 그러나 실시간 오디오 합성에서 복잡한 3D 렌더링 및 과학 컴퓨팅에 이르기까지 웹에서 가능한 것의 한계를 뛰어넘으려는 개발자에게 SharedArrayBuffer
를 마스터하는 것은 더 이상 선택이 아니라 차세대 웹 애플리케이션을 구축하기 위한 필수 기술입니다.