Node.js가 항상 나은 선택은 아니다

Knowhow

Node.js가 항상 나은 선택은 아니다

오늘날 사용할 수 있는 프로그래밍 언어는 많다. 오픈소스 생태계가 활발한 언어만 떠올려 봐도 Javascript/Typescript, Python, Go, Rust 등이 있다.

프로그래밍을 처음 시도하거나 AI의 도움을 받아 코드를 작성하다 보면 Node.js와 Python을 자주 접하게 된다. 특히 웹 개발에서는 프론트엔드에서 쓰던 Javascript를 서버에서도 사용할 수 있다는 이유로 Node.js를 선택하기 쉽다.

Node.js는 브라우저 밖에서 Javascript를 실행할 수 있게 해 주는 런타임이다. 파일과 네트워크 등을 다루는 API를 제공하므로 서버 프로그램을 만드는 데에도 널리 쓰인다. 하지만 익숙한 언어를 그대로 쓸 수 있다는 장점만으로 모든 작업에 적합한 것은 아니다.

싱글 스레드

Node.js가 싱글 스레드로 동작한다는 말을 들어 봤을 것이다. 한편으로는 I/O 처리에 스레드 풀이 사용되니 멀티 스레드라고도 한다. 이 둘은 바라보는 범위가 다르다.

기본적으로 한 Node.js 인스턴스의 Javascript 콜백은 메인 스레드에서 실행된다. 그렇다고 런타임 전체에 스레드가 하나만 있는 것은 아니다. 일부 파일 작업과 암호화 작업 등은 libuv의 스레드 풀을 사용하고, 많은 네트워크 I/O는 운영체제가 제공하는 비동기 기능을 활용한다.

서버를 만들 때 주의할 지점은 메인 스레드에서 Javascript가 오래 실행되는 경우다. 요청마다 큰 데이터를 동기적으로 직렬화하거나 복잡한 계산을 수행하면, 그동안 다른 요청의 콜백은 실행될 수 없다. 백그라운드에서 I/O가 완료되더라도 그 결과를 다루는 Javascript 코드는 메인 스레드가 비기를 기다린다.

따라서 요청마다 CPU를 오래 사용하는 서비스라면 계산을 나누거나 worker_threads로 분리하는 방법을 검토할 만하다. 여러 서버 프로세스로 요청을 분산할 수도 있다. 계산이 서비스의 주된 작업이라면 Go, Java, Rust처럼 다른 실행 모델을 가진 언어와 비교해 보는 것도 좋다.

비동기와 이벤트 루프

비동기는 하나의 스레드에서 여러 Javascript 함수가 동시에 실행된다는 뜻은 아니다. I/O 결과를 기다리는 동안 메인 스레드가 다른 일을 처리하고, 결과가 준비되면 관련 콜백을 실행할 수 있다는 뜻에 가깝다. 대기 시간이 긴 작업에는 유리하지만, 계산 자체가 자동으로 병렬화되지는 않는다.

Javascript의 비동기를 생각하면 Promise가 먼저 떠오른다. Node.js 서버에서는 여기에 이벤트 루프의 흐름도 알아 두면 좋다. 메인 스레드가 다른 일을 하는 사이 여러 I/O의 결과가 준비될 수 있다. 이벤트 루프는 준비된 이벤트를 확인하고 해당 콜백을 실행한다. 그래서 밀려 있던 비동기 작업의 결과를 모아 확인하고 처리하는 듯한 흐름으로 이해할 수 있다.

이때 poll은 I/O 이벤트를 확인하고 관련 콜백을 처리하는 주요 단계다. libuv가 I/O 이벤트를 관리하고, 콜백에서 실행할 Javascript 코드는 V8이 처리한다. 두 부분의 역할이 이어지지만, 이를 libuv 스레드와 V8 스레드가 서로 교체되는 과정으로 생각할 필요는 없다. 일반적인 네트워크 I/O의 이벤트 루프와 Javascript 콜백은 같은 메인 스레드에서 이어진다.

Node.js 공식 문서가 소개하는 이벤트 루프의 주요 단계는 다음과 같다.

timers → pending callbacks → idle, prepare → poll → check
   ↑                                             ↓
   └──────────── close callbacks ←──────────────┘
  • timers: 실행 시점이 된 setTimeout()·setInterval() 콜백을 처리한다. 지정한 시간이 지났다고 즉시 실행되는 것은 아니다.
  • pending callbacks: 다음 루프로 미뤄진 일부 I/O 콜백을 처리한다.
  • idle, prepare: Node.js 내부에서 사용하는 단계다.
  • poll: 새 I/O 이벤트를 확인하고 관련 콜백을 처리한다. 상황에 따라 이 단계에서 새 이벤트를 기다린다.
  • check: setImmediate() 콜백을 처리한다.
  • close callbacks: 소켓의 'close' 이벤트와 같은 일부 종료 콜백을 처리한다.

이 그림은 주요 단계를 간추린 것이다. 준비된 모든 비동기 결과를 poll에서 한 번에 처리한다는 뜻은 아니다. 타이머와 setImmediate()에는 각각의 단계가 있고, Promise의 후속 처리 함수는 마이크로태스크로 다뤄진다.

이벤트 루프가 단계 전체를 한 바퀴 돈 다음에야 Promise를 처리하는 것도 아니다. 일반적으로 현재 Javascript 작업이나 콜백이 끝난 뒤 다음 작업으로 넘어가기 전에 마이크로태스크가 처리된다. Node.js에는 process.nextTick()을 위한 별도 큐도 있으므로, 실행 순서가 중요한 코드를 작성할 때는 이 흐름까지 살펴야 한다.

결국 중요한 것은 준비된 콜백이 제때 실행될 수 있도록 메인 스레드를 오래 점유하지 않는 일이다. 이 특성을 이해하고 사용한다면 Node.js는 I/O 대기가 많은 서버에 좋은 선택이 될 수 있다.

이벤트 루프에 관한 Node.js 공식 문서

Worker와 Typescript

Node.js로 개발하다 보면 타입 검사를 위해 Typescript에 관심을 갖게 된다. Typescript 코드는 보통 Javascript로 변환해 실행한다. 최근 Node.js는 제거 가능한 타입 구문만 사용한 .ts 파일을 직접 실행하는 기능도 제공한다. 다만 이 기능은 타입 검사를 수행하지 않으며, Javascript 코드 생성이 필요한 Typescript 문법까지 모두 지원하지는 않는다. 타입 검사는 별도로 해야 한다.

멀티코어를 활용해야 한다면 node:worker_threads를 사용할 수 있다. 워커는 별도 Node.js 프로세스가 아니라 같은 프로세스 안의 스레드이며, Javascript 계산을 병렬로 실행할 수 있다. 실행할 파일은 new Worker(...)로 지정한다. .js 파일만 사용할 수 있는 것은 아니지만, .ts 파일을 지정한다면 사용 중인 Node.js 버전과 해당 코드의 Typescript 문법이 지원되는지 확인해야 한다.

워커를 사용하는 데에도 비용이 든다. 워커를 새로 만들거나 큰 데이터를 복사해 전달하면, 병렬 처리로 얻는 이점보다 오버헤드가 커질 수 있다. 반복되는 계산에는 워커 풀을 두고, 데이터의 특성에 따라 전송 가능한 버퍼나 공유 메모리를 활용하는 방법을 고려할 수 있다.

Javascript에서 함수는 일급 객체다. 다만 실행 중인 함수나 클로저를 메시지에 담아 다른 워커로 그대로 보낼 수는 없다. 워커가 수행할 코드와 주고받을 데이터를 구분해 설계해야 한다는 점이 개발 과정에서 느끼는 제약일 수 있다.

더 나은 선택은?

선택은 병목이 어디에 있는지에 달려 있다. I/O 대기가 주된 서버라면 Node.js의 비동기 모델을 활용하면 된다. 특정 계산이 메인 스레드를 오래 막는다면 worker_threads로 분리할 수 있다. 웹 서버의 요청을 여러 CPU 코어에 분산하고 프로세스 격리도 필요하다면 cluster나 여러 프로세스로 실행하는 방식이 어울릴 수 있다.

Deno와 Bun도 검토할 수 있는 Javascript 런타임이다. 다만 런타임을 바꾸는 것만으로 계산 작업의 병렬 처리 문제가 해결되거나 모든 프로그램이 빨라지는 것은 아니다. 실제 작업에서 응답 시간과 처리량을 측정한 뒤 결정하는 편이 낫다.

그래서 Node.js를 처음 배우는 사람에게 무작정 권하기보다는, 만들려는 프로그램이 어떤 작업을 주로 하는지 먼저 살펴보길 권한다. 프론트엔드에서 익힌 Javascript를 서버에서도 활용하고 싶다는 이유 역시 충분히 좋은 출발점이다. 그다음에는 작업의 특성에 맞춰 Node.js의 비동기 기능, 워커, 여러 프로세스, 또는 다른 언어 중에서 선택하면 된다.