백엔드 개발자(node.js)가 되는 과정

백엔드 기술면접 회고 (2) Node.js 간단하게 파헤치기

soopy 2023. 12. 27. 15:30
728x90

Node.js의 구성요소와 구조

Node.js의 소스코드는 C++, 자바스크립트, 파이썬 등을 기반으로 구성요소가 구현되어 있다고 알려져 있으며 그 중
Node.js에서 중요한 구성요소인 V8과 libuv에 대해서 알아보자

V8

C++로 만든 자바스크립트 엔진을 말하며 즉 사용자가 작성한 코드를 실행하는 프로그램을 뜻한다. 이는 파서, 컴파일러, 인터프리터, 가비지컬렉터, 콜 스텍, 힙으로 구성되어 있으며, 인터프리터 역할을 하는 이그니션과 컴파일러 역할을 하는 터보팬을 함께 사용한다.

코드의 실행 순서는 아래와 같다.

  1. 작성한 자바스크립트 코드가 파서에 전달된다.
  2. 추상 구문 트리로 만든다.
  3. 이그니션에 전달되어 추상 구문 트리를 바이트코드로 만든다.
  4. 최적화가 필요한 부분은 이그니션이 터보팬으로 추상 구문 트리를 넘긴다.
  5. 터보팬에서 컴파일을 거쳐 바이너리 코드를 만든다.
  6. 최적화가 잘 안되었다고 판단될 경우에는 최적화를 해제하고 다시 이그니션의 인터프리터 기능을 사용한다.

위 순서를 이해하기에 앞서 인터프리터와 컴파일러에 대한 간단한 이해가 필요하다.
먼저 인터프리터와 컴파일러는 프로그래밍 언어를 기계어로 변환하는 두 가지 주요 방법을 말한다. 인터프리터는 작성한 소스 코드를 한 줄 단위로 읽고서 바이트 코드로 변환하여 실행하는 방식이다. 가령 파이썬 언어로 작성된 app.py를 터미널에서 python app.py를 입력해 실행하면 기본적으로 동작하는 것이 인터프리터이다. 얼핏 보기에는 실행시간이 빠른 것 같지만 사실상 전체 실행시간의 관점에서 봤을 때 컴파일러에 비하면 느린 편이다. 왜냐하면 매번 한 줄씩 코드를 번역, 실행하는 것의 반복이기 때문에 숲을 본다는 느낌을 얻을 수 없고, 그렇다면 효율성을 추구할 수 없기 때문이다. 파이썬, 루비, 자바스크립트가 인터프리터를 적용한다.

컴파일러의 경우 인터프리터와 달리 프로그래밍 언어 전체를 기계어로 바꾸는 컴파일 작업을 완료한 뒤 실행하는 방식을 취한다. 그러므로 소스 코드 전체를 훑어본 뒤 실행하는 모양이 되고, 그렇기에 최적화가 가능한 것이다. 또한 인터프리터와 달리 컴파일러는 완성된 실행파일을 생성한 뒤 이후부터는 해당 실행파일을 실행하는 방식을 취한다. 그렇기에 초기 컴파일 작업에 소요되는 시간은 길지만 한 번 실행파일을 생성해 둔다면 이후 실행시간은 인터프리터를 능가하는 것이다. 하지만 코드를 수정하면 또 다시 컴파일해야 하므로 잦은 코드 수정 및 결과 확인에 있어서는 답답한 편이다. 또한 기계어 번역 과정이 인터프리터에 비해 단순하지 않다. 컴파일 과정에서 생성되는 오브젝트 파일과 이를 실행파일로 만드는 과정에서 통상적으로 메모리 사용량이 상대적으로 높다고 한다. 그러므로 메모리 효율 관점에서는 인터프리터가 승자로 볼 수 있다. C, C++, JAVA가 컴파일러를 적용한다.

Node.js에서 말하는 컴파일러는 JIT(Just-in-time) 컴파일러로 불린다. 이는 인터프리터와 컴파일러의 장점을 모두 취하기 위한 방식으로 코드 실행 시 둘 다 활용되며, 특히 런타임 환경에서 최적화 요소가 발견되면 이 부분은 컴파일하여 전체 실행 효율성을 높이는 역할도 한다. 그래서 최종적으로 실행 효율성을 극대화 하지만 메모리를 더 많이 쓴다는 단점을 지니게 된다.

이벤트 루프

이벤트 루프는 HTTP 통신, 파일시스템, 소켓 통신 등 비동기 처리를 위한 기능을 말한다. Node.js에서는 자바스크립트 코드를 실행하기 위한 V8과 더불어 비동기 처리를 위한 libuv라는 C++ 라이브러리를 사용한다. 그래서 libuv가 이벤트 루프 기능을 담당하며 이를 효율적으로 동작하기 위한 부수적인 기능들을 갖추고 있다.
내부 동작을 살펴보면 V8엔진이 바이트코드나 기계어로 번역하고 실행하는 과정에서 비동기적으로 처리되어야 할 요청은 libuv의 이벤트 큐에 적재한다. 그러면 이벤트 루프가 이벤트 큐에 적재된 요청을 순차적으로 운영체제 커널에 처리를 맡긴다. 하지만 동기적으로 파일을 처리하거나 DNS를 조회하는 등 비동기 처리에 있어 블로킹 현상을 유발하는 경우 워커 스레드에서 처리하도록 넘긴다. 그렇게 운영체제 또는 워커스레드에서 완료된 작업은 다시 이벤트 루프에 전달되고, 최종적으로 이벤트 루프가 완료 처리한 응답을 Node.js 애플리케이션으로 전달한다. 이러한 아키텍처로 구성되어 Node.js의 비동기 처리가 원활하게 수행되도록 돕는다.

그래서 Node.js가 싱글 스레드로 알려져 있지만 그것은 이벤트 루프 기능을 수행하는데 있어 싱글 스레드로 처리됨을 의미하며 비동기 I/O의 실제 처리 부분에 있어서는 libuv를 통해 운영체제 또는 내부 워커 스레드를 활용한다는 점을 기억하자. 참고로 libuv를 통해 연결된 운영체제 단에서의 비동기 처리 속도가 빠른 이유는 동시성 향상을 위한 CPU 스케줄링 정책 등과 같은 성능 향상 전략이 적용되어 있기 때문이다.

Node.js의 싱글 스레드와 이벤트 루프 정리

자바스크립트 엔진인 V8은 싱글 스레드로 실행되며 그 말인 즉슨 하나의 콜 스택을 지니고 있음을 뜻한다. 그러므로 소스 코드를 실행하면 각종 함수 등 명령이 콜 스택에 쌓이며 실행이 완료되면 콜 스택에서 제거되는 수순을 밟는다. 즉 순차적으로 처리가 된다는 의미이다. 그러나 I/O 처리가 필요한 부분은 콜 스택에 그냥 둘 수 없다. 블로킹 현상이 발생하기 때문이다. 그래서 libuv라는 Node.js API에게 일을 넘겨주게 된다. 즉 요청/응답이 필요한 작업은 소위 이벤트 루프라 부르는 다른 작업장에 토스하는 것이고, 그 작업장에서 업무 요청목록과 결과목록을 관리하는 일을 이벤트 루프가 하고, 실제 작업 처리를 운영체제(OS) 또는 워커 스레드가 하는 것이다. 워커 스레드는 스레드 풀을 통해 필요에 따라 생성, 제거되는 스레드이므로 쉽게 말해 멀티스레드 환경인 것이다. 하지만 이는 비동기 처리 목록에서 블로킹 작업을 처리하기 위한 수단이며 대부분의 논블로킹 처리는 운영체제 단 비동기 API가 담당한다. 그러므로 0.1초 짜리 작업이 100개가 들어와도 libuv를 통해 빠른 비동기 처리를 구현하고 그 결과물을 이벤트 기반으로 다시 V8의 콜 스택에 차곡차곡 쌓아주는 것이다. 이것이 Node.js가 단일 스레드여도 되는 이유이다.

주요 핵심 정리

V8, libuv: 비동기 처리는 libuv에게 맡겨 결과를 가져오도록 파견하고, 그 외 동기 처리는 V8이 한다.
운영체제에서의 비동기 I/O: 운영체제는 기본적으로 비동기 처리를 위한 장치가 마련되어 있다. 그래서 자바스크립트는 운영체제의 도움을 받는 것이며, 그 연결고리가 libuv이다.
워커스레드: 운영체제의 비동기 I/O 작업에서도 블로킹(길막)을 유발하는 작업은 운영체제에게 일을 넘기지 않는다. 이는 완전히 독립된 작업장인 워커스레드가 맡는 것이다.
이벤트기반아키텍처: 요청을 받고 응답을 전달하는 방식을 이벤트 기반으로 한다는 말이다. 이벤트가 발생했다는 신호를 감지하면 해당 이벤트를 처리하고 그 전까지는 신경도 쓰지 않는 처리 방식을 말한다. 그렇게 해야 작업이 응답을 기다리고 있지 않을 수 있기 때문이다.

Node.js의 장단점

동시에 여러 요청을 다루기에 용이하다. 하지만 이는 비동기를 지원하는 I/O 요청에 한정한 이야기이다. 이를 지원하지 않는 작업이 많을 경우 해당 작업은 모두 워커 스레드로 처리하려 할텐데, 그렇다면 Node.js를 채택하는 것이 올바른지 고민해 봐야 할 것이다.
또한 메모리 사용량이 적으면서도 좋은 성능을 내며 그만큼 CPU 성능에 크게 좌우되지 않기에 마이크로서비스나 클라우드 환경에서 구현하기 적합하다. (싸니까) 하지만 CPU 집중적인 작업이 많은 서비스, 가령 실시간 랭킹이나 매칭 등 반복적인 높은 양의 연산을 요구하는 서비스를 구현한다면 컴퓨팅 자원을 제어하거나 최적화가 가능한 언어 혹은 멀티스레딩이 가능한 언어를 고려해야 할 것이다.

728x90
728x90