이 게시글은 Spring에만 존재하는 @SchedulerLock 애노테이션을 NestJS의 데코레이터로 만들어 드립니다.
사실, 작업스케줄링 뿐 아니라 하나의 함수도 다중 인스턴스 간에 단독 실행을 보장합니다. (a.k.a @RedisLock)
NestJS는 ScheduleModule을 통해 작업스케줄링을 지원합니다.
이는 간편하게 데코레이터를 활용해서 스케줄링을 할 수 있습니다.
좋은 점은 CronExpression enum을 통해 Cron Pattern이 헷갈려서 검색할 필요가 없습니다. 메소드 데코레이터를 통해 간편하게 스케줄링을 등록합니다.
https://github.com/nestjs/schedule/blob/master/lib/enums/cron-expression.enum.ts
하지만, 저는 실무에서 일괄처리(Batch)를 구성할 때 채택하지 않았었습니다.
작업이 서버 단일인스턴스 내부에 등록되기 때문입니다.
@Cron을 등록한 NestJS로 구축한 서버 인스턴스를 여러 대 가동(Scale Out)할 때,
작업이 노드별로 각자 같은 시각에 실행됩니다.
(코드가 같아서 Cron Pattern도 같으니까요)
일괄처리(Batch)는 보통 많은 양의 데이터를 다룰 일이 많아 동시실행하게 되면 DB 부하가 클 수 있습니다.
당연히 같은 작업이 동시에 여러개 돌아가는 리소스 낭비도 원하지 않습니다.
Spring 진영에서는 ShedLock 라이브러리의 @SchedulerLock이라는 애노테이션이 존재하여, 작업의 1회실행을 보장해줄 수 있었습니다.
이 애노테이션을 NestJS의 데코레이터로 구현하기 위해서
다중 인스턴스간의 소통을 위해서는 공유 메모리 성격의 Redis를 사용하는 것이 가장 적절하다고 생각했습니다. (ShedLock 에서도 Redis를 사용하는 것 같았습니다)
이러한 기능을 데코레이터로 만들어서 팀내에서 편리하게 사용가능하도록 구현해낸 경험을 공유합니다.
@SchedulerLock vs @RedisLock
SchedulerLock을 의도하고 개발했으나, 이 데코레이터의 기능은 작업스케줄링에 국한되지 않기 때문에 일단 RedisLock으로 명명했습니다.
일전에 함수의 입출력을 Redis에 캐싱하는 데코레이터를 구현한 바 있어, 금방 구현할 수 있었습니다.
먼저 메타데이터를 등록하는 데코레이터를 만듭니다.
export interface RedisLockDecoratorOptions {
duration: number
}
export const REDIS_LOCK_META = Symbol('REDIS_LOCK_META')
export function RedisLock(options: RedisLockDecoratorOptions): MethodDecorator {
return applyDecorators(SetMetadata(REDIS_LOCK_META, options))
}
Redis로 Lock을 무한히 잡을 수 없으니 (함수가 중간에 실패하던지) ttl 로 사용할 옵션을 받도록 합니다.
RedisLock Dynamic Module을 구현합니다
export interface RedisLockServiceOptions extends RedisOptions {
logger: LoggerService
}
export interface RedisLockModuleOptions
extends Pick<ModuleMetadata, 'imports'> {
isGlobal: boolean
useFactory: (...args: any[]) => Promise<RedisLockServiceOptions>
inject: any[]
}
export interface RedisLockDecoratorOptions {
duration: number
}
@Module({})
export class RedisLockModule implements OnModuleInit {
constructor(
private readonly loggerService: LoggerService,
private readonly scanner: MetadataScanner,
private readonly reflector: Reflector,
private readonly discoveryService: DiscoveryService,
private readonly redisLockService: RedisLockService,
@Inject(REDIS_LOCK_OPTIONS)
private readonly redisLockServiceOptions: RedisLockServiceOptions
) {
this.loggerService = redisLockServiceOptions.logger
}
static forRootAsync(options: RedisLockModuleOptions): DynamicModule {
return {
global: options.isGlobal,
module: RedisLockModule,
imports: [DiscoveryModule],
providers: [
RedisLockService,
{
provide: REDIS_LOCK_OPTIONS,
useFactory: async (...args: any[]) => {
return await options.useFactory(...args)
},
inject: options.inject
}
],
exports: [RedisLockService]
}
}
onModuleInit(): any {
this.registerAllSchedule()
}
registerRedisLock(instance: any) {
return methodName => {
const methodRef = instance[methodName]
const metadata: RedisLockDecoratorOptions = this.reflector.get(
REDIS_LOCK_META,
methodRef
)
if (!metadata) return
const { duration } = metadata
const originMethod = (...args: unknown[]) =>
methodRef.call(instance, ...args)
const wrapper = async (...args: unknown[]) => {
try {
const redisKey = `lock:${instance.constructor.name}:${methodName}`
const lock = await this.redisLockService.acquireLock(
redisKey,
duration
)
try {
await originMethod(...args)
} catch (e) {
await lock.release()
}
} catch (err) {
this.loggerService.info('RedisLockAcquire Fail', { err })
}
}
Object.setPrototypeOf(wrapper, methodRef)
instance[methodName] = wrapper
}
}
private registerAllSchedule() {
const instanceWrappers: InstanceWrapper[] = [
...this.discoveryService.getControllers(),
...this.discoveryService.getProviders()
]
.filter(wrapper => wrapper.isDependencyTreeStatic())
.filter(
({ instance }) => Boolean(instance) && Object.getPrototypeOf(instance)
)
instanceWrappers.forEach(({ instance }) => {
this.scanner.scanFromPrototype(
instance,
Object.getPrototypeOf(instance),
this.registerRedisLock(instance)
)
})
}
}
참고) https://docs.nestjs.com/fundamentals/dynamic-modules
참고) https://zuminternet.github.io/nestjs-custom-decorator/
기본적으로 lock의 key에는 methodName이 포함되도록 했습니다.
RedisLock을 사용할 모듈에서 해당 모듈을 import하면 됩니다.
이제 redis-lock.service의 acquireLock 메소드 내부구현만 해내면 됩니다.
duration옵션은 ttl로 활용하면 되고
구현에는 두 가지 선택지가 있습니다.
메소드 시작 전 Redis에서 메소드이름으로 된 lock 이 있는지 확인한다.
없다면 메소드이름으로 만들어진 key 를 set 한다.
간단한 구현방법이고, Redis는 싱글 스레드이기에 문제도 없을 것입니다.
저는 Redlock 라이브러리를 사용해서 lock 을 획득하도록 구성했습니다.
후에 서버가 확장되었을 때, 이 데코레이터의 가용성을 위해서 였습니다.
단, Redlock 알고리즘을 명확히 이해하고 옵션을 적절히 설정해야 합니다. (retryDelay, retryJitter 등)
async acquireLock(key: string, duration: number) {
return await this.redlock.acquire([key], duration)
}
@Cron('0 * * * *')
@RedisLock({
duration: 60 * 1000 * 50
})
async batchJob() {
console.log('hi')
}
이제 다중 인스턴스 구동시에도 단독실행이 보장되었습니다 !
한계는 명확합니다
이 데코레이터를 사용할 모듈에서 import 순서를 주의해야합니다.
@Module({
imports: [
ScheduleModule.forRoot(),
RedisLockModule.forRootAsync({...})
],
})
export class AppModule {}
위의 순서대로 import 할 경우 메소드에 @Cron 데코레이터가 먼저 wrapping 됩니다.
NestJS의 동적 모듈은 imports 될 때 초기화됩니다.
@Cron이 먼저 wrapping되고나서 @RedisLock 이 wrapping 되면 안됩니다. lock의 획득여부와 무관하게 메소드는 스케줄링대로 실행하게 될 것입니다.
꼭 아래 순서로 import 하시오
@Module({
imports: [
RedisLockModule.forRootAsync({...}),
ScheduleModule.forRoot(),
],
})
export class AppModule {}
아직 글을 다듬는 중입니다. 현재 한계점이 극복되거나 사용해보면서 적절한 옵션이 정의되는대로 오픈소스를 만들건, github 템플릿 저장소를 만들던 해서 글을 완성시킬 예정입니다 :pray:
사실, 배치 프로젝트는 AWS Batch를 사용중입니다만, 그냥 유용할 수 있을 것 같아 만들었습니다.