Node.js 비동기 처리의 핵심: 이벤트 루프와 비동기 아키텍처 완벽 가이드
Node.js가 단일 스레드(Single Thread)임에도 불구하고 수많은 동시 요청을 빠르고 효율적으로 처리할 수 있는 비밀은 바로 **이벤트 루프(Event Loop)**와 비동기 아키텍처에 있습니다. 이 글에서는 이벤트 루프의 각 단계부터 시작해, 타이머 함수들의 차이, EventEmitter를 활용한 동시성 처리, 그리고 Promise의 동작 원리까지 흐름에 따라 파헤쳐 봅니다.
이벤트 루프(Event Loop)의 내부
- timers:
setTimeout(),setInterval()로 스케줄링된 콜백을 실행합니다. - pending callbacks: 시스템 작업(예: TCP 오류)에서 연기된 I/O 콜백을 실행합니다.
- idle, prepare: 시스템 내부적으로만 사용되는 단계입니다.
- poll: 새로운 I/O 이벤트를 검색하고 처리합니다. 타이머나
setImmediate를 제외한 거의 모든 콜백(예: 파일 읽기, 네트워크 요청 응답)이 여기서 실행됩니다. - check:
setImmediate()콜백이 전용으로 실행되는 큐입니다. - close callbacks:
socket.on('close', ...)와 같은 종료 작업 콜백을 실행합니다.
Event Loop는 위의 그림과 같은 각각의 단계로 구성이 되어 있습니다. 그리고 각각의 단계는 FIFO(First In First Out) Queue 를 가집니다. 각각의 단계의 큐에 있는 callback을 큐가 모두 소진하거나 최대 콜백 수에 도달할 경우 다음 단계로 이동하게 됩니다.
타이머와 Poll 단계의 상호작용
이벤트 루프에서 가장 중요한 두 축은 timers와 poll 단계입니다.
Timers: 정확한 시간이 아닌 '임계값'
timer가 추가 되어 실행되는 콜백들은 사람들이 원하는 정확한 시간에 실행되게끔 하는 것이 아닌 시간의 임계값(Threshold) 을 지정하고 일정 임계값에 해당하는 시간이 자났을 경우 가능한 빨리 미루어진 작업이 실행될 수 있게끔 합니다. 하지만 timer의 callback 작업은 운영체제나 다른 callback에 의해서 더 밀려서 실행될 수 있다는 특징을 갖습니다.
Poll: 이벤트 루프의 두뇌
poll 단계는 이벤트를 처리할 뿐만 아니라, 다음 단계로 넘어갈지 대기할지를 결정합니다. poll 은. 큐가 비어있을 경우와 비어있지 않을 경우에 작업을 처리하는 과정이 다릅니다.
큐가 비어있지 않을 때에는 큐가 소진되거나 하드 리미트에 도달할 때까지 동기적으로 콜백을 실행합니다. 큐가 비어 있을 경우에 비해 비교적 단순하게 작업을 처리하는 것이 단순합니다.
큐가 비어있을 때는 조금 더 복잡해집니다. setImmediate()에 의해 스크립트가 예약되었다면, 이벤트 루프는 poll 단계를 종료하고 check 단계로 넘어가 예약된 스크립트들을 실행합니다.
setImmediate()에 의해 스크립트가 예약되지 않았다면, 이벤트 루프는 큐에 콜백이 추가되기를 기다린 다음 즉시 그것들을 실행합니다.
poll 큐가 비워지면 이벤트 루프는 시간 임계값에 도달한 타이머를 확인합니다. 임계값에 도달하여 준비가된 타이머가 하나 이상 있을 경우 이벤트 루프는 timers 단계로 돌아가 해당 타이머의 콜백을 실행합니다. 이러한 poll 단계는 우리가 Node.js 코드를 이용하여 실행하는 비동기 작업, 이벤트 처리, I/O작업등을 효율적으로 관리하고 실행하는 핵심 역할을 수행합니다.
setImmediate() vs setTimeout()
많은 개발자가 헷갈려하는 두 타이머 함수의 핵심적인 차이는 실행되는 단계에 있습니다. setTimeout은 timers 단계에서, setImmediate는 check 단계에서 실행됩니다.
주의할 점 (사실 교정):
setImmediate가 항상setTimeout보다 먼저 실행되는 것은 아닙니다. 메인 모듈의 전역 스코프에서 두 함수를 동시에 호출하면 시스템 성능에 따라 실행 순서가 무작위로 결정됩니다. 하지만, I/O 주기(예: 파일 읽기 콜백 내부) 안에서 두 함수가 예약될 경우에는 항상setImmediate가 먼저 실행됩니다. I/O 작업은 poll 단계에서 처리되고, poll 단계가 끝나면 다음 순서가 바로 check 단계(setImmediate)이기 때문입니다.
setImmediate 는 poll 단계가 완료된 이 후 콜백을 바로 실행할 수 있게끔 하는 libuv API를 사용하는 특별한 타이머입니다. 일반적으로 코드가 실행되면 이벤트 루프의 poll 단계에 도달하여 들어오는 연결, 요청등을 기다립니다. 하지만 setImmediate 콜백이 예약되어 있고 poll 단계의 상태가 idle 상태가 될 경우 poll 이벤트를 기다리지 않고 poll 단계를 종료하고 check 단계로 넘어가게 됩니다.
이러한 특징으로 인해서 setImmediate() , setTimeout() 를 동시에 실행할 경우 setImmedate로 예약이 추가된 작업은 항상 setTimeout로 인해서 예약 된 작업보다 먼저 실행됩니다. 이러한 우선 순위는 setTimeout으로 인해 설정된 타이머 수와 상관 없이 적용됩니다.
EventEmitter를 이용하여 어떻게 병렬 처리가 가능할까?
Node.js는 Web에서 사용하는 window.addEventListener() 를 이용하여 사용자의 이벤트를 추적하는 것과 같은 기능을 수행합니다. 웹에서는 사용자의 클릭, 키보드 버튼 클릭과 같은 이벤트의 Listener을 추가함으로써 해당 이벤트가 발생할때마다 특정 callback 함수를 실행 시키는 기능을 수행합니다. EventEmitter 는 이 작업을 백엔드에서 실행할 수 있게끔 하는 events module 입니다.
여기서 명심할 점은 EventEmitter의 이벤트 방출(emit)은 기본적으로 동기적(차단, Blocking) 으로 작동한다는 것입니다. 진정한 의미의 멀티 스레드 병렬 처리가 아니므로, I/O 작업과 같은 non-JavaScript 작업이 실행되는 동안 이벤트 루프가 멈추지 않도록 비동기 설계를 결합해야 합니다.
async function processSorting() {
const task = this.wareHouse.dequeue();
this.printMsg(`${VAR.MESSAGE[task]} 물품 분류 시작`);
// 차단을 막기 위한 비동기 대기
const time = VAR.PRODUCT_TIME[task];
await new Promise((resolve) => setTimeout(resolve, time));
this.printMsg(`${VAR.MESSAGE[task]} 물품 분류 완료`);
this.deliveryQueue.enqueue(task);
// 느슨한 결합을 위한 이벤트 전송
this.emitter.emit("분류 종료");
// 이벤트 루프를 막지 않고 다음 작업을 스케줄링
setImmediate(() => this.startSorting());
}
위의 함수를 살펴보겠습니다. 일단 기본적으로 processSorting은 비동기 함수로 작동하기 때문에 기존의 수행하던 작업들과는 독립적으로 작동합니다. 이벤트를 활용해 다른 컴포넌트에 상태를 알리면 느슨한 결합(Loose Coupling) 이 형성됩니다. 구성 요소 간의 의존성이 낮아져 코드 변경이 용이해지며, setImmediate를 활용해 이벤트 루프를 차단하지 않고 다음 작업을 이어감으로써 마치 병렬 처리처럼 매끄러운 동시성을 확보할 수 있습니다.
느슨한 결합은 무엇일까요? 느슨한 결합은 서로가 약하게 연관되어 있어서 관계를 떼어낼 수 있고 그 때문에 한 구성요소에 변화가 생겼을 경우 다른 구성요소의 성능이나 존재에 최소한의 영향을 끼치는 상태를 이야기 합니다. 이러한 관계의 장점은 무엇일까요? 서로 최소한의 정보로 연결되어 있기 때문에 의존성이 낮아 코드의 변경이 가능하게 한다는 점이 있습니다.
앞서 작성한 코드가 느슨한 결합이 완벽히 이루어지지 않지만 이벤트를 이용하여 현재 작업 중인 작업과 별개의 작업을 수행할 수 있게끔 함으로써 실제로는 병렬은 아니지만 병렬과 같이 작동할 수 있게끔 하는 것입니다.
Promise와 Microtask Queue
학습자료 : https://www.joshwcomeau.com/javascript/promises/
Promise는 뭘까요? Promise는 객체입니다. 이것이 의미하는 것이 무엇일까요? 이 뜻은 Promise는 상태를 가지고 있다는 것입니다. 그렇다면 어떤 상태를 갖고 있는 것일까요?
- pending 대기
- fulfilled 이행
- rejected 거부
pending 상태는 Promise가 생성되었을 때 기본적으로 대기 상태로 작업이 수행되기 만을 기다립니다. fulfilled 상태는 Promise 의 callback 함수를 실행시킨 결과의 값이 에러가 발생하지 않고 정상적인 값을 반환 했을 경우 해당 값을 반환하는 상태에 해당합니다. rejected 의 경우 callback 을 이용하여 실행시키고자 하는 작업이 실패했을 경우의 상태에 해당합니다.
위와 같은 속성은 Promise에게 왜 필요할까요? Promise는 위와 같은 속성을 갖고 있는 객체이지만 실제 목적은 callback 함수를 사용하여 특정 작업을 수행할 것을 목표로 하고 있습니다. 실제로 이 callback 함수는 비동기 작업을 수행하며, 작업 완료 시 resolve 또는 reject를 호출합니다.
위에서 중요한 것은 비동기 작업을 수행 한다는 것입니다. Promise의 내부의 callback 함수를 이용할 경우 내부의 비동기 작업은 즉시 실행됩니다. 하지만 즉시 실행되는 작업이 바로 callstack 으로 이동하지 않습니다.
자바스크립트의 모든 작업들은 callstack 를 거쳐서 수행이 되어야 하는데 이 곳에 저장이 되지 않고 다른 장소 Microtask Queue 에서 실행 시킨 후 해당 작업이 모두 수행되었는지 수행되지 않았는지의 상태를 EventLoop가 지속적으로 감시합니다. 그리고 callstack 에 만약 작업이 있을 경우 해당 작업을 모두 끝내고 EventLoop 에 의해서 지속적으로 상태를 확인하여 callstack 내부의 작업이 모두 수행되었고 Microtask Queue 에 있던 작업들이 모두 수행되었을 경우 EventLoop 내부의 단계를 거쳐서 callstack 으로 옮겨지고 해당 작업이 수행됩니다.
마이크로태스크 큐는 이벤트 루프의 각 단계보다 우선순위가 가장 높습니다. 현재 실행 중인 콜스택(Call Stack)이 비워지는 즉시, 이벤트 루프는 다음 단계로 넘어가기 전에 마이크로태스크 큐에 있는 모든 작업을 먼저 확인하고 처리합니다. 이러한 메커니즘 덕분에 기존 콜스택의 실행 흐름을 방해하지 않으면서도, 결과값이 준비되는 즉시 빠르게 후속 작업을 병렬적으로 처리하는 것과 같은 효과를 냅니다.
결론 (Conclusion)
Node.js가 단일 스레드의 한계를 극복하고 뛰어난 성능을 발휘하는 이유는 '기다림'을 관리하는 정교한 시스템 덕분입니다.
이벤트 루프는 각 단계별 큐를 돌며 I/O와 타이머 작업의 우선순위를 조율하고, 개발자는 setImmediate와 setTimeout을 통해 이 큐의 흐름에 개입합니다. 여기에 EventEmitter를 결합하여 모듈 간의 결합도를 낮추고 동시성을 부여하며, 궁극적으로 최우선 순위인 Promise와 마이크로태스크 큐를 활용해 메인 스레드의 차단 없이 무거운 작업들을 비동기적으로 흘려보냅니다.
이러한 비동기 및 이벤트 기반 아키텍처에 대한 깊은 이해는, 단순히 코드가 동작하는 것을 넘어 고성능의 견고한 Node.js 애플리케이션을 설계하는 가장 중요한 토대가 됩니다.