나는 컴퓨터학부를 졸업한 후 졸업프로젝트에 사용했던 노드를 사용해서 바로 실무에 나섰다.
내가 봤던 많은 블로그, 서적들에는 막연히
노드는 싱글스레드로 동작한다.
라고 적혀있었고, 부끄럽지만 나는 그 말을 그냥 의심없이 받아들였다.
어느 날 갑자기 이것을 이해 해보려 했다. 노드가 싱글스레드로 움직이는 것은, 내가 코딩을 하면서 직접 체감할 수 있었다.
하지만, 학부생 시절의 기억을 더듬어가며 이해해보려 해도, 도저히 그 행동양상이 완전히 납득 되지 않았다.
흔히 하는 말로, 기술이 아닌 마법처럼 느껴졌다.
나는 이 마법의 원리를 파헤쳐 보기로 했다.
앞서말한 노드의 행동양식을 설명하기 위해 코드를 간단히 적어보자면,
const fs = require('fs');
fs.writeFile('text.txt', 'text', () => {
// callback
});
loop();
function loop() {
console.log('busy');
loop();
}
이런상황에서, Node 런타임의 시스템 프로세스는 CPU Bound의 작업을 하느라 매우 바쁜 상황일 것이다.
우리 모두가 아는대로, 콜스택이 무한히 늘어나는 상황이기 때문에, 콜스택이 비는 시간이 없어서 Microtask queue에 작업을 쌓는다고 해도 resolveing 될수 없다.
따라서 콜백 함수는 당연히 실행 될 수 없다. 실제로 실행되지 않는다.
하지만, 강제로 프로세스를 종료했을 때, text.txt라는 이름의 파일은 생성됨을 알 수 있다.
'왜? 싱글스레드라며? 싱글스레드에서 이런 여러가지 일을 문자 그대로 물리적으로 동시에하는 병렬성이 가능해?'
노드 런타임도 OS 커널 위에서 돌아가는 프로세스이기에, 노드의 어떤 부분이 어떤 복잡한 로직을 거쳐서 OS의 파일시스템 API를 호출하도록 구현되어 있음은 틀림없다.
Node의 AsyncAPI 들이 그것을 구현했을 것이고, 그것들이 싱글스레드 기반이라면 이 상황을 이해할 수 없다.
'그럼 도대체 어떤 놈이 그 복잡한 로직을 실행시키고 파일 시스템을 다루는거지? 그게 없다면 말이 안 되잖아?'
수많은 구글링 끝에 단순한 입문용 문서들을 헤치고 꽤 유익한 정보들을 발견 했다.
결론적으로 말하자면, 흔히들 Node.js는 싱글스레드라 말하지만 엄밀히 따지면 Node.js 자체는 싱글스레드로만 돌아가는 것은 아닌거 같다.
디테일한 내용에 앞서, '노드가 제공하는 기본적인 API들은 사실 V8엔진과 libuv 라는 두가지 트랙으로 작업을 수행한다.' 라는 사실을 언급하겠다.
그렇다면 libuv가 뭔지 알아보자.
libuv는 OS 시스템의 비동기 처리를 한단계 추상화하여 cross-platform 비동기를 구현한 라이브러리로 노드의 코어중 하나이다.
우리가 노드의 구동방식에 대해 공부하면 항상 나오는 EventLoop도 여기에 구현되어 있다.
우리가 사전에 정의된 노드의 비동기 API를 호출하면, libuv는 block 상태에 있던 이벤트 루프를 실행시키는데, 이벤트 루프는 작업의 종류에 따라서
이렇게 분기하여 수행하도록 uv_io라는 모듈에게 작업을 위임한다.
할일을 위임 받은 uv_io는 앞서 말한 것처럼, 커널의 비동기API 지원 여부에 따라 커널에 작업을 재위임 하거나, 우리가 일반적으로 생각하는 순차적인 blocking 로직을 워커 쓰레드를 이용하여 실행시킨다.
결국! 노드는 OS커널에서 지원하지 않는 비동기 작업은 멀티 스레드(유저쓰레드)를 사용한다.
이후의 과정은, 우리가 이벤트 루프에 대해 가볍게 공부했던 내용대로, uv_io가 작업이 끝나면 작업 종류에 따라 콜백을 이벤트 큐에 등록하고 여전히 열심히 돌고있는 무한 루프에 의해 실행되기를 기대한다.
사실 이건 노드가 의도한 바 인것 같다. 비동기작업을 추상화 하고 이벤트루프라는 인터페이스만 내놓는게 libuv의 목적이니.
널리 알려진것 처럼, 결론적으로 노드에 작성한 자바스크립트 코드는 개발자의 입장에서 비동기 IO/싱글스레드로 돈다. 이는 이벤트루프가 노드 런타임에 딱 하나만 싱글스레드로 구동되기 때문이다.
하지만 이제는 비교적 자신있게 이야기 할 수 있을거 같다.
노드에서 돌아가는 코드는 싱글스레드처럼 동작하지만, 노드 런타임 그 자체의 구현은 여러개의 워커쓰레드를 사용한다!
다음에 시간나면 libuv도 더 자세히 분석해 봐야겠다.
사실 로우레벨을 공부하는것을 좋아하는 편이라서 더 파보고 싶지만, 생산성을 생각했을 때 이 정도가 애플리케이션 레벨 개발자로서 스스로 납득이 가는 정도인 것 같다.
여담으로, 이에반해 자바로 짜여진 전통적인 서버 애플리케이션은 요청당 하나의 스레드를 만든다. 만들어진 스레드의 생에주기는 애플리케이션 레벨에서 개발자가 제어해야한다. 반면 노드는 모든 요청을 단일 스레드로 받기 때문에, 애플리케이션 레벨에서는 스레드 자원을 신경 쓸 필요 없이 동시성을 편하게 구현할 수 있다는 장점이 있다.
그러한 이유에서 노드의 단점역시 명확한데, 한 요청에서 cpu를 많이 사용하는 작업을 한다면(극단적 예, while(true) 무한루프) 이벤트루프가 이 작업을 처리하느라 이후 들어오는 요청에 대해 대응할 여유가 없으므로
요청의 응답이 느려지거나 응답하지 않을 수 있다.
주의해야할 한 가지는 노드서버를 작성할 때 스레드 자원을 신경쓰지 않는다는 말이 동시성 코드를 사용함에 주의가 필요하지 않다는 말은 아니다. 여러 개의 요청이 공유하는 리소스에 쓰기 접근할 때 특히 주의를 기울여야한다.
모든 요청이 해당 리소스에 대해서 읽기만 수행하면 괜찮지만, 쓰기 작업을 수행할 시에는 Dirty Read, Unrepeatable Read 등 공유되는 리소스에 대한 어떤 문제든지 나타날 가능성이 있으므로 주의해야 한다.