Node.js 작동방식 톺아보기
🚂 Motivation
사실 전 바보입니다. 여태 Node.js의 동작구조를 잘못 이해하고 있었어요.
Node.js 싱글스레드면 당연히 멀티스레드인 자바보다 당연히 성능이 안 좋은거 아닌가요? 의 답을 지금와서야 알았습니다.
⭐ What I Learned
Node.js ?
Nodejs는 Single thread / Non-Blocking이라고 얘기합니다. 즉, 단일 스레드에서 서버가 돌아가지만 I/O 작업에 대해선 비동기적으로 처리할 수 있다는 것이죠.
여기서 비동기적으로 처리한다는 것은, 파일 읽기나 네트워크 통신 등, 기다려야 하는 작업에 대해서 하나하나 기다리다 실행하는 것이 아닌, 이벤트루프 안에 넣어두고 처리를 진행한다고 생각하시면 될 거 같아요.
보통 Node.js의 비동기에 대해서 말하라고 하면 “그거 이벤트루프 때문이잖아”라고 다들 얘기합니다. 저도 그랬었구요. 근데 이벤트 루프만으로는 이를 설명할 수 없습니다.
libUV
libUV란 C++로 작성되어 Node.js가 사용하는 비동기 I/O 라이브러리입니다.
libUV는 운영체제의 커널을 추상화한 Wrapping 라이브러리이며 비동기 요청(편의상 이벤트라고 부르겠습니다.)이 들어왔을 때, 커널을 지원하는지 여부에 따라 이벤트를 워커 스레드풀 혹은 OS 커널로 보냅니다.
Worker Thread Pool
보통 워커 스레드 풀은 아래와 같이 CPU 집약적인 작업에서 많이 사용합니다.
- 파일 시스템 작업 (File System Operations):
- 파일 읽기/쓰기 (e.g.,
fs.readFile
,fs.writeFile
) - 디렉터리 생성/삭제
- 파일 정보 조회 (e.g.,
fs.stat
) - 파일 복사/이동
- 파일 읽기/쓰기 (e.g.,
- DNS 해석 (DNS Resolution):
- 네트워크 호출 없이 DNS 이름을 IP 주소로 변환 (e.g.,
getaddrinfo
)
- 네트워크 호출 없이 DNS 이름을 IP 주소로 변환 (e.g.,
- Crypto 작업:
- 해시 생성 (e.g., SHA256)
- 데이터 암호화 및 복호화
- 키 생성
- 압축 및 압축 해제:
- Gzip, Deflate 등 CPU 사용률이 높은 압축 알고리즘 워커 스레드 풀에서는 기본적으로 4개의 스레드가 존재하고, 값을 조정하여 최대 128개까지 가능하다고 합니다.
사실 Node.js의 단점은 이겁니다. 멀티스레드 환경에서는 128개라 하면 웃기는 수치죠?? 그러므로 Node.js는 CPU 작업(계산…계산…계산….)이 위주인 곳에서 성능 저하를 일으킵니다.
OS Kernel
OS Kernel은 아래와 같은 일을 위임합니다.
- 네트워크 I/O:
- 소켓 읽기/쓰기 (e.g., TCP/UDP 통신)
- HTTP 요청/응답
- WebSocket 연결
- 파일 시스템 작업 (커널이 캐시 처리):
- 파일 읽기/쓰기 캐시
- 메모리 매핑 (Memory-Mapped I/O)
- Timers:
- 고분해능 타이머 (High-resolution timers)
- EPOLL, KQUEUE, IOCP, EVENT_PORTS:
- 이벤트 기반 네트워크/파일 시스템 이벤트 감지
- 각 운영체제별 이벤트 감지 메커니즘을 사용
- 신호 처리:
- OS 신호 이벤트 (e.g.,
SIGINT
,SIGTERM
)
- OS 신호 이벤트 (e.g.,
- 프로세스 관리:
- 하위 프로세스 생성 및 종료 감지 사실 서버를 개발할 때, 대부분의 요청들은 네트워크 I/O입니다. (e.g., 기본적인 데이터베이스 접근이나 서드파티 서비스와의 통신)
이렇게 OS커널과 워커 스레드 풀에서 위임 받은 일을 해결하면 libUV의 태스크 큐에 반환합니다.
Task Queue
태스크 큐는 해결되어 응답으로 돌아온 데이터(e.g., SQL 요청 반환값 등)들을 FIFO로 쌓습니다. 그리고 이벤트 루프가 돌아가면서 해당 데이터에 맞는 Phases가 있을 때 Phases Queue에 적재합니다.
Event Loop(Phases)
이벤트 루프는 다음과 같은 순서로 단계를 순환하며 작업을 처리합니다.
- Timers Phase
setTimeout()
및setInterval()
로 등록된 콜백이 실행됩니다.- 타이머의 지정된 시간이 완료된 경우에만 실행됩니다.
- Pending Callbacks Phase
- 시스템 작업(예: TCP 에러 콜백 등)과 관련된 콜백이 실행됩니다.
- Idle, Prepare Phase
- Node.js 내부에서만 사용되며, 일반적인 작업과 관련이 없습니다.
- Poll Phase
- I/O 작업이 대기 중일 경우, 해당 콜백이 실행됩니다.
- 예: 데이터베이스 요청, 파일 읽기/쓰기, 네트워크 요청 등.
- Poll Phase는 태스크 큐의 상태를 확인하며, 대기 중인 I/O 작업이 완료되었는지 확인하고, 완료된 작업의 콜백을 실행합니다.
- Check Phase
setImmediate()
로 등록된 콜백이 실행됩니다.
- Close Callbacks Phase
- 소켓이나 핸들 등의 리소스를 닫는 작업의 콜백이 실행됩니다. 6개의 각각의 Phases Queue의 적재된 값을 이벤트 루프가 돌아가면서 드디어 사용자에게 반환합니다.
잘 이해가 안돼…
기본적인 예시를 들어 한번 정리하면 아래와 같습니다.
- 한 클라이언트의 데이터베이스 조회 요청
- Nodejs 서버의 libUV에 먼저 접근
- 데이터베이스 조회 요청(네트워크 I/O)은 libUV에서 커널이 지원되니 OS 커널의 비동기 API로 요청
- 데이터베이스는 해당 요청에 맞는 응답값을 libUV에 반환
- libuv는 이 반환된 데이터를 Task Queue에 등록
- Task Queue에서 Event Loop의 Phase Queue에 적재
- Event Loop가 돌면서 Poll Phase에 멈췄을 때, Phase Queue에 있던 내용들을 전부 반환
- 반환된 값을 클라이언트에게 전달
그래서 어쩌라고!
대부분의 멀티스레드 환경은 하나의 스레드에 하나의 요청이 배분받습니다.
보통 블로킹 환경으로 스레드에게 역할을 부여하고 네트워크 요청의 경우 응답이 올 때 까지 해당 스레드가 대기합니다. 그러다 해결되면 비로소 사용자에게 값을 반환해주고요.
이런 스레드를 생성시키거나 재사용하는 과정에서 컨텍스트 스위칭이나 메모리 사용을 높이는 오버헤드가 발생합니다.
하지만, Node.js는 하나의 스레드에 하나 요청을 받아들이면 안됩니다. 슬프게도 더 이상 쓸게 없거든요.
Node.js는 OS커널과 워커 스레드 풀에 역할을 위임합니다. 그리고 해당 요청의 응답이 왔을 때, 싱글 스레드(Task Queue와 Event Loop)를 활용해 효과적으로 클라이언트에게 값을 반환하는 역할만 합니다.
그래서 Node.js의 장점을 비동기 I/O 작업에 강하다고 하나봐요.
💭 Impression
원래는 비동기작업을 실시하는 과정이 이벤트루프에 있는 줄 알았습니다. 어떻게 돌아가는지도 모르고 열심히 Node.js 써왔네요. 바보 인증이긴 한데 순애라고 쳐주십쇼…😶🌫️