본문 바로가기
TYPESCRIPT

데코레이터

by 일태찡 2023. 3. 28.

 

데코레이터는 추가적인 구성과 추가적인 로직을 더 할 수 있게 합니다.

 

먼저 tsconfig.json 파일에서 이 부분을 수정해야 데코레이터를 문제없이 쓸 수 있습니다.

"experimentalDecorators": true

 

 

데코레이터 만들기

 

function Logger (constructor: Function) {
    console.log('Logging...');
    console.log(constructor);
}

// 여기
@Logger
class Person {
    name = 'KIM';

    constructor() {
        console.log('Creating person object...');
    }
}

const pers = new Person();

console.log(pers);

 

데코레이터 함수는 관행적으로 대문자로 시작하며 클래스 앞에 @함수를 써 적용합니다.

이렇게 작성하고 브라우저 콘솔창을 확인해 봅시다.

 

 

이렇게 데코레이터는 실체화되기 전 클래스가 정의만 돼도 실행됩니다.

자바스크립트에서 클래스 및 컨스트럭터의 함수 정의만 입력되면 데코레이터가 돌아갑니다.

 

 

데코레이터 팩토리

 

function Logger (logString: string) {
    return function(constructor: Function) {
        console.log(logString);
        console.log(constructor);
    }
}

@Logger('LOGGING - PERSON')
class Person {
    name = 'KIM';

    constructor() {
        console.log('Creating person object...');
    }
}

 

그냥 데코레이터와 다르게 반환 함수가 되었으므로 데코레이터를 적용할 때 함수를 실행해야 합니다.

여기서 우리는 데코레이터 함수를 실행하려는 게 아니라 데코레이터 함수와 같은 걸 반환해 줄 함수를 실행하는 겁니다.

팩토리 화하여 인자를 받아들일 수 있게 되고 데코레이터를 더 커스터마이즈 할 수 있습니다.

이렇게 데코레이터 팩토리를 사용하면 데코레이터 내부 설정에 보다 더 많은 영향력과 가능성을 펼칠 수 있습니다.

 

 

데코레이터 발전시키기

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Understanding TypeScript</title>
  <script src="dist/app.js" defer></script>
</head>
<body>
  <div id="app"></div>
</body>
</html>

 

html에 다음과 같이 id가 app인 태그를 만들고

 

app.ts

function WithTemplate(template: string, hookId: string) {
    return function(_: Function) {
        const hookEl = document.getElementById(hookId);
        if (hookEl) {
            hookEl.innerHTML = template;
        }
    }
}

@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
    name = 'KIM';

    constructor() {
        console.log('Creating person object...');
    }
}

 

이렇게 데코레이터를 만들고 적용하면

 

 

화면에 이렇게 렌더링 된 것을 볼 수 있습니다.

여기서 _는 존재는 알지만 쓰지 않겠다고 명시하는 것입니다.

 

function WithTemplate(template: string, hookId: string) {
    return function(constructor: any) {
        const hookEl = document.getElementById(hookId);
        const p = new constructor();
        if (hookEl) {
            // hookEl.innerHTML = template;
            hookEl.querySelector('h1')!.textContent = p.name;
        }
    }
}

@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
    name = 'KIM';

    constructor() {
        console.log('Creating person object...');
    }
}

 

이렇게 코드를 바꿔 화면에 렌더링 되는 문자를 바꿀 수 도 있습니다.

 

 

여러 데코레이터 추가하기

 

function Logger (logString: string) {
    console.log('LOGGER FACTORY');
    return function(constructor: Function) {
        console.log(logString);
        console.log(constructor);
    }
}

function WithTemplate(template: string, hookId: string) {
    console.log('TEMPLATE FACTORY');
    return function(constructor: any) {
        console.log('Rendering template');
        const hookEl = document.getElementById(hookId);
        const p = new constructor();
        if (hookEl) {
            hookEl.innerHTML = template;
            hookEl.querySelector('h1')!.textContent = p.name;
        }
    }
}

@Logger('LOGGING')
@WithTemplate('<h1>My Person Object<h1>', 'app')
class Person {
    name = 'KIM';

    constructor() {
        // console.log('Creating person object...');
    }
}

 

이렇게 코드를 작성하고 브라우저 콘솔창의 결과를 확인해 보면 다음과 같이 출력됩니다.

 

 

우리가 팩토리 함수를 명시한 순서 대로 실 데코레이터가 나타납니다.

하지만 실 데코레이터 함수의 실행은 밑에서부터 일어납니다.

 

 

속성 데코레이터

 

function Log(target: any, propertyName: string | Symbol) {
    console.log('Property Decorator!');
    console.log(target, propertyName);
}

class Product {
    @Log
    title: string;
    private _price: number;

    set price(val: number) {
        if(val > 0) {
            this._price = val;
        } else {
            throw new Error('Invalid price - should be positive!');
        }
    }

    constructor(t: string, p: number) {
        this.title = t;
        this._price = p;
    }

    getPriceWIthTax(tax: number) {
        return this._price * (1 + tax);
    }
}

 

이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.

 

 

target인 오브젝트의 프로토타입과 propertyName인 작업하는 프로퍼티 이름을 볼 수 있습니다.

이 역시 클래스가 정의될 때 더 자세히는 자바스크립트에 프로퍼티를 정의했을 때 실행되게 됩니다.

 

 

접근자 데코레이터

 

function Log2(target: any, name: string, descriptor: PropertyDescriptor) {
    console.log('Accessor Decorator!');
    console.log(target);
    console.log(name);
    console.log(descriptor);
}

class Product {
    title: string;
    private _price: number;

    @Log2
    set price(val: number) {
        if(val > 0) {
            this._price = val;
        } else {
            throw new Error('Invalid price - should be positive!');
        }
    }

    constructor(t: string, p: number) {
        this.title = t;
        this._price = p;
    }

    getPriceWIthTax(tax: number) {
        return this._price * (1 + tax);
    }
}

 

이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.

 

 

 

메서드 데코레이터

 

function Log3(target: any, name: string | Symbol, descriptor: PropertyDescriptor) {
    console.log('Method Decorator!');
    console.log(target);
    console.log(name);
    console.log(descriptor);
}

class Product {
    title: string;
    private _price: number;

    set price(val: number) {
        if(val > 0) {
            this._price = val;
        } else {
            throw new Error('Invalid price - should be positive!');
        }
    }

    constructor(t: string, p: number) {
        this.title = t;
        this._price = p;
    }

    @Log3
    getPriceWIthTax(tax: number) {
        return this._price * (1 + tax);
    }
}

 

이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.

 

 

 

매개변수 데코레이터

 

function Log4(target: any, name: string | Symbol, position: number) {
    console.log('Parameter Decorator!');
    console.log(target);
    console.log(name);
    console.log(position);
}

class Product {
    title: string;
    private _price: number;

    set price(val: number) {
        if(val > 0) {
            this._price = val;
        } else {
            throw new Error('Invalid price - should be positive!');
        }
    }

    constructor(t: string, p: number) {
        this.title = t;
        this._price = p;
    }

    getPriceWIthTax(@Log4 tax: number) {
        return this._price * (1 + tax);
    }
}

 

이렇게 코드를 작성하고 콘솔창을 보면 다음과 같이 출력됩니다.

 

 

프로퍼티, 메서드, 액세서, 매개 변수 등 어떤 데코레이터든 클래스를 정의되었을 때 실행됩니다.

 

 

클래스 데코레이터에서 클래스 반환

 

function WithTemplate(template: string, hookId: string) {
    console.log('TEMPLATE FACTORY');
    return function<T extends {new(...args: any[]): {name: string}}>(
        originalConstructor: T
    ) {
        // 컨스트럭터 함수를 만드는 syntactic sugar => class
        return class extends originalConstructor {
            constructor(..._: any[]) {
                // 오리지널 함수와 클래스를 저장
                super();
                console.log('Rendering template');
                const hookEl = document.getElementById(hookId);
                if (hookEl) {
                    hookEl.innerHTML = template;
                    hookEl.querySelector('h1')!.textContent = this.name;
                }        
            }
        }
    }
}

 

데코레이터 함수로 기존 클래스를 확장하는 새 클래스를 반환할 수 도 있습니다.

데코레이터 함수에서 반환되는 컨스트럭터 함수는 오리지널 클래스, 오리지널 컨스트럭터 함수를 대체합니다.

 

 

기타 데코레이터 반환 타입

 

뭔가를 반환할 수 있는 데코레이터는 클래스와 더불어 메서드에 추가한 데코레이터나 접근자에 추가한 데코레이터입니다.

즉 어떤 내용을 반환하고 타입스크립트가 그 값을 사용할 것입니다.

프로퍼티와 매개변수에 있는 데코레이터도 당연히 어떤 값을 반환하지만 타입스크립트는 이 값들을 무시할 것입니다.

따라서 반환값이 그곳에서는 지원되지 않거나 더 정확히 말하면 사용되지 않습니다.

 

 

Auotobind 데코레이터 만들기

 

html 파일에 버튼을 하나 만들고 시작합니다.

 

class Printer {
    message = 'This works!';

    showMessage() {
        console.log(this.message);
    }
}

const p = new Printer();
p.showMessage(); // 여기랑 
const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage); // 여기

 

그 버튼을 클릭하면 메시지를 출력하게 코드를 이렇게 구상해 보았습니다.

하지만 이 코드는 메시지를 출력하지 못하고 undefined가 나오고 메시지를 출력하지 못합니다.

그 이유는 이벤트 리스너를 이용했을 때 함수를 지목하면 그 함수가 실행되어야 하는데 그 함수 안에 있는 this 키워드의 콘텍스트나 레퍼런스가 p.showMessage를 호출했을 때와 동일하지 않기 때문입니다.

즉 이 시나리오에서 이벤트 리스너를 사용하면 this는 이벤트의 대상을 참조할 것입니다.

왜냐하면 addEventListener가 결국 실행되어야 하는 함수 안에 있는 this를 이벤트의 대상과 바인딩하기 때문입니다.

 

class Printer {
    message = 'This works!';

    showMessage() {
        console.log(this.message);
    }
}

const p = new Printer();

const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage.bind(p));

 

차선책으로 showMessage의 this를 p에 바인딩하여, 실행했을 때 this가 이벤트 리스너가 참조하려는 것을 참조하지 않고 다시 p를 참조하게 하는 것입니다.

 

function Autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
	// 기존 메서드에 접근
    const originalMethod = descriptor.value;
    const adjDescriptor: PropertyDescriptor = {
        configurable: true,
        enumerable: false,
        // 게터 
        get() {
            // 여기서 this는 getter 메서드를 트리거하는 것은 뭐든지 참조함
            const boundFn = originalMethod.bind(this);
            return boundFn;
        }
    }
    return adjDescriptor;
}

class Printer {
    message = 'This works!';

    @Autobind
    showMessage() {
        console.log(this.message);
    }
}

const p = new Printer();

const button = document.querySelector('button')!;
button.addEventListener('click', p.showMessage);

 

이렇게 데코레이터로 자동으로 바인드 되게 구성할 수 있습니다.

자바스크립트 바인드 설명은 아래!

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

 

Function.prototype.bind() - JavaScript | MDN

bind() 메소드가 호출되면 새로운 함수를 생성합니다. 받게되는 첫 인자의 value로는 this 키워드를 설정하고, 이어지는 인자들은 바인드된 함수의 인수에 제공됩니다.

developer.mozilla.org

 

데코레이터의 게터 메서드의 this는 우리가 정의한 객체를 항상 참조할 것입니다.

이 this는 addEventListener로 재정의되지 않습니다.

왜냐하면 getter는 실행되고 있는 함수와 그것이 속한 객체, 그리고 이벤트 리스너 사이의 추가적인 계층 같은 것이기 때문입니다.

따라서 이 thissms 원래 메서드에 안전하게 바인딩하고 원래 메서드 안에 있는 this는 모두 정확히 동일한 객체를 참조하게 할 수 있습니다.

 

 

유효성 검사용 데코레이터

 

타이틀과 금액을 입력받아 그걸 출력하는 코드를 만들고 시작합니다.

 

index.html

...
<form>
  <input type="text" placeholder="Course title" id="title">
  <input type="text" placeholder="Course price" id="price">
  <button type="submit">Save</button>
</form>
...

 

app.ts

class Course {
  title: string;
  price: number;

  constructor(t: string, p: number) {
    this.title = t;
    this.price = p;
  }
}

const courseForm = document.querySelector("form")!;

courseForm.addEventListener("submit", (event) => {
  event.preventDefault();
  const titleEl = document.getElementById("title") as HTMLInputElement;
  const priceEl = document.getElementById("price") as HTMLInputElement;

  const title = titleEl.value;
  const price = +priceEl.value;

  const createdCourse = new Course(title, price);
  console.log(createdCourse);
});

 

이는 러프하게 구성되었고 어떠한 유효성 검사도 없습니다.

이벤트 리스너 함수에 로직을 추가할 수도 있지만 데코레이터를 이용할 수 도 있습니다.

 

interface ValidatorConfig {
  [property: string]: {
    [validatableProp: string]: string[]; // ['required', 'positive']
  };
}

const registeredValidators: ValidatorConfig = {};

function Required(target: any, propName: string) {
  registeredValidators[target.constructor.name] = {
    ...registeredValidators[target.constructor.name],
    [propName]: ['required']
  };
}

function PositiveNumber(target: any, propName: string) {
  registeredValidators[target.constructor.name] = {
    ...registeredValidators[target.constructor.name],
    [propName]: ['positive']
  };
}

function validate(obj: any) {
  const objValidatorConfig = registeredValidators[obj.constructor.name];
  if (!objValidatorConfig) {
    return true;
  }
  let isValid = true;
  for (const prop in objValidatorConfig) {
    for (const validator of objValidatorConfig[prop]) {
      switch (validator) {
        case 'required':
          isValid = isValid && !!obj[prop];
          break;
        case 'positive':
          isValid = isValid && obj[prop] > 0;
          break;
      }
    }
  }
  return isValid;
}

class Course {
  @Required
  title: string;
  @PositiveNumber
  price: number;

  constructor(t: string, p: number) {
    this.title = t;
    this.price = p;
  }
}

const courseForm = document.querySelector('form')!;
courseForm.addEventListener('submit', event => {
  event.preventDefault();
  const titleEl = document.getElementById('title') as HTMLInputElement;
  const priceEl = document.getElementById('price') as HTMLInputElement;

  const title = titleEl.value;
  const price = +priceEl.value;

  const createdCourse = new Course(title, price);

  if (!validate(createdCourse)) {
    alert('Invalid input, please try again!');
    return;
  }
  console.log(createdCourse);
});

 

바로 이렇게 말이죠... 처음이라 이해도 힘들고 멘탈이 나갔습니다.

매 번 저렇게 구현해야 하나 말이 되나 싶을 때쯤 직접 만들지 말고 보다 정교하게 구현한 패키지를 찾아 슬쩍 숟가락을 얹읍시다.

https://github.com/typestack/class-validator

 

GitHub - typestack/class-validator: Decorator-based property validation for classes.

Decorator-based property validation for classes. Contribute to typestack/class-validator development by creating an account on GitHub.

github.com

 

 

'TYPESCRIPT' 카테고리의 다른 글

연습 프로젝트 2  (13) 2023.03.31
연습 프로젝트 1  (6) 2023.03.30
제네릭  (6) 2023.03.27
고급 타입  (6) 2023.03.24
인터페이스  (7) 2023.03.23