NodeJS 동작원리
NodeJS의 동작 원리를 알기 위해서는 javascript의 동작원리를 먼저 알아야 합니다.
javascript 는 싱글 스레드로 동작하는 언어입니다. 싱글 스레드이기 때문에 한 번에 하나의 작업만 처리할 수 있게 되죠.
그렇다면 자바스크립트는 어떻게 동작 할까요?
위의 그림처럼 Javascript의 런타임은 메모리 힙과 콜 스택으로 구성됩니다 메모리 힙은 메모리 할당을 담당하는 곳이고 콜 스택은 코드가 호출되면서 스택으로 쌓이는 곳입니다.
콜 스택❓
하나의 메인 스레드에서 호출되는 함수들은 콜 스택에 쌓이게 됩니다. 이 함수 들은 LIFO(나중에 쌓이는 것이 먼저 처리되는) 방식으로 실행되죠. Javascript는 싱글 스레드 기반이기 때문에 하나의 메인 스레드와 하나의 콜 스택을 가지고 있습니다.
Javascript 런타임은 자체적으로 비동기를 지원할까❓
Javascript 런타임 자체에서는 비동기 API를 지원하지 않습니다. 동시성을 보장하는 비동기, 논블로킹 작업들을 Javascript 엔진을 구동하는 런타임 환경에서 담당합니다. 여기서 런타임 환경은 NodeJS, 브라우저를 말하게 되죠.
Javascript의 엔진은 단지 코드에 대한 실행환경입니다. 각 이벤트를 스케쥴링하는 것은 그것을 둘러싸고 있는 환경이며 비동기 작업들도 이러한 환경에서 지원하죠
위에 그림이 바로 Javascript의 엔진과 외부 런타임 환경들이 합쳐진 모습니다. 그럼 각 역할을 알아봅시다
이벤트 루프❓
이벤트 루트는 이벤트 발생 시 호출되는 콜백 함수들을 관리하여 테스크 큐에 전달하고 테스크 큐에 담겨있는 콜백 함수들을 콜 스택에 넘겨줍니다. 이때 이벤트 루프는 콜 스택을 확인하고 콜 스택이 비어있는 경우에만 테스크 큐에 콜백 함수를 넘겨주게 됩니다.
테스트 큐❓
web api에서 비동기 작업들이 실행된 후 호출되는 콜백 함수들이 기다리는 공간입니다. 이벤트 루프가 정해준 순서로 기다리게 되고 FIFO으로 처리되죠. 사실 테스트 큐는 하나의 큐로 이루어 있지 않습니다.
web api❓
브라우저에서 지원하는 api로 Dom 이벤트 Ajax, setTimeout 등의 비동기 작업들을 수행할 수 있도록 api를 지원합니다. 물론 Nodejs의 이벤트 루프와는 차이가 있습니다.
런타임 환경에서 어떻게 비동기 코드가 실행될까❓
JS에서 Web api가 지원하는 setTimeout 같은 코드가 실행될 때 순서를 알아봅시다.
setTimeout 코드가 호출 스택에 쌓이게 됩니다. 이후 Javascript 엔진은 비동기 작업을 Web api에게 위임하게 됩니다. Web api는 해당 비동기 작업을 수행하고 콜백 함수를 이벤트 루프를 통해 태스크 큐에 넘겨주게 됩니다. 이벤트루프는 콜 스택에 쌓여있는 함수가 없을때 태스크 큐에서 대기하고 있던 콜백함수를 콜스택으로 넘겨주게 되죠. 이후 콜스택에 쌓인 콜백 함수가 실행되고 콜 스택에서 제거됩니다.
논블로킹 I/O❓
만약 http 요청을 동기로 수행한다면 해당 함수가 콜 스택에 쌓인 채로 머물고 JS 엔진은 해당 작업이 끝날 때까지 어떤 작업도 수행할 수 없게 되죠. 바로 동기 작업이 다른 코드들을 블로킹하게 되는 것입니다. 하지만 Javascript 는 비동기 작업들을 Web api에게 넘겨줄 수 있기 때문에 해당 작업이 완료될 때까지 다른 코드들을 실행할 수 있게 됩니다. 이것이 바로 논블로킹이죠.
console.log('실행시작');
setTimeout(() => console.log('0초니까 바로 출력될까요?'), 0);
console.log('실행끝');
다음과 같은 코드는 실행 시작 => 실행 끝 => 0초니까 바로 출력될까요? 순서로 실행되게 됩니다. 시간이 중요한 게 아니라 setTimeOut 함수가 Web api 가 지원하는 비동기 함수라는 점이 중요하게 됩니다. setTimeOut는 바로 콜 스택에 쌓여 처리되는 것이 아니라 Web api 에서 비동기로 처리된 후 콜백함수가 테스크 큐에 전달되게 되는 것이죠. 그렇기 때문에 시간이 0초여도 콜스택에 바로 쌓이는 다른 함수들보다 늦게 호출되게 됩니다.
또한 setTimeOut의 딜레이 시간을 5초도 두면 정확이 5초 뒤에 콜백 함수가 실행될까요? 아닙니다. 이벤트 큐는 콜 스택이 비어있을 경우 콜스택으로 태스크 큐의 콜백함수를 전달하기 때문에 콜스택이 많이 쌓여있으면 실행되는 시간이 지연되게 되죠.
Node JS란❓
Javascript를 브라우저 밖에서도 실행시킬 수 있는 Javascript런타임입니다. Javascript을 실행시킬 수 있는 환경이라는 뜻이죠. Node JS는 싱글 스레드입니다. 먼저 싱글 스레드와 멀티 스레드에 대한 개념을 알고 있어야 합니다. 싱글 스레드는 프로세스 내에서 하나의 스레드가 하나의 요청만을 수행합니다. 한 번에 여러 요청을 수행할 수 없는 것이죠 그래서 싱글 스레드는 블로킹 모델이라고 합니다. 반면 멀티스레드는 스레드 풀에서 실행의 요청만큼 스레드를 매칭 하여 작업을 수행합니다.
Node JS는 싱글 스레드 논블로킹 모델입니다. 싱글 스레드 지만 비동기 I/O 작업을 통해 요청들을 서로 블로킹하지 않습니다. 즉 동시에 많은 요청들을 비동기로 수행함으로써 싱글 스레드 일지라고 논블로킹이 가능합니다. 또한 NodeJS는 클러스터링을 통해 프로세스를 포크하여 멀티스레드 처럼 사용할 수 있습니다. 이는 트래픽에 따라서 프로세스를 포크 할 수 있으므로 서버의 확장성 면에서 용이하죠
NodeJS 는 완전한 싱글 스레드❓
NodeJS는 싱글 스레드이지만 완전한 싱글 스레드를 기반으로 동작하지는 않습니다. 무슨 말일까요? 일부 블로킹 작업들은 libuv의 스레드 풀에서 수행되기 때문이죠. 이를 이해하기 위해서 몇 가지 알아야 하는 사항이 있습니다.
이벤트 기반
이벤트 기반이란 이벤트가 발생할 때 미리 지정해둔 작업을 처리하는 방식입니다. NodeJS는 이벤트 리스너에 등록해둔 콜백 함수를 실행하는 방식으로 동작합니다. 즉 이벤트 루프가 이를 가능하게 해 줍니다.
Node JS 내부 구조
NodeJS를 크게 나눠보면 내장 라이브러리와 V8 엔진 그리고 libuv로 구성됩니다. NodeJS의 특성인 이벤트 기반, 논 블로킹 I/O 모델들은 모두 libuv 라이브러리에서 구현됩니다.
NodeJS에서 작성되는 거의 모든 코드들은 콜백 함수로 이루어져 있죠. 콜백 함수들은 libuv 내에 위치한 이벤트 루프에서 관리 및 처리됩니다. 이벤트 루프는 여러 개의 페이즈 들을 갖고 있으며 해당 페이즈들은 각자만에 큐를 가지고 있습니다. 이벤트 루프는 라운드 로빈 방식으로 노드 프로세스가 종료될 때까지 여러 페이지들을 계속 순회합니다. 페이즈들은 각각의 큐들을 관리하고 각각의 큐는 FIFO 순서로 콜백 함수들을 처리하게 됩니다.
논블로킹 I/O
NodeJS에서 논블로킹 I/O 모델은 Input Output 이 관련된 작업(데이터베이스 CRUD, 파일 시스템) 등의 블로킹 작업들은 백그라운드에서 수행하고 이를 비동기 콜백 함수로 이벤트 루프에 전달하는 것을 의미합니다.
I/O 작업들은 OS 커널 혹은 libuv 내의 스레드 풀에서 담당하게 됩니다. libuv는 OS 커널에서 어떤 비동기 작업들을 지원해주는지 알고 있기 때문에 작업 종류에 따라서 커널 혹은 스레드 풀로 분기합니다. libuv의 스레드풀은 커널이 지원안하는 작업들을 수행하게 됩니다. 또한 libuv 의 스레드 풀은 멀티 스레드로 이루어져 있습니다. 예를 들어 파일 시스템은 libuv에서 처리되게 되고 스레드 풀도 마찬가지로 작업을 마친 후 이벤트 루프에 콜백 함수를 전달하게 됩니다.
libuv는 어떻게 동작할까❓
NodeJS는 기본적으로 libuv 위에서 동작하여 node 인스턴스가 생성될 때 libuv 에는 스레드 풀이(기본값은 4개의 스레드)가 생성됩니다. libuv는 OS 커널이 어떤 비동기 작업들을 지원해주는지 알고 있기 때문에 libuv의 스레드는 커널이 지원하지 않는 작업들을 수행하게 됩니다.
이벤트 루프의 내부 동작 과정❓
이벤트 루프는 6 phase 들로 구성되어있습니다. 각 phase 들을 FIFO 큐를 가지고 있고 이벤트 루프가 해당 phase를 라운드 로빈 방식으로 순환하면서 실행되게 됩니다.
timers
setTimeOut(), setIntever() 같은 timer 함수들이 처리가 됩니다.
I/O callbacks
클로즈 콜백, 타이머로 스케쥴링된 콜백, setImemediate를 제외한 거의 모든 콜백들이 큐에 놓이게 됩니다.(http, api 호출, db 조회 등)
poll
poll 큐에 있는 이벤트 콜백들을 처리합니다. 이때 poll에 쌓인 콜백 함수들을 실행하게 되는데 더 이상 실행할 콜백 함수가 없을때에는 규칙에 따라 다음 단계로 넘거가거나 대기하게 됩니다. 일단 check 단계를 검사하여 setImemediate 가 있는지 확인하고 setImemediate 가 있으면 check 단계로 넘어가게 됩니다. 만약 setImemediate 가 없다면 timer 단계에 실행할 timer 함수가 있는지 확인하게 됩니다. timer 단계로 넘어갈 수있을때까지 대기하고 도중에 poll 큐에 콜백함수가 들어온다면 즉시 실행합니다.
check
setImediate() 콜백이 호출되고 실행됩니다.
close callbacks
. on('close') 같은 이벤트에 따른 콜백 함수를 실행합니다.
다음과 같은 코드들은 어떻게 동작할까 ❓
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
위 코드를 실행하면 setTimeout()는 timer 영어에 들어가고 setimmediate()는 check 영역에 들어가게 됩니다. 이벤트 루프가 돌고 있는 시점에 따라 다르게 되는데 timer 단계라면 setTimeout 이 먼저 실행되고 그렇지 않다면 setImmediate 가 먼저 실행되게 됩니다.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
위 코드를 실행하면 결과는 항상 아래와 같게 됩니다.
immediate
timeout
-
fs.readFile 는 블로킹 작업이고 OS 가 지원하지 않는 비동기 작업 이므로 libuv의 스레드 풀에서 담당하게 됩니다.
-
스레드가 작업을 완료한 뒤 I/O callback 영역에 큐에 콜백을 등록합니다.
-
이벤트 루프가 I/O callback 영역을 실행할 때 콜백을 poll 영역에 큐에 등록하게 됩니다.
-
이제 poll 영역을 실행하게 되면서 I/O callback에서 받은 콜백 내부 로직을 실행하게 됩니다. setTimeout 같은 경우는 timer 등록되게 되고 setImmediate 같은 경우는 check 영역에 들어가게 됩니다.
-
이벤트 루프가 큐를 비우고 다음 실행 영역인 check를 확인하게 됩니다. 위에서 말했듯이 check 영역에 실행할 setImmediate 이있기때문에 immediate을 출력하게 됩니다.
-
timers에 등록되었던 setTimeout의 콜백을 poll 큐에 등록하게됩되고 이벤트 루프가 poll 영역을 실행하고 timeout 을 출력하게 됩니다.
-
노드 프로세스가 반환되고 종료됩니다.
결론
JavaScript NodeJs는 싱글 스레드이다. 비동기다. 정도만 알고 있었는데 이번 기회에 NodeJs 동작원리와 내부구조를 자세하게 알 수 있었던 거 같다. 동작원리를 활용하여 조금 더 효율적인 코드를 작성할 수 있도록 앞으로 노력해야겠다.
nodejs.org/ko/docs/guides/blocking-vs-non-blocking/
https://nodejs.org/ko/docs/guides/event-loop-timers-and-nexttick/