tech.junha.kr

Node.js 비동기 처리 모델 이해하기: 실시간 대규모 처리에 맞는 이유

Node.js는 싱글 스레드에서 동작하지만, 놀랍게도 동시성과 효율적이고 빠른 응답 시간을 유지할 수 있는 비동기 처리 모델을 제공합니다. 오케스트라의 지휘자처럼 Node.js는 메인 스레드에서 비동기 작업을 관리하고, **백그라운드에서 실행되는 I/O 작업(네트워크 요청, 파일 시스템 작업)**들이 완료되면 그 결과를 처리하는 방식으로 작동하죠.

이 글에서는 Node.js의 비동기 처리 모델과 이를 가능하게 하는 libuv 라이브러리의 역할을 설명하고, 대규모 데이터를 처리할 때 스레드 풀과 외부 API 호출이 어떻게 동작하고 성능을 최적화하는지 알아보겠습니다.

1. Node.js의 이벤트 루프와 비동기 논블로킹 I/O 모델

Node.js는 단일 스레드 기반의 자바스크립트 런타임이지만, 모든 작업을 직접 처리하지 않습니다. 비동기 작업 처리를 위해 내부적으로 다중 스레드를 활용합니다. 특히, 네트워크 요청, 파일 시스템 접근, 데이터베이스 호출과 같은 I/O 작업들은 libuv 라이브러리를 통해 백그라운드의 스레드 풀에 위임됩니다. 메인 스레드에서는 이 작업들이 완료되기 전까지 다른 작업을 계속 수행하고, 완료된 결과만 처리하는 방식입니다.

이벤트 루프는 Node.js의 핵심적인 비동기 처리 메커니즘으로, 메인 스레드에서 비동기 작업을 지휘하며 각 작업이 완료되었을 때 적절한 콜백을 실행합니다. 이 논블로킹 I/O 모델 덕분에 Node.js는 실시간 채팅 애플리케이션처럼 수천, 수만 개의 요청을 동시에 처리해야 하는 환경에서 높은 성능을 발휘합니다. 예를 들어, 각 사용자로부터 들어오는 요청을 즉시 처리하면서도 백그라운드에서 데이터베이스 작업을 수행할 수 있어, 많은 사용자들이 동시에 접속해도 원활하게 운영될 수 있습니다.

만약 Node.js가 아닌, 동기적인 시스템 또는 서버였다면, 각 I/O 작업이 완료될 때까지 모든 다른 요청은 차단되어 기다려야 합니다. 하나의 네트워크 요청이 완료될 때까지 다른 요청들이 대기 상태에 머물게 되어, 동시성 처리가 불가능하고, 많은 사용자가 접속하는 상황에서 성능 저하와 병목 현상이 발생하게 되었겠죠.

2. 비동기 모델의 성능과 백그라운드 리소스

비동기 처리 모델에서 중요한 점은, 작업을 메인 스레드에서 비동기로 던진다고 해서 자동으로 무한한 동시성을 보장할 수는 없다는 것입니다. Node.js의 비동기 처리 방식은 libuv의 스레드 풀과 같은 백그라운드 리소스에 의존합니다. 이 스레드 풀은 동시에 여러 I/O 작업을 처리할 수 있지만, 그 크기와 성능은 제한적입니다. 따라서, 대규모 데이터를 처리할 때도 백그라운드에서 실제로 병렬로 작업을 수행할 충분한 컴퓨팅 파워가 있어야 비동기 모델의 이점이 제대로 발휘됩니다. 즉, 비동기 모델이 동시성을 보장하려면 내부 리소스가 이를 뒷받침해야겠죠.

그러나, 대규모 작업을 처리할 때 모든 리소스를 내부적으로 처리하는 대신, API 호출을 통해 외부 리소스에 컴퓨팅 부담을 분산시키는 방법을 많이 사용하죠. Node.js는 비동기 API 호출을 통해 제3자 서버(예: 클라우드 서비스, 외부 API)에 작업을 위임하고, 실제 리소스 소비는 외부에서 이루어집니다. 이렇게 하면, Node.js 서버는 네트워크 대역폭과 API 응답 대기 시간만 관리하면 되고, 직접적인 컴퓨팅 파워는 외부에서 제공받는 구조가 됩니다.

Node.js의 비동기 모델 덕분에 이러한 API 호출은 논블로킹으로 처리되며, 여러 작업을 동시에 던져놓고 그 결과를 비동기적으로 받아들일 수 있습니다. 따라서 이 방식은 내부 컴퓨팅 리소스가 제한된 환경에서 동시성과 성능을 유지하는 훌륭한 전략이 될 수 있습니다.

3. libuv의 역할과 이벤트 기반 아키텍처

Node.js의 비동기 성능은 libuv 라이브러리 덕분에 가능해졌습니다. libuv는 이벤트 루프와 비동기 I/O 작업을 관리하여, Node.js가 단일 스레드로 동작하면서도 높은 성능을 발휘할 수 있게 합니다. Node.js는 전통적인 멀티스레드 방식 대신, 단일 스레드 기반에서 파일 시스템 작업, 네트워크 요청, 타이머 등 다양한 I/O 작업을 비동기적으로 처리합니다. 이를 통해 개발자는 직접적인 쓰레드 관리의 복잡성을 피하고, 간편하게 비동기 함수, 콜백, Promise, async/await 패턴을 활용할 수 있습니다.

libuv는 논블로킹 방식으로 작업을 처리하며, 일부 블로킹 작업의 경우 내부 쓰레드 풀을 사용하여 처리합니다. 이러한 구조는 성능 저하를 최소화하고, I/O 작업이 많은 애플리케이션에서 높은 효율성을 발휘합니다. 특히, 내부적으로 쓰레드 풀을 통해 블로킹 작업을 관리하기 때문에, 개발자는 멀티스레드 관리의 부담 없이 비동기 작업을 직관적으로 다룰 수 있으며, 성능과 생산성을 동시에 확보할 수 있습니다.

Node.js 아키텍처

Node.js 아키텍처

4. libuv의 스레드 풀

기본적으로 Libuv의 스레드 풀은 4개의 스레드로 구성되어 있습니다. 이는 대부분의 시스템에서 I/O 작업이 그다지 많은 스레드를 요구하지 않기 때문입니다. 그러나 CPU 집약적인 작업을 자주 수행하거나, I/O 작업이 많을 경우에는 스레드 풀의 크기를 조정할 수 있습니다.

시스템 성능에 따라 더 많은 병렬 작업을 처리할 수 있도록 UV_THREADPOOL_SIZE를 사용하여 스레드 풀의 크기를 조정할 수 있습니다.

UV_THREADPOOL_SIZE=8 node app.js

5. 비동기 I/O 작업과 스레드 풀의 관계

Node.js는 비동기 I/O 작업을 처리할 때 libuv의 스레드 풀을 활용합니다. 다만, 모든 비동기 작업이 스레드 풀에서 처리되는 것은 아닙니다. 예를 들어, 네트워크 요청과 같은 작업은 커널의 비동기 I/O 기능을 사용하여 처리되므로 스레드 풀을 사용하지 않습니다.

반면에, 파일 시스템 작업처럼 블로킹 I/O 작업은 libuv의 스레드 풀을 통해 백그라운드에서 처리됩니다. 이 과정에서 메인 스레드는 블로킹 없이 다른 작업을 계속 처리할 수 있어 동시성을 높이고 자원 활용을 최적화할 수 있습니다. 이는 CPU 자원을 많이 사용하지 않는 I/O 작업에서는 매우 효과적입니다.

그러나 CPU 집약적인 작업(예: 복잡한 계산, 대규모 데이터 처리)이 발생할 경우, Node.js의 단일 스레드 모델에서는 병목 현상이 발생할 수 있습니다. 이때는 Worker Threads를 사용하여 CPU 작업을 멀티 스레드 환경에서 병렬로 처리할 수 있습니다. 이를 통해 CPU 사용이 많은 작업도 효율적으로 처리하면서, Node.js의 성능을 극대화할 수 있습니다. 하지만 애초에 CPU 바운드 작업을 많이 처리해야 하는 서버라면, 다른 언어(예: C++, Go, Rust)로 작성하는 것이 더 적합할 수 있습니다.

6. 스레드 풀을 늘렸을 때 이점이 있는 작업의 구분

1. 제3자 API 호출 작업 (예: 클라우드 API 호출)

  • 스레드 풀 영향 없음: 이러한 작업들은 주로 네트워크 I/O를 사용하며, 커널의 비동기 I/O 기능을 통해 처리되기 때문에 libuv의 스레드 풀과 무관합니다. 네트워크 요청은 논블로킹으로 처리되며, 많은 요청을 처리하는 상황에서도 스레드 풀을 늘린다고 해서 큰 이점을 얻을 수는 없습니다.

  • 이러한 경우에는 컨테이너 기반 스케일 아웃이 성능 향상에 더 효과적입니다. 여러 인스턴스로 네트워크 요청을 분산 처리하여 부하를 줄이고, 대규모 트래픽에서도 안정적인 성능을 유지할 수 있습니다.

2. CPU 집약적인 작업 (예: 대규모 데이터 처리, 복잡한 계산)

  • 스레드 풀 영향 없음: 이와 같은 CPU 집약적인 작업은 libuv의 스레드 풀에서 처리되지 않으며, 이러한 작업은 Worker Threads를 통해 멀티스레드 방식으로 처리해야 성능을 극대화할 수 있습니다. 스레드 풀을 늘려도 CPU 작업에 대한 성능 향상은 없습니다.

3. 내부 I/O 작업 (예: 파일 시스템, DNS 조회 등)

  • 스레드 풀 영향 있음: 파일 시스템 접근이나 블로킹 I/O 작업은 libuv의 스레드 풀을 사용하여 처리됩니다. 스레드 풀을 늘리면 이러한 작업들을 더 많이 병렬로 처리할 수 있어, 파일 입출력이나 DNS 조회 작업을 많이 처리해야 할 때 성능 향상을 기대할 수 있습니다.

따라서, 스레드 풀을 늘리는 것은 블로킹 I/O 작업을 더 빠르게 병렬 처리할 때 유용하며, 네트워크 I/O와 같은 작업이나 CPU 바운드 작업에서는 스레드 풀을 늘려도 성능상의 큰 이점을 얻을 수 없습니다.

결론

Node.js는 단일 스레드 모델임에도 비동기 처리 모델을 통해 놀라운 동시성과 성능을 제공합니다. 이는 libuv의 이벤트 루프와 스레드 풀 덕분이며, 파일 시스템과 같은 블로킹 I/O 작업에서 큰 효과를 발휘합니다. 그러나 모든 작업이 스레드 풀에 의존하지 않으며, 네트워크 I/O나 CPU 집약적인 작업은 다른 방식으로 처리됩니다. 따라서 대규모 데이터를 다룰 때는 스레드 풀의 크기 조정뿐만 아니라 Worker Threads나 스케일 아웃 전략을 적절히 사용하는 것이 중요하겠습니다.