[TypeScript] Decorator

Hoplin·2023년 3월 5일
0
post-thumbnail

Decorator?

데코레이터는 일종의 함수로,특정 함수를 호출할때 추가적인 기능을 제공해주는 문법이다.타입스크립트에서 Decorator는 실험적인 기능이다(하지만 매우 안정적인 문법이므로, 여러 프레임 워크, 프로젝트 등에서 많이 사용된다.). 그렇기 때문에, 데코레이터를 사용하기 위해서는 tsconfig.json파일의 experimentalDecorators를 true로 설정해 주어야한다.

    "compilerOptions": {
      ...
        "experimentalDecorators": true
      ...
    },

Decorator 맛보기

타입스크립트에서 데코레이터와 비슷한것은, 자바의 어노테이션 혹은 파이썬의 데코레이터가 있다. 잠시 예시로 파이썬 데코레이터 코드 형태를 살펴보자.

def trace(fn):
    def wrapper():
        print(fn.__name__ + " function called")
        fn()
    return wrapper

@trace
def fn1():
    print("Decorator test")

fn1()

타입스크립트에서의 데코레이터 형태는 아래와 같다

@(expression)

expression은 데커레이팅된 선언(클래스, 클래스 메소드, 매개변수 등. 일반 함수에는 적용되지 않는다.)에 대한 정보와 함께 호출되어야하는 함수이다.

자세히 들어가기 앞서 몸풀기를 위해 예시 파이썬 코드와 동일한 동작을 하는 타입스크립트 코드를 한번 작성해보자.

function trace(classPrototype:any, methodName:string,descriptor:PropertyDescriptor):any{
    console.log(`${methodName} function called`)
}

class DecoTest{
    @trace
    public fn1(){
        console.log('Decorator test')
    }
}

const dt = new DecoTest()
dt.fn1()

/*
결과 : 
fn1 function called
Decorator test
*/

데코레이터 팩토리

데코레이터 팩토리란, 데코레이터를 감싸는 래퍼함수이다. 함수에 인수를 전달하는것과 같이, 데코레이터에 인자를 전달할 수 있도록 변경이 가능하며, 이에 따라 데코레이터에 추가적인 동작을 선언할 수 있다.

다중 데코레이터는 합성함수와 같다.

데코레이터를 여러개 사용하는 경우도 있다. 예를 들어 하나의 메소드에 @first,@second순서대로 데코레이터를 연결하였다면 수학식으로 봤을때 first(second())형식으로 작동한다. 아래 예시코드를 통해 데코레이터 팩토리와 다중 데코레이터를 살펴보자

function first(str:string){
    console.log(`first() ${str} decorator called`)
    return function(classPrototype:any, methodName:string, descriptor:PropertyDescriptor):any{
        console.log("first() called")
    }
}

function second(str: string){
    console.log(`second() factory ${str} called`)
    return function(classPrototype:any, propertyKey:string,descriptor:PropertyDescriptor):any{
        console.log("second() decorator will change function's content")
        descriptor.value = () => {
            console.log("Changed Call")
        }
    }
}

class DecoTest2{
    @first("First ")
    @second("decorator")
    public method(){
        console.log("method() called")
    }
}

const dt2 = new DecoTest2();
dt2.method();

/*
first() First  decorator called
second() factory decorator called
second() decorator will change function's content
first() called
Changed Call
*/

TypeScript가 제공하는 데코레이터는 없다

타입스크립트에서 기본적으로 제공하는 데코레이터는 없다. 사용을 위해서는 데코레이터는 직접 구현하거나, 패키지 매니저(yarn, npm)를 사용해서 설치해야한다. 데코레이터를 구현하기 위해서는 특정 함수 호출 시그니처를 만족해야한다. 데코레이터 대상별로 만족해야하는 함수 호출 시그니처를 살펴보자

데코레이터 종류기대하는 함수 호출 시그니처
클래스({new (...any[]) => any}) => any
메소드(classPrototype: {}, methodName: string, descriptor: PropertyDescriptor) => any
정적메소드({new (...any[]) => any},methodName: string, descriptor: PropertyDescriptor) => any
메소드 매개변수(classPrototype: {}, paramName: string, index: number) => void
정적메소드 매개변수({new (...any[]) => any}, paramName: string, index:number) => void
프로퍼티{classPrototype: {}, propertyName:string} => any

데코레이터 해석 순서

여러가지의 데코레이터는 어떤 순서로 해석이 될까? 우선 아래 코드에는 클래스데코레이터, 메소드 데코레이터, 프로퍼티 데코레이터, 매개변수 데코레이터가 정의되어있다.

function classDeco(){
    console.log("Class deco evaluated")
    return function classDeco<T extends ClassConstructor>(Constructor: T){
        console.log("Class decorator")
        return class extends Constructor{

        }
    }    
}

function propertyDeco(){
    console.log("Property deco evaluated")
    return function(prototype:any, memberName:string){
        console.log("Property decorator")
    }
}

function paramDeco(){
    console.log("Param deco evaluated")
    return function(prototype: any, propertyKey: string, parameterIndex:number){
        console.log(parameterIndex)
        console.log("Param deco")
    }
}

function methodDeco2(){
    console.log("Method deco evaluated")
    return function(prototype:any, propertyKey:string, descriptor: PropertyDescriptor){
        console.log("Method deco")
    }
}


@classDeco()
class User{

    @propertyDeco()
    value:string=" "

    @methodDeco2()
    setP(@paramDeco() p1:string, @paramDeco() p2:number){

    }
}

const u1 = new User();
u1.setP('10',20)

위 코드의 결과는 아래와 같다

Property deco evaluated
Property decorator
Method deco evaluated
Param deco evaluated
Param deco evaluated
1
Param deco
0
Param deco
Method deco
Class deco evaluated
Class decorator

위 결과에서 알 수 있듯이, 아래 순서 대로 해석되는것을 알 수 있다.
1. 프로퍼티 데코레이터
2. 매개변수 데코레이터(여러개가 있는 경우, 인덱스 번호가 높은것부터 순차적으로)
3. 메소드 데코레이터
4. 클래스 데코레이터

클래스 데코레이터

클래스 데코레이터는 클래스 앞에 선언이 된다. 클래스 데코레이터는 클래스 생성자에 적용이 되어 클래스 기존 정의를 읽거나 수정할 수 있다. 위에서 볼 수 있듯이, 클래스 데코레이터는 생성자를 함수 호출 시그니처로 요구한다. 간단하게 클래스 데코레이터를 작성해보자.

type ClassConstructor = new (...args: any[]) => any;

function ClassDecoTest<T extends ClassConstructor>(Constructor: T){
    return class extends Constructor{
        newURL = "www.google.com"
    }
}

@ClassDecoTest
class Reporter{
    type="reporter"
    public main(){
        console.log("main()")
    }
}

const rpt = new Reporter();

console.log(rpt) //Reporter { type: 'reporter', newURL: 'www.google.com' }

console.log(rpt.newURL) // 'Reporter' 형식에 'newURL' 속성이 없습니다.ts(2339)

위 예시를 실행하면, 오류가 발생한다. Reporter클래스의 인스턴스인 rpt의 프로퍼티를 보면, newURL이 존재하지만, 사용하지 못한다. 이 오류가 발생하는 이유는 타입스크립트 타입 검사기는, 클래스 자체의 타입을 변경하는것이 아니기에, 기존의 정의가 아닌 새로운 정의에 대해서는 인식을 하지 못한다. 만약 데코레이터에 의해 사용된 타입을 사용하고 싶다면 type assertion을 사용하여 접근할 수 있다.(하지만 이 방법은 type-safe하지 못하다. 클래스 데코레이터에 대한 내용은 Github Issue에서 아직도 토론중이다)

const rpt = new Reporter();
console.log((rpt as any).newURL)

클래스 데코레이터를 활용하는 대표적인 예시로는 기존의 메소드나 속성과 같이 정의된 값에 대해 재정의 하는 방법이 있다.

type ClassConstructor = new (...args: any[]) => any;
function ClassDecoTest<T extends ClassConstructor>(Constructor: T){
    return class extends Constructor{
        newURL = "www.google.com"
        
        // Object.prototype.toString()
        toString(){
            console.log("toString() called")
        }

        main(){
            console.log("Changed main() method")
        }
    }
}

@ClassDecoTest
class Reporter{
    type="reporter"
    public main(){
        console.log("main()")
    }
}

const rpt = new Reporter();
console.log((rpt as any).newURL)
rpt.main()
rpt.toString()

메소드 데코레이터

메소드 데코레이터는 메소드 앞에 선언이 된다. 메소드의 Property Descriptor에 적용이 되고, 메소드의 정의를 읽거나 수정할 수 있다. 메소드 데코레이터가 값을 반환하면, 이는 해당 메소드의 Property Descriptor가 된다. 잠시 Property Descriptor에 대해 살펴보자.

프로퍼티 디스크립터

자바스크립트에서의 프로퍼티는 Key-Value로 되어있다. 일반적으로 코드를 작성할때 볼 수 있는것은 여기서 끝이지만, 내부적으로, 추가적인 정보들을 가지고 있고, 이를 프로퍼티 디스크립터라고 한다.

const exobj = {
    key : 'value'
}

console.log(Object.getOwnPropertyDescriptor(exobj,'key'))
/*
{
  value: 'value',
  writable: true,
  enumerable: true,
  configurable: true
}
*/

getOwnPropertyDescriptor()를 통해 특정 프로퍼티의 프로퍼티 디스크립터를 가져올 수 있다.(getOwnPropertyDescriptors()는 객체의 모든 프로퍼티 디스크립터를 가져올 수 있다). 반대로 프로퍼티 디스크립터를 재정의 하려면 defineProperty() 혹은 defineProperties()를 사용한다.

Object.defineProperty(exobj,'key',{
    value: 10
})
console.log(Object.getOwnPropertyDescriptor(exobj,'key'))

각각의 필드가 의미하는값들을 살펴보고 넘어가자.

  • value : 프로퍼티의 값을 의미한다
  • writable : 프로퍼티값의 쓰기 가능 여부를 설정한다. 기본값은 true이다.
  • configurable : 프로퍼티의 프로퍼티 디스크립터 설정이 가능한지의 여부이다. 기본값은 true이고, false인 경우 defineProperty()로 디스크립터 변경이 불가능하다.
  • enumerable : 프로퍼티를 열거하는 구문에서 해당 프로퍼티를 표출할 수 있는 지 여부를 의미한다. 기본값은 true이고, false인 경우, for in루프와 같은 열거 구문에서 무시된다.

다시 메소드 데코레이터

위에서 봤듯이, 메소드 데코레이터는 아래 세가지를 함수 호출 시그니처로 기대한다.

  • 클래스 프로토타입
  • 메소드 이름
  • 프로퍼티 디스크립터

예시 메소드 데코레이터를 작성해 본다. 함수를 실행하는 과정에서 오류를 처리하는 메소드 데코레이터를 작성해보자. 아래 코드같은 경우, testMethod()는 예외를 반환한다. 하지만, 기본적으로 예외 처리가 되어있지 않은 상태이다. 데코레이터 methodDeco()에서는 프로퍼티 디스크립터의 value 값을 통해 원래 메소드를 가져오고, descriport의 value를 예외처리한 메소드로 변경하는 형태이다.

function methodDeco(){
    return function(prototype:any,methodName: string,descriptor:PropertyDescriptor){
        console.log(prototype);
        console.log(methodName);
        console.log(descriptor);

        const originalMethod = descriptor.value

        descriptor.value = function(){
            try{
                originalMethod()
            }catch(err){
                console.log(err)
            }
        }
    }
}

class MethodDecoCls{
    @methodDeco()
    public testMethod(){
        throw new Error("Method Decorator test")
    }
}

const mdc = new MethodDecoCls();
mdc.testMethod()

접근자 데코레이터

여기서 접근자란 getter, setter를 의미한다. 들어가기 앞서 접근자에 대해 간단히 살펴보자.

class GetterSetter{
    constructor(private firstname:string, private lastname:string){}

    get getFullName():string{
        return `${this.firstname} ${this.lastname}`
    }

    set setFullName(fullname: string){
        const [firstname,lastname] = fullname.split(' ')
        this.firstname = firstname
        this.lastname = lastname
    }
}

const gs = new GetterSetter("Yoon", "Hoplin")
console.log(gs.getFullName)
gs.setFullName = "Yoon Junho"
console.log(gs.getFullName)

접근자 데코레이터가 기대하는 함수 호출 시그니처는 메소드 데코레이터와 동일하다. 동일하게 접근자 데코레이터는, 접근자 프로퍼티 디스크립터에 적용되고, 접근자의 정의에 대해 수정 및 읽기가 가능하다. 예를 들어서 객체 프로퍼티를 나열할때 setter는 나열하지 못하도록 막고싶다고 가정한다. 아래 예시를 보면 setter에 해당하는 setFullName은 열거되지 않은것을 알 수 있다.

function isEnumerable(enumerable:boolean){
    return function(prototype:any,accessorName:string,descriptor:PropertyDescriptor){
        descriptor.enumerable = enumerable;
    }
}

class GetterSetter{
    constructor(private firstname:string, private lastname:string){}

    @isEnumerable(true)
    get getFullName():string{
        return `${this.firstname} ${this.lastname}`
    }

    @isEnumerable(false)
    set setFullName(fullname: string){
        const [firstname,lastname] = fullname.split(' ')
        this.firstname = firstname
        this.lastname = lastname
    }
}

const gs = new GetterSetter("Yoon", "Hoplin")
for(let key in gs){
    console.log(`${key} : ${(gs as any)[key]}`)
}

/*
firstname : Yoon
lastname : Junho
getFullName : Yoon Junho
*/

속성 데코레이터

속성 데코레이터는, 클래스의 속성 앞에 선언된다. 속성 데코레이터는 두가지 인자를 받는다

  • 클래스 프로토타입
  • 속성(멤버) 이름

위에서 봤던, 접근자, 메소드 프로퍼티와 달리 Property Descriptor를 인자로 받지 않는다. 공식 문서를 보면 아래와 같이 프로퍼티 디스크립터를 받지 않는 이유가 나와있다.

하지만, 반환값으로 프로퍼티 디스크립터를 주면, 잘 작동이 한다. 이와 관련된 이슈와 공식 문서 링크는 아래에 있다.

우선 속성 데코레이터에서 프로퍼티 디스크립터를 반환해도 작동하므로, 예시를 작성해본다. 특정 인스턴스 멤버를 출력할 때 포맷에 맞춰 출력하는 속성 데코레이터를 작성해본다.

function formatPrinter(formatedString: string){
    return function(prototype:any, memberName: string): any{
        console.log("hit")
        let value = prototype[memberName]
        const getter = () => `${formatedString} ${value}`
        const setter = (newvalue:string) => {
            value = newvalue
        }

        return {
            get: getter,
            set: setter,
            enumerable: true,
            configurable: true,
        }
    }
}

class Greeter{
    @formatPrinter("Hello")
    public greeting:string='';
}

const t = new Greeter();
t.greeting = "World"
console.log(t.greeting)

매개변수 데코레이터

매개변수 데코레이터는 생성자 혹은 메소드 매개변수에 선언되어 적용된다. 매개변수 데코레이터는 세가지 인수를 받는다. 그리고 반환값은 무시된다.

  1. 클래스 프로토타입
  2. 멤버 이름(매개변수가 선언된 메소드 이름이 된다)
  3. 매개변수가 함수 매개변수중 몇번째 인수인지를 나타내는 인덱스(0부터 시작)

매개변수 데코레이터를 가지고 간단하게, Parameter Validator를 만들어볼 수 있다.

function MinLength(min: number){
    return function(target: any, propertyName: string, parameterIndex: number){
        target.validator = {
            minLength: function(args: string[]){
                return args[parameterIndex].length >= min;
            }
        }
    }
}

function Validate(target: any, propertyKey: string,descriptor: PropertyDescriptor){
    const method:Function = descriptor.value;
    descriptor.value = function(...args: string[]){
        Object.keys(target.validator).forEach(key => {
            console.log(args)
            if(!target.validator[key](args)){
                throw new Error("Less length")
            }
        })
        method.apply(this,args)
    }
}

class User2{
    private name:string = "";

    @Validate
    setName(
        @MinLength(3) name:string,
    ){
        this.name = name;
    }
}

const u2 = new User2()
u2.setName('ab')
/**
 * Error: Less length
    at /Users/hoplin/study/Codes-for-blog/tsdecorator/dist/index.js:265:23
 */
profile
더 나은 내일을 위해 오늘의 중괄호를 엽니다

0개의 댓글