자바스크립트는 이견이 없는 태생부터 싱글스레드지만, node.js를 싱글스레드로 보느냐 멀티스레드로 보느냐에 대해서는 관점 차이가 조금 있다. 어떻게 보느냐에 따라서 사람마다 싱글스레드라고 하는 사람도 있고, 멀티스레드라고 하는 사람도 있다. 다음의 레퍼런스에 따르면, 사람들의 답변은 "뭐 그럴 수도 있고 아닐 수도 있다."로 일축된다.
- Is Node js single threaded or multi threaded : https://www.quora.com/Is-Node-js-single-threaded-or-multi-threaded
나는 단순히 자바스크립트가 싱글스레드니까 node.js도 당연히 싱글스레드일 것이다라고 생각했다. 사실 이렇게 보는 것이 완전히 틀린 것은 아니지만, node.js는 생각보다 복잡하게 작동하기 때문에, 단순히 위와 같이 이야기하는 것으로는 싱글스레드라는 주장을 뒷받침 하기에는 어려워 보인다. 복잡한 노드의 구조를 살펴보면 멀티스레드의 요소를 갖는 부분도 있고, 싱글스레드의 요소를 같은 부분도 있기 때문에 정말 자세히 공부해야지 알 수 있다.
이번 포스팅에서는 node.js의 구조에 대해서 설명하면서 작동 원리도 함께 설명하려고 한다. 대체 왜! node.js는 사람에 따라 다 말이 다른지에 대해서, 자바스크립트가 싱글스레드라는 것은 전부 동의하는데도 말이다. 이번 포스팅은 이전에 포스팅한 Java&Spring의 멀티스레드 구현 방식과 이어진다. 재미있는 언어의 동작 방식에 관심이 있다면 이전 포스팅도 참고해보면 좋을 것 같다.
- [marsboy] Java&Spring의 멀티스레드 작동 방식 : https://marsboy.tistory.com/72
본론
자바스크립트의 특징 중 우리가 다룰 내용은 자바스크립트는 싱글스레드 기반의 언어이며, 논블로킹 이벤트루프가 있다는 내용이다. 여기서 중요한 포인트는 위 두가지 키워드이다.
자바스크립트는 싱글스레드?
자바스크립트는 초기 설계 목적이 웹 페이지의 동적인 동작을 추가하기 위해 설계되었기 때문에, 멀티스레드가 필요할 정도의 리소스를 쓰지 않으며, 멀티스레드로 인해 DOM이라는 브라우저가 웹 페이지를 이해하는 방식을 동시에 수정하면 충돌할 수가 있기 때문에 싱글스레드 방식으로 설계가 되었다. 아래는 자바스크립트라는 언어에 대한 간단한 100초짜리 소개가 있는 영상으로, 앞에서 싱글스레드와 논블로킹 이벤트루프라는 키워드가 나온다.
- [Youtube] JavaScript in 100 Seconds : https://www.youtube.com/watch?v=DHjqpvDnNGE&t=100s&ab_channel=Fireship
자바스크립트는 싱글스레드의 인터프리터이기 때문에, 자바스크립트 코드를 한줄한줄씩 실행하게 된다. 자바와 같이 컴파일을 통해서 바이트코드로 변환한 다음에 JVM을 통해 코드를 실행하는 것과는 다르다. 자바스크립트는 다음과 같은 구조를 가지고 있는데, Call Stack에 들어있는 작업(함수 등)을 하나씩 처리해나가는 싱글스레드 방식을 사용한다.
위와 같이 하나의 콜스택(Call Stack)을 가지고 있으며, 저 콜스택에 있는 내용을 처리하는 일을 할 수 있다. Memory Heap과 Call Stack에 대해서 이야기하면 둘 다 동적으로 늘어나며, 변수와 같은 정보는 Memory Heap으로 가게 되고, 함수를 실행하게 되면 Call Stack에 하나씩 쌓이게 된다.
여기까지는 그냥 자바스크립트의 평범한 특징으로 이해할 수 있다. 다음으로 설명할 내용이 가장 중요한 포인트이자. 자바스크립트가 가지고 있는 독특한 매력은 논블로킹 이벤트루프에서 나온다. 이제 본격적으로 자바스크립트의 세계관에 빠져보자.
논블로킹 이벤트루프
자바스크립트는 이전에 포스팅했던 멀티스레드 방식의 자바와는 다르게, 싱글스레드로 작동한다. 혼자서 스레드풀을 200개씩 관리하는 자바를 생각해 보면 싱글스레드의 퍼포먼스는 압도적으로 뒤떨어질 것으로 생각할 수 있으나, 다양한 방식을 통해서 크게 뒤처지지 않는 퍼포먼스를 뽑아낸다. 개인적으로 자바스크립트를 좋아하는데, 태생부터 싱글스레드로 태어난 불리한 환경을 다양한 방식으로 극복해 나가는 모습이 경이롭다고 느껴졌다. 그리고 뭔가 스포츠팀도 약팀을 응원하다 보니까 뭔가 애정이 더욱 있는 것 같다. ( 언더독 효과라고 부른다더라 )
이제 논블로킹 이벤트루프에 대해서 설명하기 전에 블로킹과 논블로킹 그리고 동기와 비동기에 대한 이해가 필요하다. 이에 관해 정말 훌륭하게 쓰여있는 레퍼런스가 있기에 이를 참고하여 포스팅에 인용하였으니, 보다 자세한 내용을 확인하고 싶다면 아래의 레퍼런스를 강추한다. 다양한 CS 지식도 함께 자세히 쓰여있다.
- [tistory] 동시성, 병럴, 비동기, 논블럭킹과 컨셉들 : https://black7375.tistory.com/90
레퍼런스에서 도표를 가져오면 위와 같다. 블로킹 로직은 특정 함수를 실행하는 동안 다른 일을 못하고 대기해야 하는 경우이다. 반대로 논블로킹은 특정 함수를 실행하는 동안 다른 일을 할 수 있다. 블로킹과 논블로킹은 제어권한이 있는가의 여부가 포인트가 된다.
동기 작업은 프로그램의 로직 중에서 특정 함수를 호출할 때, 호출한 함수의 작업 완료 여부를 로직 상에서 판단하게 된다. 위 그림에서 지속적으로 특정 함수가 끝났는지 체크하는 모습처럼, 로직이 호출한 함수를 확인한다. 반대로 비동기 작업은 Callback이 작업 완료 여부를 확인시켜주기 때문에 Callback이 돌아오기 전까지는 수동으로 작업 완료 여부를 판단할 필요가 없다. 동기와 비동기의 차이는 작업완료를 누가 확인했는가가 포인트가 된다.
자바스크립트가 싱글스레드지만 멀티스레드에 비해 크게 뒤처지지 않는 퍼포먼스를 보이는 이유는 바로 논블로킹 비동기 방식으로 동작함에 있다. 오른쪽 아래의 그림을 보면 알 수 있겠지만, 특정 작업을 Callback으로 실행한 다음에 다른 작업을 수행한다. 그 후에 비동기 작업이 처리되고 Callback 함수가 돌아오면 그때 그 작업을 처리하는 방식을 가지고 있기 때문에, 자바스크립트를 멀티스레드처럼 동작하게 할 수 있다.
자바스크립트는 async await라는 구문을 통해서 비동기 로직을 구현한다. 비동기 함수는 결과가 언제 나올 지 알 수 없기 때문에 비동기 함수가 실행되면, 비동기 함수의 내부에서 실행되는 비동기 작업(타이머, 네트워크 요청 등)이 콜 스택이 아닌 다른 곳에서 처리된다. 그 후에 비동기 함수의 결과가 나왔다면 이후에 다룰 Queue와 Event Loop를 거쳐, 그 결과가 콜 스택에 담겨 그 이후의 일을 처리하는 구조이다. 이러한 비동기 함수 안의 비동기 동작의 처리를 도와주는 친구가 멀티스레드를 돕는다.
자바스크립트 자체는 싱글스레드이기 때문에 도움을 통해서 멀티스레드를 구현해야 한다. 자바스크립트가 프론트엔드에서 HTML과 함께 사용할 때에는 WEB API가 비동기 함수를 처리하는 과정을 도와주며, 백엔드에서 node.js와 같은 자바스크립트 런타임을 사용하면 libuv가 비동기 처리를 도와준다. 이러한 구조를 더 잘 이해하기 위해서는 이벤트루프에 대해서 살펴봐야 한다.
잠깐! 비동기 함수와 비동기 동작?
앞서 비동기 함수 안에 있는 비동기 동작을 멀티스레드가 가능한 친구들이 돕는다고 했다. 프론트엔드에서는 Web APIs, 백엔드에서는 libuv가 돕는다. 비동기 함수와 비동기 동작은 다르다.
먼저, async와 await 라는 키워드를 언급했는데 각각 다음과 같은 기능을 한다.
- async : 함수 앞에 붙일 경우, 해당 함수는 자동으로 Promise를 반환하는 비동기 함수가 됨
- await : 비동기 함수가 완료될 때까지 기다린 후 결과를 반환
즉 다음의 함수는 안에 setTimeout()이 들어있는데, 이는 비동기 동작이다. async를 붙이지 않으면 setTimeout()의 결과를 받아볼 수 없다.
async function example() {
console.log("1. 함수 시작");
setTimeout(() => {
console.log("2. 비동기 작업(setTimeout) 완료");
}, 2000); // 2초 후 실행
console.log("3. 함수 종료");
}
중요한 포인트는, setTimeout()에서 멈추지 않고, 먼저 1번과 3번 내용이 실행된다. 그 후에 마지막으로 2초가 지난 후에 비동기 작업인 setTimeout이 끝난 결과 "2. 비동기 작업(setTimeout) 완료"가 반환된다. 비동기 함수 안에 있는 비동기 동작은 따로 libuv나 WEB API가 처리하고 나중에 결과를 반환한다.
그렇다면 await는 뭘까? 비동기 함수가 완료될 때까지 기다린 후 결과를 반환? 아래의 함수의 출력을 보면 알 수 있다.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function example() {
console.log("1. 함수 시작");
await delay(2000); // 2초 기다림 (비동기 동작)
console.log("2. 2초 후 실행됨");
}
example();
console.log("3. 함수 실행 후 다른 작업 수행");
위 코드는 example() 함수를 먼저 실행하던 도중, 1번 함수 시작을 출력하고 2초 동안 비동기 로직이 실행되며, await로 2초 멈춘다. 자바스크립트는 비동기 논블로킹 방식으로 동작하기 때문에 "2초 후 실행됨"은 먼저 나가지 않고, "3. 함수 실행 후 다른 작업 수행"이 먼저 나오고, 2초 후에 delay() 밑에 있는 로그가 나온다.
Promise { <pending> }
요지는 비동기 함수 자체가 따로 처리되는 것이 아니라, 비동기 함수 안의 비동기 동작이 따로 처리되게 된다. 만약 성급하게 비동기로 작동하는 코드를 출력하게 되면 다음과 같이 Promise라는 자료형이 반환된다.
async function example() {
return delay(2000); // `await`을 사용하지 않음
}
console.log(example()); // Promise { <pending> }
즉, delay와 같은 비동기 동작들은 다른 별도의 큐에서 작업이 처리된다. 그 처리된 결과를 받아오기 위해서는 await를 앞에 붙여서 비동기 처리를 받아내야 한다.
자바스크립트의 이벤트루프
앞서, 비동기 함수 내부에 있는 비동기 동작은 따로 처리된다는 것을 확인했다. 따로 처리되고 나서 돌아오기까지 await를 통해서 기다려주지 않으면 Promise라는 자료형이 반환된다는 것 또한 알 수 있었다. 그렇다면 비동기 동작이 가는 곳은 어디일까?
위와 같이 자바스크립트를 실행시키면 콜스택에 있는 함수를 하나씩 싱글스레드로 처리하게 된다. 이러한 자바스크립트가 논블로킹 비동기 방식으로 동작하려면 Web APIs라고 불리는 작업을 처리할 수 있게 도와주는 기능이 있어야 한다. 자바스크립트를 멀티스레드라고 볼 수 있는 관점은 바로 여기서 나온다. 그 이유는 웹 브라우저의 자바스크립트를 기준으로 하면 Web APIs가 멀티스레드 형식으로 작동하기 때문이고, node.js의 자바스크립트를 기준으로 했을 때에는 자바스크립트의 실행을 도와주는 V8 엔진이 사용하는 libuv가 멀티스레드를 지원하기 때문이다. 앞서 보았던 자바&스프링의 스레드풀처럼, libuv 또한 스레드풀을 가지고 있다.
이제, 자바스크립트를 멀티스레드라고 부를 수 있는 관점에 대해서까지 이야기를 했다. 스레드에 대한 논쟁은 찾아보니 크게 두 가지였다. 메인인 자바스크립트가 싱글스레드니까 싱글스레드다 vs 그래도 멀티스레드인 부분이 있고 멀티스레딩을 하니까 멀티스레드이다.
나도 그냥 자바스크립트가 싱글스레드니까 싱글스레드 아닌가? 하는 수준으로만 있었는데, 찾아보니까 둘 다 말이 되는 것을 알 수 있었다. 다만 관점에 맞게끔 그 근거가 필요하다... 이제 자바스크립트의 비동기 동작을 처리하는 방법을 좀 더 알아보자.
위 사진을 보면 자바스크립트의 외부에 다양한 것들이 존재한다. 대표적으로 Event Loop(이벤트 루프) 그리고 Callback Queue(콜백 큐)이다. 자바스크립트에서 실행된 모든 함수는 먼저 콜 스택으로 들어오게 된다. 그중에서 비동기 함수로 선언된 함수는 Web API를 호출하고, 콜백함수를 콜백 큐에 집어넣게 된다.
Callback Queue
콜백 큐는 비동기적으로 실행된 콜백 함수가 보관되는 영역이다. DOM을 조작하는 작업(document), 그리고 HTTP 요청을 통해서 네트워크를 거치는 작업(XMLHttpRequest) 마지막으로 기다리는 작업(setTimeout) 등의 작업들은 언제 끝날지 알 수가 없으며 이를 동기적으로 기다릴 수 없기 때문에, 비동기적으로 처리하게 된다.
참고로 Callback Queue(콜백 큐)는 사실 한 개가 아니라 두 개다. Task Queue라고 불리는 setTimeout, setInterval 등이 완료된 후 실행될 콜백이 대기하는 곳과 Microtask Queue라고 불리는 Promise.then(), MutationObserver 등의 작업이 들어가는 큐가 있다. 마이크로테스크 큐가 우선순위가 더 높으며, 이 큐는 이벤트 루프가 한 사이클을 돌 때마다 가장 먼저 실행되는 큐이다. 여기에 들어가는 작업들은 대부분 즉시 실행될 필요가 있는 중요한 비동기 로직들이다.
Event Loop
다음으로는 이벤트 루프이다. 앞서 콜백 큐들 ( Microtask Queue, Task Queue )에서 실행이 끝난 작업들을 이벤트 루프가 받아서 다시 자바스크립트의 콜 스택으로 보내는 일을 한다. 이벤트 루프는 콜 스택이 다 비워지면, 콜백 큐에 있는 함수들을 하나씩 Call Stack으로 옮기는 역할을 한다.
좀 더 깊게 들어가면 "이벤트 루프가 한 사이클을 돈다."라는 표현을 썼는데 왜 그럴까? Loop는 무엇을 의미하는 것일까? 이벤트 루프는 6개의 단계가 있는데, 각각을 돌아가면서 비동기 작업을 처리하기 때문이다. 비동기 작업이 완료되었을 때 실행되는 순서를 결정하는 역할을 하며 아래와 같이 여섯 가지가 있다.
- Timers : setTimeout(), setInterval() 콜백
- Pending Callbacks : 일부 시스템 I/O 작업의 콜백 실행 ( TCP 오류 등 )
- idle, prepare : 내부적인 최적화 작업 수행 ( libuv 최적화 )
- Poll : 대기 중인 I/O 이벤트를 확인하고 처리
- Check : setImmediate() 콜백 실행
- Close Callbacks : socket.on('close', callback) 같은 닫기 이벤트 실행
웹 브라우저에 내장되어 있는 경우에는 브라우저 엔진이 이를 도와주게 된다. 크로미움(Chromium)과 같은 툴이 이러한 이벤트 루프의 구현을 돕는다. 크로미움은 이벤트 루프의 기능을 가지고 있기에 위와 같은 멀티스레드처럼 동작하는 것을 돕는 주체는 크로미움이다. 그 외에도 순수하게 자바스크립트 코드를 실행하는 것을 도와주는 V8 엔진도 있다. V8 엔진은 참고로 자바스크립트 런타임인 node.js에서도 쓰인다. V8 엔진이 자바스크립트 코드를 실행하는 방법에 대한 내용은 이전 포스팅을 첨부한다.
- [marsboy tistory] 자바스크립트의 기묘한 작동 원리 : https://marsboy.tistory.com/23
node.js
백엔드 개발에 자바스크립트 및 타입스크립트를 사용했다면 node.js를 통해 서버 개발을 해보았을 것이다. node.js는 자바스크립트 런타임으로써 자바스크립트 자체만의 실행을 도와주는 역할을 한다. 앞서, 꾸준히 말했던 웹 브라우저에서 사용하는 Web APIs나 크로미움이 없기 때문에 node.js는 자체적으로 이벤트 루프나 libuv를 통해서 멀티 스레드처럼 동작하도록 한다.
공식 문서를 참고해 보면, node.js는 싱글스레드임을 내세우는 것을 확인할 수 있다. 스레드 풀을 만들어놓고 쓰는 네트워킹은 비효율적이며 매우 어렵다 -> node.js는 대조적이다.라는 장단을 통해서 다른 멀티스레드와 비교되는 node.js의 장점을 길게 써두었다.
가장 크리티컬 하게 node.js가 싱글스레드라는 증명은 Node.js 사용자는 Lock이 없다는 것이다! 전공 수업에서 운영체제에 대해서 지긋지긋하게 공부를 해봤다면, 멀티스레드라는 금기를 어긴 대가로 하나의 자원에 동시에 접근하는 두 개 이상의 스레드에 대해서 수없이 많은 고민을 해야 한다. 그 과정에서 데드락을 막기 위해서 스핀락, 세마포어 등 수많은 개념이 나왔다. 하지만 그러한 것이 없다는 것은 node.js가 시원하게 싱글스레드라는 반증이다.
그 외에도 공식 문서를 읽어보면, 스레드가 없다는 내용으로 이야기가 전개되지만 반대로 이벤트 기반의 장점에 대해서 길게 설명이 되어있는 것들을 볼 수 있다. 아무래도 메인인 자바스크립트 자체가 싱글스레드기 때문에 어쩔 수 없는 부분인 것 같다.
마치며
개발을 막 시작할 때 즈음 자바스크립트를 공부한다고 대선배 개발자에게 말했더니 "자바스크립트는 잘못 만든 언어다."라고 이야기를 했었다. 태생부터가 웹을 위한 언어다 뭐라나... 하지만 그 사람도 말은 그렇게 하면서 자바스크립트로 이것저것 하는 사람이었다(!) 실은 그 누구보다 자바스크립트를 사랑했던 것이었다...
자바스크립트 프레임워크인 nest.js를 쓰다가 spring을 쓰게 되었는데, 넘어가니까 정말 너무 답답했다. nest.js에서 prettier를 켜놓고, dev 모드로 개발하면서 swagger 페이지가 리로드 되는 걸 보면 정말 그만큼 스피드 한 느낌이 들지 않을 수 없다. 스프링을 쓰다 보면 nest.js가 그리워지면서 자바스크립트를 연모하는 마음이 생기게 된다.
그 외에 멀티스레드를 찾아보게 되면서 자바와 자바스크립트를 예시로 들었는데, 놀라운 건 둘 다 1995년에 탄생했다는 것이다. 대체 95년도에는 무슨 일이 있었던 걸까..
참고
- [BlaCk_Log] 동시성, 병렬, 비동기, 논블럭킹과 컨셉들 : https://black7375.tistory.com/90
- [node.js] node.js 소개 : https://nodejs.org/ko/learn/getting-started/introduction-to-nodejs
- [hwangtaehyun] node event loop 동작 과정 : https://hwangtaehyun.github.io/blog/node/event-queue/
- [Alexander Zlatkov] How JavaScript works : https://medium.com/sessionstack-blog/how-does-javascript-actually-work-part-1-b0bacc073cf
- [helloinyong] node.js가 왜 싱글 스레드로 불리는 지 "정확한 이유"를 알고 계신가요? : https://helloinyong.tistory.com/350
감사합니다
'Language > Javascript' 카테고리의 다른 글
웹 브라우저의 역사 ( Netscape, IE, firefox, safari ) (1) | 2024.11.22 |
---|---|
자바스크립트의 기묘한 작동 원리 (0) | 2023.12.29 |