[Node.js] 메인 스레드가 CPU bound 작업으로 인해 블록킹 되는 현상 해결

2025. 2. 18. 12:00카테고리 없음

728x90

개요

제가 진행하고 있는 프로젝트는 문서 시각화와 문서 실시간 동시 편집 기능을 제공하는 프로젝트입니다.

 

 

프로젝트를 진행하면서 문서가 많아질수록 원하는 문서를 찾기가 어려웠습니다.

 

또한 연관된 여러 개의 문서 정보를 확인하기 위해서 하나씩 클릭해서 문서 정보를 열람해야 하는 불편함이 있었습니다.

 

그래서 저희는 이 불편함을 해소하고자 LLM 기반 RAG 기능을 도입하기로 결정했습니다.

 

실제로 OpenAI API를 사용해서 문서를 벡터로 변환한 뒤 질문과 유사한 문서를 찾아서 LLM이 자연어를 생성하는 것까지 성공했으나 한 가지 문제점이 있었습니다.

 

바로 저희 프로젝트가 변경 사항이 많이 발생한다는 점이었습니다.

 

실시간 동시 편집을 지원하기 때문에 문서의 변경 사항이 많고 변경 사항이 발생할 때마다 새로운 벡터를 만들어 저장해야 합니다.

 

임베딩을 OpenAI API를 사용하다보니 문서가 변경될 때마다 OpenAI API를 호출하여 비용이 발생했습니다.

 

여러 명의 사용자가 여러 개의 문서를 동시 편집할 경우 OpenAI API 호출 횟수가 증가해 비용이 많이 발생할 것을 우려하여 저희는 로컬 임베딩 모델을 사용하기로 결정했습니다.

 

실제로 굉장히 긴 문서 100개 정도 로컬 임베딩을 적용해 본 결과 성능 저하가 크지 않았습니다.

 

하지만 상용 환경에서는 굉장히 오랜 시간이 걸렸는데요, 상용 원격 서버는 GPU가 존재하지 않는 서버를 사용했기 때문이었습니다. 

 

로컬에서는 GPU를 사용해 빠르게 임베딩을 진행했지만 원격 서버에서는 GPU가 없어 CPU가 임베딩을 수행했습니다.

 

여기서 가장 큰 문제는 저희 서버가 Node.js 기반 Nest.js 프레임워크를 사용하고 있었던 것이었습니다.

 

Node.js는 기본적으로 싱글 스레드로 동작하기 때문에 임베딩과 같은 CPU bound 작업에 매우 취약합니다.

https://growth-coder.tistory.com/305

 

[Node.js] Node.js 동작 원리 (node.js는 싱글 쓰레드일까? 멀티 쓰레드일까?)

Node.js가 싱글 쓰레드인지 멀티 쓰레드인지 알기 위해 Node.js가 자바스크립트 코드를 어떠한 방식으로 실행하는지 알아야 한다. 자바스크립트는 기본적으로 브라우저에서 실행하기 위한 언어이

growth-coder.tistory.com

 

일단 임베딩 함수가 call stack에 올라온 순간 해당 함수가 끝날 때까지 모든 작업이 blocking 됩니다.

 

Nest.js 백엔드 API 서버에서 임베딩을 수행했더니 임베딩이 모두 끝날 때까지 메인 스레드가 블록킹되어 API 요청을 받지 못 하는 현상이 발생했습니다.

 

아래는 CPU로 임베딩을 진행 중일 때 같은 API 요청을 보낸 결과입니다.

 

 

3430ms가 걸린 모습을 확인할 수 있습니다.

 

단순 health check API가 3초나 걸린 것은 애플리케이션 운영 측면에서 치명적입니다.

 

그래서 임베딩 작업을 메인 스레드와 분리하기로 결정했습니다.

멀티 스레드를 적용해보면 어떨까?

Node.js는 싱글 스레드로 동작하지만 멀티 스레드를 위한 라이브러리도 제공합니다.

 

임베딩을 메인 스레드가 아닌 다른 스레드를 만들어 그 안에서 실행한 결과를 받아오면 됩니다.

 

이 방법을 사용하면 메인 스레드와 별개의 스레드에서 임베딩이 진행되기 때문에 API 서버가 블록킹되는 현상은 발생하지 않습니다.

 

하지만 두 가지 이유 때문에 스레드를 하나 생성해서 임베딩을 진행하는 것이 아니라 프로세스를 하나 더 생성하기로 결정했습니다.

 

첫 번째, 임베딩의 실패는 전체 프로세스에 영향을 주지 않아야 합니다.

 

 

저희 프로젝트는 변경 사항이 발생할 때마다 변경 사항을 데이터베이스에 반영하지 않습니다.

 

변경 사항들을 먼저 redis에 저장해두었다가 스케줄러로 주기적으로 데이터베이스에 변경 사항을 반영합니다.

기존에는 API 서버 내부에서 스케줄러를 돌려서 주기적으로 변경 사항을 반영하고 있었습니다.

 

그런데 사실 스케줄러가 주기적으로 변경 사항을 반영하는 일은 상대적으로 중요도가 떨어집니다.

 

스케줄러가 멈추더라도 변경 사항이 일단 redis에 저장되기 때문에 다시 실행했을 때 변경 사항을 반영할 수 있습니다.

 

즉, 스케줄러가 CPU로 임베딩을 진행하는 도중 오류가 발생하더라도 API 서버는 살아있는 것이 좋습니다.

 

 하지만 임베딩 로직을 스레드로 분리하면 임베딩 실패가 전체 프로세스에 영향을 주어 API 서버도 함께 다운될 수 있습니다.

 

그에 비해 스케줄러 자체를 API 서버가 돌아가는 프로세스와 별도의 프로세스로 분리해버리면 임베딩 로직이 실패하더라도 스케줄러만 다운되지 API 서버가 다운되지는 않습니다.

 

두 번째, 멀티 프로세스 환경이 구축되어 있어서
스케줄러 로직을 별도의 프로세스로 분리하는 것이 상대적으로 쉽습니다.

 

저희 프로젝트는 이미 서버의 역할을 한 번 분리해서 API와 Websocket 서버가 별도의 프로세스로 분리되어 있습니다.

 

환경이 이미 구축되어 있기 때문에 프로젝트 하나를 추가해서 스케줄러 로직을 분리하면 docker-compose로 쉽게 별도의 container로 띄워서 프로세스를 분리하기가 쉬웠습니다.

 

이러한 이유 때문에 멀티 스레드 보다는 스케줄러 로직을 별도의 프로세스로 분리하는 것이 좋다고 판단했습니다.

 

적용 방식도 간단합니다.

 

Nest 서버를 하나 더 생성해서 API 서버의 스케줄러 로직을 그대로 가져온 다음 docker-compose에 서비스 하나를 더 추가하면 됩니다.

 

이제 여러 문서의 임베딩을 진행하는 도중에 API 요청을 보내도 메인 스레드 블록킹 이슈가 발생하지 않았고 응답 시간을 단축할수 있었습니다.

 

아래는 스케줄러 로직 분리 후 임베딩이 진행 중일 때 API 요청의 결과입니다.

 

수 ~ 수십ms로 시간이 매우 단축된 것을 확인할 수 있습니다.

728x90