2025. 4. 6. 18:41ㆍFramework/NestJS
이전에 'NestJs는 AOP를 지원할까?'에 대한 내용으로 포스팅을 했던 적이 있습니다. 당시에는 NestJs는 공식적으로 AOP를 지원하지 않는다고 했었습니다.
NestJs는 AOP를 지원할까? Spring AOP와의 비교 분석
지난 몇 년간 Spring 생태계에 익숙해져 있었던 터라 Spring에서는 로깅, 트랜잭션, 예외처리등 공통로직을 '객체'로 바라보고 비즈니스로직에서 공통로직을 별도로 분리하는 AOP(Aspect Oriented Programmi
jminc00.tistory.com
해당 블로그를 링크드인에 공유했는데, 토스의 Node.js 개발자인 김인성 님께서토스에서 NestJS에서 AOP를 사용하기 위해 만든 @toss/nestjs-aop 오픈소스 라이브러리를 소개해주셨습니다.

GitHub - toss/nestjs-aop: A way to gracefully apply AOP to nestjs
A way to gracefully apply AOP to nestjs. Contribute to toss/nestjs-aop development by creating an account on GitHub.
github.com
그래서 오늘 포스팅은 해당 라이브러리를 사용해본 후기와 더불어 어떻게 토스에서는 NestJs에서 AOP를 구현하기 위해 어떤 전략을 사용했으며 그 과정에서 알아야 할 개념들에 대해 학습한 내용을 공유해보려고 합니다.
NestJs AOP 공식 지원 안함 + Interceptor를 활용한 AOP 그리고 한계
'NestJs는 AOP를 지원할까?' 라는 포스팅에서도 이미 언급했던 내용이지만, 글을 읽지 않은 분들을 위해서 간단히 소개하자면 NestJs는 공식적으로 AOP를 지원하지 않고 있습니다. NestJs는 AOP를 지원하지는 않지만, Interceptor를 활용해서 공통 로직을 분리할 것을 제안(?)하고 있음을 문서상에서 확인할 수 있습니다.
Interceptor를 활용하는 AOP는 ExcutionContext를 의존해아만 하는데, Controller에서'만' AOP를 적용할 수 없다는 명확한 한계가 존재했습니다. 여기서 ExcutionContext는 인터셉터, 미들웨어 같은 요청(Request), 응답(Response) 사이에서 동작하는 미들웨어성 클래스에서 현재 실행 컨텍스트에 대한 정보를 제공하는 역할을 합니다.
export interface NestInterceptor<T = any, R = any> {
/**
* Method to implement a custom interceptor.
*
* @param context an `ExecutionContext` object providing methods to access the
* route handler and class about to be invoked.
* @param next a reference to the `CallHandler`, which provides access to an
* `Observable` representing the response stream from the route handler.
*/
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>;
}
때문에 이전에 제가 작성한 글에서는 NestJs에서 AOP를 Controller외에 적용하는 것은 힘들겠구나.. 하고 넘어갔었습니다. 다만, 제 결론이 매우 부끄럽게도 토스에서는 해당 문제를 보란 듯이 해결해서 오픈소스 라이브러리로 제공하는 것에 성공했습니다.
토스는 어떻게 AOP를 구현했을까?
- NestJs DI를 통해 생성된 Instance 활용하기
- Service Class에서 불필요한 의존성 없애기
- NestJs Lifecycle에 맞춰, 일반적으로 적용할 방법 찾기
토스는 위 3가지 문제를 하나하나 해결함으로써 AOP를 구현할 수 있었습니다. 저는 이제 부터 이 3가지 방법을 이해하기 위해 미리 알고 있어야 하는 개념들에 대해 정리해보려고 합니다. NestJs Korea에서 2022년 10월에 진행한 [NestJs 밋업]에서 관련 내용을 확인하실 수 있지만, 디테일한 부분을 알지 못해 이해하는데 조금 어려웠던 것 같습니다. 제가 이해하기 위해 학습했던 내용들에 대해 하나하나 공유하고 최종적으로 토스가 어떻게 AOP를 구현했는지 소개하는 식으로 글을 작성해보려고 합니다.
토스의 AOP 구현 전략

NestJs의 라이프사이클 단계 중 onModuleInit에서는 애플리케이션의 모듈, 서비스, 컨트롤러 등의 의존성을 등록하고 주입하는 단계가 끝난 이후, 각 모듈이 완전히 초기화 되는 단계입니다. 이 단계에서는 추가적으로 onModuleInit() 메서드를 구현한 클래스에서 추가적인 초기화 로직을 수행할 수도 있습니다.
- onModuleInit()을 구현한 모든 모듈 레벨의 서비스가 실행된다.
- providers 배열에 정의된 서비스들이 초기화 완료된다.
- onModuleInit() 내부에서 초기 데이터 로드, 설정 값 확인 등의 작업의 수행이 가능하다.
토스에서는 AOP를 구현하기 위한 전략으로 인스턴스가

여기서 포인트는 onModuleInit() 메서드를 구현한 클래스에서 추가적인 초기화 로직을 수행하는 것에 있습니다.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper';
import { ASPECT } from './aspect';
import { AopMetadata } from './core/types';
import { LazyDecorator } from './lazy-decorator';
/**
* Aspect 가 선언되어 있고 LazyDecorator 가 구현되어 있는 provider 가 있는 경우 ioc 에 등록된 모든 provider 를 순회하면서 LazyDecorator 를 적용함.
*/
@Injectable()
export class AutoAspectExecutor implements OnModuleInit {
private readonly wrappedMethodCache = new WeakMap();
constructor(
private readonly discoveryService: DiscoveryService,
private readonly metadataScanner: MetadataScanner,
private readonly reflector: Reflector,
) {}
onModuleInit() {
this.bootstrapLazyDecorators();
}
private bootstrapLazyDecorators() {
const controllers = this.discoveryService.getControllers();
const providers = this.discoveryService.getProviders();
const lazyDecorators = this.lookupLazyDecorators(providers);
if (lazyDecorators.length === 0) {
return;
}
const instanceWrappers = providers
.concat(controllers)
.filter(({ instance }) => instance && Object.getPrototypeOf(instance));
for (const lazyDecorator of lazyDecorators) {
for (const wrapper of instanceWrappers) {
this.applyLazyDecorator(lazyDecorator, wrapper);
}
}
}
private applyLazyDecorator(lazyDecorator: LazyDecorator, instanceWrapper: InstanceWrapper<any>) {
const target = instanceWrapper.isDependencyTreeStatic()
? instanceWrapper.instance
: instanceWrapper.metatype.prototype;
// Use scanFromPrototype for support nestjs 8
const propertyKeys = this.metadataScanner.scanFromPrototype(
target,
instanceWrapper.isDependencyTreeStatic() ? Object.getPrototypeOf(target) : target,
(name) => name,
);
const metadataKey = this.reflector.get(ASPECT, lazyDecorator.constructor);
// instance에 method names 를 순회하면서 lazyDecorator.wrap을 적용함
for (const propertyKey of propertyKeys) {
// the target method is must be object or function
// @see: https://github.com/rbuckton/reflect-metadata/blob/9562d6395cc3901eaafaf8a6ed8bc327111853d5/Reflect.ts#L938
const targetProperty = target[propertyKey];
if (!targetProperty || (typeof targetProperty !== "object" && typeof targetProperty !== "function")) {
continue;
}
const metadataList: AopMetadata[] = this.reflector.get<AopMetadata[]>(
metadataKey,
targetProperty,
);
if (!metadataList) {
continue;
}
for (const aopMetadata of metadataList) {
this.wrapMethod({ lazyDecorator, aopMetadata, methodName: propertyKey, target });
}
}
}
private wrapMethod({
lazyDecorator,
aopMetadata,
methodName,
target,
}: {
lazyDecorator: LazyDecorator;
aopMetadata: AopMetadata;
methodName: string;
target: any;
}) {
const { originalFn, metadata, aopSymbol } = aopMetadata;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const wrappedFn = function (this: object, ...args: unknown[]) {
const cache = self.wrappedMethodCache.get(this) || new WeakMap();
const cached = cache.get(originalFn);
if (cached) {
return cached.apply(this, args);
}
const wrappedMethod = lazyDecorator.wrap({
instance: this,
methodName,
method: originalFn.bind(this),
metadata,
});
cache.set(originalFn, wrappedMethod);
self.wrappedMethodCache.set(this, cache);
return wrappedMethod.apply(this, args);
};
target[aopSymbol] ??= {};
target[aopSymbol][methodName] = wrappedFn;
}
private lookupLazyDecorators(providers: InstanceWrapper[]): LazyDecorator[] {
const { reflector } = this;
return providers
.filter((wrapper) => wrapper.isDependencyTreeStatic())
.filter(({ instance, metatype }) => {
if (!instance || !metatype) {
return false;
}
const aspect =
reflector.get<string>(ASPECT, metatype) ||
reflector.get<string>(ASPECT, Object.getPrototypeOf(instance).constructor);
if (!aspect) {
return false;
}
return typeof instance.wrap === 'function';
})
.map(({ instance }) => instance);
}
}
@toss/nestjs-aop 소스를 보면서 구체적으로 NestJs AOP를 구현한 전략을 살펴보자면, 달성하려고 하는 목적의 핵심은
- NestJS에 등록된 provider/controller 중 특정 메타데이터(ASPECT)를 가진 객체에서,
- 해당 인스턴스의 메서드 중 Reflector로 정의된 메타데이터가 부여된 메서드를 찾아,
- 지정된 LazyDecorator의 wrap() 메서드로 런타임 시점에서 동적 래핑(AOP적 처리) 하는 동작을 수행합니다.
먼저 의존성 주입이 완료된 시점에 호출되는 onModuleInit()에서는 bootstrapLazyDecorators()를 호출합니다.
bootstrapLazyDecorators는 두 가지 기능을 수행하는데,
- lookupLazyDecorators()로 ASPECT 메타데이터가 부여된 클래스만 필터링합니다.
- 그런 클래스들을 대상으로 각각의 메서드를 스캔하고 wrap 합니다.
lookupLazyDecorators에서는 등록된 provider들 중에서 다음 조건을 만족하는 인스턴스를 찾습니다.
- @ASPECT 메타데이터가 클래스나 프로토타입에 존재
- instance.wrap 함수가 존재 (즉, LazyDecorator 인터페이스를 만족해야 함)
이렇게 추출된 객체들이 실제로 메서드를 래핑 할 수 있는 데코레이터 역할을 하게 되는 것입니다.
applyLazyDecorator()는 LazyDecorator와 매칭된 클래스의 메서드를 스캔하여 래핑 조건을 만족하는지 검사합니다.
- MetadataScanner.scanFromPrototype()을 이용해 메서드 이름들을 추출
- 각 메서드에 대해:
- Reflector.get()으로 AOP 메타데이터(AopMetadata [])가 존재하는지 확인합니다.
- 존재한다면 wrapMethod()를 통해 lazyDecorator.wrap()을 적용한 새로운 메서드로 대체합니다.
정리하자면, 토스는 NestJs에서 Controller에서 Interceptor를 사용해서 AOP를 구현해야 하는 기존의 한계를 Nest 생명주기를 잘 활용해서 Provider에서도 AOP 패턴을 적용하고 이 오픈소스를 만들었다고 할 수 있습니다.
더 자세한 내용은 오픈소스를 참고하시면 좋을 것 같습니다.
nestjs-aop/src/auto-aspect-executor.ts at v2.x · toss/nestjs-aop
A way to gracefully apply AOP to nestjs. Contribute to toss/nestjs-aop development by creating an account on GitHub.
github.com
왜 NsetJs는 토스의 오픈소스를 거절했을까?

저는 AOP를 사용하기 위해 토스가 설계한 이 방식이 비즈니스 로직에서 부가적인 기능이 아름답게 분리되어서 관리할 수 있다는 AOP의 설계 철학에 잘 부합한다고 생각했습니다. 다만, 공식 NestJs 팀에서는 해당 오픈소스의 PR을 거절했는데 그 이유가 궁금했습니다. 카밀이 거절한 명확한 이유를 알 수 없지만, 저는 이유를 함수형 프로그래밍 패러다임과 관련성이 있지 않나 싶었습니다.
첫째는 함수의 일급 시민성(First-Class Function)입니다.
Javascript는 함수가 '값'처럼 취급될 수 있는데,
- 변수에 저장할 수 있고,
- 다른 함수의 인자로 넘길 수 있고,
- 함수에서 반환하는 것이 가능합니다.
function logWrapper(fn) {
return function (...args) {
console.log(`[LOG] 함수 호출 - 인자: ${args}`);
return fn(...args);
};
}
function add(a, b) {
return a + b;
}
const loggedAdd = logWrapper(add);
loggedAdd(2, 3); // 로그 찍고 add 실행
예시의 logWrapper는 마치 AOP의 before advice처럼 동작하는 형태인데 고전 AOP에서는 메서드 실행 전에 로깅을 삽입하지만, JS에서는 고차 함수로 쉽게 구현할 수 있습니다.
둘째는 함수 조합 및 합성(Function Composition) 함수형 프로그래밍에서 핵심은 작은 함수들을 조합해서 새로운 기능을 만들어내는 것인데
const log = (fn) => (...args) => {
console.log('[LOG]', args);
return fn(...args);
};
const time = (fn) => (...args) => {
console.time('fn');
const result = fn(...args);
console.timeEnd('fn');
return result;
};
const add = (a, b) => a + b;
const enhancedAdd = log(time(add));
enhancedAdd(2, 3); // 로깅 + 타이머 + 결과
이 예시에서 log, time 같은 함수는 기존 함수에 새로운 부가기능을 조합한 것으로 AOP에서는 @Around, @Before, @After로 이런 걸 프레임워크 수준에서 처리하지만, 함수형에서는 직접 래핑하고 합성해서 처리할 수 있습니다. 함수 조합 자체가 AOP의 advice 구조와 거의 유사한 메커니즘으로 돌아간다고 할 수 있습니다.
정리하자면, 함수형 프로그래밍은 AOP의 목적 중 하나인 공통 관심사 분리(Cross-cutting Concerns)를 자연스럽게 실현할 수 있는 패러다임을 제공하고 있기에 JS 언어에서 이미 충분히 구현할 수 있다고 본 것 같습니다.
'Framework > NestJS' 카테고리의 다른 글
| 상반기 WMS 프로젝트 회고: 전략 패턴(Strategy Pattern) 적용기 (2) | 2025.06.15 |
|---|---|
| DDD 설계: Use Case 기반의 Application Layer 구현과 트랜잭션 관리 (2) | 2025.06.12 |
| NestJS의 Provider vs Spring의 Component: 등록 및 관리 방식의 핵심 차이 및 분석 (1) | 2025.03.23 |
| NestJs는 AOP를 지원할까? Spring AOP와의 비교 분석 (0) | 2025.03.03 |
| NestJS Guards Authentication with Passport (0) | 2025.03.02 |