Node.js가 왜 싱글 스레드로 불리는지 "정확한 이유"를 알고 계신가요?
Node.js가 왜 싱글스레드 환경으로 불리는 것일까?
Javascript 언어가 싱글 스레드 기반 언어라서?
사실 이에 대해서는 단순 허무하면서도 마냥 재미있는 이유가 숨겨져 있다.
우선 내가 Node.js가 싱글스레드라는 것을 인정하기까지 꽤나 혼란과 고생이 있었고, 결과를 알고 난 뒤에도 인정하기 어려운 결론이라 찝찝하긴 하다.
그래서 그 과정을 이렇게 블로그로 한번 남겨보려고 한다.
나는 지금까지 Node.js가 멀티 스레드인 줄 알았다.
아마 모두 위 소제목을 보자마자 "풉!" 하고 웃었을 수도 있겠다는 생각이 든다.
왜냐하면 너무나도 기초적이고 면접 준비를 조금이라도 해봤을 것이라면 무조건 알 수밖에 없을 내용이고, 그만큼 굉장히 핵심적인 내용이기 때문에.
그런데 나도 처음부터 잘못 알고 있었을까? 당연히 절대 아니다.
나도 초기에는 Node.js가 싱글 스레드로 당연히 습득하던 상태였고, Node.js 환경에서는 하나의 스레드 위에서 Asynchrounus 하게 동시성을 가질 수 있는 환경이라고 제대로 알고 있던 상태였다.
"worker_thread"라는 녀석을 알기 전까지는...
나를 헷갈리게 만든 장본인 worker_thread
Node.js 애플리케이션에서 별도의 스레드를 생성하여 빠르게 병렬처리를 할 수 있는 기능이다.
이는 Node.js 10.5 버전부터 사용할 수 있음으로써 나온 지 꽤 되었다.
병렬 처리가 가능하다는 것이 무엇일까?
말 그대로 물리적 병렬성을 가지고 물리적으로 여러 스레드를 CPU에서 동시에 수행을 할 수 있음을 뜻한다.
아래 코드를 봐보자.
(코드 출처: https://inpa.tistory.com/entry/NODE-%F0%9F%93%9A-workerthreads-%EB%AA%A8%EB%93%88)
# 아리토스테네스의 체 소수 구하기 One Thread
let
min = 2,
max = 10_000_000,
primes = [];
// 아리토스테네스의 체 소수구하기
function generatePrimes(start, range) {
let isPrime = true;
const end = start + range;
for (let i = start; i < end; i++) {
for (let j = min; j < Math.sqrt(end); j++) {
if (i !== j && i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i)
}
isPrime = true;
}
}
console.time('prime');
generatePrimes(min, max)
console.timeEnd('prime');
console.log(primes.length);
# 아리토스테네스의 체 소수 구하기 Multi Thread
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
let
min = 2,
max = 10_000_000,
primes = [];
function generatePrimes(start, range) {
let isPrime = true;
const end = start + range;
for (let i = start; i < end; i++) {
for (let j = min; j < Math.sqrt(end); j++) {
if (i !== j && i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i)
}
isPrime = true;
}
}
if (isMainThread) {
const threadCount = 8;
const threads = new Set()
const range = Math.ceil((max - min) / threadCount); // 10_000_000 max를 8개의 쓰레드에 분배를 해서 처리하기 위해서
let start = min;
console.time('prime2');
for (let i = 0; i < threadCount - 1; i++) {
const wStart = start;
threads.add(new Worker(__filename, { workerData: { start: wStart, range: range } }))
start += range;
}
threads.add(new Worker(__filename, { workerData: { start: start, range: range + ((max - min + 1) % threadCount) } }));
for (let worker of threads) {
worker.on('error', (err) => {
throw err;
})
worker.on('exit', () => {
threads.delete(worker);
if (threads.size ===0){
console.timeEnd('prime2')
console.log(primes.length);
}
});
worker.on('message', (msg) => {
primes = primes.concat(msg);
})
}
} else {
generatePrimes(workerData.start, workerData.range);
parentPort.postMessage(primes);
}
위 두 예시에서, 처리된 결과 값은 똑같지만 처리 시간을 worker_threads를 이용한 방식이 월등히 빠르다.
이는 실제로, 하나의 작업을 두 스레드가 나누어서 물리적으로 동시에 수행함으로써 병렬처리가 가능하다는 것을 증명하는 것이고, Node.js에서 여러 스레드를 동시에 구동시킬 수 있음을 뜻한다.
내가 초기에 알고 있던 "Node.js는 싱글 스레드이다"라는 개념에 의구심이 생김과 동시에 지금까지 잘못 알고 있게 된 개념의 시초가 여기부터다.
(💡 여기서 잠깐) Node.js가 비동기 처리가 가능한 동시성을 가지고 있는 환경인데, 동시성도 병렬처리가 가능한 것 아닌가요?
우선 비동기 처리는 병렬처리를 하는 방식이 아니라, 단지 CPU Time Sharing을 통해 여러 일을 동시에 작업하는 것처럼 보이게 하는 것일 뿐이다.
해당 개념을 이해하기 위해서는 동시성과 병렬성의 차이에 대해 이해를 하고 있어야 하는데,
우선 간단히 정의하면, 동시성은 하나의 스레드 위에서 여러 작업이 CPU가 한번씩 빠르게 돌아가면서 동시에 수행되는 것처럼 보이는 것이라면, 병렬성은 실제 CPU 위에서 여러 스레드를 물리적으로 동시에 수행되는 것을 뜻한다.
따라서 동시성은 여러 작업이 한 번에 수행이 되는 것처럼 보여도, 실질적으로 CPU가 한 번씩 번갈아가며 수행하고 있는 것이므로 더 빠르게 처리가 된다던가 하지 않는다.
그럼에도 "비동기가 병렬로 처리되는게 아니라고?"라고 생각하는 사람이 있을 수도 있을 것 같은데,
아마 아래와 같은 케이스일 것 같다.
const fs = require('fs');
// fs 파일 읽는데 걸리는 시간 총 10초
fs.readFile('example.txt', 'utf8', (err, data) => {
// callback 함수는 수행시간 0초
});
for (...) {
// 수행되는 데 5초 정도 걸리는 Task
}
위 코드는 총 몇 초만에 완료가 될까? 맞다 10초다.
우리가 아는 비동기 처리 방식이라면, 파일 읽기 수행에 들어감과 동시에 for문이 5초 수행이 된 후, 파일 읽기가 완료되면 콜백 함수가 실행될 것이기 때문에 총 10초의 시간이 걸릴 것이다. 이것만 보면 마치 비동기 처리가 꼭 병렬 처리를 하는 것처럼 보인다.
그런데 이러한 I/O 처리가 동시에 수행되는 것을 보고 병렬처리라고 생각해서는 안된다.
'스레드의 태스크'와 'I/O 처리'를 동일한 스레드의 태스크로 생각하면 잘못된 것이다.
해당 프로세스 내부가 아닌, 외부에서 물리적 로컬 장비(현재 컴퓨터 장비 등)를 타고 동작이 이루어져야 하는 I/O 처리(디스크에 쓰인 파일을 읽는 다던가, 혹은 네트워크 통신을 통해 네트워크 요청을 한다던가 등)들은 Node.js가 돌아가는 프로세스 바깥의 커널(하드웨어와 소프트웨어의 인터페이스)을 통해 디스크라던가 네트워크 장치를 통해 처리된 후 응답이 온다.
이처럼 I/O 처리가 커널을 통해 컴퓨터 내에서 작업이 별도로 돌아가는 것이므로, I/O 처리에 대한 응답이 오기까지 Node.js가 비동기 처리 요청만 보내놓고 이후에 다른 일을 할 수 있는 것이지, Node.js가 돌아가는 스레드 환경이 태스크를 병렬로 동시에 수행할 수 있어서가 아니다.
따라서 동시성 원리를 가진 비동기 처리는 병렬 처리가 아니다.
이러한 동시성에 대한 개념을 제대로 파악하고 넘어가자.
Node.js는 싱글 스레드? 아니면 멀티 스레드?
그러면 다시 돌아와서..
Node.js가 싱글 스레드라면서, 어떻게 여러 스레드가 일을 나누어서 물리적으로 동시에 수행을 할 수 있는 것인가.
그래서 Node.js라는 환경에 대해 조금 더 파고들어보았다.
Node.js는 Workers_threads를 이용할 때, 작업을 처리할 새로운 스레드 풀을 생성 후 스레드 별 비동기 처리가 가능하도록 지원해 주는 libuv 엔진이 세팅된다. 그리고 각 스레드에서 Javascript 엔진으로 코드를 실행시킨다.
그리고 Node.js 환경 안에서 하나의 이벤트 루프를 통해 각 스레드들끼리의 응답 처리를 Task Queue를 통해서 전달된다.
이는 Node.js에서 기존에 돌아가는 하나의 싱글 스레드 파이프라인이, 필요 시마다 추가로 스레드 파이프라인이 생성되면서 일을 처리할 수 있음을 뜻하고, 나가 기존에 알던 싱글 스레드인 Node.js 구성이랑 많이 틀리다는 것을 알게 되었다.
그리고 내 스스로 아래와 같은 결론을 지었다.
"Javascript는 싱글 스레드 기반 언어이지만, Node.js는 멀티 스레드가 가능한 환경이다."
"Node.js 환경을 구성하는 요소 중 일부인 Javascript 엔진이 싱글 스레드이지, Node.js환경이 싱글 스레드인 것은 아니다."
하지만 멀티스레드라고 부르기엔 너무 이상해..
동시성과 병렬성에 대한 블로깅을 써보려고 멀티쓰레딩 기법에 대해 좀 더 파고들다가, 문득 의아한 부분이 생겼다.
"멀티스레드인데, 왜 그냥 돌릴 파일 선언만 하고, 메시지만 주고 받을 수 있지..?"
멀티쓰레딩의 정의는 아래와 같다.
- 같은 프로세스 내에 서로 다른 스레드들이 동시에 수행된다.
- 각 스레드들은 프로세스가 할당 받은 리소스 자원을 공유한다.
위 그림을 보면, 각 스레드 별 독립적인 레지스터 저장공간과 콜스택을 가지고 있지만, 이 외 코드와 데이터, 파일은 공유되서 사용할 수 있다.
따라서 기본적으로 멀티스레딩 프로그래밍을 할 때 가장 중요한 것이, 하나의 자원에 동시에 접근하여 예상치 못한 데이터 변경이 일어나지 않도록 Lock 기법을 사용하는 것이 원칙이다.
그러나 Node.js의 Worker_Thread는 자원 공유를 하지 않는다. 단지 Message만 주고 받을 뿐이다.
Worker_Thread는 멀티 스레딩 프로그래밍과 사용하는 방식이 굉장히 달랐다.
동일한 자원을 공유한다던가 하는 식이 아닌, 단지 특정 Script File을 독립적으로 실행시키고 단지 Message만 주고받는 형식이다.
코틀린의 기본적인 멀티 스레딩 코드와 비교를 해보자.
# Kotlin Multi Thread 예시 코드
import kotlin.concurrent.thread
fun main() {
var sharedVariable = 0
// 첫 번째 스레드
val thread1 = thread {
for (i in 1..5) {
sharedVariable++
println("스레드 1: 값 증가 - 현재 값: $sharedVariable")
Thread.sleep(1000)
}
}
// 두 번째 스레드
val thread2 = thread {
for (i in 1..5) {
sharedVariable--
println("스레드 2: 값 감소 - 현재 값: $sharedVariable")
Thread.sleep(1000)
}
}
// 스레드가 실행될 때까지 대기
thread1.join()
thread2.join()
println("메인 스레드 종료 - 최종 값: $sharedVariable")
}
두개의 스레드를 생성해서 각 스레드가 1초마다 동시에 sharedVariable 변수에 접근해서 데이터를 수정하고 있다.
이 처럼 여러 스레드가 할당된 공통된 자원에 접근이 가능한 방식이 멀티 스레드라고 할 수 있다.
그러나 Node.js의 Worker_Thread는 이와 같은 방식이 아니다.
# Node.js Worker_Threads 예시 코드
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
// 메인 스레드에서 실행되는 부분
const worker = new Worker(__filename, {
workerData: 5, // Worker 스레드로 전달할 데이터
});
worker.on('message', (result) => {
console.log(`메인 스레드에서 결과를 받음: ${result}`);
});
worker.postMessage('시작'); // Worker 스레드 시작 요청
} else {
parentPort.on('message', (message) => {
if (message === '시작') {
const result = performHeavyCalculation(workerData);
parentPort.postMessage(result);
}
});
}
function performHeavyCalculation(data) {
let result = 0;
for (let i = 0; i < data; i++) {
result += i;
}
return result;
}
Worker_Threads는 Thread 인스턴스를 생성할 때 코드를 돌릴 File Path를 지정한다. 그리고 단지 porstMessage를 통해 해당 thread로 메시지를 전달하고 message 이벤트를 통해 메시지를 전달 받을 뿐이다.
이는 동일한 프로세스 내 할당된 자원을 공유하지 못하고 따로 독립된 곳에서 혼자서 돌아가는 방식임을 뜻한다.
지금 생각해보니 멀티스레드라고 부르기에는 이상한 점이 한 두가지가 아니다.
1. 애초에 동일한 프로세스 내 스레드라면 postMessage 같은거로 메시지를 주고받을 필요도 없을 뿐더러,
2. Worker_Threads에서 부모 스레드 인스턴스가 담긴 변수명이 parentPort인데, 이는 포트가 다르니까, 즉 프로세스가 다르니까 아마 이런 명칭을 쓴게 아닐까 싶다. 같은 프로세스라면 다른 스레드여도 Port는 같아야 한다.
그래서 다시 의구심이 들었다.
Node.js는 MultiThread가 맞는가?
결국 Node.js는 싱글 스레드가 맞긴 맞다.. 근데 이건 좀..;;
GPT에게 물어본 결과, 아래와 같은 답변을 받을 수 있었다.
위 GPT말에 의하면, 아래와 같다.
Worker_Threads는 Node.js 위에서 돌아가는 별도의 독립적인 스레드이긴 하지만, Node.js 자체의 핵심 구조에서 돌아가는 스레드는 아니다. 여기서 말하는 Node.js의 핵심 구조란, 이벤트 루프, 그리고 이벤트 루프와 직접적으로 상호작용하는 메인 스레드를 말한다.
정리하면, Worker_Threads로 생성된 스레드는 Node.js 환경에서 돌아가기는 하지만, 해당 스레드가 생성될 때 이벤트 루프가 하나 더 생기거나 이와 상호작용하는 스레드가 더 생기는 것이 아니기 때문에, 'Node.js 자체는 여전히 싱글 스레드' 라고 한다.
근데 애초에 멀티스레드의 개념이, 하나의 프로세스 내 여러 스레드가 CPU 위에서 동시에 작업을 수행하고 프로세스가 할당받은 리소스를 스레드끼리 공유할 수 있는 것인데, Node.js에서는 이벤트 루프가 하나이고, 이벤트 루프와 관련된 메인 스레드가 하나이기 때문에 싱글 스레드로 생각해야 하는 것인지 이해를 하지 못했다.
결론이 나왔다.
Node.js는 멀티 스레딩이 가능하다.
그렇지만, Node.js라는 환경을 이루는 특징이자 핵심 요소가 '이벤트 루프 + 메인 스레드'이며, 이러한 핵심 요소가 여러개 생기는 방식이 아니므로 개념적으로만 싱글 스레드라고 지칭하는 것.
결론은, Node.js는 '이론적으로는 멀티 스레드'가 맞지만, '개념적으로는 싱글 스레드'로 불러야 하는 것이다.. -.-
마치며
아마 대부분 개발자들이, Javascript가 싱글 스레드 기반 언어이므로 Node.js도 싱글 스레드인 것으로 단순하게 알고 있는 경우가 많았을 것이다. 그러다가 나 처럼 Worker_Threads를 접하게 되면 오잉? 하면서 의구심을 들 수도 있었을 것인데, 이번 글로 인해 Node.js의 컨셉, 그리고 여러 스레드를 돌릴 수 있는데 왜 싱글 스레드로 불리는지 정확한 이유를 가지고 갈 수 있으면 좋을 것 같다.