자바스크립트는 싱글 쓰레드 언어로 널리 알려져 있습니다. 하지만 브라우저의 Web Worker, 노드의 Worker Thread를 활용하면 멀티 쓰레드 환경을 구현할 수 있습니다. 자바스크립트로 멀티 쓰레드 환경을 구현하는 방법을 알아보기 전에 멀티 쓰레드 환경을 구현할 수 있음에도 싱글 쓰레드 언어라고 소개하는 지 짚어보고 넘어가도록 하겠습니다.
자바스크립트는 싱글 쓰레드 언어?
자바스크립트의 기본 실행 환경은 단일 쓰레드 환경입니다. 즉, 자바스크립트는 하나의 호출 스택에서 한번의 하나의 작업이 처리된다는 뜻입니다. 이런 작업의 처리는 브라우저든, Node 환경이든 메인 이벤트 루프에서 관리되며 순차적으로 처리됩니다. DOM 조작, 이벤트 처리, UI 업데이트 등의 거의 모든 자바스크립트 동작은 메인 쓰레드에서 처리됩니다. 다만 Promise로 알려진 비동기 작업의 처리는 메인 쓰레드가 아닌 각 작업을 처리하기 위한 백그라운드 쓰레드에서 동작됩니다. 즉 자바스크립트는 Worker를 사용하지 않아도 하나 이상의 쓰레드를 사용하고 있습니다. 그런데도 자바스크립트를 싱글 쓰레드라고 부르는 걸까요?
자바스크립트 엔진과 자바스크립트 실행 환경
우리가 작성한 자바스크립트를 실행하는 것은 과연 무엇일까요? 웹 개발자라면 모두 한 번은 들어보았을 크롬의 V8 엔진과 같은 자바스크립트 엔진이 바로 자바스크립트 코드를 실행하는 주체입니다. 이 엔진은 ECMAScript에 정의된 기능들을 구현합니다. ECMAScript는 자바스크립트라는 언어의 표준을 정의하는 사양으로, 이를 기반으로 구현된 언어가 바로 자바스크립트입니다. 하지만 ECMAScript는 자바스크립트의 문법과 기능을 규정하는 표준일 뿐, 자바스크립트 코드의 실행 방식에 대해서는 구체적으로 정의하지 않습니다. 특히, 비동기 처리와 관련된 개념과 도구는 ECMAScript에 정의되어 있지만, 비동기 작업을 실제로 실행하는 메커니즘은 자바스크립트 엔진이 아닌 자바스크립트 실행 환경에서 처리됩니다.
자바스크립트 엔진이 아닌 자바스크립트 실행 환경에서 처리된다는 것은 무슨 말일까요? 자바스크립트 엔진은 자바스크립트 코드를 해석하고 실행하는 역할을 하며, (앞서 언급했듯)ECMAScript 사양을 준수합니다. 그러나 비동기 작업 처리나 DOM 조작 같은 기능들은 자바스크립트 엔진의 범위를 벗어나, 자바스크립트 실행 환경에서 다루어집니다. 자바스크립트 실행 환경은 브라우저나 Node.js와 같이 자바스크립트가 실행될 수 있는 시스템을 의미하며, 코드 실행뿐만 아니라 비동기 작업 처리나 DOM 조작 등 다양한 기능을 지원합니다. 정리하자면 자바스크립트 엔진이 아닌 자바스크립트 실행 환경에서 처리된다는 것은 자바스크립트 엔진이 코드의 해석과 실행을 담당하는 반면 비동기 작업 처리나 외부 API 호출 같은 작업은 브라우저나 Node.js와 같은 실행 환경이 처리한다는 의미입니다.
즉 자바스크립트 엔진에서 처리되는 작업을 실행하는 주체는 단 하나의 메인 쓰레드입니다. Web APi나 libuv에서 처리되는 작업은 자바스크립트 엔진이 아니기 때문에 자바스크립트는 여전히 싱글 쓰레드 언어입니다. 조금 더 근본적으로 설명하면 Web API나 libuv는 C++ 혹은 C로 작성된 자바스크립트 엔진 외부에서 실행되는 자바스크립트가 아닌 어떤 것입니다.
식당으로 비유하자면,
- 주방장(자바스크립트 메인 쓰레드) = 주문을 받고(콜스택 관리), 요리 시작(코드 실행)하고, 요리가 완료되어 알람이 울리면 플레이팅(이벤트 루프)하고 정리(가비지 컬렉션)하는 역할
- 가스레인지, 오븐(Web API, libuv) = 실제로 열을 가해서 음식을 익히는 역할
여기서 가스레인지나 오븐이 실제로 요리를 한다고 해서 주방의 직원이 여러 명인 것은 아닙니다. 실제 주방 직원은 주방장 단 한명인 것이죠. 바로 이런 의미에서 자바스크립트는 싱글 쓰레드 언어라고 불리는 것입니다.
싱글 쓰레드의 한계
하지만 싱글 쓰레드로 실행되는 자바스크립트에는 치명적인 한계가 있습니다. 하나의 작업을 수행하고 있을 때는 다른 작업을 수행할 수 없다는 것인데요. 프론트 엔드 측 어플리케이션을 예로 들면 메인 쓰레드가 UI를 그리는 중에는 다른 작업을 하지 못합니다. 반대로 무겁고 오래 걸리는 로직이 수행되는 동안 사용자는 애플리케이션과 그 어떠한 상호작용도 수행할 수가 없습니다. 물론 쓰로틀링과 디바운싱을 통해 상호작용을 할 시간을 벌어 줄 수 있지만 무거운 작업을 일시정지 하거나 반복적인 작업을 한 번만 수행하는 방식일 뿐 근본적으로 하나의 일을 하는 것은 변함이 없습니다. 즉 하나의 작업이 다른 작업에 의해 진행되지 못하게 됩니다. 이를 해결하는 방법은 멀티 쓰레드를 사용하는 것인데, 지금 부터 Worker를 이용하여 자바스크립트에서 멀티 쓰레드 애플리케이션을 만드는 방법을 알아보도록 하겠습니다.
Worker
Worker는 자바스크립트에서 메인 쓰레드와 별개의 쓰레드에서 작동합니다. 메인 스레드에서 UI와 다른 작업을 처리하는 동안, 별도의 스레드에서 작업을 처리하며 애플리케이션의 성능을 최적화할 수 있습니다. Worker에 복잡한 로직 혹은 반복적으로 끊임 없이 실행되는 로직 등을 맡기면 메인 쓰레드를 차단하지 않고 무거운 작업을 수행할 수 있습니다. 브라우저는 Web Worker를 Node에서는 Worker Thread라는 이름을 사용합니다.
각 Worker는 별도의 자바스크립트 엔진을 가지기 때문에 거의 모든 자바스크립트 코드를 실행할 수 있습니다. "거의 모든"인 이유는 DOM 조작이 불가능하기 때문입니다. DOM은 메인 쓰레드에 의해 관리되는데 각 Worker는 독립된 자바스크립트 엔진을 가지고 독립적으로 실행되기 때문에 DOM에 직접 접근이 불가능합니다. 대신 DOM의 정보를 Worker에 전달해 주는 것은 가능하며 각 Woker는 메인 쓰레드와 메세지 전달 방식을 통해 데이터를 주고 받을 수 있습니다.
Web Worker 사용해 보기
앞서 언급하였듯이 Worker와 메인 스레드 간의 데이터 교환은 메시지 전달 시스템을 사용합니다. 데이터는 복사되어 전송되기 때문에 서로 공유하고 있지 않습니다. 다음은 브라우저에서 Web Worker를 사용하는 간단한 예시입니다.
Worker()
생성자는 워커를 생성하고 Worker워커를 나타내는 객체를 반환하는데, 이 객체는 워커와 통신하는 데 사용됩니다. 메인 쓰레드와 워커 쓰레드 모두 postMessage()
메서드를 사용해 데이터를 전송하고, onmessage
이벤트 핸들러(메시지는 message
의 data
속성에 들어있습니다)를 사용해 데이터를 수신합니다.
const worker = new Worker("worker.js");
worker.onmessage = function (e) {
console.log(e.data);
};
worker.postMessage("메시지 전달);
// worker.js
self.onmessage = function (e) {
console.log(`워커가 받은 메세지: ${e.data}`);
const result = `워커가 받은 메세지 "${e.data}"를 그대로 다시 메인 쓰레드로 전달`;
postMessage(result); // 메인 쓰레드로 결과 전달
document.getElementById("app").textContent = "Worker에서 내용 바꿔보기";
};
CodeSandbox에서 해보기
데이터가 잘 전달되는 것을 확인할 수 있지만 Worker가 불가능한 작업이 있다는 것을 확인할 수 있습니다. console.log
는 오류없이 작동하지만 DOM 조작을 위한 document
객체가 정의되어 있지 않다는 에러 메시지를 볼 수 있습니다.
무거운 객체 전달하기
워커에게 postMessage
를 통해 데이터를 전달하면 데이터를 복사한 뒤 전달하게 됩니다. 하지만 데이터가 너무 큰 객체라면 복사의 비용이 커지게 됩니다. 자바스크립트에서 이 문제를 해결하기 위한 방법으로 Transferable objects
가 있습니다. Transferable objects
를 사용하면 객체를 복사하여 전달하는 대신 메모리 영역 자체를 전달하여 더 빠르게 데이터를 이동시킬 수 있습니다. 이렇게 메모리 영역 자체가 전달된 데이터는 데이터를 보낸 쪽에서는 더 이상 접근할 수 없고 전달받은 쪽에서만 사용 가능하게 됩니다. 데이터를 보낸 쪽에서는 객체가 더 이상 존재하지 않아 접근할 수 없기 때문에 레이스 컨디션을 막는 효과를 가지기도 합니다.
다음은 Transferable objects
을 사용하는 코드와 사용하지 않는 코드입니다.
function copyBig() {
console.time("복사");
const hugeData = new ArrayBuffer(100 * 1024 * 1024); // 100MB 데이터
bigWorker.postMessage(hugeData);
console.timeEnd("복사");
console.log(hugeData.byteLength); // 100MB - 여전히 접근 가능
}
function transferBig() {
console.time("전달");
const hugeData = new ArrayBuffer(100 * 1024 * 1024); // 100MB 데이터
bigWorker.postMessage(hugeData, [hugeData]); // 두 번째 인자로 전송할 객체 지정
console.timeEnd("전달");
console.log(hugeData.byteLength); // 0 - 더 이상 접근 불가능
}
console.time
과 console.timeEnd
를 통해 100MB의 데이터가 복사되고 전달 되는 방식과 Transferable objects
를 통해 메모리 영역을 전달해주는 방식의 소요 시간 차이는 다음 스크린 샷에서 확인할 수 있습니다.
여러 번 반복해 본 결과 Transferable objects를 사용한 방법은 0.3ms에서 많게는 4ms까지(대부분 1~2ms) 소요되었고 기본적인 방법인 복사를 이용한 방법은 96ms에서 최대 105ms까지(대부분 97ms) 소요되었습니다. 이 시간은 메인쓰레드에서 실행하는 시간이므로 복사에 큰 시간이 걸린다면 그 만큼 메인쓰레드가 차단됩니다. 따라서 데이터가 크다면 Transferable objects를 이용하여 메모리 영역을 전달하는 것이 더 좋은 방법입니다.
더 다양한 Web Worker
브라우저에는 일반적인 Web Worker 외에도 특별한 목적을 위해 사용하는 Web Worker인 Shared Worker와 Service Worker가 있습니다. 이번 글에서는 각각의 용도에 대해서만 간단하게 설명하고 다음 글에서 더 자세히 알아보도록 하겠습니다.
Shared Worker는 여러 브라우저 탭이나 창에서 같은 워커를 공유할 수 있도록 해주는 기능입니다. 각 탭에서 동일한 데이터에 접근할 수 있으며, 메모리나 리소스를 효율적으로 공유할 수 있습니다. 예를 들어, 여러 탭에서 필요한 중복된 작업을 하나의 Worker가 처리하여 리소스 낭비를 줄일 수 있습니다. 사용법은 기본 Worker와 비슷하지만 port
를 통해 각 클라이언트(브라우저의 탭이나 창)를 관리할 수 있습니다.
Service Worker는 웹 애플리케이션에서 백그라운드 작업을 처리할 수 있도록 해주는 기술로, 네트워크 요청 캐싱, 푸시 알림, 백그라운드 동기화 등을 담당합니다. Service Worker를 사용하면 오프라인에서도 앱을 사용할 수 있게 하고, 네트워크, 푸시 알림과 관련된 성능을 향상시킬 수 있습니다.
참고 자료
자바스크립트 엔진과 런타임의 차이점은 무엇인가요?
MDN, Web Workers API, Transferable objects
HTML Standard, Web Workers
'개발 > JavaScript' 카테고리의 다른 글
자바스크립트 await는 이벤트 루프 내에서 어떻게 동작할까 (0) | 2024.10.23 |
---|---|
동적 배열을 사용하는 자바스크립트에서 일어나는 일 (0) | 2024.10.15 |
자바스크립트의 연산자 우선순위(feat. 이산수학 한 방울) (0) | 2024.10.12 |
태스크큐를 중점으로 자바스크립트 코드의 실행 순서를 알아보자 (0) | 2024.09.25 |
자바스크립트 제너레이터(Generator)로 반복가능한 객체 이터레이터(iterator)를 만들자(feat. 파이썬의 range 구현하기) (0) | 2024.07.28 |