최근 회사에서 배치 서버를 구축하면서, 배치 작업(job)의 로그를 남기는 로직을 구현하는 과정에서 여러 가지 고민을 하게 되었습니다.
이번 글에서는 NestJS의 Custom Decorator를 활용하여 공통 관심사를 효율적으로 처리하는 방법에 대해 고민한 내용을 공유하고자 합니다.
NestJS에서 Custom Logging Decorator가 필요한 이유를 AOP(Aspect-Oriented Programming) 관점에서 살펴보겠습니다.

위 사진처럼 프로세스마다 공통되는 기능을 횡단 관심사라고 부르며, 이러한 관심사들의 분리는 AOP, 즉 관점 지향 프로그래밍에서 모듈성을 높이기 위한 패러다임입니다.
핵심 비즈니스 로직(서비스 메서드에 담긴 비즈니스 로직)은 로깅과 관련이 없으며, 데이터 저장이나 업데이트와 같은 주요 작업을 수행합니다.
따라서 AOP 관점에서 로깅 관련 로직을 커스텀 데코레이터로 구현하여 핵심 비즈니스 로직과 분리하고, 이를 필요로 하는 서비스 메서드에서 재사용할 수 있도록 합니다.
이러한 접근 방식은 코드의 모듈성과 재사용성을 향상시키며, 로깅과 같은 공통 기능을 중앙에서 관리하여 유지보수를 용이하게 합니다.
아래는 기존에 배치 작업을 처리하고 로그를 남기는 방식의 예시입니다.
@Injectable()
export class CampaignApplicationBidBatch {
constructor(
private readonly service: TestService,
private readonly logRepository: LogRepository,
) {}
@Cron(CronExpression.EVERY_DAY_AT_9AM, { timeZone: 'Asia/Seoul' })
async sendUnselectedInfluencerNotifications() {
// 1. 배치 작업이 시작될 때 로그를 생성합니다.
const jobLog = await this.logRepository.save({
type: 'testJob',
});
// 2. 서비스 메서드에 배치 job id를 전달하여 배치 작업을 수행합니다.
await this.service.sendUnselectedInfluencerNotifications(jobLog.id);
}
}
@Injectable()
export class TestService {
async sendUnselectedInfluencerNotifications(jobLogId: number) {
try {
// ... 배치 작업 수행
// 3. 배치 작업이 성공했을 때 로그 상태를 '성공'으로 업데이트합니다.
await this.logRepository.update(jobLogId, {
status: BatchJobLogStatus.Success,
});
} catch (error) {
// 4. 배치 작업이 실패했을 때 로그 상태를 '실패'로 업데이트합니다.
await this.logRepository.update(jobLogId, {
status: BatchJobLogStatus.Failed,
});
}
}
}
반복적인 로그 관련 코드: 각 배치 작업마다 로그를 생성하고 업데이트하는 코드가 반복적으로 작성되어야 하므로, 코드 중복이 발생합니다. 이러한 중복은 로그 관련 정책이 변경될 때 수정해야 할 포인트가 늘어나 유지보수를 어렵게 만들 수 있습니다.
서비스 계층 책임 분산: 배치 서비스는 본래 배치 작업 로직을 처리하는 데 집중해야 하지만, 로그 관리까지 수행하게 되면서 다양한 책임을 지게 됩니다. 이로 인해 서비스 코드의 가독성이 떨어지고, 핵심 로직과 부가적인 로깅 로직이 혼재되어 유지보수가 어려워질 수 있습니다.
기존 방식에서 발생했던 반복적인 로그 처리 로직을 분리하기 위해, Typescript의 Method Decorator를 활용해봅시다. Decorator를 사용하면 로깅과 같은 공통된 기능을 각 서비스 메서드에서 분리하여 코드의 중복을 줄이고, 핵심 비즈니스 로직에만 집중할 수 있게 됩니다.
아래는 로그 처리 로직을 분리하기 위해 구현한 메서드 데코레이터의 예시입니다.
export function BatchJobLogWrapper() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const batchJobLogRepository: BatchJobLogRepository = this.batchJobLogRepository;
let id: number;
try {
// 1. 배치 작업이 시작될 때 로그를 생성합니다.
const batchJobLog = await batchJobLogRepository.save({ jobName });
id = batchJobLog.id;
// 2. 원래의 배치 작업을 수행합니다.
const result = await originalMethod.apply(this, args);
// 3. 배치 작업이 성공했을 때 로그 상태를 '성공'으로 업데이트합니다.
await batchJobLogRepository.updateBatchJobLogOnSuccess(id);
return result;
} catch (error) {
// 4. 배치 작업이 실패했을 때 로그 상태를 '실패'로 업데이트합니다.
await batchJobLogRepository.updateBatchJobLogOnFailure(id, error.message);
throw error;
}
};
return descriptor;
};
}
@Injectable()
export class TestService {
@BatchJobLogWrapper()
async sendUnselectedInfluencerNotifications(jobLogId: number) {
// 배치 작업 관련 로직만 남습니다.
// 데코레이터가 로깅 로직을 처리합니다.
}
}
로그 처리 로직을 별도의 데코레이터로 분리함으로써, 서비스는 오직 비즈니스 로직에 집중할 수 있게 되었습니다. 그 결과 로깅 로직과 비즈니스 로직이 명확하게 분리되어 코드의 가독성이 향상 되었습니다.

batchModule에서 BatchJobLogRepository를 import하더라도, Test Service에서 batchJobLogRepository를 주입하지 않으면 @BatchJobLogWrapper 데코레이터 내부에서 batchJobLogRepository에 접근할 수 없습니다. 이로 인해 @BatchJobLogWrapper 데코레이터를 사용하려면 항상 해당 클래스에 batchJobLogRepository를 주입해야 하는 불편함이 발생합니다.
또한, batchJobLogRepository를 주입하더라도, 클래스 내부에서 반드시 batchJobLogRepository라는 이름의 멤버 변수로 접근해야 하기 때문에 휴먼 에러가 발생할 위험이 높아지는 만큼 좋은 방법이라고 할 수는 없습니다.
NestJS 프레임워크의 Lifecycle과 SetMetadata, DiscoveryModule, MetadataScanner를 통해 로그를 기록하는 Decorator를 구현하는 방법에 대해 알아보겠습니다.
BatchJobLog decorator 구현
SetMetadata로 key-value 값을 등록합니다.OnModuleInit이 실행되는 시점에 DiscoveryService로 Singleton Container에 있는 instance에 접근.
MetadataScanner로 decorator의 instance에 대한 metadata를 가져온다.
SetMetadata로 등록된 값들을 조회.먼저 Decorator를 정의해야 합니다.
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { BatchJobName } from 'libs/unify-entity/batch-job-log/batch-job-log.interface';
export const BATCH_JOB_LOG_KEY = Symbol('BATCH_JOB_LOG');
export function BatchJobLog(batchJobName: BatchJobName): MethodDecorator {
return applyDecorators(SetMetadata(BATCH_JOB_LOG_KEY, batchJobName));
}
decorator의 parameter를 MetadataScanner를 통해서 받아올 수 있습니다.
NestJS는 DiscoveryModule 을 제공합니다. DiscoveryModule의 DiscoveryService에서는 내부적으로 modulesContainer를 사용하여 모든 모듈의 Controller와 Provider 클래스를 조회할 수 있습니다.
@Module({
imports: [
BatchJobLogRepositoryModule, // 배치 작업 로그를 처리할 리포지토리 모듈
DiscoveryModule, // 서비스 인스턴스를 발견하기 위한 모듈
],
providers,
})
export class BatchModule implements OnModuleInit {
constructor(
private readonly discoveryService: DiscoveryService, // 서비스 인스턴스 검색을 위한 서비스
private readonly metadataScanner: MetadataScanner, // 메타데이터를 스캔하기 위한 스캐너
private readonly reflector: Reflector, // 메타데이터에 접근하기 위한 리플렉터
private readonly batchJobLogRepository: BatchJobLogRepository, // 배치 작업 로그를 저장하고 업데이트할 리포지토리
) {}
// 모듈 초기화 시 호출되는 메서드
async onModuleInit() {
this.wrapBatchJobsWithLogging(); // 배치 작업에 로깅을 추가하는 메서드 호출
}
// 서비스 인스턴스를 검색하여 반환하는 메서드
private getInstances() {
return this.discoveryService
.getProviders() // provider 목록 가져오기
.filter((v) => v.isDependencyTreeStatic()) // 정적 의존성 트리인 경우 필터링
.filter(({ metatype, instance }) => instance && metatype); // 인스턴스와 메타타입이 존재하는 경우 필터링
}
private wrapBatchJobsWithLogging() {
const instances = this.getInstances(); // 인스턴스 가져오기
for (const instance of instances) {
this.wrapInstanceMethods(instance);
}
}
}
private wrapInstanceMethods(instance: any) {
// 인스턴스의 프로토타입에서 모든 메서드 이름을 가져옵니다.
const methodNames = this.metadataScanner.getAllMethodNames(
Object.getPrototypeOf(instance.instance),
);
for (const methodName of methodNames) {
const originalMethod = instance.instance[methodName];
// BATCH_JOB_LOG_KEY 심볼을 메타데이터로 가지고 있는 메서드들을 찾습니다.
const jobName = this.reflector.get(BATCH_JOB_LOG_KEY, originalMethod);
if (jobName) {
instance.instance[methodName] = this.wrapMethod(originalMethod, instance.instance, jobName);
}
}
}
private getInstances() {
return this.discoveryService
.getProviders()
.filter((v) => v.isDependencyTreeStatic())
.filter(({ metatype, instance }) => instance && metatype);
}
private wrapBatchJobsWithLogging() {
const instances = this.getInstances();
for (const instance of instances) {
this.wrapInstanceMethods(instance);
}
}
this.metadataScanner.getAllMethodNames(...)를 사용하여 인스턴스의 모든 메서드 이름을 가져옵니다.
this.reflector.get(BATCH_JOB_LOG_KEY, originalMethod)를 사용하여 BATCH_JOB_LOG_KEY 심볼을 메타데이터로 가지고 있는 메서드들을 찾습니다.
// ...
private wrapMethod(originalMethod: any, instance: any, jobName: BatchJobName) {
const { batchJobLogRepository } = this; // 현재 모듈의 배치 로그 리포지토리 참조
return async function (...args: any[]) {
try {
// 배치 작업 시작 시 로그를 생성
const batchJobLog = await batchJobLogRepository.save({ jobName });
// 원래의 메서드 실행
const result = await originalMethod.apply(instance, args);
// 배치 작업이 성공했을 때 로그 상태를 '성공'으로 업데이트
await batchJobLogRepository.updateBatchJobLogOnSuccess(id);
return result; // 원래의 메서드 결과 반환
} catch (error) {
// 배치 작업이 실패했을 때 로그 상태를 '실패'로 업데이트
await batchJobLogRepository.updateBatchJobLogOnFailure(id, error.message);
}
};
}
private wrapInstanceMethods(instance: any) {
// 인스턴스의 프로토타입에서 모든 메서드 이름을 가져옵니다.
const methodNames = this.metadataScanner.getAllMethodNames(
Object.getPrototypeOf(instance.instance),
);
for (const methodName of methodNames) {
const originalMethod = instance.instance[methodName];
// BATCH_JOB_LOG_KEY 심볼을 메타데이터로 가지고 있는 메서드들을 찾습니다.
const jobName = this.reflector.get(BATCH_JOB_LOG_KEY, originalMethod);
if (jobName) {
instance.instance[methodName] = this.wrapMethod(originalMethod, instance.instance, jobName);
}
}
}
private getInstances() {
return this.discoveryService
.getProviders()
.filter((v) => v.isDependencyTreeStatic())
.filter(({ metatype, instance }) => instance && metatype);
}
// ...
BATCH_JOB_LOG_KEY 심볼을 메타데이터로 가지고 있는 메서드들을 대상으로 로깅 로직이 담겨있는 메서드로 래핑합니다.기존 방식에서는 레포지토리 주입이 서비스별로 수동으로 이루어져 휴먼 에러가 발생할 수 있었던 반면, 개선된 방식에서는 모듈 수준에서 레포지토리를 관리하고 자동으로 주입받음으로써 오류 가능성을 줄일 수 있게 되었습니다.
@BatchJobLog(BatchJobName.BidCampaignKakaoSend)
async sendUnselectedInfluencerNotifications(jobLogId: number) {
// batch 관련 로직 ...
}
이번 글에서는 NestJS에서 Custom Decorator를 활용하여 배치 작업의 로깅 로직을 효율적으로 처리하는 방법에 대해 살펴보았습니다. 기존 방식의 문제점을 분석하고, AOP(Aspect-Oriented Programming) 관점에서 로깅과 같은 공통 관심사를 분리함으로써 코드의 가독성과 유지보수성을 개선하는 방법을 고민하였습니다.
이 과정에서 많은 도움이 된 아티클을 공유합니다: