Java의 스레드와 node.js의 스레드

이지호·2024년 9월 29일
0
post-thumbnail

(부제목 : 스레드 간의 공유 변수)

node.js의 worker_threads를 공부하는 과정에서 겪은 시행착오에 대한 기록이다. (node.js의 싱글 스레드 동작방식 및 worker_threads의 개념에 대한 충분한 학습 없이 일단 부딪쳐보다가 겪은 시행착오임을 미리 알려드립니다. 아마 개념이 탄탄하신 분들은 왜 이런 바보 같은 삽질을 했는지 이해하기 어려우실 수 있습니다..ㅎ)

삽질의 발단

필자는 Java 문법을 배우면서 스레드라는 것을 처음 배웠다.

스레드에 대한 간단한 문법을 배우고 나서는, 공유 변수를 사용하는 경우에 발생하는 동시성 이슈를 보여주면서 락을 걸어 해결하는 법에 대해 배웠던 기억이 난다.

이후 node.js에서도 worker_threads를 배우면서 Java의 스레드와 비슷하지 않을까, 하는 지레짐작을 했었다. 그래서 node.js에서도 Java에서 공유 변수로 인한 동시성 이슈를 재현해봐야겠다는 생각을 했다.

삽질 과정

Java의 스레드

Java에서는 Thread 클래스를 상속받아 사용하거나 Runnable 인터페이스를 구현하여 스레드를 만들어볼 수 있다.

이제 Java에서 스레드 간 공유 변수 사용 모습을 한번 관찰해보자.

다음과 같은 코드에서 공유 변수 SharedNumber, 스레드 100개가 100번씩 count해서 토탈 10000이 나오길 기대한다.

public class Main {
	public static void main(String[] args) {
		SharedNumber s = new SharedNumber();

		for(int sId = 0; sId < 100; sId++) {
			final int sid=sId;
			new Thread() {
				public void run() {
					for(int cnt = 0; cnt < 100; cnt++) {
						System.out.println("["+sid+"]"+s.up());
					}
				}
			}.start();
		}
	}
}

class SharedNumber {
	private int num;
	public int up() {return ++num;}
}

결과는 아래와 같다. 매번 돌려볼 때마다 다르지만, 아래 모습에서는 10000을 예상했으나 9998까지밖에 나오지 않는 모습을 관찰할 수 있다.

...
[34]9994
[34]9995
[34]9996
[34]9997
[34]9998

이런 문제를 ‘동시성 문제’라고 한다. 동시에 여러 스레드가 접근하게 되면서 발생하는 일이다.

이를 해결하기 위해 Java에서는 synchronized 키워드를 사용하여 lock을 걸어볼 수 있다.

→ 예제에서 public synchronized int up() {return ++num;}해주어 해결 가능하다.

node.js의 스레드

worker_threads 문법

Node.js에서는 worker_threads 모듈을 활용하여 멀티 스레드 환경을 만들어 볼 수 있다.

worker_threads 문법을 알아보고자 아래와 같이 스레드 간의 통신을 구현해봤다.

const { Worker, isMainThread, parentPort } = require("worker_threads");

if (isMainThread) {
	//현재 위치 worker 생성
	const worker = new Worker(__filename);

	//작업이 완료되면 메시지 받기
	worker.on("message", (value) =>
		console.log(`부모가 받은 메시지 : [${value}]`)
	);

	//worker thread 종료
	worker.on("exit", () => console.log("worker 종료"));

	//worker thread에게 메시지 보내기
	worker.postMessage("부모가 보내는 메시지 : 아들~ 밥은 먹었어?");
} else {
	//parentPort를 통해 메시지 받아옴
	parentPort.on("message", (value) => {
		console.log(`자식이 받은 메시지 : [${value}]`);

		parentPort.postMessage("자식이 보내는 메시지 : 그럼요~");

		parentPort.close();
	});
}

node.js의 스레드에서 공유 변수 문제

그럼 node.js에서도 동시성 문제를 만들어볼까? 일단 공유 변수를 전달하는 코드부터 작성해보자.

위의 Java 코드와 동일한 일을 하도록 아래처럼 만들어보았다.

const { Worker, isMainThread, parentPort } = require("worker_threads");

class SharedNumber {
	#num = 0;
	up() {
		return ++this.#num;
	}
}

if (isMainThread) {
	const s = new SharedNumber();

	//현재 위치 worker 생성
	for (let sId = 0; sId < 100; sId++) {
		const worker = new Worker(__filename);
		const data = { sid: sId, shared: s };
		worker.postMessage(data);
	}
} else {
	//parentPort를 통해 메시지 받아옴
	parentPort.on("message", (value) => {
		for (let cnt = 0; cnt < 100; cnt++) {
			console.log(`[${value.sid}]${value.shared.up()}`);
		}

		//워커 스레드가 종료
		parentPort.close();
	});
}

위 코드를 실행하니 다음과 같은 오류가 난다.

node:internal/event_target:1094
  process.nextTick(() => { throw err; });
                           ^
TypeError [Error]: value.shared.up is not a function

위와 같은 오류가 발생하는 이유는 스레드 간 데이터 전달 방식 때문이다.

postMessage()를 사용하여 데이터를 전달할 때 HTML structured clone algorithm이 사용되는데, 이 알고리즘에 의해 인스턴스가 직렬화되어 전달된다. 직렬화 결과 #num이라는 private 필드도 손실되며, up 메서드 또한 사라진다. 이러한 이유로 worker 입장에서는 shared.up() 호출이 불가능했던 것이다.

worker_threads는 언제 써야할까

위 실험을 통해 알게 된 점은, JS에선 스레드간 통신에 있어 공유 변수 전송이 불가능하다는거다. 무조건 직렬화를 해서 넘겨야 한다. 당연히 클래스 인스턴스도, 클로저도 보낼 수가 없다.

그렇다면 worker_threads는 언제 써야할까? node.js의 공식문서에 따르면, Workers (threads) are useful for performing CPU-intensive JavaScript operations.라고 한다. 다시 말해 워커의 가장 중요한 목표는 CPU 집약적인 작업의 퍼포먼스를 향상시키는 데에 있다. 그러니 하드한 CPU 작업만을 분리하여 스레드에 시키는 게 솔루션이라고 생각한다.

이러한 결론을 기반으로 worker_threads를 다시 사용해보자.

Java 코드의 목표를 먼저 생각해보면, 각 스레드가 1을 100번 더하는 데에 있다. 그러니 굳이 SharedNumber클래스를 만들 것이 아니고, 각 스레드에게 반복문을 통해 1을 100번 더하라고 시킨 뒤, 그 값을 메인 스레드에서 모두 합쳐 10000을 만들면 되는 문제이다. 굳이 SharedNumber 객체에 매몰될 이유가 없다.

const { Worker, isMainThread, parentPort } = require("worker_threads");

if (isMainThread) {
	let result = 0;
	let completedWorkers = 0;

	// 현재 위치 worker 생성
	for (let sId = 0; sId < 100; sId++) {
		const worker = new Worker(__filename);
		worker.postMessage(sId);
		worker.on("message", (value) => {
			result += parseInt(value);
			completedWorkers += 1;

			// 모든 워커가 완료되었을 때 결과 출력
			if (completedWorkers === 100) {
				console.log("최종결과 : ", result);
			}
		});
	}
} else {
	//parentPort를 통해 메시지 받아옴
	parentPort.on("message", (sid) => {
		let sum = 0;
		for (let cnt = 0; cnt < 100; cnt++) {
			++sum;
		}
		parentPort.postMessage(sum);

		//워커 스레드가 종료
		parentPort.close();
	});
}

삽질의 결론

어쨌든 node.js에서 worker_thread 사용하기의 결론은!

  1. 스레드 간 통신 과정에서 직렬화를 사용하기 때문에 클로저나 객체를 넘길 수 없다. (컨텍스트 자체를 전달해줄 수가 없다.)
  2. node.js의 worker는 Java의 worker와 달리 CPU 집약적인 일을 시키기 위해 고안되었다. (Java의 Thread는 CPU 집약적인 작업뿐만 아니라 IO 집약적인 작업 등 다양한 일에 모두 적합하다.) 그러니 worker에게는 메인 스레드에서 하기엔 너무 CPU를 많이 사용해야 하는 일을 시켜야 한다. 본질에 알맞게 접근해야 한다.

worker_threads에 대한 추가 궁금증

1. node.js는 싱글 스레드인데, worker_threads를 사용하게 되면 내부적으로 어떻게 동작할까?

  • worker_threads는 실제로 별도의 V8 인스턴스를 생성한다.
  • 각 워커는 자체 이벤트 루프와 JavaScript 컨텍스트를 가진다.
  • 이들은 운영체제 수준의 스레드로 실행되어 진정한 병렬처리를 가능케 한다.

2. 어차피 변수 공유도 안 하는데, cluster 모듈로 process를 만드는 것과 다를 바 없는 거 아닌가?

  • worker_threads : 스레드 기반으로, 같은 프로세스 내에서 병렬처리를 한다. 메모리 공간을 공유하며, CPU 집약적인 작업에 적합하다.
  • cluster : 프로세스 기반으로, 각 프로세스가 독립적인 node.js 인스턴스이다. 메모리 공간을 전혀 공유하지 않으며, 주로 서버 스케일링에 사용한다.
    - 서버 스케일링 : 서버의 성능과 처리 능력을 향상시키기 위해 시스템의 자원을 늘리거나 구조를 변경하는 과정.

0개의 댓글

관련 채용 정보