일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- 자청
- 개발자
- 일상속귀한배움
- 오브젝트
- 조영호
- serverless
- nodemailer
- futureself
- OOP
- 퓨처셀프
- PRISMA
- Validation
- 세이노의가르침
- 클린코드
- 객체지향의사실과오해
- BOOK
- Study
- Nestjs
- typescript
- googleapis
- 스터디
- AWS
- UNiQUE
- nodejs
- 독후감
- 역행자
- 북스터디
- 부자아빠가난한아빠2
- validator
- Object
- Today
- Total
우당탕탕 우리네 개발생활
[Nestjs] 간단한 이슈를 해결하려다 @Cron과 node-cron 오픈소스를 뜯어보게 되었습니다? 본문
최근 운영하고 있는 서비스에서 이슈를 발견했습니다.
@Injectable()
class TestProvider {
constructor() {}
@Cron('0 */10 * * * *')
async methodA(): Promise<void> {
// group에 대한 데이터 처리
// 하위 item에 대한 데이터 처리
// 3rd party API
}
@Cron('0 */10 * * * *')
async methodB(): Promise<void> {
// 하위 time에 대한 데이터 처리
// 3rd party API
}
}
이슈가 있는 서비스로직에서는 특정 기능과 관련된 서드파티 API를 호출해야 합니다.
이 서드파티 API를 트랜잭션에 포함하고 있는 2개의 서로 다른 cron job이 있습니다. 이 cron job들은 모두 10분 간격으로 동작합니다. 각 cron job들을 A와 B라고 칭하겠습니다.
A와 B는 서로 관련된 데이터를 다루고 있습니다.
A는 그룹에 대한 처리를 담당하는 데 그룹의 상태를 변경시킴에 따라 그 하위 아이템들의 상태도 변경하는 로직이 있습니다.
B는 하위 아이템들의 상태만을 변경하는 로직이 있습니다.
하필 위에서 언급한 서드파티 API는 하위 아이템들의 상태를 다룰 때 호출됩니다.
예상을 하셨을지 모르겠지만, A에서 그룹을 처리한 이후 처리하는 하위 아이템과 B에서 처리하는 하위 아이템 중 공통분모가 생길 때, 한 곳에서 먼저 서드파티 API를 통해 서드파티 쪽 서버에 데이터를 갱신하게 되고 다른 쪽에서 서드파티 API를 시간차로 같은 데이터로 호출하면서 에러가 발생했습니다.
이 문제에 대한 원인을 cron이 비동기 즉, 병렬로 동작하는데 그에 맞지 않게 트랜잭션을 분리하여 각각 cronJob으로 등록한 것이라고 생각하게 됐습니다. 그런데 불현듯 cron이 정말로 병렬로 동작하는지 궁금해졌습니다. 지금껏 cron을 유용하게 사용해 왔으면서도 내부적으로 어떻게 동작하고 있는지 모르고 있었습니다. 이에 따라 nestjs의 @Cron 분석부터 시작하여 node-cron 내 로직까지 분석을 하였습니다. 이틀 동안 재밌게 분석했던 내용을 공유하고자 합니다.
@nestjs/schedule
우선 @Cron이 어느 라이브러리에 있는지 확인을 해보니, @nestjs/schedule라이브러리에 있었습니다.
이를 npm사이트에서 검색했고, github오픈소스를 확인할 수 있었습니다.
// cron.decorator.ts
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { CronJobParams } from 'cron';
import { SchedulerType } from '../enums/scheduler-type.enum';
import {
SCHEDULE_CRON_OPTIONS,
SCHEDULER_NAME,
SCHEDULER_TYPE,
} from '../schedule.constants';
/**
* @ref https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/cron/index.d.ts
*/
export type CronOptions = {
/**
* Specify the name of your cron job. This will allow to inject your cron job reference through `@InjectCronRef`.
*/
name?: string;
/**
* Specify the timezone for the execution. This will modify the actual time relative to your timezone. If the timezone is invalid, an error is thrown. You can check all timezones available at [Moment Timezone Website](http://momentjs.com/timezone/). Probably don't use both ```timeZone``` and ```utcOffset``` together or weird things may happen.
*/
timeZone?: unknown;
/**
* This allows you to specify the offset of your timezone rather than using the ```timeZone``` param. Probably don't use both ```timeZone``` and ```utcOffset``` together or weird things may happen.
*/
utcOffset?: unknown;
/**
* If you have code that keeps the event loop running and want to stop the node process when that finishes regardless of the state of your cronjob, you can do so making use of this parameter. This is off by default and cron will run as if it needs to control the event loop. For more information take a look at [timers#timers_timeout_unref](https://nodejs.org/api/timers.html#timers_timeout_unref) from the NodeJS docs.
*/
unrefTimeout?: boolean;
/**
* This flag indicates whether the job will be executed at all.
* @default false
*/
disabled?: boolean;
} &
(
// make timeZone & utcOffset mutually exclusive
| {
timeZone?: string;
utcOffset?: never;
}
| {
timeZone?: never;
utcOffset?: number;
}
);
/**
* Creates a scheduled job.
* @param cronTime The time to fire off your job. This can be in the form of cron syntax, a JS ```Date``` object or a Luxon ```DateTime``` object.
* @param options Job execution options.
*/
export function Cron(
cronTime: CronJobParams['cronTime'],
options: CronOptions = {},
): MethodDecorator {
const name = options?.name;
return applyDecorators(
SetMetadata(SCHEDULE_CRON_OPTIONS, {
...options,
cronTime,
}),
SetMetadata(SCHEDULER_NAME, name),
SetMetadata(SCHEDULER_TYPE, SchedulerType.CRON),
);
}
가장 먼저 파일들을 둘러보면서 눈에 띄는 lib > decorator > cron.decorator.ts파일을 확인했습니다.
가장 아래에 Cron 함수가 export 되어있음을 보게 되었습니다.
applyDecorators? setMetaData?
위 두 가지 함수가 어떤 동작을 하는지 네이밍으로만 추측했을 뿐 실제로는 알지 못했습니다. 그래서 찾아봤습니다.
간략히 설명하면 applyDecorators 함수는 파라미터로 복수개의 decorator 함수들을 입력할 수 있고 이들을 합치는 역할을 했습니다. 그로 인해 setMetaData가 decorator 함수라는 사실을 유추하게 되었고, 실제로 알아보니 key와 value구조로 metadata를 저장하는 단순한 decorator함수였습니다.
setMetaData decorator 함수를 검색하여 데이터를 수집하던 중 아래와 같은 좋은 글을 발견했습니다(제가 nestjs 내 decorator에 대해 기본적으로 알아야 할 부분들을 여기서 손쉽게 이해할 수 있었습니다).
https://toss.tech/article/nestjs-custom-decorator
NestJS 환경에 맞는 Custom Decorator 만들기
NestJS에서 데코레이터를 만들기 위해서는 NestJS의 DI와 메타 프로그래밍 환경 등을 고려해야 합니다. 어떻게 하면 이러한 NestJS 환경에 맞는 데코레이터를 만들 수 있을지 고민해보았습니다.
toss.tech
때마침 cron decorator를 예로 들어주면서 이에 대한 분석을 '마킹 - 조회 - 등록'이라고 정리해 줬던 부분이 특히 도움이 되었습니다(자세한 내용은 꼭 위 링크를 참고해 주세요! 저와 같은 처지의 분들이시라면 도움이 많이 될 거예요).
Cron decorator 함수 코드에 대해서는 비교적 쉽게 이해할 수 있었습니다. 그러고 나니 그 위에 선언되어 있는 CronOptions type에도 눈길이 갔습니다. 다양한 멤버들이 있는데, 유독 disabled라는 멤버는 default값이 false인 것을 볼 수 있었습니다. 이를 우선 유심히 봐뒀습니다. 결론적으로 Cron decorator는 metadata를 저장하는 '마킹' 역할을 하고 있음을 알게 되었습니다.
ScheduleExplorer
위 토스 테크블로그의 내용을 기반으로 이제 '조회'를 하는 부분이 어딜까 찾으면서 lib 디렉터리 내 schedule.explorer.ts파일을 확인하게 되었습니다.
// schedule.explorer.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
import { SchedulerType } from './enums/scheduler-type.enum';
import { SchedulerMetadataAccessor } from './schedule-metadata.accessor';
import { SchedulerOrchestrator } from './scheduler.orchestrator';
@Injectable()
export class ScheduleExplorer implements OnModuleInit {
private readonly logger = new Logger('Scheduler');
constructor(
private readonly schedulerOrchestrator: SchedulerOrchestrator,
private readonly discoveryService: DiscoveryService,
private readonly metadataAccessor: SchedulerMetadataAccessor,
private readonly metadataScanner: MetadataScanner,
) {}
onModuleInit() {
this.explore();
}
explore() {
const instanceWrappers: InstanceWrapper[] = [
...this.discoveryService.getControllers(),
...this.discoveryService.getProviders(),
];
instanceWrappers.forEach((wrapper: InstanceWrapper) => {
const { instance } = wrapper;
if (!instance || !Object.getPrototypeOf(instance)) {
return;
}
const processMethod = (name: string) =>
wrapper.isDependencyTreeStatic()
? this.lookupSchedulers(instance, name)
: this.warnForNonStaticProviders(wrapper, instance, name);
// TODO(v4): remove this after dropping support for nestjs v9.3.2
if (!Reflect.has(this.metadataScanner, 'getAllMethodNames')) {
this.metadataScanner.scanFromPrototype(
instance,
Object.getPrototypeOf(instance),
processMethod,
);
return;
}
this.metadataScanner
.getAllMethodNames(Object.getPrototypeOf(instance))
.forEach(processMethod);
});
}
lookupSchedulers(instance: Record<string, Function>, key: string) {
const methodRef = instance[key];
const metadata = this.metadataAccessor.getSchedulerType(methodRef);
switch (metadata) {
case SchedulerType.CRON: {
const cronMetadata = this.metadataAccessor.getCronMetadata(methodRef);
const cronFn = this.wrapFunctionInTryCatchBlocks(methodRef, instance);
return this.schedulerOrchestrator.addCron(cronFn, cronMetadata!);
}
case SchedulerType.TIMEOUT: {
const timeoutMetadata = this.metadataAccessor.getTimeoutMetadata(
methodRef,
);
const name = this.metadataAccessor.getSchedulerName(methodRef);
const timeoutFn = this.wrapFunctionInTryCatchBlocks(
methodRef,
instance,
);
return this.schedulerOrchestrator.addTimeout(
timeoutFn,
timeoutMetadata!.timeout,
name,
);
}
case SchedulerType.INTERVAL: {
const intervalMetadata = this.metadataAccessor.getIntervalMetadata(
methodRef,
);
const name = this.metadataAccessor.getSchedulerName(methodRef);
const intervalFn = this.wrapFunctionInTryCatchBlocks(
methodRef,
instance,
);
return this.schedulerOrchestrator.addInterval(
intervalFn,
intervalMetadata!.timeout,
name,
);
}
}
}
warnForNonStaticProviders(
wrapper: InstanceWrapper<any>,
instance: Record<string, Function>,
key: string,
) {
const methodRef = instance[key];
const metadata = this.metadataAccessor.getSchedulerType(methodRef);
switch (metadata) {
case SchedulerType.CRON: {
this.logger.warn(
`Cannot register cron job "${wrapper.name}@${key}" because it is defined in a non static provider.`,
);
break;
}
case SchedulerType.TIMEOUT: {
this.logger.warn(
`Cannot register timeout "${wrapper.name}@${key}" because it is defined in a non static provider.`,
);
break;
}
case SchedulerType.INTERVAL: {
this.logger.warn(
`Cannot register interval "${wrapper.name}@${key}" because it is defined in a non static provider.`,
);
break;
}
}
}
private wrapFunctionInTryCatchBlocks(methodRef: Function, instance: object) {
return async (...args: unknown[]) => {
try {
await methodRef.call(instance, ...args);
} catch (error) {
this.logger.error(error);
}
};
}
}
가장 먼저 scheduleExplorer가 OnModuleInit이라는 인터페이스를 implements 하고 있는 것을 확인했습니다. 이는 지금 다루진 않고 조금 뒤에 다룰 예정입니다(굉장히 중요한 인터페이스이기 때문에 기억해 주세요).
쭉 코드를 확인하다 보니 instanceWrappers 배열을 선언하는 부분에서, controller와 provider라는 익숙한 단어들을 보게 되었습니다.
nestjs에서 관리하고 있는 자원들(controller, provider 등)을 탐색할 수 있는 기능을 제공하는 nestjs의 DiscoveryService를 이용하여 controller와 provider인스턴스들을 instanceWrappers배열에 spreading 하여 저장하는 것을 알 수 있었습니다.
그 후에 이 instanceWrappers 배열을 forEach를 통해 순회하면서 instance내에 있는 method를 찾는 부분이 눈에 띄었습니다.
@Injectable()
class TestProvider {
constructor() {}
@Cron('0 */10 * * * *')
async methodA(): Promise<void> {}
@Cron('0 */10 * * * *')
async methodB(): Promise<void> {}
}
제가 cron을 등록했던 대상은 위와 같이 @Injectable decorator 가 달려있는 특정 provider내 methodA와 methodB였습니다.
const processMethod = (name: string) =>
wrapper.isDependencyTreeStatic()
? this.lookupSchedulers(instance, name)
: this.warnForNonStaticProviders(wrapper, instance, name);
위에 있는 @Injectable decorator를 염두에 두면서 explorer코드로 돌아가 processMethod를 선언하는 부분을 보시면 wrapper.isDependencyTreeSet을 조건으로 하는 것으로 볼 수 있는데, 이는 Nestjs의 IoC Container 내 controller와 provider인스턴스들을 취급하는 방식과 관련이 있습니다.
https://docs.nestjs.com/fundamentals/injection-scopes
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea
docs.nestjs.com
저도 위 링크를 통해 공부하면서 알게 된 사실인데, Nestjs는 provider 인스턴스의 관리방법은 총 3가지로 되어 있습니다. 그중에서 가장 보편적인 Default가 싱글턴방식입니다. 싱글턴 방식은 인스턴스를 최초 한번 생성하고 이를 계속 재사용을 하는 방식입니다(추가적인 자세한 설명은 공식사이트를 참고해 주시길 바랍니다). 위에서 다뤘던 wrapper.isDepencyTreeSet이 true가 되는 옵션은 wrapper의 provider scope가 Default(싱글턴) 일 때입니다. 제 cron method들이 속하는 provider는 @Injectable() 내 다른 옵션을 특별히 설정하지 않았기 때문에 자연스럽게 Default scope에 속하게 되고 이에 따라 true값을 갖게 됩니다.
true값에 따라 isLookUpSchedulers 함수를 실행시키게 되었고 드디어 methodA와 methodB에 접근하게 되었습니다. 각 method에 해당되는 메타데이터를 accessor.getMetaData 함수를 통해 가져옵니다(@Cron을 통해 저장했던 메타데이터입니다). metaType이 Cron이기 때문에 switch에서 cron부분으로 빠지게 되고, 이 안에서 Cron 메타데이터와 Cron method의 구현코드 즉, method자체를 Orchestrator.addCron 함수에 입력하게 됩니다.
addCron이라는 명칭이 '등록'을 나타내는 것 같았습니다.
ScheduleOrchestrator
// schedule.orchestrator.ts
import {
Injectable,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { CronCallback, CronJob, CronJobParams } from 'cron';
import { v4 } from 'uuid';
import { CronOptions } from './decorators/cron.decorator';
import { SchedulerRegistry } from './scheduler.registry';
type TargetHost = { target: Function };
type TimeoutHost = { timeout: number };
type RefHost<T> = { ref?: T };
type CronOptionsHost = {
options: CronOptions & Record<'cronTime', CronJobParams['cronTime']>;
};
type IntervalOptions = TargetHost & TimeoutHost & RefHost<number>;
type TimeoutOptions = TargetHost & TimeoutHost & RefHost<number>;
type CronJobOptions = TargetHost & CronOptionsHost & RefHost<CronJob>;
@Injectable()
export class SchedulerOrchestrator
implements OnApplicationBootstrap, OnApplicationShutdown {
private readonly cronJobs: Record<string, CronJobOptions> = {};
private readonly timeouts: Record<string, TimeoutOptions> = {};
private readonly intervals: Record<string, IntervalOptions> = {};
constructor(private readonly schedulerRegistry: SchedulerRegistry) {}
onApplicationBootstrap() {
this.mountTimeouts();
this.mountIntervals();
this.mountCron();
}
onApplicationShutdown() {
this.clearTimeouts();
this.clearIntervals();
this.closeCronJobs();
}
mountIntervals() {
const intervalKeys = Object.keys(this.intervals);
intervalKeys.forEach((key) => {
const options = this.intervals[key];
const intervalRef = setInterval(options.target, options.timeout);
options.ref = intervalRef;
this.schedulerRegistry.addInterval(key, intervalRef);
});
}
mountTimeouts() {
const timeoutKeys = Object.keys(this.timeouts);
timeoutKeys.forEach((key) => {
const options = this.timeouts[key];
const timeoutRef = setTimeout(options.target, options.timeout);
options.ref = timeoutRef;
this.schedulerRegistry.addTimeout(key, timeoutRef);
});
}
mountCron() {
const cronKeys = Object.keys(this.cronJobs);
cronKeys.forEach((key) => {
const { options, target } = this.cronJobs[key];
const cronJob = CronJob.from({
...options,
onTick: target as CronCallback<null, false>,
start: !options.disabled
});
this.cronJobs[key].ref = cronJob;
this.schedulerRegistry.addCronJob(key, cronJob);
});
}
clearTimeouts() {
this.schedulerRegistry.getTimeouts().forEach((key) =>
this.schedulerRegistry.deleteTimeout(key),
);
}
clearIntervals() {
this.schedulerRegistry.getIntervals().forEach((key) =>
this.schedulerRegistry.deleteInterval(key),
);
}
closeCronJobs() {
Array.from(this.schedulerRegistry.getCronJobs().keys()).forEach((key) =>
this.schedulerRegistry.deleteCronJob(key),
);
}
addTimeout(methodRef: Function, timeout: number, name: string = v4()) {
this.timeouts[name] = {
target: methodRef,
timeout,
};
}
addInterval(methodRef: Function, timeout: number, name: string = v4()) {
this.intervals[name] = {
target: methodRef,
timeout,
};
}
addCron(
methodRef: Function,
options: CronOptions & Record<'cronTime', CronJobParams['cronTime']>,
) {
const name = options.name || v4();
this.cronJobs[name] = {
target: methodRef,
options,
};
}
}
ScheduleOrchestrator class 코드를 보자마자 OnApplicationBootstrap라는 인터페이스를 implements 하고 있음을 보게 되었습니다. 이는 위에서 중요하다고 했던 OnModuleInit 인터페이스와 함께 Nestjs의 애플리케이션 라이프사이클 순서에 중요한 부분을 차지하고 있습니다.
https://docs.nestjs.com/fundamentals/lifecycle-events
Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea
docs.nestjs.com
맨 처음 nest core가 모든 모듈들을 초기화하는 과정을 포함한 bootstraping 과정이 끝나면 OnModuleInit 인터페이스를 상속하고 있는 하위 controller 혹은 provider들의 OnModuleInit 함수가 동작하게 되고 이 과정이 끝나면 OnApplicationBootstrap 인터페이스를 상속하고 있는 하위 controller 혹은 provider들의 OnApplicationBootstrap 함수가 동작하게 됩니다.
이를 현재상황에 적용해 보면, onModuleInit 인터페이스를 상속받은 SchedulerExplorer 클래스 내 onModuleInit 함수가 실행됨에 따라 그 함수 내부에 있는 this.explorer 함수가 실행되면서 결국 Orchestrator.addCron 함수를 통해 Orchestrator의 영역으로 method들을 입력하게 되고, Orchestrator는 onApplicationBootstraping 함수로 인해 순서를 완전히 보장받으면서 cronJob들을 mount 하게 됩니다. 여기서 addCron 함수가 먼저 동작하고, mountCron 함수가 그 뒤에 동작하는 이유를 확인할 수 있겠습니다.
mountCron 함수를 살펴보게 되면 드디어 node-cron의 영역이 등장하게 됩니다. node-cron라이브러리에서 CronJob이라는 클래스의 정적함수인 from을 통해 cron인스턴스를 생성하게 됩니다. options를 스프레드로 입력하고 있고, onTick 변수와 start 변수는 특히 명시해서 입력하고 있습니다. start 변수에 대입하고 있는 값이 아까 전에 CronOptions를 메타데이터로 설정할 때 봤던 disabled값을 ! 조건으로 뒤집은 값입니다. disabled값이 false였으니 start값은 true겠네요. 이를 scheduleRegistry.addCronJob 함수에 입력하면서 ScheduleRegistry의 영역으로 넘기게 됩니다.
nestjs의 역할이 거의 다 끝났습니다.
ScheduleRegistry
// schedule.registry.ts
import { Injectable, Logger } from '@nestjs/common';
import { CronJob } from 'cron';
import { DUPLICATE_SCHEDULER, NO_SCHEDULER_FOUND } from './schedule.messages';
@Injectable()
export class SchedulerRegistry {
private readonly logger = new Logger(SchedulerRegistry.name);
private readonly cronJobs = new Map<string, CronJob>();
private readonly timeouts = new Map<string, any>();
private readonly intervals = new Map<string, any>();
doesExist(type: 'cron' | 'timeout' | 'interval', name: string) {
switch (type) {
case 'cron':
return this.cronJobs.has(name);
case 'interval':
return this.intervals.has(name);
case 'timeout':
return this.timeouts.has(name);
default:
return false;
}
}
getCronJob(name: string) {
const ref = this.cronJobs.get(name);
if (!ref) {
throw new Error(NO_SCHEDULER_FOUND('Cron Job', name));
}
return ref;
}
...
addCronJob(name: string, job: CronJob) {
const ref = this.cronJobs.get(name);
if (ref) {
throw new Error(DUPLICATE_SCHEDULER('Cron Job', name));
}
job.fireOnTick = this.wrapFunctionInTryCatchBlocks(job.fireOnTick, job);
this.cronJobs.set(name, job);
}
...
getCronJobs(): Map<string, CronJob> {
return this.cronJobs;
}
deleteCronJob(name: string) {
const cronJob = this.getCronJob(name);
cronJob.stop();
this.cronJobs.delete(name);
}
...
private wrapFunctionInTryCatchBlocks(methodRef: Function, instance: object): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]) => {
try {
await methodRef.call(instance, ...args);
} catch (error) {
this.logger.error(error);
}
};
}
}
scheduleRegistry에서 addCronJob 함수의 내부를 보면, method(methodA, methodB)를 trycatch로 한 겹 감싸주는 wrapFunctionInTryCatchBlocks라는 함수를 이용하여 method를 wrapping 한 후 cron의 fireOnTick이라는 필드로 대입을 시켜줍니다. fireOnTick은 사실상 method의 실행과 관련이 있겠구나 추측을 하면서 여기서 nestjs의 역할은 끝남을 확인했습니다. cron의 실질적인 동작은 node-cron을 통해 하게 될 것입니다.
node-cron
드디어 node-cron을 확인합니다. 이 라이브러리도 똑같이 npm공식사이트를 통해 오픈소스를 확인해 봅니다.
// cron.ts
import { spawn } from 'child_process';
import { CronError, ExclusiveParametersError } from './errors';
import { CronTime } from './time';
import {
CronCallback,
CronCommand,
CronContext,
CronJobParams,
CronOnCompleteCallback,
CronOnCompleteCommand,
WithOnComplete
} from './types/cron.types';
export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
cronTime: CronTime;
running = false;
unrefTimeout = false;
lastExecution: Date | null = null;
runOnce = false;
context: CronContext<C>;
onComplete?: WithOnComplete<OC> extends true
? CronOnCompleteCallback
: undefined;
private _timeout?: NodeJS.Timeout;
private _callbacks: CronCallback<C, WithOnComplete<OC>>[] = [];
constructor(
cronTime: CronJobParams<OC, C>['cronTime'],
onTick: CronJobParams<OC, C>['onTick'],
onComplete?: CronJobParams<OC, C>['onComplete'],
start?: CronJobParams<OC, C>['start'],
timeZone?: CronJobParams<OC, C>['timeZone'],
context?: CronJobParams<OC, C>['context'],
runOnInit?: CronJobParams<OC, C>['runOnInit'],
utcOffset?: null,
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
);
constructor(
cronTime: CronJobParams<OC, C>['cronTime'],
onTick: CronJobParams<OC, C>['onTick'],
onComplete?: CronJobParams<OC, C>['onComplete'],
start?: CronJobParams<OC, C>['start'],
timeZone?: null,
context?: CronJobParams<OC, C>['context'],
runOnInit?: CronJobParams<OC, C>['runOnInit'],
utcOffset?: CronJobParams<OC, C>['utcOffset'],
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
);
constructor(
cronTime: CronJobParams<OC, C>['cronTime'],
onTick: CronJobParams<OC, C>['onTick'],
onComplete?: CronJobParams<OC, C>['onComplete'],
start?: CronJobParams<OC, C>['start'],
timeZone?: CronJobParams<OC, C>['timeZone'],
context?: CronJobParams<OC, C>['context'],
runOnInit?: CronJobParams<OC, C>['runOnInit'],
utcOffset?: CronJobParams<OC, C>['utcOffset'],
unrefTimeout?: CronJobParams<OC, C>['unrefTimeout']
) {
this.context = (context ?? this) as CronContext<C>;
// runtime check for JS users
if (timeZone != null && utcOffset != null) {
throw new ExclusiveParametersError('timeZone', 'utcOffset');
}
if (timeZone != null) {
this.cronTime = new CronTime(cronTime, timeZone, null);
} else if (utcOffset != null) {
this.cronTime = new CronTime(cronTime, null, utcOffset);
} else {
this.cronTime = new CronTime(cronTime, timeZone, utcOffset);
}
if (unrefTimeout != null) {
this.unrefTimeout = unrefTimeout;
}
if (onComplete != null) {
// casting to the correct type since we just made sure that WithOnComplete<OC> = true
this.onComplete = this._fnWrap(
onComplete
) as WithOnComplete<OC> extends true ? CronOnCompleteCallback : undefined;
}
if (this.cronTime.realDate) {
this.runOnce = true;
}
this.addCallback(this._fnWrap(onTick));
if (runOnInit) {
this.lastExecution = new Date();
this.fireOnTick();
}
if (start) this.start();
}
static from<OC extends CronOnCompleteCommand | null = null, C = null>(
params: CronJobParams<OC, C>
) {
// runtime check for JS users
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (params.timeZone != null && params.utcOffset != null) {
throw new ExclusiveParametersError('timeZone', 'utcOffset');
}
if (params.timeZone != null) {
return new CronJob<OC, C>(
params.cronTime,
params.onTick,
params.onComplete,
params.start,
params.timeZone,
params.context,
params.runOnInit,
params.utcOffset,
params.unrefTimeout
);
} else if (params.utcOffset != null) {
return new CronJob<OC, C>(
params.cronTime,
params.onTick,
params.onComplete,
params.start,
null,
params.context,
params.runOnInit,
params.utcOffset,
params.unrefTimeout
);
} else {
return new CronJob<OC, C>(
params.cronTime,
params.onTick,
params.onComplete,
params.start,
params.timeZone,
params.context,
params.runOnInit,
params.utcOffset,
params.unrefTimeout
);
}
}
...
}
src디렉토리를 보니 cron이 보이진 않고 job.ts가 보입니다. 이를 확인해 봤습니다.
CronJob 클래스가 등장했습니다. 그리고 앞서봤던 from 함수도 보이네요. static 함수인 from을 이용하여 인스턴스를 생성한 후 return 합니다. 또한 생성자들이 오버로딩으로 여러 개 선언되어 있는 것도 보입니다. 생성자 코드의 구현부를 확인했습니다. 쭉 코드를 보면서 다른 부분들은 제쳐두고 익숙한 onTick과 start를 발견했습니다. onTick은 methodA와 methodB를 대입했던 프로퍼티였는데 이를 this.callback에 push 하고 있습니다. 그 이후 start가 true값임에 따라 true 조건에 해당되는 start 함수를 호출했습니다.
// job.ts
start() {
if (this.running) {
return;
}
const MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait.
let timeout = this.cronTime.getTimeout();
let remaining = 0;
let startTime: number;
const setCronTimeout = (t: number) => {
startTime = Date.now();
this._timeout = setTimeout(callbackWrapper, t);
if (this.unrefTimeout && typeof this._timeout.unref === 'function') {
this._timeout.unref();
}
};
// The callback wrapper checks if it needs to sleep another period or not
// and does the real callback logic when it's time.
const callbackWrapper = () => {
const diff = startTime + timeout - Date.now();
if (diff > 0) {
let newTimeout = this.cronTime.getTimeout();
if (newTimeout > diff) {
newTimeout = diff;
}
remaining += newTimeout;
}
// If there is sleep time remaining, calculate how long and go to sleep
// again. This processing might make us miss the deadline by a few ms
// times the number of sleep sessions. Given a MAXDELAY of almost a
// month, this should be no issue.
if (remaining) {
if (remaining > MAXDELAY) {
remaining -= MAXDELAY;
timeout = MAXDELAY;
} else {
timeout = remaining;
remaining = 0;
}
setCronTimeout(timeout);
} else {
// We have arrived at the correct point in time.
this.lastExecution = new Date();
this.running = false;
// start before calling back so the callbacks have the ability to stop the cron job
if (!this.runOnce) {
this.start();
}
this.fireOnTick();
}
};
if (timeout >= 0) {
this.running = true;
// Don't try to sleep more than MAXDELAY ms at a time.
if (timeout > MAXDELAY) {
remaining = timeout - MAXDELAY;
timeout = MAXDELAY;
}
setCronTimeout(timeout);
} else {
this.stop();
}
}
start 함수 내부를 살펴봤습니다. 우선 변수들을 선언해 뒀고, 내부 함수 scope에서만 사용할 수 있는 함수들(setCronTimeout, callbackWrapper)도 선언해 뒀습니다. 실질적으로 동작하는 부분을 확인해 보니, timeout 변수의 값이 존재하느냐에 따라 위에서 선언해 둔 함수를 호출함을 볼 수 있습니다. 그 함수를 확인해 보기로 했습니다. setCronTimeout이라는 함수의 내부를 보니 setTimeout(callbackFunction, timeout)이라는 js 내장함수를 사용하여 timeout 파라미터에 입력한 시간 이후에 아래 선언되어 있는 callbackWrapper라는 함수를 호출하고 있었습니다. 이 부분을 확인하면서 최종 목표에 거의 다 도달한 것 같았습니다. setTimeout이라는 내장함수가 결국 원하는 시간 뒤에 비동기로 callback 함수를 호출하는 기능을 하고 이게 cron의 메인 동작이라고 생각했기 때문입니다.
// job.ts
// The callback wrapper checks if it needs to sleep another period or not
// and does the real callback logic when it's time.
const callbackWrapper = () => {
const diff = startTime + timeout - Date.now();
if (diff > 0) {
let newTimeout = this.cronTime.getTimeout();
if (newTimeout > diff) {
newTimeout = diff;
}
remaining += newTimeout;
}
// If there is sleep time remaining, calculate how long and go to sleep
// again. This processing might make us miss the deadline by a few ms
// times the number of sleep sessions. Given a MAXDELAY of almost a
// month, this should be no issue.
if (remaining) {
if (remaining > MAXDELAY) {
remaining -= MAXDELAY;
timeout = MAXDELAY;
} else {
timeout = remaining;
remaining = 0;
}
setCronTimeout(timeout);
} else {
// We have arrived at the correct point in time.
this.lastExecution = new Date();
this.running = false;
// start before calling back so the callbacks have the ability to stop the cron job
if (!this.runOnce) {
this.start();
}
this.fireOnTick();
}
};
그다음엔 callbackWrapper함수의 내부를 봤습니다.
const diff = startTime + timeout - Date.now();
diff라는 변수는 규칙적인 시간차로 동작하는 cron이라면 양수가 되긴 힘들겠다고 생각했습니다. startTime변수는 start 함수가 호출되자마자 timestamp값이 대입되었고 timeout변수는 우리가 @Cron에서 설정해 둔 시간동작규칙에 맞게 cronTime이라는 클래스의 내부 로직을 통해 생성된 delay number입니다. 이 두 변수의 데이터는 정적인 데이터입니다. 여기에 현재 시간을 Date.now()로 하여 수식에 대입했음을 볼 수 있었습니다.
여기서 주목할 부분은 setTimeout이 엄격하게 시간을 지키며 동작하는 함수가 아니라는 사실입니다(자세한 건 이벤트루프에 대해 검색해 보시면 확인하실 수 있을 거예요!).
이에 따라 setTimeOut함수가 아주 엄격하게 동작하더라도 diff는 0일 것이고, 조금이라도 delay시간이 오버된다면 수식의 결과가 음수가 나올 수밖에 없습니다. 저는 methodA와 methodB를 규칙적인 10분 간격으로 사용하고 있었기 때문에 diff의 값이 음수로 나올 것이라고 생각했습니다. 그렇게 따졌을 때, timeout 값에 대한 if 조건을 패스하고, 아래에 있는 remaining 값에 대한 조건도 else로 넘어가게 되었습니다. 다음에 나오는 this.runOnce 프로퍼티는 초기값이 false이기 때문에 조건을 만족하여 this.start 함수가 재호출 되었습니다. 그리고 마지막으로 this.fireOnTick 함수를 호출했습니다.
fireOnTic k함수는 this.callback 배열에 push 해둔 callback함수(methodA 또는 methodB / 다들 이해하시겠지만, methodA와 methodB는 현재 같은 callback안에 push 되어 있지 않습니다. 앞에서 cronJob인스턴스자체가 이미 다르게 생성되어 있는 상황입니다.)를 callback.call 함수를 통해 동작시킵니다. 이렇게 되면 다시 start함수에서 setCronTimeout함수로 인해 다음 callbackWrapper함수가 등록되고 시간이 되었을 때 실행되는 상황이 반복되게 됩니다. 시간에 따라 반복되는 우리가 알고 있는 cron의 동작모양입니다.
드디어 제가 궁극적으로 확인하고자 했던 cron은 비동기적으로 동작하는 가에 대한 질문에 답을 할 수 있게 되었습니다.
결론적으로 cron은 비동기적으로 동작합니다.
정확히 하고자 하는 말은 methodA와 methodB의 실행순서가 정해질 순 있지만, methodA가 다 끝날 때까지 methodB가 동작하지 않는 게 아닌 비동기적으로 두 method의 동작타이밍이 맞물릴 수 있다는 것입니다. 가장 주요했던 것은 setTimeout의 사용입니다. setTimeout자체가 비동기 함수이기 때문에 결론적으로 cron은 병렬로 동작한다고 말할 수 있게 됐습니다.
후기
내용을 정리하다 보니 굉장히 길어졌습니다. 이틀을 수시로 고민하며 내용을 이해했더니 글이 길지만 정리된 선에서 술술 작성할 수 있었습니다. 아직 부족한 개념이 많아 정리한 내용 중 틀린 내용들을 분명 있을 거라고 생각합니다. 우선 전체적인 맥락과 여러 요소들이 이 글을 보시는 분들에게 도움이 되었으면 좋겠고, 문제가 되는 부분들은 지적해 주시면 감사한 마음으로 고치고 배우겠습니다.
참조
- https://github.com/nestjs/schedule/blob/master/lib/scheduler.orchestrator.ts
- https://docs.nestjs.com/techniques/task-scheduling
- https://zuminternet.github.io/nestjs-custom-decorator/
- https://toss.tech/article/nestjs-custom-decorator
- https://medium.com/jspoint/what-are-internal-slots-and-internal-methods-in-javascript-f2f0f6b38de
- https://www.typescriptlang.org/ko/docs/handbook/decorators.html
- https://kr.devitworld.com/nestjs-tutorial-5-decorator/
- https://jeonghwan-kim.github.io/2023/06/20/reflect-metadata
- https://docs.nestjs.com/fundamentals/injection-scopes
- https://docs.nestjs.com/fundamentals/lifecycle-events
- https://developer.mozilla.org/ko/docs/Web/API/setTimeout
- https://nodejs.org/api/timers.html#timeoutunref
- https://www.tutorialspoint.com/node-js-timeout-ref-and-timeout-unref-method
'tech' 카테고리의 다른 글
[JS] 객체 내 property를 동적으로 만들 수 있다? (0) | 2024.03.28 |
---|---|
[Prisma] update와 updateMany의 where option은 다르다 (0) | 2024.03.09 |
[Slack] Console에서 Incoming Webhook 생성해보기 (1) | 2024.02.24 |
[Nestjs] ! 과 ? 그리고 class-validator에 대한 고찰 (0) | 2024.02.23 |
[postgresql] pg_bigm extension적용하기(feat. MacOS) (0) | 2023.12.25 |