서론
스프링에서 어노테이션을 통해 AOP를 적용했던 기억이 있어 타입스크립트에서도 비슷하게 적용할 수 있는지 궁금해졌다.
사실 타입스크립트로 로또 뽑기를 구현하다가 try-catch가 너무 반복되는게 신경 쓰여서 AOP를 쓰려고 한건데,
결론적으로 적용은 못하고(ㅋㅋ) 공부만 했다. 나중에 써먹을 데가 있겠지...🥲
각설하고 AOP를 타입스크립트에 적용하기 위해 찾아서 알게 된 건 Decorator 였다.
타입 스크립트 공식 문서에선 Decorator를 이렇게 소개한다.
데코레이터는...
- 클래스 선언과 멤버에 어노테이션과 메타-프로그래밍 구문을 추가할 수 있는 방법을 제공한다.
- 클래스 선언, 메서드, 접근자, 프로퍼티 또는 매개 변수에 첨부할 수 있는 특수한 종류의 선언이다.
- @expression 형식을 사용하며, 여기서 expression은 데코레이팅 된 선언에 대한 정보와 함께 런타임에 호출되는 함수여야 한다.
쉽게 말하자면 아래처럼 쓸 수 있는 거다.
// 데코레이터들은 클래스와 클래스 내 메서드, 프로퍼티, 접근자, 매개 변수에 한해 적용할 수 있다고 생각하면 편하다
@classDecorator
class DecoratedClass {
@propertyDeorator
private _decoratedProperty: string;
@methodDecorator
decoratedMethod(@parameterDecorator param: string) {
// do something
}
@accessorDecorator
get decoratedProperty() {
return this._decoratedProperty;
}
}
스프링 프레임워크를 사용해봤다면 이 코드가 좀 더 와닿을 수도 있겠다.
@Controller("api")
export class UsersController {
@Get("users")
getAllUsers(): User[] {
// get all user
}
@Get("users/:id")
getUser(@Param("id") id: string): User {
// get sepcific user by id
}
@Post("users")
createUser(@Body() body: CreateUserInput): User {
// make user with user info input
}
}
데코레이터는 아직 실험적으로 지원 중인 거라 따로 커맨드 라인을 쓰거나 컴파일러 옵션을 활성화해야 한다.
compilerOptions-target은 ES5 이상이어야 한다.
나중에 다시 설명하겠지만, 데코레이터를 만들 때 인자로 쓰이는 PropertyDescriptor는 ES5부터 생겨났기 때문이다.
(이전 버전에서는 PropertyDescriptor를 타입으로 가진 변수는 undefined가 뜬다.)
- 커맨드 라인
tsc --target ES5 --experimetalDecorators
- tsconfig.json:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
본론
처음엔 대충 훑어보고 아 그렇구나~😮 하고 바로 적용해보려고 했다.
하지만 대충해서 잘 되는 코딩은 늘 그렇듯이... 없었다 😇
그래서 기왕 하는 거 좀 더 파헤쳐보기로 했다!
데코레이터 시그니쳐
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (target: Function, propertyKey: string | symbol, parameterIndex: number) => void;
// 접근자 데코레이터의 경우는 메서드 데코레이터와 시그니쳐가 같다 :3
참고로 eslint를 쓰고 있다면(prettier 컨벤션 기준) 데코레이터를 만들 경우 매개 변수 타입 단에서 아래와 같은 경고를 마주할 수 있다.
데코레이터 사용이 불가피하다면 suppress를 하자.
데코레이터 합성
아래 예제와 같이 하나의 선언에 여러 데코레이터를 적용할 수 있다.
// 단일 행
@f @g x: string
// 다수의 행
@f
@g
x: string
수학의 합성 함수 (f(g(x))와 유사하게 표현은 f -> g 순으로 되어있지만, 호출은 g -> f 순으로 간다.
데코레이터 팩토리
데코레이터가 선언에 적용되는 방식, 즉 런타임에 호출할 표현식을 바꿀 때 사용한다.
아래 예시를 보자.
// 데코레이터 팩토리를 사용하면 아래 두 메서드처럼 데코레이터 표현식을 바꿀 수 있다.
function decorateFunctionWithFactory() {
return function (target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
console.log(`${methodName} is decorated!!`);
};
}
function decorateFunctionWithFactory2(value: string) {
return function (target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
console.log(`${methodName} is decorated!!`);
console.log(`given value is ${value}!!`);
};
}
function decorateFunction(target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
console.log(`${methodName} is decorated!!`);
}
// 데코레이터 사용하기
@decorateFunctionWithFactory
function doSomething() {
// code here
}
@decorateFunctionWithFactory2('hello')
function doSomethingFun() {
// code here
}
// 이 경우 시그니쳐 성립이 안 된다는 경고가 뜬다
@decorateFunction()
function doSomethingStupid() {
// code here
}
클래스 데코레이터
// 클래스 데코레이터
function decorateClass(target: Function) {
Object.seal(target);
console.log(`${target.name} is sealed!!`);
}
@decorateClass
class SomeClass {
// some code blah blah
}
// console
// > SomeCalss is sealed!!
매개 변수 target은 Function 타입으로, 새로운 클래스의 생성자가 인자로 들어온다. 클래스 데코레이터는 자신이 적용되는 클래스를 extend하여 로깅 등의 원하는 기능을 추가할 수 있다.
예시로 쓰인 Object.seal은 해당 객체를 봉인하여 새 프로퍼티를 추가하거나 기존 프로퍼티를 변경/삭제하는 걸 방지한다.
메서드 데코레이터
// 메서드 데코레이터(feat.데코레이터 합성)
function decorate() {
return function decorateFunction(target: any, methodName: string, propertyDescriptor: PropertyDescriptor) {
console.log(`${methodName} is decorated!!`);
}
}
class SomeClass {
// ...
@decorate()
function someMethod() {
// some code
}
}
// console
// >someMethod is decorated!!
메서드 데코레이터는 아래 세 개를 매개 변수로 받는다.
- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)
- 메서드명(string)
- propertyDescriptor(PropertyDescriptor)
propertyDescriptor는 ES5에서 처음 생긴 스펙으로 , 메서드 데코레이터는 자신이 적용되는 메서드의 property Descriptor를 수정해 메서드를 확장해나간다.
개인적으로 메서드 데코레이터를 만들려고 했을 때 난관을 많이 겪었다.
MS의 타입스크립트 깃헙 #2249 이슈에서는 ES7부터 메서드 데코레이터를 못 쓰게 한다고 했다.
하지만 난 es2017(ES8)를 쓰는 중인데 잘 됐다...😮
특정 구간에서만 못 쓰게 하는 건가 싶기도 하다. 공식 문서를 보면 메서드 데코레이터는 선언 파일, 오버로드 또는 기타 주변 건텍스트(예: 선언 클래스)에서 사용할 수 없다고 뜨기 때문이다. 사실 이것만 읽고 무슨 소린가 싶어서(;;) 여기저기 다 넣어본 결과 클래스 내에 있는 메소드면 데코레이터를 붙일 수 있다는 걸 알았다. (틀렸다면 지적 부탁드립니다...)
(+) 클래스 내 익명 메서드에도 넣을 수 있다! 다만 이 경우 프로퍼티 데코레이터 시그니쳐를 써야 하기에 데코레이터를 익명과 네임드 메서드에 골고루 적용해야 한다면 데코레이터 함수를 아래와 같이 변경하면 된다. 여길 참고했다.
function decorateFunction() {
return (target: Object, propertyKey: string, descriptor?: TypedPropertyDescriptor<any>) => {
// do something
}
}
접근자 데코레이터
// 접근자 데코레이터
function decorateAccessor() {
return function(target: any, propertyName: string, propertyDescriptor: PropertyDescriptor) {
console.log(`${propertyName} is decorated!!`);
}
}
class SomeClass {
private readOnly _someProperty: string;
// ...
@decorateAccessor()
get someProperty() {
return this._someProperty;
}
}
// console
// > _someProperty is decorated!!
접근자 데코레이터는 메서드 데코레이터와 같은 시그니쳐를 사용한다. getter와 setter에 적용할 수 있다.
다만 제약이 조금 있다. 하나의 데코레이터를 getter와 setter에 동시에 적용할 수 없다.
Given the fact that Property Descriptors contains both the setter and getter for a particular member, accessor decorators can only be applied to either the setter or the getter of a single member, not to both.
- How to Use Decorators in TypeScript
설명하자면, 데코레이터에 인자로 들어오는 Property Descriptors는 getter setter 둘 다 포함하고 있지만 각각의 접근자에 대한 Property Descriptors는 없다. 때문에 코드 순서상 먼저 오는 접근자에 한해서만 적용이 된다.
하지만 접근자에 데코레이터를 쓸 일이 생길까...? 🤔
프로퍼티 데코레이터
// 프로퍼티 데코레이터
function decorateProperty() {
return function (target: any, propertyKey: string) {
console.log(`${propertyKey} is decorated!!`);
};
}
class SomeClass {
@decorateProperty()
private readOnly _someProperty: number;
}
// console
// > _someProperty is decorated!!
프로퍼티 데코레이터는 두 가지 인자를 받는다.
- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)
- 프로퍼티명(string)
메서드 혹은 접근자 데코레이터와 다르게 PropertyDescriptor를 받지 않는다. 대신 Property Descriptor 형식의 객체를 return 하여 프로퍼티의 설정을 변경할 수 있다.
매개변수 데코레이터
// 매개 변수 데코레이터
function decorateParameter() {
return function (target: Function, propertyName: string, parameterIndex: number) {
console.log(`${propertyName} is decorated!! index is ${parameterIndex}`);
};
}
class SomeClass {
function(@decorateParameter someParameter: string) {
// some code
}
}
// console
// > someParmater is decorated!! index is 0
매개 변수 데코레이터는 아래의 세 개의 인자를 받는다.
- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)
- 매개 변수명(string)
- 매개 변수 인덱스(number)
매개 변수 데코레이터의 경우, 메서드 데코레이터와 함께 사용되거나 metadata를 활용하여 커스텀하는 편이라고 한다.
메서드 데코레이터와 함께 사용하는 경우는 이 링크의 예시가 좋으니 참고하자.
메타데이터
메타데이터란 쉽게 말하자면 데이터에 대한 데이터다. 이렇게 말하면 뭔가 싶을 거다. 아래 예시를 보자.
function hello() {
console.log("hello!");
}
이 hello 메소드에선 무엇을 얻을 수 있을까?
이렇게 콘솔에 찍힌 hello와 0은 메서드 hello의 속성 정보, 즉 메타데이터라고 볼 수 있다.
타입스크립트에서 메타데이터를 활용하려면 reflect-metadata 라이브러리를 추가하고 tsconfig.json에서 emitDecoratorMetadata 옵션을 설정해야 한다. reflect-metadata도 ES5 이후로 실험적으로 제공하는 API라 차후 변동이 생길 수도 있다 😔
// install reflect-metada library
npm i reflect-metadata --save
// add compiler option
tsc --target ES5 --emitDecoratorMetadata
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
메타데이터를 활용한 데코레이터는 이 링크나 여기를 참고하면 좋다.
여담
지금까지 타입스크립트에서 제공하는 Decorator의 다양한 적용 방법에 대해 알아보았다.
다 쓰고 보니 스프링 쓸 때도 커스텀 어노테이션을 많이 쓰지 않았는데 뻘하게 많이 찾아봤나 싶기도 하고...
그래도 Decorator는 TypeORM과 NestJS 등에서 많이 쓰인다고 하니 조만간 접하게 될 때 금방 이해할 수 있을 것 같다.
Decorator 외에도 타입스크립트에서의 AOP에 대해 좀 더 찾아보니 ts-aspect, AspectTS 같은 라이브러리도 있는 것 같다.
개인 취향에 맞게 가져다 쓰면 좋을 듯 하다!!
각 데코레이터들을 어떻게 적용하는지 간단하게 적어보았는데, 기회가 된다면 유용한 커스텀 데코레이터도 만들어서 소개해보도록 하겠다 :>
참고자료