NestJS 및 tsyringe에서 AOP 데코레이터를 사용하여 애플리케이션 동작 향상
Emily Parker
Product Engineer · Leapcell

소개
현대 소프트웨어 개발에서 애플리케이션은 종종 여러 모듈 또는 구성 요소에 걸쳐 공통적이고 반복적인 작업을 공유합니다. 로깅, 캐싱, 인증, 오류 처리 또는 트랜잭션 관리와 같은 횡단 관심사라고 하는 이러한 작업은 해당 로직이 코드베이스 전체에 흩어져 있으면 코드 중복, 복잡성 증가 및 유지보수성 감소로 이어질 수 있습니다. 이는 종종 "콜백 지옥" 또는 대규모 "스파게티 코드"로 알려진 현상입니다. 이때 AOP(Asp ect-Oriented Programming)가 도움을 줍니다. AOP는 이러한 횡단 관심사를 모듈화하여 핵심 비즈니스 로직에서 분리하는 강력한 패러다임을 제공합니다.
JavaScript 및 TypeScript 생태계, 특히 NestJS와 같은 프레임워크와 tsyringe와 같은 종속성 주입 라이브러리 내에서 데코레이터는 AOP 원칙을 구현하는 우아하고 관용적인 방법을 제공합니다. 이를 통해 원래 구현을 변경하지 않고 클래스와 메서드에 동작을 선언적으로 주입할 수 있어 코드 가독성과 유지보수성을 크게 향상시킬 수 있습니다. 이 글에서는 NestJS와 tsyringe에서 데코레이터를 활용하여 궁극적으로 더 깨끗하고 효율적이며 관리하기 쉬운 애플리케이션으로 이어지는 로깅 및 캐싱과 같은 일반적인 시나리오에 대한 실용적인 AOP 구현을 달성하는 방법을 자세히 살펴봅니다.
AOP의 구성 요소 이해
실질적인 예제를 살펴보기 전에 논의의 중심이 될 몇 가지 주요 AOP 용어를 명확히 해 보겠습니다.
- Aspect(측면): 횡단 관심사의 모듈화된 단위. 측면은 "로깅" 또는 "캐싱"과 같이 애플리케이션의 여러 부분에 걸쳐 있는 동작을 캡슐화합니다.
- Join Point(조인 포인트): 측면을 적용할 수 있는 프로그램 실행의 특정 지점. 객체 지향 프로그래밍에서 이는 일반적으로 메서드 호출, 메서드 실행, 생성자 호출 및 필드 액세스를 포함합니다.
- Advice(조언): 특정 조인 포인트에서 측면이 수행하는 작업. 조언은 측면이 무엇을 하는지 정의합니다. 일반적인 조언 유형은 다음과 같습니다.
- Before advice: 조인 포인트 전에 실행됩니다.
- After advice: 조인 포인트 후에 실행됩니다(결과에 관계없이).
- After returning advice: 조인 포인트가 성공적으로 완료된 경우에만 실행됩니다.
- After throwing advice: 조인 포인트에서 예외가 발생하는 경우에만 실행됩니다.
- Around advice: 조인 포인트 주위에 래핑되어 사전 및 사후에 사용자 지정 논리를 실행하고 인수 또는 반환 값을 수정할 수도 있습니다. 이것이 가장 강력한 조언 유형입니다.
- Pointcut(포인트컷): 조언이 적용될 조인 포인트 집합. 포인트컷은 측면의 동작이 어디에 주입되어야 하는지를 지정합니다. 데코레이터 기반 접근 방식에서 데코레이터의 존재 자체가 종종 포인트컷을 정의합니다.
- Weaving(위빙): 최종 실행 가능한 시스템을 만들기 위해 측면과 핵심 애플리케이션 코드를 결합하는 프로세스. 데코레이터를 사용하면 이 위빙은 TypeScript 컴파일 및 JavaScript 실행에 따라 컴파일 타임 또는 런타임에 발생합니다.
TypeScript의 데코레이터는 클래스, 메서드, 접근자, 속성 또는 매개 변수를 주석 처리할 수 있는 구문 설탕을 제공합니다. AOP에 사용될 때 이들은 효과적으로 포인트컷으로 작동하며 데코레이터 함수 내의 로직은 조언을 구현합니다.
데코레이터를 사용한 AOP 구현: 로깅 및 캐싱
NestJS 컨텍스트에서 데코레이터를 사용하여 로깅 및 캐싱에 대한 AOP를 구현하는 방법을 살펴 보겠습니다. 이는 tsyringe에도 적용 가능한 원칙을 보여줍니다.
로깅 측면
일반적인 요구 사항은 인수 및 반환 값, 발생할 수 있는 오류를 포함하여 메서드 실행을 기록하는 것입니다. 이는 디버깅 및 모니터링에 도움이 됩니다.
// log.decorator.ts import { Logger } from '@nestjs/common'; export function LogMethod( logLevel: 'log' | 'error' | 'warn' | 'debug' | 'verbose' = 'log', logArgs: boolean = true, logResult: boolean = true, logError: boolean = true, ) { const logger = new Logger('LogMethod'); return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const methodName = `${target.constructor.name}.${propertyKey}`; if (logArgs) { logger[logLevel](`Calling ${methodName} with args: ${JSON.stringify(args)}`); } else { logger[logLevel](`Calling ${methodName}`); } try { const result = await originalMethod.apply(this, args); if (logResult) { logger[logLevel]( `Method ${methodName} returned: ${JSON.stringify(result)}`, ); } return result; } catch (error) { if (logError) { logger.error( `Method ${methodName} threw an error: ${error.message}`, error.stack, ); } throw error; // Re-throw the error so the original logic can handle it } }; return descriptor; }; }
이제 이 데코레이터를 NestJS 서비스에 적용해 보겠습니다.
// hero.service.ts import { Injectable } from '@nestjs/common'; import { LogMethod } from './log.decorator'; interface Hero { id: number; name: string; } @Injectable() export class HeroService { private heroes: Hero[] = [ { id: 1, name: 'Superman' }, { id: 2, name: 'Batman' }, ]; @LogMethod('verbose', true, true, true) async findAllHeroes(): Promise<Hero[]> { // Simulate async operation await new Promise((resolve) => setTimeout(resolve, 100)); return this.heroes; } @LogMethod('error', true, false, true) async findHeroById(id: number): Promise<Hero> { await new Promise((resolve) => setTimeout(resolve, 50)); const hero = this.heroes.find((h) => h.id === id); if (!hero) { throw new Error(`Hero with ID ${id} not found.`); } return hero; } }
이 예제에서 @LogMethod는 Around advice 역할을 합니다. findAllHeroes 및 findHeroById 메서드를 가로채고 실행, 인수 및 결과(또는 오류)를 기록한 다음 원래 메서드 실행을 진행합니다. 비즈니스 로직이 로깅 문구로 인해 깔끔하게 유지되는 것을 알 수 있습니다.
캐싱 측면
캐싱은 특히 성능 최적화를 위해 또 다른 중요한 횡단 관심사입니다. 메서드 결과를 캐시하는 데코레이터를 만들 수 있습니다.
// cache.decorator.ts import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject } from '@nestjs/common'; import { Cache } from 'cache-manager'; export function CacheResult(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { // NestJS는 런타임에 데코레이터에 종속성을 주입하는 방법을 제공합니다 // 더 강력한 솔루션을 위해, 특히 tsyringe의 경우, // 캐시 관리자에 액세스하기 위해 사용자 지정 데코레이터 팩토리 또는 서비스 로케이터 패턴이 필요할 수 있습니다. // 여기서는 CacheManager를 사용할 수 있다고 가정합니다. let cacheManager: Cache; try { // 이것은 단순화된 액세스입니다. 실제 NestJS 앱에서는 // 일반적으로 생성자를 통해 CacheManager를 주입합니다. // 데코레이터의 경우 폐쇄에 사용할 수 있도록 사용자 지정 제공자 또는 모듈 설정을 사용하거나 // Inject 데코레이터를 더 직접적으로 사용해야 할 수 있습니다. // 시연을 위해 캐시 관리자가 this에서 결국 확인된다고 가정하거나 // 전역 메커니즘이 존재한다고 가정합니다. // Cache에 대한 프레임워크 독립적인 데코레이터는 // 캐시 인스턴스를 얻는 방법이 필요합니다. 지금은 모의하거나 가용성을 가정합니다. // 여기서는 단순성을 위해 기본 인메모리 캐시를 사용하도록 보여주거나 // 프레임워크가 방법을 제공한다고 가정합니다. cacheManager = (this as any).cacheManager; // cacheManager가 클래스에 주입되었다고 가정 if (!cacheManager) { throw new Error('CacheManager not available in this context for CacheResult decorator.'); } } catch (e) { console.warn('CacheManager not found for decorator, proceeding without cache. Error:', e.message); // 캐시 관리자를 사용할 수 없는 경우 원래 메서드로 대체 return originalMethod.apply(this, args); } const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheManager.get(cacheKey); if (cachedResult) { console.log(`Cache hit for ${cacheKey}`); return cachedResult; } console.log(`Cache miss for ${cacheKey}, executing original method.`); const result = await originalMethod.apply(this, args); await cacheManager.set(cacheKey, result, ttlSeconds * 1000); // ttl in milliseconds return result; }; return descriptor; }; }
데코레이터에서 CacheManager 주입에 대한 참고 사항: NestJS/TypeScript에서 CACHE_MANAGER (또는 다른 서비스)를 메서드 데코레이터에 직접 주입하는 것은 데코레이터가 인스턴스가 생성되거나 종속성 주입이 발생하기 전에 모듈 로드 시점에 실행되기 때문에 간단하지 않습니다. 위 예제는 cacheManager가 this에서 사용 가능하다고 가정하여 단순화합니다. 실제 NestJS 애플리케이션에서는 인터셉터를 사용하거나 데코레이터 팩토리를 종속성 주입 기능으로 래핑하는 사용자 지정 제공자를 만드는 것이 캐싱 AOP를 구현하는 더 강력한 방법일 것입니다.
tsyringe와 같은 라이브러리의 경우 클래스를 방식으로 래핑하여 데코레이터 팩토리 내에서 종속성을 해결하기 위해 컨테이너를 활용할 수 있습니다.
// cache.service.ts import { injectable, container } from 'tsyringe'; @injectable() export class CacheService { private cache = new Map<string, any>(); async get<T>(key: string): Promise<T | undefined> { return this.cache.get(key); } async set<T>(key: string, value: T, ttlMs: number): Promise<void> { this.cache.set(key, value); if (ttlMs > 0) { setTimeout(() => this.cache.delete(key), ttlMs); } } // CacheService를 tsyringe에 등록 static register() { container.registerSingleton(CacheService); } } // 메인 애플리케이션 설정에서: // CacheService.register(); // cache.decorator.ts (tsyringe 인식) import { container } from 'tsyringe'; import { CacheService } from './cache.service'; export function CacheResultTsyringe(ttlSeconds: number = 60) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; descriptor.value = async function (...args: any[]) { const cacheService = container.resolve(CacheService); // tsyringe 컨테이너에서 해결 const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`; const cachedResult = await cacheService.get(cacheKey); if (cachedResult) { console.log(`Cache hit for ${cacheKey}`); return cachedResult; } console.log(`Cache miss for ${cacheKey}, executing original method.`); const result = await originalMethod.apply(this, args); await cacheService.set(cacheKey, result, ttlSeconds * 1000); return result; }; return descriptor; }; }
그런 다음 tsyringe에 의해 관리되는 tsyringe 클래스에서 메서드에 @CacheResultTsyringe를 사용하게 됩니다.
// hero.service.ts (tsyringe 관리) import { injectable } from 'tsyringe'; import { CacheResultTsyringe } from './cache.decorator'; interface Hero { id: number; name: string; } @injectable() export class HeroServiceTsyringe { private heroes: Hero[] = [ { id: 1, name: 'Wonder Woman' }, { id: 2, name: 'Aquaman' }, ]; @CacheResultTsyringe(30) // 30초 동안 캐싱 async findAllHeroes(): Promise<Hero[]> { console.log('Fetching all heroes from data source...'); await new Promise((resolve) => setTimeout(resolve, 500)); // 지연 시뮬레이션 return this.heroes; } } // 사용법: // const heroService = container.resolve(HeroServiceTsyringe); // await heroService.findAllHeroes(); // 첫 번째 호출 - 캐시 누락 // await heroService.findAllHeroes(); // 두 번째 호출 - 캐시 히트
애플리케이션 시나리오
- 로깅: 기본 메서드 진입/종료를 넘어서 AOP 로깅은 특정 중요 작업 감사, 성능 메트릭 추적(메서드 실행 시간) 또는 민감한 데이터 액세스 로깅에 사용될 수 있습니다.
- 캐싱: 특히 데이터베이스 또는 외부 API에 액세스하는 읽기 집약적인 작업에 대한 고성능 애플리케이션에 필수적입니다. 데이터베이스 쿼리, API 응답 또는 계산적으로 비용이 많이 드는 함수 결과를 캐시할 수 있습니다.
- 인증/인가: 메서드가 실행되기 전에 데코레이터를 사용하여 사용자 권한을 확인할 수 있습니다.
- 트랜잭션 관리: 데이터베이스 작업 시퀀스가 모두 성공하거나 모두 실패하도록 보장합니다. 데코레이터는 메서드를 데이터베이스 트랜잭션으로 래핑할 수 있습니다.
- 입력 유효성 검사: 메서드 로직이 실행되기 전에 데코레이터를 사용하여 메서드 인수를 자동으로 유효성 검사할 수 있습니다.
- 오류 처리: 데코레이터는 특정 예외를 잡고 추가 컨텍스트로 장식하거나 사용자 지정 대체 동작을 트리거할 수 있습니다.
결론
NestJS 및 tsyringe를 사용한 데코레이터는 Aspect-Oriented Programming을 구현하는 매우 효과적인 방법을 제공하여 개발자가 로깅 및 캐싱과 같은 횡단 관심사를 깔끔하고 효율적으로 관리할 수 있도록 합니다. 이러한 일반적인 동작을 재사용 가능한 데코레이터로 중앙 집중화함으로써 상용구 코드, 모듈성 향상, 애플리케이션의 전반적인 유지보수성 및 가독성을 크게 줄입니다. 데코레이터로 구동되는 AOP는 비즈니스 로직이 집중되고 명확하며 필수 인프라 문제가 눈에 띄지 않게 처리되는 강력하고 확장 가능한 시스템을 구축할 수 있도록 합니다. 이 접근 방식은 보다 민첩한 개발과 변화하는 요구 사항에 대한 쉬운 적응으로 이어집니다.