✍🏻 학습 배경
자바스크립트에 대해서 학습하던 중 궁금한 점이 생겼다.
매일메일 - 자바스크립트는 싱글 스레드 언어인데, 어떻게 동시에 여러 작업을 수행하나요?
자바스크립트는 브라우저의 Web API나 Node의 libuv, 이벤트 루프, 태스크 큐를 이용하여 비동기 작업을 동시에 처리합니다.
자바스크립트가 싱글 스레드이기 때문에 동시에 여러 작업을 처리하기 위해서는 추가적인 라이브러리나 인터페이스가 필요하다.
브라우저의 경우 자바스크립트는 Web API를 사용해 비동기 작업을 처리하는데, Node.js 환경에서는 libuv 라이브러리를 사용하여 비동기 작업을 처리한다고 한다. 정리해서 말하자면 자바스크립트 실행 환경은 다음과 같은 요소들을 조합하여 비동기 처리를 구현한다.
1. 브라우저: Web API + Event Loop + Task Queue
2. Node.js: libuv(Event Loop + Thread Pool) + Task Queue
JavaScript를 알기 위해서는 Node.js를 기본으로 알고 있어야 한다고 생각했다. 여기서 Node.js의 libuv라는 용어는 처음 들어봤던 터라, 이게 왜 Node.js의 비동기 처리와 연관이 있는 것인지 더 자세히 공부해보고자 하였다. 우선 Node.js와 JavaScript의 간략한 탄생 배경과 그 관계에 대해서 짚고 넘어가겠다.
🐣 태초의 JavaScript
처음에 JavaScript는 단순히 HTML의 DOM을 원하는대로 조작하여 웹 페이지를 동적으로 바꿔주는 단순히 보조 역할을 하는 언어였다.
그러나 Node.js의 등장으로 JavaScript가 하나의 독립적인 프로그래밍 언어로서 더 많은 기능을 할 수 있는 언어가 되었다.
🔍 JavaScript는 누가 해석하나?
그렇다면 Node.js란 무엇인가? 쉽게 말하면 JavaScript를 해석하는 엔진이라고 보면 된다. 그 당시 브라우저 개발자들은 웹 사이트 렌더링 성능을 향상시키기 위해 JavaScript를 더 빠르게 읽고 해석할 수 있도록 하는 엔진 개발에 열풍이었다.
실제로 브라우저마다 JavaScript를 해석하는 엔진이 다 다르다는 것을 알 수 있다.
- Chrome, Edge(current): V8
- Explorer, Edge(legacy): Chakra
- FireFox: SpiderMonkey
여기서 V8이라는 엔진이 성능이 매우 뛰어나, V8만 따로 빼내어 출시한 것이 Node.js인 것이다.
🙋🏻 Node.js에서 비동기 처리의 주요 원리
Node.js는 JavaScript를 브라우저가 아닌 로컬 환경이나 서버에서도 실행하도록 해주는 런타임 환경, 즉 실행창 같은 것이다.
Node.js는 싱글 스레드 기반으로 동작하는데, 때문에 하나의 단일 스레드에서만 작업을 수행하게 된다. 이 말은 즉슨, 한 번에 하나의 일밖에 하지 못한다는 것인데, 그렇다면 Node.js는 어떻게 한 번에 여러 개의 작업을 동시에 처리할 수 있는 것일까?
Non-blocking I/O, on the other hand, allows a program to continue executing other tasks while waiting for I/O operations to complete. Instead of halting the entire program, non-blocking I/O utilizes asynchronous callbacks or promises to handle I/O operations in the background. This enables Node to handle multiple operations concurrently without being blocked, resulting in better performance and responsiveness.
What is the event-driven, non-blocking I/O model in Node JS?
위 글을 보면 Non-blocking I/O 라는 단어가 나오는데, 이는 Node.js의 특징 중 하나이다. Non-blocking I/O 라는 것은 Node.js가 비동기 처리를 수행한다는 근거가 될 수 있다.
Node.js가 비동기 처리를 하는 이유는 우선 근본적으로 보면 싱글 스레드로 동작한다는 것이 가장 큰 주요 원인이다. 싱글 스레드는 I/O 작업을 요청할 때 Blocking 방식으로 처리되기 때문에, 하나의 요청이 끝날 때까지 다음 요청을 처리할 수 없게 된다. 이렇게 되면 성능이 저하되는 것은 당연한 문제다.
이를 해결하기 위해 Node.js는 Non-blocking I/O 방식으로 동작하게 된 것이고, 이를 가능하게 해주는 것이 libuv라는 것이다. Node.js는 libuv를 사용하여 기본적인 I/O 작업들을 비동기적으로 처리해준다. 그렇다면 libuv가 대체 뭐길래 비동기 처리를 해주는 것일까? 이제 libuv가 정확히 뭔지 좀 더 알아보겠다.
⚙️ Libuv의 동작 원리 (ft. 이벤트 루프)
libuv는 Node.js의 I/O 작업(파일 시스템이나 네트워크 작업 등)을 처리하기 위한 백그라운드 라이브러리이다.
구두로만 하게 되면 이해가 잘 안될 수 있으니 Node.js의 구조를 보면서 살펴보겠다.
먼저, 애플리케이션에서 작성한 JavaScript 코드는 V8 엔진을 통해 해석하여 실행된다. 하지만 V8은 기본적으로 CPU 연산을 수행하는 엔진이기 때문에, 파일 시스템이나 네트워크 요청 같은 I/O 작업은 직접 처리하지 않는다. 따라서, 이러한 작업이 발생한 경우에는 Node.js의 네이티브 C++ 바인딩(Node.js Core Module)이 libuv에 요청을 전달한다.
libuv는 Node.js의 비동기 I/O를 지원하는 핵심 라이브러리로, OS의 비동기 API를 활용하여 파일 입출력, 네트워크 요청, 타이머 등의 작업을 수행한다. libuv는 내부적으로 이벤트 루프가 동작하는데, 이는 비동기 작업을 관리하고, 완료된 작업의 콜백을 실행하는 역할을 한다.
이벤트 루프의 기본 원리는 다음과 같다.
- 콜 스택에 실행할 작업이 있는지 확인한다.
- 비어있다면 다음 단계 진행
- 비어있지 않다면 현재 실행 중인 작업이 끝날 때까지 대기
- 이벤트 큐에서 작업(콜백)을 하나 꺼내어 콜 스택에 추가한다.
- 콜 스택에서 해당 콜백 함수를 실행한다.
- 실행이 끝나면 콜 스택에서 제거한다.
- 다시 이벤트 큐를 확인하고, 위 과정을 반복 진행한다.
예를 들어, 애플리케이션에서 파일을 읽는 비동기 함수인 fs.readFile()을 호출한다고 가정해보자. 아래는 예시 코드이다.
const fs = require('fs');
fs.readFile('Example.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log('파일 읽기 요청 보냄!');
우선 자바스크립트는 기본적으로 한 줄씩 차례대로 처리하는 동기식 처리 방식을 사용한다.
이 방식대로라면 우선 'fs'라는 파일 시스템 모듈을 불러올 것이고, 그 다음 fs.readFile() 함수가 호출될 것이다.
그러나 이 함수는 비동기 함수이기 때문에, libuv 내부에 있는 이벤트 루프가 fs.readFile()라는 함수의 콜백을 이벤트 큐에 넣어둘 것이다.
그리고 그 아래에 있는 "파일 읽기 요청 보냄!"이 먼저 수행될 것이고 그 다음에 fs.readFile() 콜백 함수 내부가 실행된다.
여기서 fs.readFile()이 Node.js 내부에서 실행되는 과정은 다음과 같다.
우선 Node.js Core 모듈이 libuv에다가 fs.readFile()의 '파일 읽기 작업' 요청을 위임할 것이다.
이때 libuv의 uv_io에서 I/O 이벤트를 감지하게 되고 이 작업을 처리하게 되는데, 여기서 두 가지 방식이 존재한다.
- OS의 비동기 API 사용한다.
- 그렇지 않다면 스레드 풀(Thread Pool)에 있는 워커 스레드(Worker Thread)에서 실행한다.
먼저 OS에 fs.readFile() 즉, 비동기 파일 읽기 API가 있는지 확인한다.
있다면, 해당 API를 사용해 파일을 읽고, 작업이 완료되면 이벤트 큐에 해당 콜백을 추가한다.
그렇지 않은 경우, 스레드 풀에 있는 워커 스레드에 작업을 넘겨서 백그라운드에서 실행된다.
그리고 위 과정이 끝나면, 이벤트 루프가 이벤트 큐에서 콜백을 꺼내, 이벤트 루프 내부에서 진행되는 순서에 맞춰 콜백을 실행한 후, 읽어온 파일의 내용을 JavaScript 코드에서 사용할 수 있도록 반환해준다.
📍 워커 스레드란?
여기서 말하는 스레드 풀과 워커 스레드에 대해서 좀 더 알아보자.
Node.js에서 libuv의 스레드 풀(Thread Pool)은 기본적으로 4개의 워커 스레드(Worker Thread)로 구성되어 있다.
이 4개의 스레드는 파일 시스템 작업, DNS 요청, 사용자 정의 코드 등 CPU 집약적인 작업이나 블로킹 연산을 수행하는 데 사용된다.
이러한 4개의 스레드는 libuv가 자동으로 생성하며, 필요에 따라서 UV_THREADPOOL_SIZE 환경 변수를 설정하여 최대 1024개까지 늘릴 수 있다.
그렇다면 왜 워커 스레드가 필요한 것일까?
쉽게 말하자면, CPU 집약적인 작업이나 블로킹 연산 등을 다른 스레드(즉, 워커 스레드)로 위임함으로써 메인 스레드가 차단되는 것을 방지하기 위함이다.
일부 I/O 작업은 비동기적으로 실행이 되지만, 일부 작업은 비동기적으로 처리하기 어려울 수 있어 블로킹 연산도 필요하다.
또한, CPU 집약적인 작업들이 실행되게 되면, 메인 스레드에서 과부하가 걸릴 수도 있다. 실제로 Node.js는 단일 스레드 아키텍처를 기반으로 하기 때문에 CPU 집약적인 작업에 적합하지 않다.
따라서, 이러한 블로킹 연산이나 CPU 집약적인 작업들 둘 다 메인 이벤트 루프를 차단할 수도 있는 것이다.
이렇게 되면 이들은 Node.js의 황금률, 즉 이벤트 루프를 차단하지 않는다는 원칙을 위반하게 된다.
따라서, 워커 스레드는 Node.js의 논블로킹 I/O 모델을 유지하면서 메인 스레드 차단을 방지하여 이벤트 루프를 보호하기 위해 필요하다.
*. 그러나 Node.js에서 CPU 집약적인 작업들이 모두 libuv의 스레드 풀에서 처리되는 것은 아니다. 개발자가 명시적으로 워커 스레드를 사용하지 않는 한, 대부분의 JavaScript 코드는 메인 스레드에서 실행된다.
🍀 실행 환경에 따른 JavaScript 비동기 작업 처리 방식
이렇듯 JavaScript는 싱글 스레드 언어로 알려져 있지만, 실제로는 비동기 작업을 효율적으로 처리할 수 있는 메커니즘을 가지고 있다.
이러한 비동기 처리 방식은 JavaScript가 실행하는 환경에 따라 다르게 구현된다. 브라우저와 Node.js 환경에서 JavaScript 비동기 작업 처리 방식에 대해 비교해보겠다.
✔️ 브라우저 환경
- 브라우저 환경에서 사용 가능한 비동기 API는 Web API가 처리한다.
- I/O 작업: Web API가 모두 처리 (네트워크 요청, 타이머, 파일 읽기 등)
- DOM 관련 이벤트: Web API 에서 감지 후 콜백을 태스크 큐로 전달
- CPU 집약적 작업: Web API 개입 없이 자바스크립트 V8엔진에서 직접 실행
✔️ Node.js 환경
- Node.js 환경에서는 I/O 작업, 파일 처리 등 대부분의 비동기 작업을 libuv가 처리한다.
- I/O 작업: 대부분 libuv가 처리 (파일 시스템, 네트워크 요청, 스트림 등)
- CPU 집약적 작업: libuv의 메인 스레드 혹은 워커 스레드에서 처리 가능 (e.g., Crypto 암호화 작업, zlib 압축 등)
- 타이머: setTimeout, setInterval 등은 libuv가 관리하고 이벤트 루프에서 처리
- 이벤트 처리: Node.js의 이벤트 루프가 콜백을 스케줄링하고 실행
결론적으로, JavaScript 비동기 작업 처리는 실행 환경에 따라 다르게 구현되지만 근본적인 목표는 동일하다.
브라우저의 Web API와 Node.js libuv는 각 환경에서 효율적으로 비동기 처리를 하고, 이를 통해 싱글 스레드인 JavaScript에서 복잡한 비동기 작업을 원활하게 해준다.
📜 참고 자료