Node.js는 하나의 JavaScript 스레드를 사용한다.
여기서 스레드란 운영체제에서의 프로세스로 하나의 스레드만 사용하면서 어떻게 들어오는 여러 개의 요청을 처리할까?
사실 싱글 스레드란 말은 맞을 수도 있고 틀릴 수도 있다.
Node.js는 엔진인 V8의 코드가 실행되는 메인 스레드 하나만 사용하는 만큼 각 요청마다 스레드를 지정할 수 없다(이런 이유 때문에 싱글스레드라고 말하는 것).결국 모두 하나의 스레드에서 실행될 것이고 이렇게 되면 데이터끼리 접근하게 되는 경우가 생길 수도 있을 것이고, 이는 보안에 큰 영향을 끼친다.
추가적으로 하나의 요청을 처리중이라면 다른 요청을 못 받는다고도 생각할 수 있다. 이를 블로킹(Blocking)이라고 한다. 하지만 이러한 문제를 Non-Blocking 방식인 비동기 작업을 통해 해결한다. 근데 또 이 비동기 작업을 하려면 멀티스레드가 필요하다. 띠용?
이에 대해 이해하기 위해선 아키텍쳐를 보다 자세히 볼 필요가 있다.
만약에 const fs = require('fs')
를 통해서 파일 시스템에 접근할 수 있는 코드가 있다고 생각해보자.
파일을 다루는 작업은 크기에 따라 시간이 다르게 걸릴 것이고, 만약에 크기가 큰 파일이 들어온다면 다음 요청은 당장 처리할 수 없기 때문에 기다려야 하거나 거부하는 상황이 일어나기도 한다. 이러한 현상이 웹 페이지가 다운되는 현상이다. 그렇기에 이러한 빠른 처리와 오래 걸리는 처리를 구분해야 할 필요가 있기 때문에 Event Loop와 Thread Pool을 구분하여 효율성을 높인다.
이벤트루프는 Node.js가 코드를 실행하면 프로그램에 의해 자동으로 시작된다.
이러한 이벤트 루프는 이벤트 콜백을 다룬다. 뭐 파일을 읽고 쓴다던지 하는 등의 행동을 다룬다.
특정 이벤트가 일어났을 떄, 이벤트 루프가 해당 코드를 실행한다. 하지만 시간이 오래 걸리는 파일 연산에는 도움이 되지 않을 뿐더러 이벤트 루프는 이런 연산을 다루지 않는다. 이벤트 루프는 오직 완성된 쓰기 파일에 정의한 콜백에 대한 코드들만 처리한다. 이러한 코드들은 빠르게 처리할 수 있는 코드들이다.
즉 이벤트 루프는 빨리 끝낼 수 있는 코드를 포함한 콜백만을 다룬다.
여기서 중요한 점은 이벤트 루프는 메인 스레드에서 실행되며, Non-Blocking 방식의 비동기 작업이 이루어진다는 것이다. 비동기 작업이 필요할 때는 가용할 수 있는 Thread Pool의 스레드를 사용한다.
근데 좀아까는 싱글 스레드라매;
이러한 의문점은 Thread Pool을 알면 어떻게 가능한지 이해할 수 있다.
파일 시스템 연산과 같이 오래 걸리는 연산들은 Thread pool에 보내진다. 이 또한 Node.js가 자동으로 시작하고 관리한다.
무거운 작업을 담당하는 Thread Pool(Worker Pool)은 JS 코드로부터 분리되어 작동된다.
코드로부터 분리되었기 때문에 무거운 작업을 모두 처리할 수 있는 것이다.
따라서 파일과 관련된 작업을 할 때는 Thread Pool이 코드, 요청 및 이벤트 루프와 분리된 상태에서 작업을 수행하지만 그럼에도 이벤트 루프와 연결점이 있다.
워커가 파일 읽기 등의 무거운 작업을 마치면 읽기 파일 연산에 대한 콜백이 시작되는데 이벤트 루프가 이벤트와 콜백을 책임지기 때문에 결국 이벤트 루프에 돌아가게 된다. 여기서 돌아갈 때 Node.js는 이벤트 루프에 돌아가 가장 Node.js가 알맞은 콜백을 실행한다.
중요 포인트는, 이벤트 루프 자체는 싱글 스레드지만 Thread Pool은 멀티스레드라는 것이다. 미리 할당된 스레드 집합을 사용하며 기본적으로 4개의 스레드를 사용한다는 것이다. 그렇기 때문에 원래는 싱글 스레드인 이벤트 루프가 필요할 경우 Thread Pool의 스레드를 사용할 수 있다.
이벤트 루프란 Node.js를 계속 실행하도록 하는 루프로 모든 콜백을 처리하며, 이 많은 콜백들을 처리하는 데에는 일정한 순서가 있다.
이러한 루핑을 계속하는 루프로 새로운 반복이 시작될 때마다 가장 처음으로 실행해야 하는 타이머 콜백이 있는지 확인한다.
setTimeout과 setInterval 메서드를 통해 타이머를 설정할 수 있다.
타이머를 설정하면 타이머가 끝나고 실행할 함수를 Node.js가 알고 있어서 항상 새로운 루프가 일어날 때마다 시간이 다 된 콜백을 실행한다.(타이머가 끝난 콜백을 실행)
다음으로 다른 콜백들을 체크하는데, I/O와 관련된 연산이 끝난 콜백들을 실행한다. 이러한 콜백들을 Pending Callback이라 한다.
여기서 I/O는 일반적으로 파일 연산이지만 네트워크 연산인 경우도 있다. 보통 오래 걸리는 블로킹 연산을 가리킨다.
Node.js는 어느 시점에 이 단계를 떠나는데 아직 처리되지 않은 콜백이 너무 많이 있다면 루프 반복을 이어가는 대신 남은 콜백을 다음 반복에서 실행하도록 미룬다.
이렇게 열린 콜백들을 모두 처리하고 나면 Poll단계에 진입한다.
Poll 단계에서는 Node.js가 새로운 I/O 이벤트를 찾아 최대한 해당 이벤트의 콜백을 빨리 실행하도록 한다. 가능하지 않다면 실행을 미루고 대기 콜백(Pending callback)으로 등록한다.
여기에 추가적으로 타이머가 다 되어 실행해야 하는 콜백도 확인하는데 만약 있다면 반복을 이어가지 않고 타이머 단계로 다시 돌아가 바로 실행한다.
만약 없다면 계속해서 루프를 이어가며 Check 단계로 이어진다.
Check단계에서는 setImmediate 콜백이 실행된다.
setImmediate은 setTimeout이나 setInterval처럼 바로 실행되기는 하지만 반드시 열린 콜백들이 모두 실행된 다음에 실행된다.
이 뒤부턴 이론적인 단계지만 간단히 몇가지 이야기해보자면
close 이벤트 콜백이 모두 실행된다. 이 때 이 콜백들은 따로 처리하고, 모두 처리가 되면 프로그램을 종료하는데, 이 전에 등록한 이벤트 핸들러가 남지 않았는지 확실히 하기 위해 ref==0
처럼 내부적으로 열린 이벤트 리스너를 추적해서 숫자를 세는 ref 등을 통해 확인한 후 실행할 콜백이 아무것도 없다면 비로소 종료한다.
보통 서버 환경에서 listen을 통해 들어오는 요청을 듣는 이벤트 리스너는 절대 끝나지 않기 때문에 refs가 항상 1 이상일 수밖에 없고, 이를 통해 웹 서버 프로그램이 계속 유지된다.
Nodejs는 이러한 복잡한 작업들을 처리하는 과정으로 무거운 작업을 처리하는 Thread Pool과 이 Thread Pool의 스레드를 이용하여 이벤트 루프가 비동기적으로 처리 할 수 있어 빠른 성능을 유지할 수 있는 것이다.
따라서 Nodejs가 단일 스레드라는 사실은 맞을 수도, 틀릴 수도 있는 것이다!
상황에 따라 다른 것이기 때문이다.
v8의 코드는 메인 스레드, 즉 단일 스레드에서 실행되지만 해당 아키텍쳐에서 작업이 진행되면서 Thread Pool과 함께 멀티 스레드를 사용할 수 있는 것!
https://medium.com/preezma/node-js-event-loop-architecture-go-deeper-node-core-c96b4cec7aa4
https://www.geeksforgeeks.org/node-js-event-loop/