👨💻 개인 공부 기록용 블로그 입니다.
💡 틀린 내용이나 오타는 댓글, 메일로 제보해주시면 감사하겠습니다!! (__)
Introduction
지난 4편에서는 예외처리 필터와 파이프에 대해 알아보았다.
NestJS 해체분석기 4 - 예외처리 필터(Exception Filter)와 파이프(Pipe)
NestJS가 예외 처리와 입력값 검증을 우아하게 다루는 방법
시리즈를 지나오면서 우리는 미들웨어, 가드, 인터셉터 등 NestJS가 제공하는 강력한 AOP 기능들을 통해 횡단 관심사를 우아하게 처리하는 법을 익혔다. 하지만 정작 “그 기능들이 어떻게 제공될 수 있는지” 에 대한 근원적인 질문은 잠시 미뤄두었다.
우리는 너무나 당연하게 @Controller(), @Get(), @Injectable()을 붙여왔다. 하지만 이 데코레이터들이 도대체 무슨 짓을 하길래, 클래스 위에 붙이기만 하면 라우팅이 되고 의존성이 주입되는 걸까?
코드가 코드를 제어하다: 메타 프로그래밍
이 “마법”의 실체는 바로 메타 프로그래밍(Meta-programming) 이다. 메타 프로그래밍이란 프로그램이 자기 자신 혹은 다른 프로그램을 데이터로 취급하며 읽고, 쓰고, 수정하는 기법을 말한다. 쉽게 말해, “코드를 제어하는 코드”다.
이 개념 덕분에 우리는 선언적 프로그래밍(Declarative Programming) 이라는 강력한 개발 경험을 누릴 수 있다. 이를 레스토랑에 비유하자면 다음과 같다.
명령형(Imperative) vs 선언적(Declarative)
명령형 (주방장): “냉장고 문을 열고, 달걀을 꺼내고, 팬에 기름을 두르고, 불을 켜고…” (모든 과정을 하나하나 지시)
선언적 (손님): “달걀 프라이 하나 주세요.” (원하는 결과만 선언)
NestJS 개발자는 손님과 같다. 우리는 그저 @Get('/users')라고 선언(주문)만 하면 된다. 실제로 URL을 어떻게 파싱하고 라우터를 어떻게 등록할지는 NestJS라는 주방장이 알아서 처리한다. 이것이 가능한 이유는 NestJS가 내부적으로 reflect-metadata를 이용해 우리가 붙인 “주문서(데코레이터)“를 읽어내기 때문이다.
다시, 본질로
기억하실지 모르겠지만, 이 시리즈의 시작이었던 1편에서 아래와 같이 언급하고 지나왔다.
지금은 TypeScript 리플렉션(Reflect.metadata)을 통해 클래스/메서드/프로퍼티 정보를 읽고, NestJS 내부에서 이를 IoC 컨테이너에 등록한다는 원리만 알고있어도 문제없다. 즉, TypeScript의 데코레이터는 Python에서 제시하는 함수 합성의 개념보다는 Java에서의 어노테이션에 좀 더 가깝다.
이번 편에서는 그때 미처 다 하지 못했던 이야기를 깊게 파고들어 보려 한다. 단순히 기능을 “사용”하는 단계를 넘어, NestJS가 어떻게 우리의 코드를 해석하고 조립하는지, 그 마법의 원리(Under the hood) 를 해체해보자.
데코레이터(Decorator) 와 리플렉션(Reflection)
우리가 흔히 접하는 데코레이터는 크게 두 가지 얼굴을 가지고 있다. 하나는 Python의 데코레이터이고, 다른 하나는 NestJS(TypeScript)의 데코레이터다. 문법은 @로 시작하여 비슷해 보이지만, 그 본질적인 역할은 완전히 다르다.
Python: 함수를 감싸는 포장지 (Wrapper)
기존 Python 포스팅에서 다뤘듯이, Python의 데코레이터는 고차 함수(Higher-order Function) 의 성격이 강하다.
고차 함수(Higher-Order Function)란? 함수를 인자로 받거나, 함수를 반환하는 함수를 말한다. Python 데코레이터는 이 원리를 이용해 원본 함수를 감싸서(Wrap) 동작을 가로채거나 확장한다.
def simple_decorator(func):
def wrapper():
print("함수 실행 준비!")
func() # 원본 함수 실행
print("함수 실행 완료!")
return wrapper # 새로운 함수 반환
@simple_decorator
def say_hello():
print("Hello!")
# 실제 동작: say_hello = simple_decorator(say_hello)
이 방식의 핵심은 행위(Behavior)의 합성이다. 데코레이터가 원본 함수를 wrapper 함수로 완전히 대체해버린다. 즉, 실행 시점에 say_hello()를 호출하면 실제로는 wrapper()가 실행되는 것이다.
TypeScript: 정보를 남기는 꼬리표 (Metadata)
반면, NestJS에서 사용하는 TypeScript 데코레이터는 접근 방식이 다르다. 물론 기술적으로 감싸는(Wrapping) 것도 가능하지만, NestJS의 설계 철학은 “동작은 건드리지 않고, 정보(Metadata)만 남긴다” 는 것에 집중한다.
이것이 1편에서 **“TypeScript 데코레이터는 Java의 어노테이션(Annotation)에 더 가깝다”**고 했던 이유다.
Java의 어노테이션이 그 자체로는 아무 기능 없는 ‘명찰(Tag)’ 에 불과하듯, NestJS의 데코레이터도 마찬가지다.
@Controller('/users')를 붙인다고 해서 클래스가 갑자기 라우터로 변신하는 게 아니다. 단지 “이 클래스는 컨트롤러이고, 경로는 /users야”라는 포스트잇을 붙여놓은 것뿐이다.
이 포스트잇을 읽어서 실제로 동작하게 만드는 것은 리플렉션(Reflection) 의 몫이다.
실험실: 커스텀 데코레이터 해부하기
백문이 불여일견. 실제로 아무 기능도 없는 커스텀 데코레이터를 만들고, Reflect.getMetadata를 통해 어떤 흔적이 남는지 확인해보자.
import 'reflect-metadata';
// 1. 클래스 데코레이터: Path 정보를 남김
function Controller(path: string) {
return function(target: Function) {
Reflect.defineMetadata('path', path, target);
};
}
// 2. 메서드 데코레이터: HTTP Method와 Route 정보를 남김
function Get(route: string) {
return function(target: any, propertyKey: string) {
Reflect.defineMetadata('route', route, target, propertyKey);
Reflect.defineMetadata('method', 'GET', target, propertyKey);
};
}
// 3. 파라미터 데코레이터: 파라미터 인덱스 정보를 남김
function Param(paramName: string) {
return function(target: any, propertyKey: string, parameterIndex: number) {
const existingParams = Reflect.getOwnMetadata('params', target, propertyKey) || [];
existingParams.push({ index: parameterIndex, name: paramName });
Reflect.defineMetadata('params', existingParams, target, propertyKey);
};
}
// 데코레이터 적용
@Controller('/users')
class UserController {
@Get('/:id')
getUser(@Param('id') id: string): string {
return `User ${id}`;
}
}
// --- 메타데이터 확인 (Reflection) ---
console.log('--- Metadata Analysis ---');
// 1. 클래스 메타데이터 읽기
const controllerPath = Reflect.getMetadata('path', UserController);
console.log(`Controller Path: ${controllerPath}`);
// Output: Controller Path: /users
// 2. 메서드 메타데이터 읽기 (prototype에서 읽어야 함)
const methodRoute = Reflect.getMetadata('route', UserController.prototype, 'getUser');
const httpMethod = Reflect.getMetadata('method', UserController.prototype, 'getUser');
console.log(`Method: ${httpMethod} ${methodRoute}`);
// Output: Method: GET /:id
// 3. 파라미터 메타데이터 읽기
const params = Reflect.getMetadata('params', UserController.prototype, 'getUser');
console.log('Params:', params);
// Output: Params: [ { index: 0, name: 'id' } ]
// 4. [중요] TypeScript가 자동으로 남겨준 타입 정보 (emitDecoratorMetadata: true)
const paramTypes = Reflect.getMetadata('design:paramtypes', UserController.prototype, 'getUser');
const returnType = Reflect.getMetadata('design:returntype', UserController.prototype, 'getUser');
console.log('Auto-generated Types:', paramTypes.map(t => t.name));
// Output: Auto-generated Types: [ 'String' ]
이 실험을 통해 우리는 중요한 사실을 알 수 있다. 데코레이터는 실행 흐름을 가로채는 마법이 아니다. 그저 나중에 누군가(NestJS 프레임워크)가 읽어주길 기다리며 데이터를 기록하는 행위일 뿐이다.
특히 design:paramtypes는 매우 중요하다. 우리가 constructor(private s: Service)라고만 적어도 의존성이 주입되는 이유가 바로 TypeScript 컴파일러가 이 메타데이터를 자동으로 남겨주기 때문이다.
시각화: IoC 컨테이너는 어떻게 작동하는가?
그렇다면 이 메타데이터들을 누가, 언제, 어떻게 읽어가는 걸까? NestJS 애플리케이션이 부트스트랩(NestFactory.create) 될 때 일어나는 일을 단계별로 살펴보자.
스캐닝 (Scanning)
NestJS 애플리케이션이 시작되면, NestFactory는 AppModule부터 시작해 모든 모듈을 탐색한다. 이때 @Module 데코레이터에 등록된 controllers, providers 배열을 훑는다.
메타데이터 수집 (Metadata Collection)
스캐너는 각 클래스를 순회하며 Reflect.getMetadata를 호출한다.
- UserController 발견: “어,
path메타데이터에/users라고 적혀있네? 넌/users경로를 담당해.” - getUser 메서드 발견: “어,
method가GET이고route가/:id네? 그럼GET /users/:id요청이 오면 얘를 실행해야겠다.”
의존성 분석 (Dependency Analysis)
이제 인스턴스를 만들 차례다. 하지만 그냥 new UserController()를 할 수 없다. 생성자에 뭐가 필요한지 알아야 하기 때문이다.
이때 design:paramtypes 메타데이터를 확인한다.
- “생성자 첫 번째 인자가
UserService네? IoC 컨테이너에UserService만들어둔 거 있나?”
인스턴스 생성 및 주입 (Instantiation & DI)
필요한 의존성들이 모두 준비되면, 비로소 NestJS는 인스턴스를 생성한다.
// 내부적으로 이런 일이 일어남 (의사 코드)
const userService = container.get(UserService);
const controller = new UserController(userService);라우팅 테이블 등록 (Routing Registration)
마지막으로, 수집한 경로 정보와 생성된 인스턴스를 매핑하여 Express(혹은 Fastify)의 라우터에 등록한다.
expressApp.get('/users/:id', (req, res) => {
const args = extractParams(req); // @Param 정보를 보고 id 추출
return controller.getUser(...args);
}); 결국 NestJS의 “마법”은 데코레이터가 부린 것이 아니다. 데코레이터는 설계도(Metadata) 를 그렸을 뿐이고, NestJS(IoC 컨테이너) 라는 노련한 건축가가 그 설계도를 보고 건물을 지은 것이다.
이 원리를 이해했다면, 이제 우리는 단순한 사용자를 넘어 NestJS의 확장(Extension) 을 꾀할 수 있다. 그런데, 만약 우리가 이 구조를 비틀어서 서비스 레이어의 메서드에 데코레이터를 붙이려고 한다면 어떻게 될까? 여기서부터 진짜 고통.. 재미있는 모험이 시작된다.
Service Layer AOP 와 순환참조
원리를 알았으니, 이제 실전이다. 지금까지 데코레이터와 리플렉션의 원리를 파헤쳤으니, 이제 실제로 활용해볼 차례다.
그런데 막상 NestJS 공식 문서를 뒤져보면 한 가지 의문이 든다. 왜 대부분의 횡단 관심사 데코레이터들이 Controller와 Module에만 집중되어 있을까?
-
@UseGuards(),@UseInterceptors(),@UsePipes()→ 주로 Controller 메서드나 클래스에 적용 -
@UseFilters()→ Controller나 전역 레벨에서 사용
물론 인터셉터를 전역으로 등록하거나 Module 레벨에서 적용할 수 있지만, 이는 요청(Request)과 응답(Response) 의 경계에서 작동한다. 하지만 실무에서의 비즈니스 로직은 컨트롤러보다 더 깊은 곳, 즉 Service Layer에 있다.
왜 Service Layer AOP가 필요한가?
컨트롤러 레벨의 AOP만으로는 해결할 수 없는 “비즈니스 로직의 순수성”과 “재사용성” 문제가 존재하기 때문이다.
Controller AOP의 한계점
범위(Scope) : 컨트롤러 인터셉터는 HTTP 요청 전체를 감싼다. 만약 하나의 요청 안에서 특정 로직만 캐싱하고 싶다면?
재사용성(Reusability) : 컨트롤러에 로직을 두면, Cron Job이나 WebSocket, 혹은 다른 서비스에서 해당 로직을 호출할 때 AOP가 적용되지 않는다.
가독성(Readability) : 트랜잭션이나 락(Lock) 같은 인프라 코드가 비즈니스 로직과 뒤섞여 “스파게티 코드”가 된다.
우리가 Service Layer AOP를 도입해야 하는 결정적인 순간들은 다음과 같다.
언제 사용되는가?
- 정교한 캐싱 (Fine-Grained Caching)
/dashboard API가 있다고 가정하자. 이 API는 사용자 정보, 주문 내역, 알림 등을 모두 조회한다.
-
Controller AOP:
/dashboard전체 응답을 캐싱한다. 데이터가 하나라도 바뀌면 전체 캐시가 무효화된다. 비효율적이다. -
Service AOP:
getUserProfile(),getOrderStats()같은 개별 메서드 단위로 캐싱한다. 훨씬 효율적이고 재사용 가능하다.
- 선언적 트랜잭션 (Declarative Transaction)
NestJS에서 QueryRunner를 직접 다루면 비즈니스 로직이 인프라 코드에 파묻히게 된다.
- 분산 락 (Distributed Lock)
재고 차감이나 선착순 쿠폰 발급처럼 동시성 제어가 필요한 경우, 메서드 진입 전 락을 잡고 끝날 때 해제하는 로직은 AOP로 처리하기 가장 좋은 케이스다.
// 쿠폰 발급 메서드
@Lock({ key: 'coupon_issue', ttl: 5000 })
async issueCoupon(userId: string) {
// 락 획득/해제 로직은 AOP가 처리하므로, 여기선 발급 로직만 집중하면 된다.
// ...
}
직접 구현해보기: 고통의 시작
이처럼 Service Layer AOP는 코드의 품질을 비약적으로 높여준다.
물론, 모든 기술에는 트레이드오프(Trade-off)가 존재한다. 과도한 AOP 적용은 로직의 실행 흐름을 데코레이터 뒤로 숨겨버리기 때문에, 코드를 처음 보는 동료에게는 ‘이해할 수 없는 마법(Magic Code)‘이 되어 디버깅을 어렵게 만들 수도 있다.
하지만 적재적소에 사용된다면 비즈니스 로직의 순수성은 포기하기 힘든 매력적인 가치다.
‘그래, 주의해서 쓰면 되지! 당장 도입하자!’
라고 생각하며 호기롭게 구현을 시작하는 순간, 우리는 NestJS가 숨겨두었던 복잡한 현실과 마주하게 된다.
그럼 이전에 배운 DiscoveryService와 MetadataScanner를 사용해서 커스텀 캐싱 데코레이터를 직접 구현해보자. NestJS의 내부를 탐험하는 느낌이 들 것이다.
데코레이터 마킹
먼저 메타데이터를 남기는 데코레이터를 만든다. 앞서 배운 SetMetadata를 활용한다.
import { SetMetadata } from '@nestjs/common';
export const CACHEABLE_KEY = Symbol('CACHEABLE');
export interface CacheableOptions {
ttl: number;
key: string;
}
export function Cacheable(options: CacheableOptions): MethodDecorator {
return SetMetadata(CACHEABLE_KEY, options);
}ExplorerService로 메타데이터 탐색
이제 애플리케이션이 부트스트랩될 때(OnModuleInit), 모든 Provider를 뒤져서 @Cacheable이 붙은 메서드를 찾아내고 래핑(Wrapping)해야 한다.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
@Injectable()
export class CacheExplorerService implements OnModuleInit {
constructor(
private readonly discoveryService: DiscoveryService,
private readonly metadataScanner: MetadataScanner,
private readonly reflector: Reflector,
) {}
onModuleInit() {
this.explore();
}
private explore() {
// 1. 모든 Provider 인스턴스 가져오기
const providers = this.discoveryService
.getProviders()
.filter((wrapper) => wrapper.isDependencyTreeStatic())
.filter(({ instance }) => instance && Object.getPrototypeOf(instance));
// 2. 각 Provider의 메서드 스캔
providers.forEach(({ instance }) => {
const prototype = Object.getPrototypeOf(instance);
this.metadataScanner.scanFromPrototype(
instance,
prototype,
(methodName: string) => {
// 3. @Cacheable 메타데이터 확인
const cacheOptions = this.reflector.get<CacheableOptions>(
CACHEABLE_KEY,
instance[methodName],
);
if (cacheOptions) {
console.log(`Found @Cacheable on ${instance.constructor.name}.${methodName}`);
this.wrapMethod(instance, methodName, cacheOptions);
}
},
);
});
}
private wrapMethod(instance: any, methodName: string, options: CacheableOptions) {
const originalMethod = instance[methodName];
// 4. 원본 메서드를 캐싱 로직으로 감싸기 (Monkey Patching)
instance[methodName] = async function (...args: any[]) {
const cacheKey = options.key.replace(/\{\{(\w+)\}\}/g, (_, key) => args[0]);
// (가상의 함수) 캐시 확인
const cached = await getFromCache(cacheKey);
if (cached) {
return cached;
}
// 원본 메서드 실행
const result = await originalMethod.apply(this, args);
// (가상의 함수) 캐시 저장
await saveToCache(cacheKey, result, options.ttl);
return result;
};
}
}Module에 등록
@Module({
imports: [DiscoveryModule],
providers: [CacheExplorerService],
})
export class CacheModule {} 여기까지는 순조롭다. 코드를 실행하면 실제로 @Cacheable이 붙은 메서드를 찾아내고 래핑하는 것을 확인할 수 있다. 하지만 진짜 문제는 AOP 모듈이 ‘외부 의존성’을 갖는 순간 시작된다.
순환 참조(Circular Dependency)의 늪
실제 캐싱을 구현하려면 RedisService 같은 저장소 의존성이 필요하다. 그래서 CacheExplorerService에 RedisService를 주입하는 순간, 지옥문이 열린다.
만약 @Cacheable을 적용하려는 타겟 서비스인 UserService가 RedisService를 의존하고 있다면 어떻게 될까?
// user.service.ts
@Injectable()
export class UserService {
constructor(
// UserService가 Redis에 의존
private readonly redisService: RedisService,
) {}
@Cacheable({ ttl: 3600, key: 'user:{{userId}}' })
async getUserProfile(userId: string) {
// ...
}
}
이제 의존성 그래프는 꼬리에 꼬리를 물게 된다.
CacheModule
└─ CacheExplorerService (AOP 로직)
└─ RedisService (의존)
UserModule
└─ UserService (@Cacheable 적용 대상)
└─ RedisService (의존)
└─ (CacheExplorerService가 런타임에 UserService를 건드림)
CacheExplorerService가 모든 Provider를 탐색하고 래핑하려면, 모든 Provider가 이미 인스턴스화되어 있어야 한다. 하지만 NestJS가 UserService를 생성하려고 보니, RedisService가 필요하고, 다시 CacheExplorerService와 엮이는 복잡한 상황에서 초기화 순서가 꼬여버린다.
결국 서버를 실행하면 다음과 같은 악명 높은 에러를 마주하게 된다.
Error: Nest cannot create the UserService instance. The module at index [1] of the UserService “imports” array is undefined.
Potential causes:
A circular dependency between modules. Use forwardRef() to avoid it.
…
혹은 더 직접적인 순환 참조 에러가 발생하기도 한다.
[Nest] ERROR [ExceptionHandler] A circular dependency has been detected (please, make sure that each side of a bidirectional relationships are using “forwardRef()”).
Scope [AppModule -> CacheModule -> CacheExplorerService]
forwardRef()로 해결되지 않는 이유
공식 문서에서는 순환 참조를 forwardRef()로 해결하라고 한다. 하지만 이 경우엔 통하지 않는다. forwardRef()는 정적인 모듈 간의 참조를 해결해주지만, 지금 우리가 하는 것은 DiscoveryService를 이용해 런타임에 동적으로 모든 컴포넌트를 뒤지고 다니는 작업이기 때문이다.
onModuleInit()이 실행되는 시점에 일부 Provider는 아직 초기화되지 않았거나, 의존성 해결이 덜 된 상태일 수 있다. 이 불안정한 시점에 인스턴스를 꺼내서 메서드를 덮어씌우는(Wrapping) 행위는 NestJS의 라이프사이클과 정면으로 충돌한다.
결국, Service Layer AOP를 직접 구현하는 것은 이론적으로는 가능하나, 실무에서 유지보수하기에는 너무나 큰 위험(Risk)을 동반한다.
그렇다면 우리는 이 마법 같은 기능을 포기해야 할까? 다행히도, 이 복잡한 문제를 아주 우아하게 해결해주는 라이브러리가 있다.
데코레이터 지연평가 (Lazy Decorator) (feat. @toss/nestjs-aop)
앞선 섹션에서 우리는 Service Layer에 AOP를 직접 구현하려다 순환 참조(Circular Dependency) 라는 거대한 벽에 부딪혔다.
이 문제의 본질은 평가 시점 에 있다.
문제의 본질: 시점이 틀렸다
일반적인 TypeScript 데코레이터는 클래스가 선언되는 시점, 즉 앱이 부트스트랩되기도 전에 즉시 실행된다.
function ImmediateDecorator() {
console.log('1. 데코레이터 평가: 클래스 로딩 시점');
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log('2. 데코레이터 실행: 아직 NestJS 컨테이너는 태어나지도 않음');
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
// ! 이 시점에는 RedisService 인스턴스를 가져올 방법이 없다.
// ! this.redisService? 아직 주입되지 않았을 수도 있다.
const cached = await getFromRedis(key);
};
};
}
데코레이터 실행 시점에는 IoC 컨테이너가 없으므로 RedisService 같은 프로바이더를 주입받을 수 없다. 이를 우회하려고 onModuleInit 시점에 래핑(Wrapping)을 시도하면, AOP 엔진과 타겟 서비스가 서로를 의존하게 되어 NestJS가 초기화 순서를 정하지 못하고 죽어버린다.
해결책: 마킹만 먼저 해놓고, 래핑은 나중에
이 난제를 해결하기 위한 방법은 바로 Lazy Decorator(지연 평가 데코레이터) 패턴이다. 핵심 아이디어는 간단하다.
클래스 선언 시점에는 ‘이거 AOP 필요함’ 이라고 포스트잇(Metadata)만 붙여두자. 실제 메서드 래핑은 모든 프로바이더가 생성되고 난 뒤(onModuleInit) 에 안전하게 수행하자.
Lazy Decorator의 흐름
-
선언 시점 : @Cacheable 데코레이터는 메타데이터만 남기고 아무 일도 하지 않는다.
-
부트스트랩 : NestJS가 RedisService, UserService, AopEngine 등 모든 인스턴스를 생성한다. (순환 참조 없음)
-
초기화 완료(onModuleInit) : AopEngine이 메타데이터가 붙은 메서드를 찾아서, 이미 생성된 RedisService를 활용해 메서드를 감싼다.
실전 구현: @toss/nestjs-aop
바퀴를 다시 발명할 필요는 없다. 토스에서 이 패턴을 라이브러리로 만들어 오픈소스로 공개했다. 이를 활용해 Service Layer 캐싱(@Cacheable) 을 구현해보자.
@toss/nestjs-aop
A way to gracefully apply AOP to nestjs
라이브러리 설치 및 설정
먼저 패키지를 설치하고 AppModule에 등록한다.
npm install @toss/nestjs-aop// app.module.ts
import { AopModule } from '@toss/nestjs-aop';
@Module({
imports: [
AopModule, // AopModule 전역 등록
// ...
],
})
export class AppModule {}데코레이터 정의 (Marking)
메타데이터를 남기는 데코레이터를 정의한다. 이 데코레이터는 단순히 심볼(Key)과 옵션을 저장하는 역할만 한다.
// decorators/cacheable.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CACHEABLE_KEY = Symbol('CACHEABLE');
export interface CacheableOptions {
ttl: number;
key?: string; // 예: 'user:{{id}}'
}
export const Cacheable = (options: CacheableOptions) =>
SetMetadata(CACHEABLE_KEY, options);Lazy Decorator 구현 (Logic)
이제 실제 AOP 로직을 담당할 클래스를 만든다. 여기서 중요한 점은 이 클래스도 NestJS의 Provider라는 점이다. 즉, 생성자 주입을 자유롭게 사용할 수 있다!
// decorators/cacheable-lazy.decorator.ts
import { Injectable, Inject } from '@nestjs/common';
import { Aspect, LazyDecorator, WrapParams } from '@toss/nestjs-aop';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { CACHEABLE_KEY, CacheableOptions } from './cacheable.decorator';
// 1. @Aspect 데코레이터로 "이 클래스는 CACHEABLE_KEY를 처리한다"고 선언
@Aspect(CACHEABLE_KEY)
@Injectable()
export class CacheableLazyDecorator implements LazyDecorator<any, CacheableOptions> {
// 2. DI를 통해 CacheManager를 안전하게 주입받음 (순환 참조 걱정 끝!)
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
// 3. 실제 래핑 로직
wrap({ method, metadata: options }: WrapParams<any, CacheableOptions>) {
return async (...args: any[]) => {
// 캐시 키 생성 (간단한 파싱 로직)
const key = options.key
? options.key.replace(/\{\{(\w+)\}\}/g, (_, k) => args[0][k] || args[0])
: `${method.name}:${JSON.stringify(args)}`;
// 캐시 조회
const cached = await this.cacheManager.get(key);
if (cached) {
console.log(`✅ Cache Hit: ${key}`);
return cached;
}
// 원본 메서드 실행
console.log(`❌ Cache Miss: ${key}`);
const result = await method(...args);
// 캐시 저장
await this.cacheManager.set(key, result, options.ttl);
return result;
};
}
}모듈 등록 및 사용
마지막으로 CacheableLazyDecorator를 모듈의 providers에 등록하고, 서비스에서 사용하면 된다.
// user.module.ts
@Module({
imports: [CacheModule.register()],
providers: [
UserService,
CacheableLazyDecorator // Provider로 등록 필수
],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
// 드디어 Service Layer에서 우아한 캐싱이 가능하다!
@Cacheable({ ttl: 60000, key: 'user:{{userId}}' })
async getUserProfile(userId: string) {
return await this.userRepository.findOne(userId);
}
} 마법이 일어나는 원리 (Under the hood)
어떻게 순환 참조가 발생하지 않는 걸까? 실행 순서를 보면 명확해진다.
-
Bootstrap : NestJS가
UserService,CacheManager,CacheableLazyDecorator를 모두 생성한다. 이때는 서로 참조하지 않는다. -
OnModuleInit : @toss/nestjs-aop 의
AutoAspectExecutor가 실행된다. -
Scan & Wrap :
AutoAspectExecutor가UserService의getUserProfile에@Cacheable이 붙은 것을 발견한다. -
Binding : 이미 생성되어 있는
CacheableLazyDecorator를 가져와서getUserProfile메서드를wrap함수로 감싼다.
결과적으로 DI가 완료된 후에 로직을 주입하기 때문에, 우리는 아무런 부작용 없이 강력한 AOP 기능을 Service Layer에서 누릴 수 있게 된 것이다.
Tip: 메타데이터 보존 (Metadata Preservation)
여러 데코레이터를 겹쳐 쓸 때(예: @Cacheable 위에 @Transactional), 래핑된 함수가 원본 함수의 메타데이터를 잃어버리면 다른 데코레이터가 동작하지 않을 수 있다.
@toss/nestjs-aop 는 내부적으로 Object.setPrototypeOf 등을 활용해 래핑된 함수가 원본 함수의 메타데이터를 상속받도록 처리해준다. 덕분에 우리는 안심하고 여러 데코레이터를 조합(Composition)해서 사용할 수 있다.
시리즈를 마치며
여정을 돌아보며
NestJS 해체분석기 시리즈를 통해 “겉으로 보이는 우아함” 너머의 세계를 탐험했다.
-
1편: Express의 자유로움이 가져온 혼란 속에서 체계와 구조를 갈망했던 Node.js 생태계의 염원을 확인했다.
-
2편: 모듈 시스템과 DI(의존성 주입) 의 원리를 파헤치며, IoC 컨테이너가 단순한 객체 저장소가 아니라 의존성 그래프를 해석하는 정교한 엔진임을 깨달았다.
-
3편: 가드와 인터셉터를 통해 AOP(관점 지향 프로그래밍) 의 실체를 마주하고, 횡단 관심사 분리가 유지보수성의 핵심임을 확인했다.
-
4편: 예외 필터와 파이프를 통해 NestJS가 제공하는 선언적 검증과 일관된 에러 처리 메커니즘을 체험했다.
그리고 이번 5편에서 드디어 마법의 정체(Under the hood) 를 밝혔다. 데코레이터와 리플렉션은 단순한 문법적 설탕(Syntactic Sugar)이 아니라, 메타데이터를 기반으로 한 거대한 코드 생성 시스템이었다.
특히 Service Layer AOP를 구현하며 겪었던 순환 참조의 고통과 LazyDecorator라는 실용적 해법은, 프레임워크를 “그냥 쓰는 것”과 “이해하고 쓰는 것”의 차이를 절감하게 해주었다.
현재의 NestJS: 실험실 위의 모래성?
하지만 여기서 우리는 불편한 진실을 하나 마주해야 한다. 우리가 지금까지 찬양했던 이 모든 마법은 ‘비표준’ 기술 위에 서 있다.
NestJS를 사용하는 프로젝트의 tsconfig.json을 열어보면 항상 켜져 있는 두 가지 옵션이 있다.
{
"compilerOptions": {
"experimentalDecorators": true, // "실험적" 기능
"emitDecoratorMetadata": true // 표준 아님 (TypeScript 전용)
}
}
이 옵션들은 이름 그대로 “실험적(Experimental)” 이다. 2015년경 제안된 초기 스펙(Stage 1/2)을 바탕으로 구현되었지만, 정작 표준화 과정에서는 탈락하거나 크게 변경되었다.
더욱이 NestJS의 생명줄과도 같은 reflect-metadata 라이브러리 또한 정식 표준이 아니다. 즉, 현재 NestJS 생태계는 ‘표준화되지 않은 실험적 문법’ 과 ‘TypeScript 전용 메타데이터 시스템’ 에 전적으로 의존하고 있다.
다가오는 미래: TC39 Stage 3와 NestJS의 딜레마
2022년, 드디어 새로운 데코레이터 표준(Stage 3) 이 승인되었고, TypeScript 5.0부터 이를 정식 지원하기 시작했다. 하지만 NestJS는 여전히 옛날 방식(Legacy)을 고수하고 있다. 왜일까?
가장 큰 이유는 파라미터 데코레이터(Parameter Decorators) 의 부재다.
표준 스펙 위원회(TC39)는 성능과 복잡성 문제로 파라미터 데코레이터를 제외했다. 하지만 NestJS에게 @Param, @Body, @Req 없는 컨트롤러는 상상할 수 없다.
이 문제에 대해 NestJS의 창시자 Kamil Myśliwiec은 GitHub 이슈에서 다음과 같이 입장을 밝혔다.
Issue #11414: Support TC39 decorators
Kamil: I don't think we'll support JS decorators till the metadata support & parameter decorators are implemented.
메타데이터 지원과 파라미터 데코레이터가 구현되기 전까지는 (표준) JS 데코레이터를 지원할 생각이 없습니다.
결국 NestJS와 TypeORM을 비롯한 거대 생태계는 당분간 ‘실험적 데코레이터’에 묶여있을 수밖에 없는 상황이다.
다음 시리즈 예고: 본질로 돌아가다
프레임워크는 언어 위에 지어진 성이다. 언어(JavaScript/TypeScript)의 표준이 바뀌면 성은 흔들릴 수밖에 없다. 언젠가 표준 데코레이터로의 대이동이 시작될 때, 단순히 “문법이 바뀌었네”라며 당황할 것인가, 아니면 “내부 구조가 이렇게 변하겠군”이라며 변화를 주도할 것인가? 그 답을 찾아보기 위해, 프레임워크를 걷어내고, 그 밑바탕을 이루는 언어와 런타임의 심연으로 들어가보려고 한다.
- JavaScript: 실행 컨텍스트, 클로저, 프로토타입의 실체
- Node.js: 이벤트 루프와 비동기 I/O (libuv), V8 엔진의 메모리 구조
- TypeScript: 타입 시스템의 원리(Structural Typing)와 컴파일러의 마법
References
NestJS 환경에 맞는 Custom Decorator 만들기 | Toss Tech
NestJS에서 제공하는 DiscoveryService와 MetadataScanner를 활용하여, 런타임에 메서드를 탐색하고 래핑하는 커스텀 데코레이터 구현 방법을 상세히 소개합니다.
toss/nestjs-aop
순환 참조 문제없이 NestJS의 프로바이더를 데코레이터에서 자유롭게 사용할 수 있게 해주는 Lazy Decorator 패턴 구현체입니다.
NestJS Custom Caching Decorator 만들기 | ZUM 기술 블로그
기본 캐시 인터셉터의 한계를 넘어, 커스텀 Key 생성 전략과 TTL 설정을 지원하는 유연한 캐싱 데코레이터 구현 과정을 다룹니다.
Deep Dive into NestJS Decorators: Internals, Usage, and Custom Implementations
NestJS 데코레이터가 내부적으로 어떻게 동작하는지, 그리고 reflect-metadata가 어떤 역할을 하는지 깊이 있게 분석한 글입니다.
NestJS 및 tsyringe에서 AOP 데코레이터를 사용하여 애플리케이션 동작 향상
데코레이터를 활용하여 로깅, 캐싱 등 횡단 관심사를 모듈화하고 AOP를 적용하는 실용적인 패턴을 설명합니다.
Bring Spring-Style AOP to NestJS with nestjs-saop
Spring 프레임워크의 강력한 AOP 스타일을 NestJS로 가져와 사용하는 방법과 라이브러리를 소개합니다.
Implementing AOP in NestJS: Lessons from Spring and TypeScript
Spring과 TypeScript의 경험을 바탕으로 NestJS에서 AOP를 구현하는 방법과 교훈을 공유합니다.
[NestJS] AOP 패턴 적용하기 (feat. 데코레이터, 메타데이터)
NestJS에서 데코레이터와 메타데이터를 활용하여 AOP 패턴을 직접 구현해보는 예제와 설명을 담고 있습니다.
Lazy loading modules | NestJS Documentation
애플리케이션 초기 구동 속도를 개선하기 위해 모듈을 지연 로딩(Lazy Loading)하는 공식 가이드입니다.