[NestJS] Scheduler Lock 데코레이터 만들기 (a.k.a. @RedisLock)

이준규·2024년 5월 27일
0

백엔드

목록 보기
14/15
post-thumbnail

이 게시글은 Spring에만 존재하는 @SchedulerLock 애노테이션을 NestJS의 데코레이터로 만들어 드립니다.

사실, 작업스케줄링 뿐 아니라 하나의 함수도 다중 인스턴스 간에 단독 실행을 보장합니다. (a.k.a @RedisLock)


1. 배경

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를 사용하는 것 같았습니다)

이러한 기능을 데코레이터로 만들어서 팀내에서 편리하게 사용가능하도록 구현해낸 경험을 공유합니다.


2. 작명

@SchedulerLock vs @RedisLock

SchedulerLock을 의도하고 개발했으나, 이 데코레이터의 기능은 작업스케줄링에 국한되지 않기 때문에 일단 RedisLock으로 명명했습니다.


3. 구현

일전에 함수의 입출력을 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하면 됩니다.

  1. discoveryService를 통해 모든 컨트롤러와 프로바이더들의 인스턴스를 불러옵니다.
  2. scanner를 통해 @RedisLock 데코레이터가 달려있는 (메타데이터가 등록된) 메소드를 찾고 wrapping 합니다.
  3. 데코레이터가 붙은 메소드의 시작시 lock 획득
    3-1. 메소드 실행 (originMethod())
    3-2. lock release
    3-3. lock획득 실패시 originMethod 실행 안함

이제 redis-lock.service의 acquireLock 메소드 내부구현만 해내면 됩니다.

duration옵션은 ttl로 활용하면 되고
구현에는 두 가지 선택지가 있습니다.

  1. redis의 get, set 명령어만 이용한다.

메소드 시작 전 Redis에서 메소드이름으로 된 lock 이 있는지 확인한다.
없다면 메소드이름으로 만들어진 key 를 set 한다.

간단한 구현방법이고, Redis는 싱글 스레드이기에 문제도 없을 것입니다.

  1. Redlock 알고리즘을 사용한다.

저는 Redlock 라이브러리를 사용해서 lock 을 획득하도록 구성했습니다.
후에 서버가 확장되었을 때, 이 데코레이터의 가용성을 위해서 였습니다.
단, Redlock 알고리즘을 명확히 이해하고 옵션을 적절히 설정해야 합니다. (retryDelay, retryJitter 등)


  async acquireLock(key: string, duration: number) {
    return await this.redlock.acquire([key], duration)
  }

4. usage

  @Cron('0 * * * *')
  @RedisLock({
    duration: 60 * 1000 * 50
  })
  async batchJob() {
  	console.log('hi')
  }

이제 다중 인스턴스 구동시에도 단독실행이 보장되었습니다 !

5. 한계

한계는 명확합니다

이 데코레이터를 사용할 모듈에서 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 {}

6.

아직 글을 다듬는 중입니다. 현재 한계점이 극복되거나 사용해보면서 적절한 옵션이 정의되는대로 오픈소스를 만들건, github 템플릿 저장소를 만들던 해서 글을 완성시킬 예정입니다 :pray:

사실, 배치 프로젝트는 AWS Batch를 사용중입니다만, 그냥 유용할 수 있을 것 같아 만들었습니다.

profile
백엔드

0개의 댓글