이번 글은 Node.js가 libuv를 통해 비동기 작업을 처리하는 방법에 대한 글입니다. 최근 Spring Framework의 비동기 및 논블로킹(Non-blocking) 모듈인 Spring WebFlux에 대해 깊게 공부하게 되었습니다.
저는 Spring WebFlux 뿐만 아니라 대표적인 JavaScript 런타임인 Node.js는 어떻게 비동기 처리를 구현하는 지에 대해 궁금했는데요.
사실 제가 비동기라는 개념을 처음 배운 기술이 Node.js라서 더욱 흥미가 생겼었습니다.
Event Loop
이벤트 루프란 I/O 멀티플렉싱(I/O Multiplexing)을 리액터 패턴(Reactor Pattern)으로 구현한 모델입니다.
이벤트 루프 모델을 사용하면 I/O 멀티플렉싱을 활용해 단일 스레드로도 비동기 처리를 할 수 있다는 장점이 있습니다.위 코드는 libuv의 공식 문서에 있는 이벤트 루프의 의사 코드(Pseudo Code)인데요.
이벤트 루프가 발생한 이벤트들을 가져오고 처리하는 작업을 반복 수행한다는 것을 확인할 수 있습니다. Spring WebFlux의 WAS(Web Application Server)인 Netty나 Node.js는 이벤트 루프를 통해 비동기 작업을 처리하는데요.
Netty는 Java NIO(New I/O)의 Selector를 통해, Node.js는 libuv라는 라이브러리를 통해 이벤트 루프를 구현합니다.
사실 추상화의 차이일 뿐, 내부적으로는 Selector나 libuv 모두 epoll()이나 kqueue() 등의 이벤트 관리 시스템 콜(System Call)을 통해 구현되어 있습니다.
libuv
여러 라이브러리 중 Node.js의 libuv에 대해 자세히 알아보겠습니다.
공식 문서에 따르면 libuv는 다중 플랫폼에 기반한 비동기 I/O 라이브러리입니다.
사실 이벤트 루프를 구현하는데 사용하는 epoll()이나 kqueue() 등의 시스템 콜들은 특정 플랫폼에 종속적인데요.
이때, libuv는 이들을 모두 통합해 추상화한 이벤트 루프 API(Application Programming Interface)를 제공함으로써 다중 플랫폼을 지원합니다.
Event Loop with libuv
libuv의 주요 기능은 이벤트 루프입니다.libuv에서 이벤트 루프는 uv_loop_t 타입을 가지고 있는데요.
위에서는 동적 메모리 할당을 통해서 이벤트 루프를 생성했지만, uv_default_loop()를 통해서도 이벤트 루프를 생성할 수도 있습니다. uv_default_loop()를 사용하면 동적 메모리 할당 및 해제 과정을 생략할 수 있습니다.uv_run()은 실제로 이벤트 루프가 수행하는 반복문이 있는 함수입니다. 이벤트 루프는 여러 페이즈(Phase)로 나뉘게 되는데요.
그래서 uv_run()의 반복문 내부를 보면 uv__run_pending()이나 uv__run_idle() 등 해당 페이즈에 대한 작업을 순서대로 호출하는 것을 확인할 수 있습니다.
대표적으로 uv__run_timers()인 Timer 페이즈에서는 setTimeout() 등의 시간 관련 JavaScript 함수의 콜백을 처리하게 됩니다.
Handle
libuv에서는 이벤트 루프에 등록하는 콜백을 핸들(Handle)이라고 합니다.
핸들은 이벤트 종류에 따라 여러 타입이 있으며, 각 핸들은 특정 페이즈에 처리됩니다.위 코드는 유휴 핸들을 통해 이벤트 루프가 유휴 상태일 때 로그를 출력하도록 하는 코드인데요.
실제로 이벤트 루프가 실행되자마자 유휴 상태로 전환되고, 유휴 핸들의 콜백인 on_idle()이 호출되는 것을 확인할 수 있습니다. 이번엔 소켓 프로그래밍의 예시도 한 번 보겠습니다.위 코드는 TCP(Transmission Control Protocol) 핸들을 통해서 클라이언트가 연결 요청을 보내면 로그를 출력하도록 하는 코드입니다.
libuv의 이벤트 루프를 사용했기 때문에 하나의 스레드로 여러 클라이언트 소켓의 요청을 받을 수 있습니다.
Node.js의 Event Loop
이제 Node.js 내부에서 libuv의 이벤트 루프가 어떻게 실행되는지 살펴보겠습니다.Node.js는 구동 시 Start()를 호출합니다. Start() 내에서 호출되는 StartInternal()에서는 uv_default_loop()으로 생성한 이벤트 루프를 NodeMainInstance에 전달하는 것을 확인할 수 있습니다.
또 다시 StartInternal()에서는 NodeMainInstance의 Run()을 호출하는데요.Run()은 핸들 설정 및 이벤트 루프 실행을 위해 LoadEnvironment()와 SpinEventLoopInternal()를 호출합니다.LoadEnvironment()는 Node.js의 환경을 설정하는 함수인데요.
실제로 이벤트 루프의 각 페이즈에 핸들을 등록하는 부분은 Environment의 InitializeLibuv()입니다.SpinEventLoopInternal()에서는 최종적으로 uv_run()을 통해 이벤트 루프를 실행시키게 됩니다.