홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

NestJS 크론 작업을 Worker 프로세스로 분리하기 — Redis 없이 순수 프로세스 분리

정기창·2026년 4월 8일

블로그 백엔드를 NestJS로 운영하고 있습니다. API 서버와 크론 작업 7개가 하나의 프로세스에서 돌아가는 구조였는데, 크론이 점점 무거워지면서 이 구조가 신경 쓰이기 시작했습니다.

외부 API 데이터 수집, 웹사이트 모니터링 같은 크론은 매시간 AI API를 호출합니다. AI 호출은 CPU와 메모리를 꽤 잡아먹는 작업이고, 이게 API 서버와 같은 프로세스에서 돌아간다는 건 결국 사용자 요청에 영향을 줄 수 있다는 뜻입니다. 더 근본적인 문제는, 크론이 죽으면 API도 같이 죽는다는 것이었습니다.

BullMQ와 Redis를 도입하면 깔끔하게 해결할 수 있다는 건 알고 있었습니다. 하지만 개인 블로그 수준의 서비스에 Redis 인스턴스를 추가로 관리하는 건 과하다는 생각이 들었습니다. 크론 작업은 "정해진 시간에 실행"되는 것이지, API 요청에 반응하여 비동기로 처리하는 게 아니니까요. 프로세스만 분리하면 충분하지 않을까, 하는 판단이었습니다.

문제: API와 크론이 한 프로세스에서 실행

당시 돌아가고 있던 크론 목록입니다.

크론 주기 특성
트렌드 데이터 수집 매시간 AI API 호출
외부 사이트 모니터링 매시간 스크래핑 + AI 분석
검색 트렌드 분석 매일 외부 API
Daily Report 매일 Analytics + Slack
커뮤니티 다이제스트 매일 AI 요약
Blog Scheduler 10분마다 예약 발행
Swimming Monitor 비활성 스크래핑

이 중 트렌드 수집, 사이트 모니터링, 커뮤니티 다이제스트 세 개가 AI API를 호출하는 무거운 작업입니다. 특히 트렌드 수집과 모니터링은 매시간 실행되기 때문에, 이들이 동시에 돌아갈 때 API 응답 지연이 발생할 수 있는 구조였습니다.

NestJS 크론 분리 설계: 3가지 방법 비교

NestJS에서 API와 크론을 분리하는 방법을 세 가지로 검토했습니다.

방법 A: 각 서비스에 APP_MODE 체크

크론을 실행하는 각 서비스의 @Cron() 핸들러에서 APP_MODE를 확인하고 early return하는 방식입니다. 동작은 하지만, 크론이 추가될 때마다 같은 보일러플레이트를 넣어야 합니다. 모듈 자체가 여전히 로드되기 때문에 리소스 절약 효과도 제한적입니다.

방법 B: AppModule에서 조건부 import

AppModule의 imports 배열에서 APP_MODE에 따라 크론 모듈을 포함하거나 제외하는 방식입니다. 나쁘지 않지만, API와 Worker가 공유하는 인프라(DB 연결, AI 모듈 등)와 분리할 모듈이 AppModule에 뒤섞이면서 모듈이 복잡해집니다.

방법 C: SharedInfraModule + WorkerModule (채택)

공유 인프라를 SharedInfraModule로 추출하고, Worker 전용 진입점을 만드는 방식입니다. API는 기존 AppModule을 사용하고, Worker는 WorkerModule이 SharedInfraModule과 크론 모듈만 가져옵니다. 각자 필요한 것만 로드하기 때문에 가장 깔끔했습니다.

SharedInfraModule — 공유 인프라 추출

API와 Worker 모두 필요로 하는 인프라를 하나의 모듈로 묶었습니다.

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, ... }),
    ScheduleModule.forRoot(),
    MongooseModule.forRootAsync({ ... }),
    AIModule.forRootAsync({ isGlobal: true, ... }),
    NotificationModule.forRootAsync({ isGlobal: true, ... }),
    MonitoringModule,
    DrizzleModule,
  ],
})
export class SharedInfraModule {}

ThrottlerModule(Rate Limiting)은 여기에 포함하지 않았습니다. Rate Limiting은 HTTP 요청을 받는 API에만 필요한 것이지, 크론 작업에는 의미가 없기 때문입니다. 이런 식으로 "누가 이 모듈을 필요로 하는가"를 기준으로 나누는 게 핵심이었습니다.

APP_MODE 3모드 설계

환경변수 APP_MODE 하나로 동작 모드를 제어합니다.

APP_MODE API 서버 크론 5개 BlogPostScheduler
미설정 O O O
api O X O
worker X O X

APP_MODE를 설정하지 않으면 기존과 동일하게 모든 것이 한 프로세스에서 실행됩니다. 하위호환성을 유지하는 게 중요했습니다. 로컬 개발 환경에서는 굳이 프로세스를 나눌 이유가 없으니까요.

AppModule에서의 분기 처리는 이렇게 됩니다.

const appMode = process.env.APP_MODE;
const workerCronModules: Type[] = [
  DailyReportModule, HnDigestModule, WishketMonitorModule,
  NaverTrendModule, YoutubeTrendModule,
];

@Module({
  imports: [
    SharedInfraModule,
    ThrottlerModule.forRoot([...]),
    ...(appMode === 'api' ? [] : workerCronModules),
  ],
})
export class AppModule {}

appMode === 'api'일 때 크론 모듈을 아예 로드하지 않습니다. 조건부 실행이 아니라 조건부 로딩이라는 점이 중요합니다. 모듈이 로드되지 않으면 해당 서비스의 의존성도 주입되지 않으므로, 메모리 사용량도 줄어듭니다.

worker.ts — HTTP 없는 NestJS

Worker의 진입점은 main.ts가 아닌 별도의 worker.ts입니다. 핵심은 NestFactory.createApplicationContext입니다.

async function bootstrap() {
  const app = await NestFactory.createApplicationContext(WorkerModule);
  app.enableShutdownHooks();
  logger.log('Worker process running. Cron jobs active.');
}
bootstrap();

createApplicationContext는 NestJS의 DI 컨테이너와 모듈 시스템은 그대로 사용하되, HTTP 서버를 띄우지 않습니다. 크론 작업은 HTTP 요청을 받을 필요가 없으므로 이것으로 충분합니다.

한 가지 알아두어야 할 점이 있습니다. 크론 모듈에 컨트롤러가 선언되어 있더라도 createApplicationContext에서는 HTTP 라우트가 등록되지 않습니다. 모듈이 로드되고 DI는 일어나지만, 요청을 받을 서버가 없으니 컨트롤러는 사실상 무해합니다. 굳이 컨트롤러를 제거하는 리팩토링을 하지 않아도 된다는 뜻입니다.

enableShutdownHooks()는 SIGTERM/SIGINT 시 NestJS의 onModuleDestroy, onApplicationShutdown 라이프사이클 훅을 호출합니다. 컨테이너 환경에서 graceful shutdown을 위해 반드시 필요합니다.

BlogPostScheduler를 API에 남긴 이유

크론 7개 중 BlogPostScheduler만 API 서버에 남겼습니다. 모든 크론을 Worker로 보내는 게 깔끔해 보이지만, 이 경우는 분리하지 않는 편이 낫다는 판단이었습니다.

BlogPostScheduler는 예약된 블로그 글을 발행할 때 EmbeddingService를 호출합니다. EmbeddingService는 AI 임베딩 생성과 MongoDB Atlas의 vectorSearch 인덱스를 사용하는데, 이 의존성 체인이 꽤 깊습니다. Worker에서도 이 서비스를 쓰려면 관련 모듈을 모두 Worker에 import해야 하고, 그만큼 SharedInfraModule이 불필요하게 커집니다.

무엇보다 예약 발행은 하루 0~2건 수준입니다. 10분마다 실행되긴 하지만, 실제로 발행할 글이 없으면 DB 조회 한 번으로 끝나는 가벼운 작업입니다. 분리해서 얻는 이득보다 의존성 복잡도 증가가 더 큰 상황이었습니다.

모든 것을 일관되게 분리하고 싶은 욕구가 있었지만, 실용적으로 판단하는 게 중요하다는 생각이 들었습니다.

프로세스 분리 vs Redis 큐

이 글에서 다루는 "프로세스 분리"와 BullMQ 같은 "Redis 큐"는 해결하는 문제가 다릅니다.

구분 프로세스 분리 Redis 큐 (BullMQ)
트리거 시간 (cron) 이벤트 (API 요청 등)
프로세스 간 통신 없음 Redis를 통한 메시지 전달
추가 인프라 없음 Redis 필요
적합한 상황 정해진 시간에 독립 실행 요청 기반 비동기 처리

크론 작업은 API 서버와 통신할 필요가 없습니다. 정해진 시간에 알아서 실행되고, 결과를 DB에 저장하거나 Slack으로 알림을 보내면 끝입니다. 이런 작업에 Redis 큐를 도입하는 건 불필요한 복잡도를 추가하는 것이라고 판단했습니다.

Redis가 필요해지는 시점은 명확합니다. "사용자가 어떤 액션을 했을 때, 그 결과를 백그라운드에서 비동기로 처리해야 할 때"입니다. 예를 들어, 사용자가 글을 발행하면 관련 글 분석을 큐에 넣고 Worker가 처리하는 식입니다. 지금은 그런 요구사항이 없으니, 프로세스 분리만으로 충분했습니다.

배포: Dockerfile CMD만 바꾸기

배포 구성은 놀라울 정도로 단순합니다. Worker용 Dockerfile은 기존 API Dockerfile에서 CMD 한 줄만 바꾼 것입니다.

# API (기존)
CMD ["node", "packages/backend/dist/backend/src/main.js"]

# Worker (새로 추가)
CMD ["node", "packages/backend/dist/backend/src/worker.js"]

Worker는 HTTP 요청을 받지 않으므로 EXPOSE와 HEALTHCHECK가 필요 없습니다. 메모리도 API의 384~512MB보다 낮은 256MB로 설정했습니다.

배포 순서는 중요합니다. 반드시 API 먼저(크론 끔), Worker 다음(크론 킴) 순서로 배포해야 합니다. 반대로 하면 기존 API의 크론과 새 Worker의 크론이 동시에 실행되는 이중 실행 문제가 발생합니다. Coolify에서 순차 배포를 설정할 때 이 순서를 지켜야 합니다.

Worker 분리 후 결과 확인

배포 후 로그를 확인하면, NestJS 프로세스 분리가 제대로 이루어졌는지 바로 알 수 있습니다.

API 서버 로그에서는 크론 모듈이 로드되지 않은 것을 확인할 수 있고, Worker 로그에서는 크론만 활성화된 것을 볼 수 있습니다.

// Worker 로그
Worker process initialized. Cron jobs are active.
[YoutubeTrendCron] Next execution: ...
[WishketMonitorCron] Next execution: ...
[DailyReportCron] Next execution: ...

API 프로세스가 가벼워진 것은 체감할 수 있었습니다. 크론이 AI API를 호출하는 시간대에도 API 응답에 영향이 없어졌으니까요.

정리

돌이켜보면, 이번 작업에서 가장 중요했던 판단은 "Redis를 도입하지 않는 것"이었습니다. 문제의 본질은 API와 크론이 같은 프로세스에서 리소스를 공유한다는 것이었고, 그 해결책은 프로세스를 나누는 것이지 메시지 큐를 도입하는 것이 아니었습니다.

SharedInfraModule로 공유 인프라를 추출하고, createApplicationContext로 HTTP 없이 NestJS 컨텍스트를 부팅하고, Dockerfile의 CMD만 바꿔서 배포하면 됩니다. 새로운 인프라 없이, NestJS가 이미 제공하는 도구만으로 충분했습니다.

다만 이건 "크론 작업"이기 때문에 가능한 이야기입니다. API 요청에 의해 트리거되는 비동기 작업이 필요해지면, 그때는 Redis와 BullMQ를 도입하는 것이 맞을 것입니다. 중요한 것은 인프라를 추가하기 전에, 지금 가진 것만으로 문제를 해결할 수 있는지 먼저 따져보는 것이라는 생각이 들었습니다.

NestJSWorker ProcessCron JobcreateApplicationContext프로세스 분리SharedInfraModule백엔드 아키텍처

관련 글

NestJS Cron으로 Grafana Cloud 서버 메트릭을 Slack에 자동 보고하기

NestJS 스케줄러와 Grafana Cloud Prometheus API를 연결해 매일 아침 서버 상태를 Slack으로 자동 보고하는 시스템을 구축한 과정을 정리했습니다. 이미 갖춰진 인프라를 활용해 최소한의 코드로 일일 리포트를 만드는 방법입니다.

관련도 90%

NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)

이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.

관련도 89%

NestFactory.create()를 호출하면 무슨 일이 일어나는가 — NestJS 소스코드 추적 (1편)

NestJS로 서버를 만들 때마다 실행하는 NestFactory.create(AppModule) 한 줄. 이 한 줄이 내부에서 DI 컨테이너 생성, 모듈 스캔, 인스턴스 로딩, Express 바인딩까지 5단계를 거친다는 사실을 NestJS 소스코드를 직접 추적하며 확인합니다.

관련도 89%