Post

Node.js는 정말 느릴까?

Node.js는 정말 느릴까?

이 글에서는 V8 엔진의 JIT 컴파일과 libuv의 이벤트 루프 동작 원리를 통해 Node.js 성능에 대한 오해를 바로잡고, Node.js의 비동기 처리 구조를 설명합니다.

Node.js는 정말 느릴까?

2025년 GitHub 기준, 가장 인기 있는 백엔드 프레임워크 순위를 보면 흥미로운 점을 발견할 수 있습니다.

Node.js 기반의 NestJS와 Express가 상위권을 차지하고 있는 것을 보고 문득 이런 의문이 들었습니다.

“Node.js는 느리지 않나?”

저는 아래와 같은 이유로 Node.js의 성능에 대해 막연한 오해를 하고 있었습니다.

  • Node.js는 싱글 스레드이므로 멀티 스레드 언어보다 동시 처리가 약할 것이다.
  • Node.js는 인터프리터 언어라 C, Go와 같은 컴파일 언어보다 근본적으로 느릴 것이다.

하지만 여러 기술 서적과 자료를 깊이 있게 살펴본 결과, 이것이 얼마나 큰 오해였는지 깨닫게 되었습니다. 이 글에서는 Node.js의 핵심 동작 원리를 파헤쳐보고, 성능에 대한 오해를 바로잡고자 합니다.

Node.js의 등장 배경: 복잡성과의 싸움

과거 개발자들은 논블로킹(Non-blocking) I/O 기반의 고성능 서버를 구현하기 위해 멀티 스레딩과 공유 자원 관리(교착 상태, 경쟁 상태 등)의 복잡함을 직접 감당해야 했습니다.

Node.js의 창시자 라이언 달(Ryan Dahl)은 이러한 복잡성을 해결하고자 했습니다. 그는 “모든 I/O는 비동기로 처리되어야 한다”는 철학 아래, 개발자가 이벤트 기반의 비동기 I/O를 손쉽게 다룰 수 있는 환경을 만들었고, 이것이 바로 Node.js의 시작입니다.

Node.js의 핵심: V8 엔진과 libuv

Node.js는 단순히 JavaScript를 서버에서 실행하는 도구가 아닙니다. 고성능을 내기 위한 여러 핵심 요소가 유기적으로 결합된 런타임 환경입니다. 그 중심에는 V8 엔진libuv가 있습니다.

  • V8 엔진: Google이 개발한 고성능 JavaScript 및 WebAssembly 엔진입니다. JavaScript 코드를 기계어로 컴파일하여 실행 속도를 극대화합니다.
  • libuv: 비동기 I/O에 초점을 맞춘 C++ 라이브러리입니다. Node.js의 핵심 특징인 이벤트 루프스레드 풀을 제공하여, 운영체제에 관계없이 일관된 비동기 처리 모델을 가능하게 합니다.
  • Node.js 바인딩: JavaScript와 C++로 작성된 libuv, V8 엔진 등의 내부 구성 요소를 연결하는 ‘접착제’ 역할을 합니다. 이를 통해 JavaScript 코드에서 파일 시스템(fs), 네트워크(http) 같은 네이티브 기능을 호출할 수 있습니다.
  • Node.js API: fs, http, path 등 개발자가 애플리케이션을 만들 때 사용하는 친숙한 JavaScript 인터페이스입니다.

오해 1: Node.js는 인터프리터라 느리다?

결론부터 말하면, “아니오”입니다. Node.js는 순수한 인터프리터 방식이 아닙니다. 내부의 V8 엔진이 인터프리터와 컴파일러의 장점을 결합한 JIT(Just-In-Time) 컴파일 방식을 사용하기 때문입니다.

  1. 파싱 (Parsing): JavaScript 코드를 받아 컴퓨터가 이해할 수 있는 추상 구문 트리(AST)로 변환합니다.

  2. Ignition (인터프리터): AST를 기반으로 바이트코드를 생성하고 실행합니다. 이 방식은 매우 빨라 초기 구동 속도를 보장하며, 자주 실행되지 않는 코드 처리에 효율적입니다.

  3. TurboFan (최적화 컴파일러): Ignition이 코드를 실행하면서 프로파일링을 통해 반복적으로 수행되는 ‘핫(Hot)’한 코드(예: 반복문 안의 함수)를 식별합니다. TurboFan은 이 코드를 받아 최적화된 기계어로 컴파일하여 실행 속도를 비약적으로 향상시킵니다.

  4. 역최적화 (Deoptimization): 만약 최적화된 코드의 가정이 런타임에 깨지면(예: 항상 숫자가 들어오던 변수에 갑자기 문자열이 들어오는 경우), TurboFan은 최적화된 기계어를 버리고 다시 Ignition의 바이트코드를 실행하는 방식으로 안전하게 전환합니다.

이처럼 Node.js는 빠른 초기 구동 속도(인터프리터)강력한 실행 성능(컴파일러)이라는 두 마리 토끼를 모두 잡았습니다.

실제로 I/O 작업이 많은 웹 애플리케이션 환경에서 컴파일 언어인 Go와 성능을 비교한 벤치마크를 보면, Node.js가 결코 뒤처지지 않으며 때로는 더 나은 성능을 보여주기도 합니다. V8 엔진의 JIT 컴파일 덕분입니다. (참조 영상)

요약: Node.js는 JIT 컴파일러를 통해 인터프리터의 빠른 시작과 컴파일러의 강력한 실행 성능을 모두 갖추었으므로, 단순한 인터프리터 언어보다 훨씬 뛰어난 성능을 보입니다.

오해 2: Node.js는 싱글 스레드라 동시 처리에 약하다?

이 말은 “반은 맞고 반은 틀립니다.” 정확히는 개발자가 작성하는 JavaScript 코드는 단일 스레드(메인 스레드)에서 실행되지만, Node.js 자체는 내부적으로 멀티 스레드를 활용해 동작합니다. 이 중심에 이벤트 루프스레드 풀이 있습니다.

  1. 요청 접수와 위임: Node.js 애플리케이션에 요청이 들어오면, 이벤트 루프는 이를 받습니다. 만약 이 작업이 시간이 걸리는 I/O(파일 읽기, 네트워크 요청 등)라면, Node.js는 직접 처리하지 않고 작업을 운영체제 커널(OS Kernel)이나 libuv의 스레드 풀(Thread Pool)에 위임하고 즉시 다음 요청을 받으러 갑니다. 이것이 바로 논블로킹(Non-blocking) 동작 방식입니다.

  2. 작업 처리 주체
    • OS 커널: 네트워크 요청처럼 운영체제가 비동기 처리를 효율적으로 지원하는 대부분의 I/O 작업은 커널이 처리합니다.
    • 스레드 풀: 파일 시스템 접근(일부), 암호화, 압축 등 커널이 비동기로 지원하지 않거나 CPU 사용량이 높은 작업들은 libuv가 관리하는 워커 스레드(기본 4개, 설정 가능)들이 나누어 처리합니다.
  3. 콜백과 실행: 위임했던 작업이 완료되면, 그 결과와 함께 등록된 콜백 함수가 태스크 큐(Task Queue)에 추가됩니다. 이벤트 루프는 콜 스택(Call Stack)이 비어있을 때마다 태스크 큐에서 콜백 함수를 하나씩 꺼내와 메인 스레드에서 실행시킵니다.

요약: 우리의 JavaScript 코드는 단일 스레드에서 실행되어 스레드 안전성을 보장하지만, 무거운 작업은 내부적으로 멀티 스레드로 처리됩니다. 덕분에 Node.js는 싱글 스레드의 한계를 뛰어넘어 수많은 동시 요청을 효율적으로 처리할 수 있습니다.

Node.js의 약점: CPU 집약적 작업

위에서 보았듯이 Node.js의 이벤트 루프 모델은 I/O 작업이 잦은 애플리케이션에서 최고의 성능을 발휘합니다. 하지만 이 구조에는 치명적인 약점이 있는데, 바로 CPU를 많이 사용하는 작업(CPU-bound task)에 취약하다는 것입니다.

여기서 한 가지 혼동하지 말아야 할 점이 있습니다. “내부에 스레드 풀이 있는데 왜 계산을 나눠서 못 하지?”라는 의문이 들 수 있습니다.

libuv의 워커 스레드는 파일 처리나 암호화 같은 특정 시스템 작업만을 수행하도록 설계되어 있습니다. 우리가 작성한 JavaScript 코드(반복문, 계산 로직 등)를 실행하는 것은 오직 단 하나의 메인 스레드뿐입니다.

따라서 메인 스레드에서 복잡한 연산이나 이미지 처리 같은 무거운 JavaScript 코드를 실행하면, 이벤트 루프가 그 작업이 끝날 때까지 멈춰버립니다. 이는 해당 작업이 완료될 때까지 다른 모든 요청의 처리가 중단됨을 의미합니다. Node.js 개발 시 반드시 피해야 할 최악의 시나리오입니다.

해결 방안

물론 이러한 CPU 집약적 작업에 대응할 방법이 없는 것은 아닙니다.

  • worker_threads 모듈: Node.js v12부터 정식 도입된 기능으로, 무거운 계산 작업을 별도의 워커 스레드로 보내 메인 스레드의 부담을 덜 수 있습니다. libuv의 스레드 풀과 달리, 이 모듈은 개발자가 직접 JavaScript 코드를 실행할 수 있는 스레드를 생성합니다.

  • 외부 시스템 연동: RabbitMQ, Redis 같은 메시지 큐를 도입하여 무거운 작업을 별도의 워커 프로세스(또는 서버)들이 비동기적으로 처리하도록 시스템을 설계할 수 있습니다.

  • 마이크로서비스 아키텍처: CPU 집약적인 기능은 Go, Rust, Java 등 해당 작업에 더 적합한 언어로 별도의 마이크로서비스를 구축하여 분리하는 방법도 효과적입니다.

결론: Node.js, 알고 보니 강력하다

“Node.js는 느리다”는 저의 오해는 그 동작 원리를 깊이 있게 공부하지 않은 데서 비롯되었습니다. Node.js는 JIT 컴파일러를 사용하는 V8 엔진과 논블로킹 I/O를 처리하는 libuv의 이벤트 루프 덕분에, I/O가 잦은 현대적인 웹 애플리케이션 환경에서 매우 빠르고 효율적인 성능을 보여준다는 것을 알게 되었습니다.

다만, 그 특성을 정확히 이해하고 CPU 집약적인 작업으로 이벤트 루프를 막지 않도록 주의해야 한다는 교훈도 얻었습니다.

앞으로 특정 기술을 선택할 때, 막연한 편견이나 유행을 따르기보다 그 기술의 동작 원리와 철학을 먼저 공부해야겠습니다. 그래야만 비로소 그 기술의 장점을 극대화하고 약점을 보완하는 설계를 할 수 있는 진정한 개발자로 성장할 수 있을 테니까요.

참고 자료

This post is licensed under CC BY 4.0 by the author.