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 환경의 충돌
이제 문제의 본질을 정리해 보겠습니다.
SampleQueryBuilder
는Scope.REQUEST
로 정의됨SampleService
는 이 빌더를 주입받음TasksService
의 Cron 실행 중SampleService
를 호출 → 결국SampleQueryBuilder
도 필요
하지만 Cron 실행은 HTTP 요청 없이 동작합니다.
Nest는 Cron 실행 시 Request Context
를 생성하지 않기 때문에,Scope.REQUEST
로 선언된 의존성을 만들 수가 없습니다.
즉, 의존성 트리가 중간에서 끊겨 버리는 것입니다.
정리하자면
- HTTP 요청 → Request Scope 생성됨 → 빌더 정상 동작 ✅
- 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 GitHub 이슈 #9953 - Cannot register cron job ... because it is defined in a non static provider
- NestJS 공식 문서 - Custom Providers & Scopes
최종 요약
NestJS에서 Scope.REQUEST는 강력하지만, Cron이나 CLI처럼 요청이 없는 환경에서는 절대 사용하면 안 된다.
이런 환경에서는 반드시 기본 스코프나 별도의 provider를 설계해야 한다.
'Framework > NestJS' 카테고리의 다른 글
NestJS 완전정복: CLI 활용하기 (0) | 2025.05.26 |
---|---|
NestJS 완전정복: 미들웨어, 가드, 인터셉터, 파이프의 모든 것 (0) | 2025.05.26 |
NestJS 완전정복: MikroORM 사용하기 (0) | 2025.05.25 |
NestJS 완전정복: 모듈, 컨트롤러, 서비스의 모든 것 (0) | 2025.05.16 |
NestJS 톺아보기 : NestJS 개요 및 아키텍처 (0) | 2025.05.15 |
댓글