Node.js Event Loop 속속들이 파헤쳐보기: 싱글 스레드 모델의 비밀, 알아볼까요?
Node.js (노드js)는 이벤트 기반에 비동기 I/O (입출력) 방식을 사용해서, 싱글 스레드인데도 엄청난 동시성을 자랑하는 JavaScript (자바스크립트) 실행 환경을 만들어냈습니다.
싱글 스레드라고 하면 뭔가 하나씩 순서대로 처리해야 할 것 같은데, Node.js (노드js)는 어떻게 단 하나의 스레드로 높은 동시성과 비동기 I/O (입출력)를 가능하게 했을까요?
오늘은 이 질문을 중심으로 Node.js (노드js)의 싱글 스레드 모델을 속속들이 파헤쳐보는 시간을 가져보겠습니다.
- 높은 동시성을 위한 전략
보통 높은 동시성을 구현하려면 멀티 스레드 모델을 사용하는 것이 일반적입니다.
서버가 클라이언트 요청마다 스레드를 하나씩 할당하고, 동기 I/O (입출력)를 사용하는 방식인데요.
동기 I/O (입출력) 호출에 시간이 오래 걸리는 단점을 스레드 스위칭으로 보완하는 거죠.
예를 들어, Apache (아파치) 웹 서버가 이런 전략을 사용합니다.
I/O (입출력) 작업은 원래 시간이 많이 걸리기 때문에, 이런 방식으로는 높은 성능을 내기가 어렵습니다.
하지만 구현하기는 매우 간단하고, 복잡한 상호 작용 로직을 구현하는 데는 편리합니다.
사실 대부분의 웹 서버는 복잡한 계산을 많이 하지 않습니다.
요청을 받으면 다른 서비스 (예: 데이터베이스 읽기)에 요청을 전달하고, 결과가 돌아오기를 기다렸다가, 다시 결과를 클라이언트에게 보내는 게 주된 역할인데요.
Node.js (노드js)는 이런 상황에 딱 맞는 싱글 스레드 모델을 사용합니다.
들어오는 요청마다 스레드를 새로 만드는 대신, 메인 스레드 하나로 모든 요청을 처리하고, I/O (입출력) 작업은 비동기적으로 처리해서 스레드를 만들고 없애고, 스레드끼리 왔다 갔다 하는 복잡함과 오버헤드를 줄이는 거죠.
- Event Loop (이벤트 루프)
Node.js (노드js)는 메인 스레드 안에 이벤트 큐라는 것을 가지고 있습니다.
요청이 들어오면 이 큐에 이벤트 형태로 추가하고, 계속해서 다른 요청을 받는 거죠.
그러다가 메인 스레드가 할 일이 없어지면 (요청이 더 이상 안 들어오면), 이벤트 큐를 빙글빙글 돌면서 처리해야 할 이벤트가 있는지 확인합니다.
여기서 두 가지 경우가 생기는데요.
I/O (입출력) 작업이 아닌 일반적인 작업은 메인 스레드가 직접 처리하고, 콜백 함수를 통해서 결과를 알려줍니다.
I/O (입출력) 작업인 경우에는 스레드 풀에서 스레드를 하나 가져와서 이벤트를 처리하도록 하고, 콜백 함수를 지정해 놓은 다음에, 다시 이벤트 큐에 있는 다른 이벤트를 처리하러 갑니다.
스레드 풀에서 I/O (입출력) 작업이 끝나면, 지정해둔 콜백 함수가 실행되고, 작업이 완료된 이벤트는 다시 이벤트 큐 맨 뒤에 놓여서, 이벤트 루프가 다시 처리해주기를 기다립니다.
메인 스레드가 이벤트 큐를 돌다가 이 이벤트를 다시 만나면, 이번에는 직접 처리해서 결과를 알려주는데요.
이 전체 과정을 바로 Event Loop (이벤트 루프) 라고 합니다.
작동 원리를 그림으로 보면 아래와 같습니다.
이 그림은 Node.js (노드js)의 전체적인 작동 방식을 보여주는데요.
왼쪽에서 오른쪽, 위에서 아래로 Node.js (노드js)를 네 개의 계층으로 나누어 설명하고 있습니다.
애플리케이션 계층, V8 엔진 계층, Node API (노드 API) 계층, LIBUV (리브유브이) 계층인데요.
- 애플리케이션 계층: JavaScript (자바스크립트) 코드와 직접 상호작용하는 계층입니다.
http
나fs
같은 Node.js (노드js) 모듈이 여기에 해당됩니다. - V8 엔진 계층: V8 엔진을 사용해서 JavaScript (자바스크립트) 문법을 분석하고, 아래 계층 API (응용 프로그래밍 인터페이스)와 통신합니다.
- Node API (노드 API) 계층: C 언어로 구현되어 있고, 위쪽 계층 모듈들을 위해 시스템 호출을 제공하며, 운영체제와 통신합니다.
- LIBUV (리브유브이) 계층: 운영체제에 상관없이 사용할 수 있는 하위 계층으로, Event Loop (이벤트 루프), 파일 작업 등을 구현하고, Node.js (노드js)가 비동기 방식을 쓸 수 있도록 해주는 핵심 부분입니다.
Linux (리눅스)든 Windows (윈도우)든, Node.js (노드js)는 내부적으로 스레드 풀을 사용해서 비동기 I/O (입출력) 작업을 처리하고, LIBUV (리브유브이)는 서로 다른 플랫폼의 호출 방식을 통일해주는 역할을 합니다.
그러니까 Node.js (노드js)에서 싱글 스레드라고 하는 건 JavaScript (자바스크립트) 코드가 싱글 스레드로 실행된다는 의미이지, Node.js (노드js) 전체가 싱글 스레드라는 뜻은 아닙니다.
- 작동 원리
Node.js (노드js)가 비동기 방식을 구현하는 핵심은 바로 이벤트입니다.
모든 작업을 이벤트로 취급하고, Event Loop (이벤트 루프)를 통해서 비동기적인 효과를 만들어내는 건데요.
이 사실을 좀 더 확실하게 이해하고 받아들이기 쉽도록, 가짜 코드를 사용해서 작동 원리를 설명해볼게요.
-
- Event Queue (이벤트 큐) 정의
큐는 First-In, First-Out (FIFO, 선입선출) 방식의 자료 구조인데요.
JavaScript (자바스크립트) 배열을 사용해서 다음과 같이 표현할 수 있습니다.
/**
* 이벤트 큐 정의
* 큐에 넣기: push()
* 큐에서 빼기: shift()
* 큐가 비었는지 확인: length === 0
*/
let globalEventQueue = [];
배열을 사용해서 큐 구조를 흉내내는 건데요.
배열의 첫 번째 요소가 큐의 맨 앞, 마지막 요소가 큐의 맨 뒤가 되는 거죠.
push()
는 큐의 맨 뒤에 요소를 넣고, shift()
는 큐의 맨 앞에서 요소를 빼내는 역할을 합니다.
이렇게 간단하게 이벤트 큐를 구현할 수 있습니다.
-
- 요청 접수
모든 요청은 가로채서 처리 함수로 들어가게 되는데요.
코드로 보면 아래와 같습니다.
/**
* 사용자 요청 접수
* 모든 요청은 이 함수를 거쳐감
* request, response 객체를 파라미터로 전달
*/
function processHttpRequest(request, response) {
// 이벤트 객체 정의
let event = createEvent({
params: request.params, // 요청 파라미터 전달
result: null, // 요청 결과 저장
callback: function() {} // 콜백 함수 지정
});
// 이벤트를 큐의 맨 뒤에 추가
globalEventQueue.push(event);
}
이 함수는 사용자의 요청을 이벤트 형태로 만들어서 큐에 넣는 역할만 하고, 계속해서 다른 요청을 받습니다.
-
- Event Loop (이벤트 루프) 정의
메인 스레드가 할 일이 없어지면, 이벤트 큐를 빙글빙글 돌기 시작하는데요.
이벤트 큐를 도는 함수를 정의해야 합니다.
/**
* 이벤트 루프의 메인 로직, 메인 스레드가 적절한 시점에 실행
* 이벤트 큐 순회
* Non-IO 작업 처리
* IO 작업 처리
* 콜백 실행 및 결과 반환
*/
function eventLoop() {
// 큐가 비어있지 않으면 계속해서 루프 실행
while (this.globalEventQueue.length > 0) {
// 큐의 맨 앞에서 이벤트 하나 꺼내기
let event = this.globalEventQueue.shift();
// 시간이 오래 걸리는 작업인지 확인
if (isIOTask(event)) {
// 스레드 풀에서 스레드 하나 가져오기
let thread = getThreadFromThreadPool();
// 스레드에게 작업 처리하도록 넘겨주기
thread.handleIOTask(event);
} else {
// 시간이 오래 걸리지 않는 작업은 직접 처리하고 결과 반환
let result = handleEvent(event);
// 콜백 함수를 통해 V8 엔진에 결과 반환, V8 엔진은 애플리케이션에 결과 반환
event.callback.call(null, result);
}
}
}
메인 스레드는 계속해서 이벤트 큐를 감시합니다.
I/O (입출력) 작업은 스레드 풀에 넘겨서 처리하도록 하고, I/O (입출력) 작업이 아닌 작업은 직접 처리하고 결과를 반환합니다.
-
- I/O (입출력) 작업 처리
스레드 풀이 작업을 받으면, 데이터베이스 읽기 같은 I/O (입출력) 작업을 직접 처리합니다.
/**
* IO 작업 처리
* 작업 완료 후 이벤트를 큐 맨 뒤에 다시 추가
* 스레드 반환
*/
function handleIOTask(event) {
// 현재 스레드
let curThread = this;
// 데이터베이스 작업
let optDatabase = function (params, callback) {
let result = readDataFromDb(params);
callback.call(null, result);
};
// IO 작업 실행
optDatabase(event.params, function (result) {
// 이벤트 객체에 결과 저장
event.result = result;
// IO 작업 완료 후에는 더 이상 시간이 오래 걸리는 작업이 아님
event.isIOTask = false;
// 이벤트를 큐 맨 뒤에 다시 추가
this.globalEventQueue.push(event);
// 현재 스레드 반환
releaseThread(curThread);
});
}
I/O (입출력) 작업이 끝나면 콜백 함수가 실행되고, 요청 결과를 이벤트에 저장하고, 이벤트는 다시 큐에 들어가서 이벤트 루프가 다시 처리해주기를 기다립니다.
마지막으로 현재 스레드는 반환됩니다.
메인 스레드가 이벤트 큐를 돌다가 이 이벤트를 다시 만나면, 이번에는 직접 처리합니다.
위 과정을 정리해보면, Node.js (노드js)는 오직 하나의 메인 스레드만 사용해서 요청을 받습니다.
요청을 받은 후에는 바로 처리하는 게 아니라 이벤트 큐에 넣어두고, 계속해서 다른 요청을 받습니다.
그러다가 할 일이 없어지면 Event Loop (이벤트 루프)를 통해서 이벤트들을 처리하면서 비동기적인 효과를 만들어내는 건데요.
물론 I/O (입출력) 작업은 시스템 레벨의 스레드 풀을 사용해서 처리해야 합니다.
따라서 Node.js (노드js) 자체는 멀티 스레드 플랫폼이지만, JavaScript (자바스크립트) 레벨에서는 싱글 스레드로 작업을 처리한다고 이해하면 쉽습니다.
- CPU (중앙처리장치) 연산 작업에는 약점
지금까지 Node.js (노드js)의 싱글 스레드 모델에 대해 간단하고 명확하게 알아보았습니다.
이벤트 기반 모델을 통해 높은 동시성과 비동기 I/O (입출력)를 구현하는 방식인데요.
하지만 Node.js (노드js)가 잘 못하는 것도 있습니다.
위에서 설명했듯이, I/O (입출력) 작업은 스레드 풀에 넘겨서 비동기적으로 처리하기 때문에 효율적이고 간단합니다.
그래서 Node.js (노드js)는 I/O (입출력) 작업이 많은 작업에 적합합니다.
하지만 모든 작업이 I/O (입출력) 작업만 있는 건 아니죠.
CPU (중앙처리장치) 연산 작업, 즉 CPU (중앙처리장치) 계산에만 의존하는 작업 (예: 데이터 암호화/복호화 (node.bcrypt.js), 데이터 압축/해제 (node-tar))을 만나면, Node.js (노드js)는 작업을 하나씩 순서대로 처리합니다.
이전 작업이 끝나기 전까지는 다음 작업은 계속 기다려야 하는 거죠.
아래 그림처럼요.
이벤트 큐에서 이전 CPU (중앙처리장치) 연산 작업이 끝나지 않으면, 다음 작업들은 계속 막혀서, 응답 속도가 느려지게 됩니다.
운영체제가 싱글 코어라면 어쩔 수 없겠지만, 요즘 서버는 대부분 멀티 CPU (중앙처리장치) 또는 멀티 코어인데요.
Node.js (노드js)는 Event Loop (이벤트 루프)를 하나만 가지고 있기 때문에, CPU (중앙처리장치) 코어를 하나만 사용합니다.
Node.js (노드js)가 CPU (중앙처리장치) 연산 작업에 묶여서 다른 작업들이 막히게 되면, CPU (중앙처리장치) 코어가 남아돌아도 활용을 못 하는 자원 낭비가 발생합니다.
그래서 Node.js (노드js)는 CPU (중앙처리장치) 연산 작업에는 적합하지 않습니다.
- 활용 분야
- RESTful API (레스트풀 API): 요청과 응답 데이터 양이 적고, 복잡한 로직 처리가 많이 필요하지 않습니다. 따라서 수만 개의 연결을 동시에 처리할 수 있습니다.
- 채팅 서비스: 가볍고, 트래픽이 많고, 복잡한 계산 로직이 없습니다.
'Javascript' 카테고리의 다른 글
Node.js Cluster (클러스터) 완벽 해부: 핵심 개념 파헤쳐보기 - Node.js (노드js) 성능 향상의 비밀, 알아볼까요? (0) | 2025.03.22 |
---|---|
JavaScript 디버깅의 숨겨진 보석: error.cause, 에러의 근본 원인을 쉽게 찾아볼까요? (0) | 2025.03.22 |
TypeScript 컴파일러, Go 언어로 갈아탄다고요?! 속사정 한번 알아볼까요? (0) | 2025.03.22 |
TypeScript 객체 타입 Union과 Intersection (0) | 2025.03.22 |
타입스크립트(TypeScript) 왜 써야 할까? (0) | 2025.03.22 |