서버 사이드 자바스크립트 런타임인 Node.js로 백엔드에 입문하겠습니다. 어떤 원리로 서버 사이드에서 자바스크립트가 실행되는지 알아보고, 기술적인 특징인 싱글 스레드, 이벤트 루프, 그리고 Node.js 장단점을 알아봅시다. 총 2편입니다.
[Node.js] 자바스크립트로 백엔드 입문하기 ❷
3. Node.js의 기술적인 특징
Node.js 이전의 자바스크립트는 브라우저에서 주로 실행되는 언어였습니다. 브라우저에는 V8 같은 자바스크립트 엔진이 들어 있습니다. 언어 자체가 싱글 스레드이고, 이벤트 기반(Event Driven) 아키텍처입니다. 따라서 자바스크립트 런타임인 Node.js도 자연스럽게 싱글 스레드로 구현되고 이벤트 기반 아키텍처를 구현했습니다. 이번 절에서는 이러한 Node.js의 기술적인 특징을 알아봅시다.
3.1 싱글 스레드
자바스크립트 엔진(V8)은 자바스크립트를 실행하는 힙과 콜 스택을 가지고 있습니다. 그리고 싱글 스레드로 실행됩니다. 싱글 스레드라는 이야기는 콜 스택이 하나만 있다는 말입니다. 콜 스택이 하나이므로 한 번에 하나의 작업만 가능합니다.
간단한 코드로 콜 스택 동작을 알아봅시다.
▼ 싱글스레드의 콜 스택
▼ 콜 스택 예제
function func1() {
console.log("1");
func2();
return;
}
function func2() {
console.log("2");
return;
}
func1();
1 2
func1( ), func2( ) 함수 2개가 있습니다. 코드에서는 func1( )만 실행하고 func1( )에서는 1을 출력 후 func2( )를 실행합니다. 1과 2와 출력될 때까지 콜 스택에는 어떤 일이 일어나는지 알아봅시다.
▼ 콜 스택 작동 예시
❶ 코드에서 func1( )을 최초로 실행하므로 func1( )이 콜 스택에 추가됩니다. ❷ func1( ) 함수 몸체 첫 번째 줄에 있는 console.log(“1”)이 콜 스택에 추가됩니다. ❸ console.log(“1”) 함수가 실행 완료되어 1이 출력되고, console.log(“1”) 함수는 콜 스택에서 제거됩니다. ❹ 콜 스택에 func2( ) 함수가 콜 스택에 추가됩니다. ❺ func2( )에 있는 console.log(“2”) 함수가 콜 스택에 추가됩니다. ❻ console.log(“2”) 함수가 실행 완료되어 2가 출력되고, console.log(“2”) 함수는 콜 스택에서 제거됩니다. ❼ 함수 func2( )의 return이 실행되어 종료되고, 콜 스택에서 제거됩니다. ❽ 함수 func1( )의 return이 실행되어 종료되고, 콜 스택에서 제거됩니다.
콜 스택이 어떤 것인지 이해했으니, 비동기 처리로 넘어가봅시다.
3.2 이벤트 기반 아키텍처
Node.js처럼 싱글 스레드로 요청을 처리하는 서버가 있습니다. 한 번에 하나를 처리하는 서버에 0.1초가 걸리는 요청이 동시에 100개가 온다면 마지막에 요청한 사람은 10초를 기다려야 응답을 받을 수 있습니다. 멀티 스레드를 지원하는 언어라면 스레드를 100개 만들어서 동시에 처리할 수 있지만 싱글 스레드인 자바스크립트는 그렇게 할 수 없습니다. 어떻게 하면 요청을 하나의 스레드로 동시에 처리할 수 있을까요?
▼ 싱글 스레드에 100개의 요청을 동시에 보냈을 때
방법은 이벤트 기반 아키텍처를 적용하는 겁니다. 콜 스택에 쌓인 작업을 다른 곳에서 처리한 다음 처리가 완료되었을 때 알림을 받으면 스레드가 하나라도 빠르게 처리할 수 있습니다.
예를 들어 커피숍을 들 수 있습니다. 카운터에서 주문을 완료하면 주문은 제조를 하는 직원에게 건네집니다. 카운터는 커피가 나올 때까지 기다리지 않고 다음 고객의 주문을 받습니다. 진동벨을 받은 고객은 진동벨이 울릴 때까지 기다렸다가 울리면 주문한 음료를 받아갑니다. 이때 줄을 섰던 순서와는 다르게 빠르게 제조된 음료가 먼저 나올 수 있습니다.
이런 방식으로 처리하는 것이 이벤트 기반 아키텍처입니다. Node.js에서는 동시 요청을 어떻게 처리하는지 알아봅시다.
▼ Node.js의 이벤트 기반 아키텍처
자바스크립트 코드는 ❶ V8의 콜 스택에 쌓이고 I/O 처리가 필요한 코드는 이벤트 루프로 보내게 됩니다. ❷ 이벤트 루프에서는 말그대로 루프를 실행하면서 운영체제 또는 스레드 워커에 I/O 처리를 맡기게 됩니다. ❸ 스레드 워커와 운영체제는 받은 요청에 대한 결과를 이벤트 루프로 돌려주고 ❹ 이벤트 루프에서는 결괏값에 대한 코드를 콜 스택에 다시 추가합니다.
전반적인 동작을 확인했으니 이번에는 간단한 코드를 사용해 이벤트 루프를 살펴봅시다.
▼ 콜 스택과 이벤트 루프
console.log("1");
setTimeout(() => console.log(2), 1000);
console.log("3");
1
3
(1초 후)
2
❶ 소스 코드의 첫 번째 라인을 읽어서 콜 스택에 console.log(“1”) 함수가 추가됩니다.
❷ 콜 스택에 있는 console.log(“1”)이 실행되어서 1이 출력됩니다.
❸ 콜 스택에 setTimeout()이 추가됩니다.
❹ setTimeout()은 Node.jsAPI입니다. 주어진 시간 동안 대기합니다.
❺ setTimeout()이 기다리는 동안 console.log(“3”)을 콜 스택에 추가합니다.
❻ console.log(“3”)을 실행해서 3을 출력합니다.
❼ 지정된 시간이 지나고 Node.js API에서 setTimeout( )을 이벤트 루프의 태스크 큐로 추가합니다.
❽ 태스크 큐에 추가된 setTimeout( )을 이벤트 루프의 각 단계를 진행하면서 콜 스택에 다시 추가합니다.
❾ 콜 스택에 추가한 setTimeout()을 실행해 2를 출력합니다.
💡 Note: setTimeout( )의 두 번째 인수로 0을 넣어도 똑같은 결과가 나옵니다. Node.js API 영역에서 기다리는 시간이 0일뿐 태스크 큐에 추가하고 이벤트 루프를 통해서 콜 스택에 추가하는 것은 동일하기 때문입니다. 궁금하면 인수값을 0으로 변경해서 실행 결과가 같은지 살펴보세요.
Node.js는 오래 걸리는 일을 이벤트 루프에 맡긴다는 사실을 알게 되었습니다. 이벤트 기반 아키텍처를 구현했기에, 10ms인 요청이 동시에 100개가 오더라도 Node.js는 그 요청을 거의 동시에 처리할 수 있습니다. 실제로 100개 요청을 동시에 처리하는지 2.6절 ‘정말로 동시에 요청을 처리하는지 성능 테스트하기’에서 테스트합니다. 지금까지 이벤트 기반 아키텍처가 Node.js에 어떤 방식으로 적용되었는지 알아보았습니다. 다음 절에서는 이벤트 루프를 더 자세하게 알아보겠습니다.
3.3 이벤트 루프
Node.js에서는 이벤트 기반 아키텍처를 구축하는 데 반응자 패턴(Reactor Pattern)을 사용했습니다. 반응자 패턴은 이벤트 디멀티플렉서와 이벤트 큐로 구성됩니다. 반응자 패턴은 이벤트를 추가하는 주체와 해당 이벤트를 실행하는 주체를 분리(Decoupling)하는 구조입니다. 반응자 패턴에서 이벤트 루프는 필수입니다. Node.js의 이벤트 루프는 libuv에 있습니다. 각 운영체제의 계층(IOCP, kqueue, epoll, 이벤트 포트)을 추상화한 기능을 제공합니다. libuv 소스11 파일의 uv_run( ) 함수를 살펴보면 다음과 같은 while문을 사용해 반복 실행합니다.
▼ libuv의 이벤트 루프 구현 코드 일부
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
uv__io_poll(loop, timeout);
uv__metrics_update_idle_time(loop);
uv__run_check(loop);
uv__run_closing_handles(loop);
r = uv__loop_alive(loop);
}
▼ Node.js 이벤트 루프의 흐름*
* https://www.voidcanvas.com/nodejs-event-loop
이벤트 루프는 여러 개의 FIFO 큐로 이루어져 있습니다. 각 단계를 돌면서 각 큐에 쌓인 이벤트를 모두 처리합니다. ❶ 이벤트 루프의 시작 및 각 반복(iteration)의 마지막에 루프가 활성화 상태인지 체크합니다. ❷ 타이머 단계에서는 타이머 큐(Timer Queue)를 처리합니다. setTimeout( ), setInterval( )을 여기서 처리합니다. ❸ 펜딩(Pending) I/O 콜백 단계에서는 다음 반복(iteration)으로 연기된 콜백을 처리합니다. ❹ 유휴(Ldle), 준비(Prepare) 단계는 내부적으로만 사용됩니다. ❺ 폴(Poll) 단계에서는 새로운 연결(소켓 등)을 맺고, 파일 읽기 등의 작업을 합니다. 각 작업은 비동기 I/O를 사용하거나 스레드 풀을 사용합니다. ❻ 검사check 단계에서는 setImmediate( )를 처리합니다. ❼ 종료 콜백 단계에서는 콜백의 종료 처리(파일 디스크립터 닫기 등)를 합니다.
여기서 nextTickQueue와 microTaskQueue는 조금 특별한 장치입니다. 번호를 매겨놓지 않은 이유는 각 단계의 사이마다 nextTickQueue와 microTaskQueue에 있는 작업을 먼저 실행하기 때문입니다. 즉 타이머 단계가 끝나면 nextTickQueue와 microTaskQueue를 실행합니다. 또한 펜딩 I/O 콜백 단계가 끝나면 그 사이에 쌓인 nextTickQueue와 microTaskQueue를 실행합니다. 따라서 nextTickQueue와 microTaskQueue에 코드를 추가하면 조금은 우선순위가 올라갑니다. Node.js의 process.nextTick( ) 함수로 nextTickQueue에 작업을 추가할 수 있습니다. microTaskQueue에는 Promise로 만든 콜백 함수가 추가됩니다. Promise는 비동기 함수를 동기 함수처럼 사용하는 객체입니다(5.3절 ‘Promise 객체’ 참조). nextTickQueue가 microTaskQueue보다 우선순위가 높습니다. 즉 process.nextTick( )으로 작성된 코드가 Promise로 작성된 코드보다 먼저 실행됩니다.
콜 스택이 하나지만, 이벤트 동시 처리를 어떻게 하는지 살펴보았습니다. 이벤트 루프에서 운영체제의 비동기 I/O 기능을 사용하거나, 또는 스레드 풀을 사용해서 모든 작업을 비동기로 처리했습니다. 이벤트 루프에서는 여러 큐를 사용해 특정 우선순위대로 작업들을 처리해줍니다.
4. Node.js 과연 쓸 만한가?
Node.js를 사용하면 자바스크립트로 고성능 서버를 손쉽게 개발할 수 있습니다. 그래서 프론트엔드 개발자가 백엔드 개발에 입문하고 싶을 때, 자바스크립트를 사용하면 새로운 언어를 배우지 않고 서버를 개발할 수 있습니다. 페이팔, 넷플릭스, 우버, 링크드인, 나사, 네이버는 Node.js를 실제 상용 서비스에서 사용합니다. 그 외에도 많은 회사가 Node.js로 서비스를 합니다. 우려하지 않아도 될 정도로 Node.js 서버는 견고하다고 보아도 좋습니다.
CPU 사용량이 많은 작업을 하는 서버가 아니라면, 굉장히 적은 메모리로 아주 좋은 성능을 낼 수 있습니다. 또한 Node.js는 마이크로서비스와 클라우드 환경에도 적합합니다. 메모리를 적게 사용하며 CPU 성능에 크게 좌우되지 않아서 비용을 절약할 수 있기 때문입니다.
다만 Node.js가 장점만 있는 것은 아닙니다. 랭킹이나 매칭 등 CPU를 많이 사용해야 하는 서비스에는 Node.js가 적합하지 않습니다. 비동기 프로그래밍에 익숙하지 않다면, 이 또한 허들이 될수 있습니다.
▼ Node.js의 장점과 단점