Node.js는 I/O 작업을 어떻게 처리할까?
이 글에서는 I/O, 블로킹/논블로킹, Node.js의 I/O 작업 처리 방식에 대해 살펴보려고 한다.
I/O란 무엇인가
I/O(Input/Output)란 프로세서가 입출력 디바이스(디스크, 네트워크 등)와 데이터를 주고받는 과정이다.
사용자 공간(User Space)에서는 보안과 하드웨어 자원 관리 문제로 I/O를 처리할 수 없다. 대신 OS가 제공하는 I/O 시스템 콜을 호출하여 커널 공간(Kernel Space)으로 제어권을 넘긴다. 시스템 콜을 받은 커널은 디바이스에 I/O 작업을 요청하고, 작업이 완료되면 디바이스는 인터럽트를 발생시켜 CPU에게 완료를 알린다.
블로킹과 논블로킹
블로킹
블로킹이란 특정 작업이 완료될 때까지 스레드가 대기 상태로 전환되는 것을 의미한다.
스레드가 대기 상태로 전환된다는 것은 단순히 멈추는 것이 아니다. OS는 멈춘 스레드 대신 실행 가능한 다른 스레드로 교체하는 컨텍스트 스위칭을 수행하고, 이 과정에서 CPU 레지스터 상태를 저장하고 복원하는 비용이 발생한다. 그래서 블로킹이 지속된다면 시스템 성능이 저하된다.
논블로킹
논블로킹은 블로킹과 달리 작업을 처리할 때 스레드가 대기 상태로 전환되지 않는다.
대신 호출한 스레드에게 제어권을 반환하며, 제어권을 돌려받은 스레드는 곧바로 다른 작업을 이어서 실행할 수 있게 된다. 작업의 완료 여부는 폴링(Polling) 또는 이벤트 기반(Event Driven) 방식으로 확인한다.
블로킹 I/O와 논블로킹 I/O
블로킹의 개념을 I/O에 적용하면 두 가지로 구분할 수 있다.
- 블로킹 I/O: I/O 시스템 콜을 호출했을 때, 작업이 완료되기 전까지 스레드가 블로킹된다. 스레드는 대기하는 동안 아무 작업도 수행하지 못한다. 그 동안 CPU는 다른 스레드로 작업을 처리해야 한다.
- 논블로킹 I/O: I/O 시스템 콜을 호출했을 때, 작업의 완료 여부와 무관하게 즉시 호출한 스레드에게 제어권을 반환한다.
Node.js와 논블로킹 I/O
Node.js는 libuv 기반으로 I/O 작업을 처리하며, 대부분의 I/O 작업을 이벤트 기반의 논블로킹 형태로 처리한다. 이벤트 기반의 논블로킹은 libuv 내부의 epoll(linux), kqueue(mac)와 같은 I/O 멀티플렉싱 기반의 인터페이스로 구현되어 있다.

하지만 파일 I/O와 같이 커널 수준에서 논블로킹 인터페이스를 제공하지 않는 경우 워커 스레드를 사용한다.

블로킹 I/O와 워커 스레드
Node.js의 이벤트 루프는 싱글 스레드(메인 스레드) 기반으로 동작한다. 그래서 I/O 작업으로 블로킹이 발생하면 메인 스레드에서 다른 작업을 처리할 수 없게 된다.
이를 해결하기 위해 Node.js는 블로킹 I/O 작업을 libuv의 내부에 워커 스레드로 처리하며, 스레드 풀 기반의 멀티 스레딩 방식으로 동작한다.
예를 들어 파일 읽기 I/O 작업이 실행되면 메인 스레드는 작업을 스레드 풀에 위임하고, 워커 스레드는 블로킹 방식의 시스템 콜을 호출하여 파일을 읽는다. 메인 스레드는 워커 스레드에 작업을 위임한 직후 즉시 제어권을 반환 받는다. 워커 스레드가 작업을 완료하면 libuv에 완료 이벤트를 전달하고, 이벤트 루프가 이를 감지하여 등록된 콜백을 순서에 맞게 실행한다.
Node.js는 항상 성능이 좋을까?
Node.js는 대기 시간이 짧은 I/O Bound에서 높은 성능을 보여준다. 하지만 모든 상황에서 성능이 좋은 것은 아니다. 크게 두 가지 경우를 살펴보자.
I/O 대기 시간 자체가 느린 경우
I/O 디바이스는 CPU에 비해 물리적으로 느리다. 논블로킹으로 I/O 작업을 처리해도, I/O 디바이스의 대기 시간은 줄어들지 않는다. 병목이 I/O 디바이스 자체에 있다면 논블로킹 구조만으로는 성능을 개선할 수 없다.
CPU Bound 환경인 경우
논블로킹 I/O는 CPU가 I/O 대기 없이 다른 작업을 이어서 처리할 수 있을 때 효과적이다. 하지만 크기가 큰 JSON 파싱과 같은 무거운 CPU 작업이 메인 스레드에 들어오면 메인 스레드가 블로킹되어 성능이 저하된다.
결론
Node.js 서버의 성능을 개선할 때 무작정 코드를 수정하기보다는 병목 지점을 정확히 파악하는 것이 먼저다. I/O 작업에서 지연이 발생하는지, 아니면 메인 스레드에 무거운 CPU 연산으로 이벤트 루프가 블로킹되고 있는지 확인해야 한다.
참고
- nodejs의 내부 동작 원리
https://sjh836.tistory.com/149. - 스레드 풀 사이즈를 UV_THREADPOOL_SIZE 환경 변수로 변경하는 방식
https://nodejs.org/docs/latest-v24.x/api/cli.html#uv_threadpool_sizesize - Node.js의 이벤트 루프가 콜백을 처리하는 방식
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
댓글