오래 걸리는 요청과 작업 큐 분리
REST API에서 파일 변환, 대량 보고서 생성, 이메일 발송처럼 오래 걸리는 일을 요청 안에서 끝내려 하면 문제가 생깁니다. 처리 시간이 길어질수록 HTTP 연결이 끊길 가능성이 커지고, 같은 요청이 재전송되면 비싼 작업이 중복 실행될 수 있습니다.
이 장에서는 하나의 보고서 생성 API를 네 절에 걸쳐 확장합니다. 이번 절에서는 HTTP 요청과 실제 작업을 분리할 기준을 세우고, NestJS 애플리케이션에 BullMQ와 Redis를 연결합니다.
동기 처리에서 분리해야 하는 신호
모든 작업을 큐로 보낼 필요는 없습니다. 짧은 조회나 즉시 확정해야 하는 결제 승인처럼 요청 안에서 결과가 필요한 일은 동기 처리가 자연스럽습니다. 반대로 다음 신호가 겹치면 백그라운드 작업을 검토합니다.
- 처리 시간이 HTTP 타임아웃에 가까워집니다.
- 순간적으로 요청이 몰리면 서버 CPU나 외부 API가 버티지 못합니다.
- 실패했을 때 일정한 간격으로 다시 시도해야 합니다.
- 작업 상태와 결과를 요청이 끝난 뒤에도 조회해야 합니다.
- API 서버와 작업 처리기의 개수를 서로 다르게 늘리고 싶습니다.
작업 큐를 사용하면 API는 작업을 접수하고, Worker는 나중에 작업을 실행합니다. 클라이언트에는 최종 결과 대신 작업 ID를 먼저 반환하고, 상태 조회 API로 진행 상황을 확인하게 합니다.
| 구분 | HTTP 요청 안에서 처리 | 작업 큐로 분리 |
|---|---|---|
| 응답 | 결과가 나올 때까지 기다림 | 작업 ID를 즉시 반환 |
| 실패 | 요청 실패로 바로 노출 | 재시도와 실패 보관 가능 |
| 확장 | API 인스턴스와 함께 확장 | Worker만 별도로 확장 가능 |
| 상태 | 연결이 끝나면 잃기 쉬움 | Redis에 작업 상태가 남음 |
큐가 보장하지 않는 것
큐를 도입했다고 업무 처리가 자동으로 정확해지는 것은 아닙니다.
- 정확히 한 번 실행은 기본 보장이 아닙니다. Worker가 외부 저장소에 기록한 직후 종료되면 같은 작업이 다시 실행될 수 있습니다.
- 작업 순서는 큐 옵션, 우선순위, Worker 수에 따라 달라질 수 있습니다.
- Redis가 실행 중이어도 데이터 보존 설정과 백업이 없으면 장애 후 작업을 잃을 수 있습니다.
- CPU를 오래 점유하는 코드를 같은 Node.js 프로세스에서 실행하면 lock 갱신이 막혀 stalled 이벤트가 발생하고 작업이 waiting 또는 failed로 이동할 수 있습니다.
따라서 큐는 실행 기회를 저장하는 장치이고, 실제 부수 효과의 중복 방지는 업무 코드에서 따로 설계해야 합니다. 이 문제는 3절에서 다룹니다.
실습 환경 준비
이 교재는 Nest의 공식 BullMQ 통합인 @nestjs/bullmq를 사용합니다. BullMQ는 Redis에 작업 데이터와 상태를 저장합니다.
Redis 실행
로컬에 Redis를 직접 설치하는 대신 Docker 컨테이너를 사용합니다. 볼륨과 AOF를 켜면 컨테이너를 다시 만들어도 학습 중인 작업 데이터를 유지할 수 있습니다.
docker volume create nest-queue-data
docker run --name nest-queue-redis \
-p 6379:6379 \
-v nest-queue-data:/data \
-d redis:7-alpine \
redis-server --appendonly yesWindows PowerShell에서는 줄바꿈 대신 한 줄로 실행해도 됩니다.
docker run --name nest-queue-redis -p 6379:6379 -v nest-queue-data:/data -d redis:7-alpine redis-server --appendonly yesRedis가 준비됐는지 확인합니다.
docker exec nest-queue-redis redis-cli ping정상 응답은 PONG입니다. Connection refused가 나오면 Nest 코드를 보기 전에 컨테이너 상태와 포트 매핑부터 확인합니다.
docker ps --filter name=nest-queue-redis
docker logs nest-queue-redis패키지 설치
npm install @nestjs/bullmq bullmq@nestjs/bullmq는 Nest 모듈과 데코레이터를 제공하고, bullmq는 Queue, Job 같은 핵심 타입과 실행 엔진을 제공합니다. 구형 @nestjs/bull과 bull 패키지를 섞지 않습니다.
Nest 모듈에 큐 연결하기
실습에서는 reports라는 큐 하나를 사용합니다. 큐 이름은 등록, 주입, Worker 연결에 모두 쓰이므로 문자열을 한곳에 고정합니다.
export const REPORT_QUEUE = 'reports';
export const GENERATE_REPORT_JOB = 'report.generate';기능 모듈에서 사용할 큐를 등록합니다.
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { REPORT_QUEUE } from './reports.constants';
@Module({
imports: [
BullModule.registerQueue({
name: REPORT_QUEUE,
}),
],
})
export class ReportsModule {}루트 모듈에서는 Redis 연결을 한 번 설정합니다. 포트 환경 변수는 문자열이므로 숫자로 변환합니다.
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ReportsModule } from './reports/reports.module';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
},
}),
ReportsModule,
],
})
export class AppModule {}forRoot()는 공통 Redis 연결을 만들고, registerQueue()는 이름이 reports인 큐를 현재 기능 모듈에 등록합니다. 둘 중 하나가 빠지면 이후 @InjectQueue(REPORT_QUEUE) 또는 @Processor(REPORT_QUEUE)가 같은 큐를 찾지 못합니다.
연결 확인
애플리케이션과 Redis를 함께 실행합니다.
npm run start:dev다른 터미널에서 Redis 연결 수를 확인합니다.
docker exec nest-queue-redis redis-cli CLIENT LISTNest를 실행한 뒤 BullMQ 연결이 보이면 기반 구성이 끝난 것입니다. 아직 Producer와 Worker를 만들지 않았으므로 작업은 생성되지 않습니다.
책임 경계를 먼저 고정하기
다음 절부터 코드를 추가할 때는 네 책임을 섞지 않습니다.
- Controller는 요청을 받고
202 Accepted와 작업 ID를 반환합니다. - Producer Service는 직렬화 가능한 작업 데이터와 옵션을 큐에 넣습니다.
- Redis는 waiting, active, completed, failed 같은 상태를 보관합니다.
- Worker는 실제 보고서를 만들고 진행률과 결과를 기록합니다.
자주 막히는 지점
| 증상 | 먼저 볼 곳 | 원인 |
|---|---|---|
ECONNREFUSED 127.0.0.1:6379 | Redis 컨테이너와 포트 | Redis가 꺼졌거나 주소가 다름 |
| 큐 주입 실패 | registerQueue()의 이름 | 상수와 등록 이름이 다름 |
| 앱 종료 뒤 작업이 사라짐 | Redis 볼륨과 AOF | 메모리 전용 Redis를 사용함 |
| 앱은 뜨지만 아무 일도 없음 | Producer와 Worker 등록 | 현재 절에서는 정상 상태 |
작업 큐를 도입하는 핵심 이유는 요청을 빨리 끝내기 위해서만이 아닙니다. 작업의 대기, 실행, 실패, 복구를 HTTP 연결과 분리하여 관찰 가능한 상태로 만드는 데 있습니다.
다음 절에서는 같은 reports 큐에 Producer와 Worker를 연결하고, 작업 ID로 상태와 진행률을 조회합니다.