2025. 9. 6. 19:31ㆍBackend Development/Node.js
우리는 지금까지 세뇌처럼 들어왔던 이야기들이 있습니다. "Node.js 는 싱글쓰레드 + 비동기 IO 모델 이다" 라는 말입니다. 이 개념에 대해서는 다들 어느정도 수준으로는 이해하고 있을 거라고 생각합니다.
하지만 자주 헷갈리는 부분이 있습니다. 바로 싱글쓰레드인 Node.js 에서 IO작업은 병렬적으로 처리되는 듯한 느낌 그리고 그것을 증명해주듯 공식문서에 명시되어 있는 기능인 Promise.all 이 그것들입니다.
오늘은 해당 이야기를 기반으로 개념을 정리해보고자 합니다.
들어가기 앞서: Node.js의 이벤트 루프
오늘 설명할 병렬적 작업과 Promise.all 에 대한 설명에 앞서 기본적으로 알아둬야할 부분입니다. 설명에 필요한 부분만 이야기할 예정이니 이론적으로 얕은 내용입니다.
이번 글을 위해서는 딱 3가지만 알면됩니다. 바로 Call stack, Task Queue, MicroTask Queue가 그것들 입니다. (Tick 까지는 다루지 않습니다.)
간략하게 설명하자면 각 개념에 맞는 작업들이 각 Stack 이나 Queue에 저장되고, 작업을 처리하며 그 Stack 이나 Queue를 비우는 것을 반복으로 이벤트를 처리합니다. 순서는 Call stack 이 비어야 MicroTask Queue를 비웁니다. 그 후에 Task Queue 를 비웁니다.
간단하게 코드로 보는 예시
Call stack
Call stack 은 JS내 에서 싱글쓰레드로 동기적인 작업이 처리되는 영역으로 볼 수 있습니다.
이 처리 과정을 통해서 작업에 맞는 Queue 나 Stack 에 작업 단위를 저장하고 해당 Call Stack이 전부 처리되고 나면 그 후 작업을 처리합니다.
function third() {
console.log('3');
}
function second() {
third();
console.log("2");
}
function first() {
second();
console.log("1");
}
first();
// 아래 순서로 출력
// 3
// 2
// 1
위 코드의 기준으로 Call stack 에는 first() > second() > third() 순서대로 Call Stack 에 추가가 됩니다. 그리고 스택의 규칙에 따라 third() > second() > first() 순으로 처리되죠.
Task Queue
Task Queue 는 브라우저 기준으로 렌더링 되는 단위로 생각하시면 *얼추* 맞습니다.
해당 Queue 에 적재되는 작업들로는 setTimeout, setInterval, I/O 완료 콜백(poll) 등이 있습니다. 간단하게 말하자면 "다음 턴에 실행할 큰 단위의 콜백이 올라온다" 고 보시면 쉽습니다.
setTimeout(function a() {}, 0);
setTimeout(function b() {}, 500);
setTimeout(function c() {}, 100);
function d() {}
d();
// 끝나는 순서
// a()
// c()
// b()
부가적인 설명으로 이 또한 Task Queue 에 올라가기 때문에 Call Stack이 비어야 실행됩니다. 즉 Call Stack이 500ms 이상의 작업으로 바쁘면 (동기 작업으로 막혀 있다면) 여러 개가 한꺼번에 만료 상태가 되어 같은 턴에 몰려 실행될 수 있습니다.
(하지만 브라우저에서는 FIFO 를 보장하지만, Node는 대체로 그럴 뿐 엄격 보장은 약하다는 사실)
Microtask Queue
Microtask Queue는 Task Queue의 작업 직후로 짧은 후속처리를 위한 작업들이 적재되어 있다고 보면 좋습니다.
setTimeout(function a() {}, 0); // Task Queue
Promise.resolve().then(function b() {}); // Microtask Queue
// 실행 순서
// b()
// a()
예로 들어서 특정 작업을 처리하는데, 어떤 작업은 즉시성이 필요하지 않다면 setTimeout() 을 통해서 Task Queue에 적재하고 더 급한 작업들을 이어가는 식으로 처리할 수 있을겁니다.
예시 코드:
function a() {}
function b() {}
function c() {}
// 실행순서 Call Stack > a > b > c
Promise.resolve().then(a);
Promise.resolve().then(b);
Promise.resolve().then(c);
console.log('Call stack');
// 실행순서 Call Stack > a > c > b
Promise.resolve().then(a);
setTimeout(b, 0);
Promise.resolve().then(c);
console.log('Call stack');
Node.js에서의 병렬 처리
사실 우리가 Node.js 코드를 실행시키면 정말 쓰레드를 단 한개만을 사용하지는 않습니다. OS에 작업을 위임하기도하고, libuv 스레드풀을 이용해서 다양한 작업들을 동시에 처리하기도 하죠.
사실 눈치 빠른 분들이라면 Task Queue에서 설명할때 눈치를 채셨겠지만, setTimeout 의 전신 또한 libuv 루프에 기반하고 있습니다. 그렇기에 Call stack 에서 설정된 만료시간을 지난다면 동시에 Task Queue의 작업들이 실행되는 것이지요.
그렇기에 기본적으로 Node.js에서 IO 작업은 병렬로 처리되고 있습니다.
예시 코드:
setTimeout(() => console.log('task: setTimeout(0)'), 0);
Promise.resolve().then(() => console.log('microtask: Promise.then'));
console.log('sync: first phase done');
// 테스트 편의를 위한 가짜 IO
function fakeIO(name, ms) {
console.log('start', name);
return new Promise(resolve => {
setTimeout(() => {
console.log('done', name);
resolve(name);
}, ms);
});
}
const p1 = fakeIO('A', 300);
const p2 = fakeIO('B', 100);
const p3 = fakeIO('C', 200);
console.log('All I/O Schedules Done');
// 출력 순서
// sync: first phase done
// start A
// start B
// start C
// All I/O Schedules Done
// microtask: Promise.then
// task: setTimeout(0)
// done B
// done C
// done A
Promise.all의 역할은 무엇인가?
이미 I/O작업들이 병렬로써 처리가 되고 있다면 굳이 Promise.all은 왜 필요한지에 대해 의문일수 있습니다. 하지만 위 예시코드에서 보았던 것처럼 이러한 비동기 작업은 완료가 언제된 후처리를 하기에 애매한 부분이 있습니다.
Call back 과 Then 그리고 Promise와 await/async 까지의 내용은 여기에서는 다루지 않겠습니다.
상황을 가정해보겠습니다.
우리의 서비스에서 p1, p2, p3 의 데이터를 받아와서 모두 합쳐서 보여줘야한다는 요구사항이 있습니다.
그럴때 사용하기에 좋은 것이 Promise.all 입니다.
예시 코드:
setTimeout(() => console.log('task: setTimeout(0)'), 0);
Promise.resolve().then(() => console.log('microtask: Promise.then'));
console.log('sync: first phase done');
// 테스트 편의를 위한 가짜 IO
function fakeIO(name, ms) {
console.log('start', name);
return new Promise(resolve => {
setTimeout(() => {
console.log('done', name);
resolve(name);
}, ms);
});
}
const p1 = fakeIO('A', 300);
const p2 = fakeIO('B', 100);
const p3 = fakeIO('C', 200);
Promise.all([p1, p2, p3]).then(values => {
console.log('Promise.all resolved with', values.join(''));
});
console.log('All I/O Schedules Done');
이런 식으로 코드를 작성하면 각 300 + 100 + 200 ms 를 기다릴 필요없이 300 ms 만 기다리면 작업이 완료됩니다. 또한 코드의 가독성도 좋아지죠.
이뿐만 아니라 Promise.all이 제공하는 기능은 아래와 같습니다.
- 순서 보장: 동시에 시작해 둔 여러 프로미스의 완료를 카운팅해 모둘 fullill되면 resolve
- 하나라도 reject이면 즉시 reject
헷갈리지 말아야할 부분은 Promise.all 이 하나의 태스크가 되는 것이 아닌, 여러 비동기 작업을 이미 시작해둔 상태에서 Promise.all이 그 완료를 묶어서 기다립니다.
마무리: 정리하자면..
Node.js는 다양한 도구들을 통해서 비동기 작업이 됨으로써 I/O 작업에 한해서는 병렬처리가 이루어지고 있습니다. 즉 싱글쓰레드라는 말은 자바스크립트를 처리하는 메인 쓰레드가 싱글이라는 점입니다.
I/O는 Task에서 들어오고, 그 이후 처리(then/await)는 Microtask에서 즉시 소화됩니다. 그리고 Promise.all은 그 결과를 한 번에 모아주는 "게이트"일 뿐, 속도는 "동시에 시작된 I/O"에서 나온다는 것.
우리가 이미 느낌적으로 알거나 익숙해져 있는 이 도구들을 이해하고 사용하는 것은 또 다른 관점을 가져다줍니다.
'Backend Development > Node.js' 카테고리의 다른 글
map() vs forEach() 언제 사용해야할까? (0) | 2025.05.06 |
---|---|
ChatGPT와 Node.js로 네이버 뉴스 자동 요약 시스템 만들기 (2) | 2025.05.01 |
Node.js로 ChatGPT API 연동합시다. (0) | 2025.04.29 |