Express 라우트 내부에서 이미지 크기 조절을 실행하는 것은 재앙을 초래하는 지름길입니다. 사용자가 10MB짜리 사진을 업로드하면, 서버는 픽셀을 계산하느라 바빠지고 30초 뒤에는 요청이 타임아웃됩니다. 백그라운드 작업 큐는 바로 이런 문제를 방지하기 위해 존재합니다. Node.js 생태계에서 Bull과 BullMQ는 Redis를 통해 비동기 작업을 처리하는 두 핵심 라이브러리로 자리 잡았습니다. 두 라이브러리는 뿌리는 같지만, 철학과 일상적인 사용 편의성 측면에서는 확연히 다릅니다. 나중에 교체하는 것이 단순한 패키지 업데이트만큼 쉽지 않기 때문에, 처음부터 올바른 것을 선택하는 것이 중요합니다.

The Shared Foundation

두 라이브러리 모두 Redis를 기반으로 작동합니다. Redis는 원자적 연산(atomic operations), 지연된 작업을 위한 정렬된 집합(sorted sets), 이벤트 처리를 위한 pub/sub을 담당합니다. 이미 캐싱이나 세션 관리를 위해 Redis를 사용 중이라면, 작업 큐를 추가하기 위해 새로운 인프라를 구축할 필요는 없습니다. Bull과 BullMQ 모두 우선순위, 지수 백오프(backoff)를 포함한 재시도, 동시성 제어, 반복 작업(repeatable jobs)을 지원합니다. 이러한 기능적 중복은 선택을 더 어렵게 만듭니다. 단순히 기능 목록을 비교하는 것만으로는 부족합니다. 대신, 각 라이브러리가 코드를 어떤 방식으로 구조화하기를 원하는지를 살펴봐야 합니다.

Bull: The Battle-Tested Veteran

Bull은 수년간 존재해 왔으며 수천 개의 프로덕션 애플리케이션에서 실행되고 있습니다. 검증된 라이브러리입니다. API는 모든 기능을 단일 Queue 인스턴스에 담고 있습니다. 동일한 객체에서 인스턴스를 생성하고, 처리 함수를 정의하며, 이벤트를 수신합니다. 이러한 모놀리식(monolithic) 설계는 이전의 Node.js 패턴에 익숙한 개발자에게 친숙하게 느껴질 수 있습니다. async/await가 널리 보급되기 전의 코드베이스는 Bull과 자연스럽게 어울립니다. Bull이 콜백과 초기 Redis 클라이언트와 함께 성장했기 때문입니다.

단점은 강한 결합(tight coupling)입니다. API 서버가 작업을 생성할 때, 워커(worker) 로직이 포함된 동일한 Queue 객체를 임포트하게 됩니다. 실제로 이는 웹 프로세스가 실행하지도 않을 의존성까지 끌어오게 된다는 것을 의미합니다. 치명적인 결함은 아니지만, 깔끔한 아키텍처 관점에서는 아쉬운 부분입니다. 단순한 작업 환경에서는 눈에 띄지 않을 수 있지만, 수십 개의 모듈을 가진 대규모 팀에서는 이러한 마찰이 쌓이게 됩니다.

BullMQ: A Ground-Up Rebuild

BullMQ는 공식 후속작입니다. 처음부터 TypeScript로 재작성되었기 때문에, 타입(type)이 JavaScript 소스에 나중에 덧붙여진 것이 아닙니다. API는 책임을 별도의 클래스로 분리합니다. Queue는 작업 추가를, Worker는 작업 처리를, QueueEvents는 관찰 가능성(observability)을 담당합니다. 이러한 분리는 현대적인 분산 시스템의 작동 방식을 그대로 반영합니다. API 포드(pod)에는 Queue 클래스와 Redis 연결만 필요합니다. 워커 포드에는 Worker 클래스를 임포트하면 됩니다. 경계가 개념적인 수준을 넘어 물리적으로 구분됩니다.

이러한 변화는 대규모 팀에서 빛을 발합니다. 새로운 기능을 배포하는 개발자는 프로세서가 어느 파일에 있는지 몰라도 작업을 큐에 넣을 수 있습니다. 컴파일러가 런타임이 아닌 초기 단계에서 작업 데이터와 핸들러 간의 타입 불일치를 잡아냅니다. 또한 async/await API가 현대적인 Node.js 환경에 자연스럽게 녹아들어 있어, 레거시 관습과 싸울 필요가 없습니다.

Job Flows: From Hacks to First-Class Citizens

다단계 워크플로우(multi-step workflows)는 두 라이브러리 사이의 가장 큰 차이를 보여줍니다.

이커머스 인보이스(invoicing) 파이프라인을 구축한다고 가정해 봅시다. 고객이 결제를 완료하면 재고를 예약하고, 카드를 결제하고, PDF를 생성하고, 이메일을 보내야 합니다. Bull을 사용하면 이러한 단계들을 체이닝(chaining)하기 위해 수동으로 관리해야 합니다. 하나의 프로세서가 다음 작업을 실행하면서 Redis를 통해 상태를 전달하거나 무거운 데이터 페이로드를 넘겨주는 방식을 사용해야 할 수도 있습니다. 부모-자식 간의 조정 로직을 직접 작성해야 하며, 이는 잘 작동하다가도 어느 순간 문제가 생깁니다. 특히 재시도(retry) 로직이 복잡해집니다. 만약 PDF 생성 단계에서 실패한다면, 이미 진행된 결제를 취소하기 위해 실수하기 쉬운 커스텀 보상 로직(compensation code)을 직접 작성해야 합니다.

BullMQ는 FlowProducer를 도입했습니다. 이를 통해 부모 작업이 자식 작업이 완료될 때까지 자동으로 기다리는 작업 트리(tree of jobs)를 정의할 수 있습니다. 인보이스 예시에서 finalize-order라는 루트 작업을 만들고, 그 아래에 reserve-inventory, charge-payment, generate-pdf라는 세 개의 자식 작업을 둡니다. 이메일 알림은 PDF 작업의 자식으로 설정할 수 있습니다. Redis는 이 그래프 구조를 저장하며, 부모 작업은 모든 의존성이 성공했을 때만 활성화됩니다. 자식 중 하나라도 실패하면 해당 브랜치 전체가 중단됩니다. 폴링 루프나 재귀적인 작업 생성기를 직접 작성할 필요가 없습니다. 이것은 단순한 문법적 설탕(syntactic sugar)이 아니라, 비즈니스 로직을 모델링하는 방식 자체를 바꾸는 것입니다.

Rate Limiting: Blunt Instrument vs. Scalpel

두 라이브러리 모두 처리량(throughput)을 조절할 수 있지만, 그 정밀도(granularity)에서 엄청난 차이가 납니다.

Bull은 큐당 처리율 제한(rate limit)을 적용합니다. 큐가 초당 100개의 작업을 처리하도록 설정하면, 그 상한선은 큐 내의 모든 작업에 동일하게 적용됩니다. 이는 균일한 워크로드에는 문제가 없습니다. 하지만 멀티테넌트 SaaS 플랫폼에서는 문제가 발생합니다. 한 명의 소음 유발 고객(noisy customer)이 공유 큐에 백만 개의 웹훅 전달을 쏟아붓는 상황을 상상해 보세요. Bull의 큐 레벨 제한 방식으로는 다른 모든 사용자를 느리게 만들지 않고 특정 테넌트의 속도만 늦출 수 없습니다. 선택지는 좋지 않습니다. 고객별로 별도의 Redis 큐를 생성하여 동적으로 관리하거나, 아니면 불공평함을 감수해야 합니다.

BullMQ는 그룹 기반 처리율 제한 기능을 추가했습니다. 각 작업에 일반적으로 테넌트 또는 사용자 ID인 그룹 키를 태그하고, 그룹별로 제한을 정의합니다. 동일한 큐가 모든 테넌트의 작업을 처리하지만, 스케줄러가 각 그룹을 독립적으로 조절합니다. 고객 A의 작업이 급증하더라도 고객 B의 작업이 방해받지 않습니다. 이를 통해 큐가 무분별하게 늘어나는 것을 방지하고 Redis 키스페이스를 깔끔하게 유지할 수 있습니다. 'noisy-neighbor' 문제가 우려되는 플랫폼이라면, 이 기능 하나만으로도 마이그레이션을 결정할 충분한 이유가 됩니다.

실무에서의 더 깔끔한 아키텍처

큐와 워커의 분리는 운영 환경에서 장애를 디버깅하기 전까지는 그 중요성이 미미하게 느껴질 수 있습니다. Bull을 사용할 때는 무거운 처리 의존성을 함께 임포트하는 라우트 핸들러 깊숙한 곳에 작업 생성 코드가 있는 경우가 흔합니다. BullMQ는 작업이 어디에서 수행될지를 결정하도록 강제합니다. 웹 서버는 가볍게 유지할 수 있습니다. 워커 컨테이너에 무거운 라이브러리, 이미지 프로세서 또는 headless browser를 포함시키면 됩니다. 메모리 누수가 발생하면 어떤 프로세스 유형을 프로파일링해야 할지 정확히 알 수 있습니다. 이 멘탈 모델은 Celery나 Sidekiq 같은 시스템과 더 유사합니다.

선택하기

새로 시작하는 프로젝트라면 BullMQ로 시작하세요. TypeScript 정의가 정확하고 완벽합니다. 작업 흐름(job flows) 기능을 통해 방대한 양의 오케스트레이션 코드를 제거할 수 있습니다. 그룹 처리율 제한은 공정성 문제가 발생하기 전에 이를 해결해 줍니다. async/await API는 매우 자연스럽습니다. 신규 프로젝트(greenfield project)를 위해 구형 라이브러리를 선택할 이유는 거의 없습니다.

이미 잘 작동하고 있다면 Bull을 그대로 사용하세요. 마이그레이션은 시간과 안정성 리스크를 수반합니다. 작업이 단순하고 독립적이라면, 실제로 필요한 기능을 놓치고 있는 것이 아닙니다. 비밀번호 재설정 이메일을 보내고 아바타 크기를 조정하는 큐에 플로우 그래프는 필요하지 않습니다. 이론적인 순수성을 위해 잘 작동하는 코드를 다시 쓰는 것은 엔지니어링이 아닙니다. 그것은 취미 활동일 뿐입니다.

마이그레이션 현실 점검

만약 전환하기로 했다면, 이를 코드 리팩터링이 아닌 인프라 변경으로 취급해야 합니다. Bull과 BullMQ는 서로 다른 Redis 키 스키마를 사용합니다. 서로의 작업 데이터나 상태를 읽을 수 없습니다. 피처 플래그를 켠다고 해서 기존 작업이 완료되기를 기대할 수는 없습니다. 기존의 모든 큐를 완전히 비운 다음, 새로운 워커를 배포하고 BullMQ로 작업을 큐에 넣기 시작해야 합니다. 기존 워커가 레거시 큐를 처리하는 동안 새로운 워커가 새 큐를 처리하는 점검 시간(maintenance window)이나 블루-그린 배포(blue-green deployment)를 계획하십시오.

결론