BullMQ를 사용해 Node.js에서 느린 LLM 호출을 요청 경로에서 분리하는 방법
요약
LLM 호출처럼 시간이 오래 걸리는 작업을 HTTP 요청 경로에서 분리하는 방법을 설명합니다. Redis 기반의 BullMQ 큐를 사용하여 작업을 비동기 워커가 처리하고, 클라이언트는 SSE(Server-Sent Events)로 진행 상황을 받습니다. 이를 통해 타임아웃 문제와 재시도 중복 비용 문제를 해결할 수 있습니다.
핵심 포인트
- LLM 호출은 HTTP 핸들러 외부에서 분리해야 합니다.
- BullMQ 큐를 사용해 비동기 워커가 느린 작업을 처리합니다.
- SSE를 이용해 클라이언트에게 실시간 진행 상황을 전달합니다.
- 재시도, 속도 제한, 데드레터 처리를 체계적으로 관리할 수 있습니다.
LLM 호출이 20초 이상 걸릴 수 있다면, 이를 HTTP 핸들러 외부로 빼내야 합니다. Redis 기반의 BullMQ 큐에 작업을 넣고, 즉시 202 Accepted와 함께 작업 ID를 반환하며, 별도의 워커가 느린 호출을 처리하는 동안 클라이언트는 Server-Sent Events(SSE)를 통해 진행 상황 업데이트를 받게 해야 합니다.
이 게시물의 나머지 부분은 해당 설정을 위한 작동 코드를 담고 있습니다. 재시도와 백오프(backoff), 동시성 및 속도 제한, 데드레터 처리, 그리고 브라우저로의 진행 상황 전송을 다룹니다.
요청 경로가 잘못된 이유
인라인 LLM 호출은 개발 단계에서는 괜찮아 보입니다. 하지만 실제 트래픽 환경에서는 다음과 같은 문제들이 발생합니다:
- 타임아웃이 누적됩니다. 로드 밸런서, 리버스 프록시, 클라이언트 각각 고유의 유휴 시간 제한(idle timeout)을 가지고 있습니다. 긴 생성 과정은 이 중 하나를 초과할 수 있으며, 모델 호출은 성공했음에도 불구하고 사용자에게 오류가 표시됩니다.
- 재시도가 중복을 만듭니다. 요청이 타임아웃되면 브라우저나 사용자가 다시 시도합니다. 그러면 두 번의 생성을 비용 지불하게 되고, 두 개의 결과물을 작성할 수도 있습니다.
- 제공사 제한에 모두가 부딪힙니다. 트래픽 급증은 호출을 제공사에게 직접적으로 폭발적으로 보냅니다. 429 에러는 사용자들에게 실패로 돌아옵니다.
- 배포가 진행 중인 작업을 종료시킵니다. API 프로세스를 재시작하면 아직 실행 중이던 모든 생성이 사라집니다.
큐를 사용하면, API는 빠르게 유지되고 느린 부분은 자체적인 재시도 정책, 자체적인 동시성 제한, 그리고 자체적인 실패 처리를 갖게 됩니다.
구조
- 클라이언트가
POST /summaries를 전송합니다. API는 작업을 추가하고{ jobId }를 반환합니다. - 워커 프로세스가 작업을 가져와 모델을 호출하고 결과를 저장합니다.
- 클라이언트는
GET /summaries/:jobId/events를 열고 작업이 완료될 때까지 진행 상황을 받습니다. - 영구적으로 실패한 작업은 데드레터 큐(dead-letter queue)로 이동하여 누군가 검토할 수 있게 합니다.
패키지를 설치하세요:
npm install bullmq ioredis express
공유 큐 설정
// queue.js
import { Queue, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
...
Workers와 QueueEvents는 블로킹 Redis 명령을 사용합니다. 각각에 전용 연결을 할당하면 서로 방해하지 않습니다.
즉시 작업 추가 및 반환하기
// api.js
app.post('/summaries', async (req, res) => {
const { documentId } = req.body;
...
사용자 정의 jobId는 가장 저렴한 중복 제거 방법입니다. 사용자가 버튼을 세 번 클릭하더라도 첫 번째 작업이 아직 존재한다면 BullMQ는 동일한 ID를 가진 두 번째 작업을 추가하지 않습니다. 작업의 고유성을 결정하는 기준(예: 문서와 프롬프트 버전)을 기반으로 ID를 선택하세요.
attempts: 5 및 2초부터 시작하는 지수 백오프(exponential backoff)를 사용하면 재시도 간격이 대략 2초, 4초, 8초, 16초가 됩니다. 이 간격은 속도 제한을 받는 제공업체(rate-limited provider)가 복구할 시간을 벌어줍니다.
워커: 동시성 및 속도 제한
// worker.js
import { Worker, UnrecoverableError } from 'bullmq';
import { makeConnection, deadLetterQueue } from './queue.js';
...
이 두 가지 설정은 서로 다른 것을 제어합니다:
concurrency: 하나의 워커 프로세스가 동시에 실행하는 작업의 수입니다. 동시성(concurrency) 8인 워커 프로세스 3개를 사용하면 최대 24개의 모델 호출이 진행 중일 수 있습니다.limiter: 모든 워커에 걸쳐 전체 큐에 적용됩니다. 여기서는 분당 50개 작업만 허용합니다. 이 값을 제공업체의 할당량보다 약간 낮게 설정하여 키를 공유하는 다른 서비스들도 여유 공간을 갖도록 하세요.
만약 분류(classification)와 장문 생성(long-form generation)처럼 저렴한 작업과 비싼 작업을 섞어 사용한다면, 별도의 큐에 넣어주세요. 그렇지 않으면 느린 작업들이 슬롯을 모두 사용하여 빠른 작업들이 그 뒤에서 기다리게 됩니다.
적절한 오류만 재시도하기
잘못된 요청을 다섯 번 재시도하는 것은 돈을 낭비하고 피드백 속도를 늦춥니다. 모델을 호출하는 지점에서 오류를 두 그룹으로 분류하세요:
async function callModel(text, timeoutMs) {
try {
// llm.summarize는 제공업체 SDK 호출의 대체 예시입니다.
...
UnrecoverableError는 BullMQ에게 남은 시도들을 건너뛰고 작업을 즉시 실패하도록 지시합니다. 모든 시도에서 동일하게 실패할 것이 확실한 경우에는 이를 사용하세요.
모델 호출에는 항상 자체 타임아웃을 설정해야 합니다. 제공업체(provider)가 멈추고 아무것도 시간 초과되지 않으면, 해당 작업은 영원히 동시성 슬롯을 차지합니다.
데드레터 처리 (Dead-letter handling)
BullMQ는 내장된 데드레터 큐(dead-letter queue)를 가지고 있지는 않지만, 몇 줄의 코드를 추가하여 구현할 수 있습니다. 워커(worker)의 failed 이벤트는 모든 실패 시도마다 발생하므로, 이것이 마지막 시도였는지 확인해야 합니다:
worker.on('failed', async (job, err) => {
if (!job) return;
const outOfAttempts = job.attemptsMade >= (job.opts.attempts ?? 1);
...
별도의 데드레터 큐를 사용하면 알림(alert), 검사(inspect), 재실행(replay)을 할 수 있는 단일 위치가 생깁니다. 아무도 모든 큐의 실패 집합(failed set)을 검색할 필요가 없습니다.
한 가지 제한 사항이 있습니다: 이 핸들러는 워커 프로세스 내부에서 실행됩니다. 만약 최종 실패 직후에 프로세스가 충돌하면, 데드레터 쓰기 작업이 발생하지 않습니다. removeOnFail 때문에 해당 작업은 여전히 24시간 동안 실패 집합에 남아 있으므로, 예약된 스윕(sweep)을 통해 누락된 부분을 채울 수 있습니다.
재실행을 위해서는 데드레터 큐에서 읽어와 원인을 수정하고 원래 ID로 작업을 다시 추가하는 작은 관리 스크립트(admin script)를 추가하세요.
클라이언트로 진행 상황 전송하기 (Sending progress back to the client)
서버 전송 이벤트(Server-Sent Events, SSE)가 이 경우에 잘 작동합니다. 이는 일반 HTTP를 사용하며, 브라우저가 스스로 재연결하고, 데이터가 한 방향으로만 흐르도록 할 필요가 있습니다.
app.get('/summaries/:jobId/events', async (req, res) => {
const { jobId } = req.params;
res.writeHead(200, {
...
사람들은 종종 마지막 상태 확인(state check) 단계를 건너뜁니다. 빠른 작업은 POST와 브라우저가 스트림을 여는 순간 사이에 완료될 수 있습니다. 이 확인 과정이 없으면 클라이언트는 영원히 기다리게 됩니다.
done 이벤트는 결과 자체를 보내는 것이 아니라 URL을 보냅니다. 이렇게 하면 대용량 출력이 Redis 이벤트 페이로드에 포함되는 것을 막고, 클라이언트는 일반 인증(auth) 검사를 통해 데이터베이스에서 결과를 가져올 수 있습니다.
각 API 프로세스는 하나의 QueueEvents 인스턴스만 필요합니다. 많은 오픈 스트림은 많은 리스너를 추가할 수 있으므로, llmEvents.setMaxListeners(0)을 호출하거나 작업 ID로 키가 지정된 자체 조회 시스템을 통해 클라이언트에게 이벤트를 전송하세요.
프로덕션 체크리스트
- 배포 전에 진행 중인 작업이 완료되도록
await worker.close()를 사용하여SIGTERM을 처리합니다. - 워커를 API와 분리된 프로세스 또는 컨테이너에서 실행하고, 별도로 확장합니다.
- Redis 영속성을 활성화합니다. 이것 없이는 Redis 재시작 시 모든 대기열 작업이 손실됩니다.
- 오류 횟수뿐만 아니라 데드레터 큐 깊이와 가장 오래된 작업의 대기 시간에 대해 알림을 설정합니다.
saveSummary를멱등성(idempotent)하게 만듭니다. 부분 쓰기 후 재시도는 중복하는 것이 아니라 덮어쓰도록 해야 합니다.- 모든 모델 호출에 대해 작업 ID, 시도 횟수 및 토큰 사용량을 기록합니다.
대기열이 필요하지 않은 경우
단시간 분류(short classification)처럼 호출이 보통 몇 초 또는 몇 분 안에 완료되고, 사용자가 화면에서 기다리는 상황이라면 응답을 인라인 스트리밍하는 것이 더 간단합니다. 호출이 느리거나, 급증하거나, 반복 비용이 많이 들거나, 배포를 거쳐도 생존해야 할 때는 대기열을 사용하세요.
Geminate Solutions에서는 프로덕션 환경에서 분당 10M+ 요청을 처리한 시험 플랫폼을 포함하여 대기열 중심의 시스템을 구축했습니다. 대부분의 문제는 라이브러리 자체보다는 잘못된 오류를 재시도하거나 빠른 완료를 놓치는 것과 같은 위의 세부 사항들에서 발생했습니다.
Node.js 백엔드에 모델 호출을 추가하는 더 넓은 그림(provider 선택부터 스트리밍까지)은 Node.js AI 통합 가이드를 참고하세요. 이러한 작업이 라이브되면, 프로덕션에서 AI 에이전트 모니터링하기가 무엇을 관찰해야 하는지 설명합니다.
계속 진행하고 싶다면 전체 Node.js AI 통합 가이드가 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기