Framework/NestJS

Nest에서 Cron 서비스가 갑자기 돌지 않는다면?

Joonfluence 2025. 9. 22.

1. 배경: 코드 구조 리팩토링과 QueryBuilder의 도입

NestJS로 백엔드 서비스를 운영하다 보면, 엔티티(Entity) 클래스가 점점 비대해지는 문제가 생깁니다.
예를 들어 특정 엔티티 안에 “날짜 범위 검색”, “OO별 필터링” 같은 쿼리 매핑 메서드를 static으로 붙여두면, 어느 순간 엔티티가 데이터 모델링과 쿼리 빌더 역할을 동시에 하게 됩니다.

이를 해결하기 위해 팀에선 전용 QueryBuilder 클래스를 도입했습니다.

// src/purchase/query-builders/Sample-query.builder.ts
@Injectable({ scope: Scope.REQUEST })
export class SampleQueryBuilder {
  private filters: FilterQuery<Sample> = {};

  withDateRange(dateType?: SampleSearchDateType, date?: [string, string]): this {
    if (!dateType || !date) return this;

    const startDate = startOfDay(new Date(date[0]));
    const endDate = endOfDay(new Date(date[1]));

    switch (dateType) {
      case PoSearchDateType.SAMPLE_EXPECTED_ARRIVE_AT:
        Object.assign(this.filters, {
          expectedArriveAt: { $gte: startDate, $lte: endDate },
        });
        break;
      case PoSearchDateType.SAMPLE_RESERVED_ARRIVE_AT:
        Object.assign(this.filters, {
          reservedArriveAt: { $gte: startDate, $lte: endDate },
        });
        break;
      case PoSearchDateType.SAMPLE_RECEIVED_AT:
        Object.assign(this.filters, {
          receivedAt: { $gte: startDate, $lte: endDate },
        });
        break;
    }
    return this;
  }
}

여기서 핵심은 @Injectable({ scope: Scope.REQUEST })입니다.
각 HTTP 요청마다 새로운 QueryBuilder 인스턴스를 생성하도록 하여 동시성 안전성을 보장하기 위함이었죠.

2. SampleService에서의 주입 방식

이제 이 빌더는 SampleService에 의존성 주입(DI)으로 연결됩니다.

@Injectable()
export class SampleService {
  constructor(
    @InjectRepository(Sample)
    private readonly SampleRepository: EntityRepository<Sample>,
    private readonly orm: MikroORM,
    private readonly googleSpreadSheetService: GoogleSpreadSheetService,
    @Inject(forwardRef(() => PurchaseOrderService))
    private readonly purchaseOrderService: PurchaseOrderService,
    private readonly paginationService: PaginationService,
    private readonly SampleQueryBuilder: SampleQueryBuilder,   // ← Request Scope 의존성
    private readonly SampleSortBuilder: SampleSortBuilder,     // ← 역시 Request Scope
  ) {}
}

HTTP 요청을 통해 SampleService를 사용할 때는 아무 문제가 없습니다.
요청마다 새로운 SampleQueryBuilder 인스턴스가 생성되고, 각 요청에 대해 독립적인 쿼리 조건을 안전하게 조립할 수 있습니다.

3. 문제의 발현: Cron이 갑자기 멈췄다

문제는 스케쥴러에서 터졌습니다. 

// src/tasks/tasks.service.ts
@Cron('0 */10 6-19 * * *')
@ProductionOnly()
async executeTaskPeriodically() {
  this.logger.info('주기적 Task 실행 시작');

  const em = this.taskRepository.getEntityManager().fork();
  const tasks = await em.find(Task, {
    scheduledAt: { $lte: new TZDate(new Date(), 'Asia/Seoul') },
    error: null,
    executedAt: null,
    finishedAt: null,
  });

  this.logger.info(`실행할 Task 개수: ${tasks.length}`);
  await this.executeTasks(tasks);
  await em.flush();
}

이 Cron은 SampleService를 간접적으로 참조합니다.
예를 들어 TaskType.WRITE_SAMPLE_SHEET를 실행할 때는 내부적으로 SampleService.writeSamplesToMigrationSheet()가 호출됩니다.
그리고 그 과정에서 SampleQueryBuilder가 의존성으로 함께 주입됩니다.

하지만 실행 시 로그에는 아래와 같은 경고가 뜹니다.

[Nest] WARN [Scheduler] Cannot register cron job "TasksService@executeTaskPeriodically" because it is defined in a non static provider.

그리고 실제로 Cron 작업이 등록되지 않아, 배치가 “조용히” 멈춰버렸습니다.

4. 원인 분석: Request Scope와 Request-less 환경의 충돌

이제 문제의 본질을 정리해 보겠습니다.

  • SampleQueryBuilderScope.REQUEST로 정의됨
  • SampleService는 이 빌더를 주입받음
  • TasksService의 Cron 실행 중 SampleService를 호출 → 결국 SampleQueryBuilder도 필요

하지만 Cron 실행은 HTTP 요청 없이 동작합니다.
Nest는 Cron 실행 시 Request Context를 생성하지 않기 때문에,
Scope.REQUEST로 선언된 의존성을 만들 수가 없습니다.

즉, 의존성 트리가 중간에서 끊겨 버리는 것입니다.

정리하자면

  1. HTTP 요청 → Request Scope 생성됨 → 빌더 정상 동작 ✅
  2. Cron 실행 → Request Scope 없음 → 빌더 생성 불가 ❌

5. MikroORM과의 유사한 맥락

사실 이 문제는 MikroORM의 EnsureRequestContext와도 비슷합니다. 
MikroORM도 기본적으로는 request context에 entity manager를 보관하는데, 
CLI나 Cron 같은 환경에서는 request context 자체가 없기 때문에 identity map이 깨집니다. 

NestJS의 Request Scope도 동일한 문제를 겪은 겁니다. 
즉, “Request Scope는 요청이 없으면 아예 생길 수 없는 것”입니다. 

6. 해결 방법

방법 1: Cron에서만 기본 스코프 사용

Cron이 참조하는 서비스 체인에서 Request Scope 의존성을 제거하는 방법입니다. 

@Injectable() // ← 기본 스코프로 변경
export class SampleQueryBuilder {
  ...
}

이 경우 Cron은 정상적으로 동작합니다.
하지만 이제 동시성 안전성은 개발자가 직접 보장해야 합니다.
(예: 내부적으로 상태를 갖지 않도록 주의)

방법 2: Provider 분리 전략

더 나은 방법은 Request Scope 빌더와 Cron-safe 빌더를 분리하는 것입니다. 

@Injectable({ scope: Scope.REQUEST })
export class SampleQueryBuilder {
  private filters: FilterQuery<Sample> = {};
  // 요청 단위로 안전한 쿼리 빌더
}

@Injectable()
export class SampleQueryBuilderStatic {
  buildWithDefaults(...) {
    return { ... }; // 상태 없이 바로 리턴
  }
}
  • HTTP 요청에서는 SampleQueryBuilder 사용
  • Cron/CLI 환경에서는 SampleQueryBuilderStatic 사용

이렇게 하면 동시성 안전성과 Cron 호환성을 모두 챙길 수 있습니다.

7. 언제 Request Scope를 피해야 할까?

실행 환경 Request Scope 사용 가능 여부
HTTP 요청 처리 (Controller/Service) ✅ 가능
GraphQL Resolver ✅ 가능
WebSocket 연결 ⚠️ 제한적 가능
Cron 작업 ❌ 불가
CLI 실행 ❌ 불가

즉, 항상 request context가 보장되는 환경에서만 Request Scope를 써야 한다는 점이 핵심입니다.

8. 마무리: 배운 것

쿼리 빌더를 도입하며 코드 품질은 좋아졌지만, Cron에서 배치가 돌지 않는 치명적인 문제가 발생했습니다.
문제를 추적해보니, 원인은 단순했습니다.

  • “Request Scope는 요청이 없는 곳에서는 존재하지 않는다.”

NestJS의 의존성 주입 스코프 개념을 깊이 이해하지 못한 채 적용했다가 겪은 실무적인 실패담이었습니다.

9. 참고자료

최종 요약

NestJS에서 Scope.REQUEST는 강력하지만, Cron이나 CLI처럼 요청이 없는 환경에서는 절대 사용하면 안 된다.
이런 환경에서는 반드시 기본 스코프나 별도의 provider를 설계해야 한다.

반응형

댓글