아티클/개발
[Node.js] 싱글스레드 서버에 대한 고찰
원스
Node.js는 잘 알려져 있듯 싱글 스레드 기반의 이벤트 루프 모델을 사용한다. 서버 자원 소모가 적고 경량화되었기에, "성능이 부족하지 않을까?"라고 생각할 수 있지만, 사실 서버 작업의 대부분을 차지하는 DB 쿼리나 네트워크 요청 같은 I/O 중심의 작업은 비동기 처리를 통해 문제없이, 아주 효율적으로 처리된다.
하지만 CPU 집약적인 작업(복잡한 계산, 암호화, 이미지 처리 등) 을 만나면 이야기가 달라진다. 이런 작업이 메인 스레드를 점유해버리면 이벤트 루프가 블로킹(Blocking)되어, 뒤따라오는 가벼운 요청들까지 처리되지 못하는 병목 현상이 발생하기 때문이다. 처음에는 이를 대처하기 위해 "단순히 PM2 클러스터 모드를 사용하면 되겠지"라고 생각했다. 실제로 PM2로 인스턴스를 늘려 서버를 실행해두곤 했는데, 운영하다 보니 내가 잘못 알고 있는 부분이 있었다.
PM2 클러스터 모드는 라운드 로빈(Round Robin) 방식에 따라 들어오는 요청을 순차적으로 각 프로세스에 분배한다. 문제는 로드 밸런서가 현재 '어떤 프로세스가 CPU 작업을 하느라 바쁜지' 까지는 정교하게 인지하지 못한다는 점이다. 그렇기에 특정 프로세스의 이벤트 루프가 막혀 있어도 그쪽으로 요청을 보낼 수 있다는 허점이 존재했다.
게다가 서버 비용을 아끼려고 Redis 같은 외부 캐시 저장소를 쓰지 않고 Node.js 내부 변수(메모리)를 활용했었는데, PM2로 띄운 프로세스 간에는 메모리가 공유되지 않는다는 맹점도 있었다. (결국 동기화 문제가 발생한다.)
이를 해결하기 위해 몇 가지 방법을 고민해 보았다.
- 알고리즘 최적화: 일단 복잡한 계산 로직 자체를 효율적으로 짜는 게 우선이고... ㅎㅎ(희망 사항)
Worker Threads 활용: 메인 스레드 대신 별도의 워커 스레드에서 복잡한 연산을 처리하는 방법이다.
하지만 Worker Threads 역시 만능은 아니었다. 요청이 폭증하면 결국 생성된 스레드들이 CPU와 메모리를 점유하게 되고, 메인 스레드와 워커 스레드 간에 데이터를 주고받을 때 발생하는 데이터 복사 비용(Serialization overhead) 도 무시할 수 없다. 근본적인 아키텍처 해결책이라기보다는 임시 방편에 가까웠다.
그래서 힌트를 얻고자 요즘 채용 시장과 개발 트렌드를 뒤져보았는데, Node.js 서버 + 메시징 시스템(RabbitMQ, AWS SQS) 조합이 자주 보였다. 이에 대해 며칠 공부해보니, 싱글 스레드의 한계를 극복할 열쇠가 여기에 있었다. 무거운 작업은 즉시 처리하지 않고 메시지 큐에 넣어두고, 별도의 Worker 프로세스가 이를 비동기로 가져가서 처리하는 구조(Producer-Consumer 패턴)라면 앞선 문제들을 대부분 커버할 수 있을 것 같았다. (물론 직접 적용해서 삽질을 해봐야 알겠지만...)
유저 트래픽이 적고 연산 작업량이 많지 않은 현재 나의 서버 상황에선, 메시지 큐까지 도입하는 건 Too much일 수 있다. 하지만 만약을 대비해서 기술적인 선택지를 미리 확보해두는 건 중요하다고 생각했다.
신입부터 사수 없이 혼자 회사의 백엔드를 맡아 Node.js 서버를 운영해온 지 어느덧 1년이 다 되어간다. 요즘 들어 절실히 느끼는 건 ‘과유불급’ 이다. 무작정 최신 기술을 덕지덕지 붙이는 게 아니라, 확장을 염두에 두되 현재 상황에 가장 최적화된 아키텍처를 구축하는 것이 진짜 실력, 즉 ‘찐또배기’ 가 아닐까 하는 생각이 든다. ㅎㅎ
최근 든 생각들을 정리해놓고 싶었다. 아직 배워야 할 것은 산더미지만, 급하게 먹지 말고 차근차근 해결해 나가보자.