그동안 NestJS로 개발을 하면서, class-validator와 같은 라이브러리를 사용하는 것은 매우 일상적인 일이었다.
별다른 고민 없이 데이터 유효성 검사를 자동으로 처리하는 데 class-validator를 사용해 왔지만,
실제로 내부에서 어떻게 작동하는지에 대한 깊은 이해 없이 그저 편리함에 의존해왔다.
하지만 이제는 그 원리를 제대로 이해하고, 더 창의적으로 Custom Decorator를 생성해서 활용해 보고자 한다.
이번 포스팅에서는 Reflect Metadata를 활용하여 런타임에도 타입 정보를 유지하고 검증하는 방법과
Custom Decorator를 만드는 법을 알아보겠다.
Reflect Metadata란?
TypeScript는 컴파일 시점에 타입 정보를 가지고 있지만, 컴파일 후 JavaScript로 변환되면 그 정보가 사라진다.
이때 Reflect Metadata를 사용하면 런타임에도 타입 정보를 유지하고 활용할 수 있다.
이는 주로 데코레이터를 사용할 때 추가적인 메타데이터를 주고받기 위해 사용되며,
클래스, 메서드, 파라미터 등의 메타정보를 코드에 삽입하거나 수정할 수 있게 해준다.
Reflect Metadata의 주요 요소
Reflect Metadata를 사용하기 위해서는 다음과 같은 요소들이 필요하다:
1. 메타데이터의 키: 메타데이터를 식별하기 위한 고유한 키.
2. 메타데이터 키에 저장할 값: 메타데이터 키에 매핑되어 저장될 값.
3. 메타데이터를 저장할 객체: 메타데이터를 저장할 대상 객체.
4. (선택 사항) 메타데이터를 저장할 객체의 프로퍼티: 메타데이터를 저장할 객체의 특정 프로퍼티.
Method Decorator
function TestMethodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {}
1. target: 데코레이터가 적용된 메서드가 속한 클래스의 프로토타입 또는 생성자 함수.
- 스태틱 메서드인 경우 생성자 함수.
- 인스턴스 메서드인 경우 프로토타입.
2. propertyKey: 데코레이터가 적용된 메서드의 이름.
3. descriptor: 메서드의 속성 디스크립터로, { value, writable, enumerable, configurable } 등의 속성을 가진다.
Descriptor attributes의 역할에 대해서도 간략하게 집고 넘어가자.
1. value:
- 메서드 자체를 나타내는 값이다. 즉, 해당 메서드의 실제 구현이 저장된 속성이다.
2. writable:
- 이 속성이 true일 경우, 메서드의 value를 수정할 수 있다. false일 경우 메서드를 덮어쓸 수 없게 된다.
(값 변경시 에러는 안뜨지만 절때 안바뀐다.)
3. enumerable:
- 이 속성이 true일 경우, 객체의 속성을 열거할 때(예: for...in 루프) 이 메서드가 포함된다. false일 경우 열거되지 않는다.
(false로 설정해두고 값 변경시 값이 안보이지만 그 값이 없어진건 아님 호출하면 나온다.)
4. configurable:
- 이 속성이 true일 경우, 나중에 메서드의 속성(예: writable 등)을 수정하거나, 메서드 자체를 삭제할 수 있다.
false일 경우, 속성을 변경하거나 삭제할 수 없게 된다.
(writable 속성은 한 번만 true에서 false로 변경할 수 있다. false로 설정한 후에는 다시 true로 되돌릴 수 없다.)
데코레이터와 Reflect Metadata 예제 code
아래의 예제 코드를 통해 Reflect Metadata와 Custom Decorator를 어떻게 활용하는지 알아보자.
class Idol {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
sing(style: 'hiphop' | 'rock') {
return `${this.name}이 ${style} 노래를 부릅니다.`;
}
}
sing 메서드는 style 매개변수로 'hiphop' 또는 'rock'만을 허용한다.
하지만 컴파일 후 JS에서는 타입 정보가 사라지기 때문에, 다른 값이 들어와도 런타임에서는 오류가 발생하지 않는다.
이것이 문제점이자, Reflect Metadata 를 사용하는 가장 큰 이유이다.
매번 메서드 내부에서 조건문으로 검증하는 것은 번거롭고 매개변수가 많아질수록 코드가 아주 지저분해진다.
따라서 Reflect Metadata와 데코레이터를 사용하여 런타임에서도 매개변수 값을 제한할 수 있다.
이제 적용한 코드를 살펴보자.
npm install --save reflect-metadata
import 'reflect-metadata';
Reflect Metadata 설치하고 import 시켜준다.
const restrictParamValueKey = Symbol('restrictParamValue');
interface RestrictionInfo<T> {
index: number;
restrictedValues: T[];
}
function RestrictParamValue<T>(restrictedValues: T[]) {
return (target: any, propertyKey: string, index: number) => {
const prevMeta = Reflect.getOwnMetadata(restrictParamValueKey, target, propertyKey) ?? [];
const info = {
index,
restrictedValues,
};
Reflect.defineMetadata(restrictParamValueKey, [...prevMeta, info], target, propertyKey);
};
}
매개변수 데코레이터 생성
여기서 Symbol('restrictParamValue')는 고유한 메타데이터 키를 생성한다.
이 키를 통해 메타데이터에 접근할 수 있다.
RestrictParamValue는 허용할 값들의 배열을 인자로 받아 매개변수 데코레이터를 반환한다.
내부 함수는 데코레이터의 표준 형태로, target, propertyKey, index를 인자로 받는다.
- target: 데코레이터가 적용된 메서드가 속한 객체.
- propertyKey: 메서드의 이름.
- index: 매개변수의 인덱스.
Reflect.getOwnMetadata를 통해 기존에 저장된 메타데이터를 가져온다. ??로 없으면 빈 배열을 사용한다.
info 객체에 현재 매개변수의 인덱스와 허용할 값들을 저장한다.
Reflect.defineMetadata를 통해 메타데이터를 갱신한다.
function ValidateMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const metas = Reflect.getOwnMetadata(restrictParamValueKey, target, propertyKey) ?? [];
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
for (const meta of metas) {
const argValue = args[meta.index];
if (!meta.restrictedValues.includes(argValue)) {
throw new Error(`매개변수 ${meta.index}의 값 "${argValue}"는 허용되지 않습니다.`);
}
}
return originalMethod.apply(this, args);
};
}
메서드 데코레이터 생성
ValidateMethod는 메서드 데코레이터로, 메서드 실행 전에 매개변수 검증을 수행한다.
Reflect.getOwnMetadata를 통해 해당 메서드에 저장된 매개변수 제한 정보를 가져온다.
originalMethod에 원래의 메서드 구현을 저장한다.
descriptor.value를 재정의하여 매개변수 검증 로직을 추가한다.
- 각 매개변수에 대해 허용된 값인지 검사한다.
- 허용되지 않은 값이 있으면 에러를 throw한다.
- 검증이 통과하면 원래의 메서드를 실행한다.
class Idol {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
@ValidateMethod
sing(@RestrictParamValue(['hiphop', 'rock']) style: string) {
return `${this.name}이 ${style} 노래를 부릅니다.`;
}
}
데코레이터 적용
@RestrictParamValue(['hiphop', 'rock']) 데코레이터로 sing 메서드의 style 매개변수에 허용할 값들을 지정한다.
@ValidateMethod 데코레이터로 메서드 실행 전에 매개변수 검증을 수행하도록 한다.
const idol = new Idol('Newjeans', 20);
console.log(idol.sing('hiphop')); // 정상 동작
console.log(idol.sing('ballad')); // 오류 발생: 매개변수 0의 값 "ballad"는 허용되지 않습니다.
허용되지 않은 'ballad'를 전달하면, 에러가 발생한다.
한번 더 전체적인 흐름을 정리해보자.
1. 메타데이터 키 생성: 메타데이터를 저장하고 읽을 때 사용할 고유한 키를 생성한다.
2. 매개변수 데코레이터: 매개변수에 허용할 값들을 지정하고, 해당 정보를 메타데이터에 저장한다.
3. 메서드 데코레이터: 메서드 실행 전에 메타데이터를 참고하여 매개변수를 검증한다.
4. 데코레이터 적용: 클래스의 메서드와 매개변수에 데코레이터를 적용하여 검증 로직을 추가한다.
메서드 실행 시간을 측정하는 Custom Decorator 만들기
이제 전반적인 흐름에 대해 이해를 했으니 간단한 Custom Decorator를 만들어보자.
애플리케이션을 개발하다 보면 성능 최적화가 필요한 부분을 식별해야 할 때가 많다.
어떤 메서드가 오래 걸리는지, 병목 현상이 어디서 발생하는지 알기 위해서는 메서드의 실행 시간을 측정하는 것이 중요하다.
예전에 CloudWatch로 메서드의 실행 시간을 비교해서 모니터링 하는것을 만들어 보기도 했었다.
https://ilikezzi.tistory.com/71
이번에는 간단하게 실행 시간이 특정 임계값(threshold)을 초과할 때만 로그를 출력하는
LogExecutionTime 데코레이터의 코드를 짜보자.
function LogExecutionTime(threshold: number = 0) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = Date.now();
const result = originalMethod.apply(this, args);
const finish = Date.now();
const time = finish - start;
if (time >= threshold) {
console.log(`${propertyKey} 실행 시간: ${time}ms`);
}
return result;
};
};
}
데코레이터 팩토리: LogExecutionTime은 데코레이터 팩토리 함수로, threshold라는 매개변수를 받는다.
이 매개변수는 실행 시간이 이 값 이상일 때만 로그를 출력하도록 한다.
데코레이터 함수 반환: 데코레이터 팩토리는 실제 데코레이터 함수를 반환한다.
이 함수는 target, propertyKey, descriptor를 매개변수로 받는다.
(해당 매개변수에 대한 설명은 위에서 작성했어서 생략한다)
원래 메서드 저장: const originalMethod = descriptor.value;를 통해 원래의 메서드 구현을 저장해둔다.
이후에 이 메서드를 호출해야 하므로 따로 보관한다.
메서드 재정의: descriptor.value에 새로운 함수를 할당하여 메서드를 재정의한다.
이 새로운 함수는 실행 시간 측정 로직을 포함한다.
실행 시간 측정: 메서드 실행전 (start), 메서드 실행 후(finish)로 실행 시간을 계산한다.
조건부 로그 출력: if (time >= threshold) { ... }를 통해 실행 시간이 임계값 이상인 경우에만 로그를 출력한다.
결과 반환: return result;를 통해 원래 메서드의 실행 결과를 반환한다.
class Calculator {
@LogExecutionTime(10) // 실행 시간이 10ms 이상인 경우에만 로그 출력
fibonacci(n: number): number {
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
}
const calc = new Calculator();
calc.fibonacci(10);
fibonacci 실행 시간: 55ms
@LogExecutionTime(10) 데코레이터를 fibonacci 메서드에 적용해보았다.
이 데코레이터는 실행 시간이 10ms 이상인 경우에만 실행 시간을 로그로 출력한다.
calc.fibonacci(10);을 호출하면 피보나치 수를 계산하면서 실행 시간이 측정된다.
n의 값이 커질수록 재귀 호출이 많아져 실행 시간이 길어지므로, 임계값을 적절히 조정하면 된다.
마지막으로 방금 데코레이터에 기능을 좀 더 추가한 데코레이터를 살펴보자.
function LogExecutionTime(threshold: number = 0) {
let maxTime = 0;
let maxMethod = '';
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = Date.now();
const result = originalMethod.apply(this, args);
const finish = Date.now();
const time = finish - start;
if (time >= threshold) {
console.log(`${propertyKey} 실행 시간: ${time}ms`);
}
if (time > maxTime) {
maxTime = time;
maxMethod = propertyKey;
}
return result;
};
// 클래스가 로드될 때 실행되는 코드
process.on('exit', () => {
console.log(`가장 오래 걸린 메서드: ${maxMethod} (${maxTime}ms)`);
});
};
}
이 코드는 LogExecutionTime 데코레이터를 정의하여 메서드의 실행 시간을 측정하고,
임계값을 초과하면 로그를 출력하며, 가장 오래 걸린 메서드를 추적한다.
(전반적인 프로세스는 다 똑같아서 설명은 생략하겠다)
마치며
첫번째 예시를 잘 이해했다면 따라오기 쉬웠을 것이다.
이렇게 데코레이터를 사용하면 중복 코드 제거, 관심사의 분리, 깔끔한 코드 등 장점이 있으나, 물론 단점도 존재한다.
디버깅 어려움: 데코레이터를 통해 메서드가 재정의되므로 디버깅 시 호출 스택이 복잡해질 수 있다.
성능 영향: 데코레이터 자체가 추가 연산을 수행하므로 성능에 민감한 부분에서는 주의해야 한다.
가독성 저하: 데코레이터가 너무 많으면 코드가 복잡해져 가독성이 떨어질 수 있다.
이번 포스팅에서는 Reflect Metadata와 Custom Decorator를 활용하여 런타임에서도
타입 정보를 유지하고 검증하는 방법을 알아보았다.
우리가 NestJS와 같은 프레임워크에서 이미 편리하게 사용하고 있던 class-validator 등의 라이브러리가
내부적으로 어떻게 동작하는지 이해하게 되면서, 그 원리를 직접 적용해 보았다.
Reflect Metadata를 사용하면 런타임에도 타입 정보를 유지하며, 메서드와 매개변수에 대한 검증을 더욱 쉽게 할 수 있다.
또한, 메서드의 실행 시간을 측정하거나, 메서드의 동작을 제한하는 등 다양한 커스텀 데코레이터를 통해
코드의 중복을 줄이고 가독성을 높일 수 있었다.
물론, 데코레이터의 남용은 코드의 복잡성을 높이고 디버깅을 어렵게 할 수 있기 때문에 적절히 사용하는 것이 중요하다.
앞으로도 TypeScript의 강력한 기능들을 깊이 있게 이해하고 활용하여 더욱 효율적이고 안정적인 코드를 작성하도록 해야겠다!
참조 :
https://www.npmjs.com/package/reflect-metadata
https://www.typescriptlang.org/ko/docs/handbook/decorators.html
'Programming Language > Typescript' 카테고리의 다른 글
[Typescript] class-transformer 끝장내기 (0) | 2023.03.06 |
---|