[Nest.js] Decorator Pattern과 Decorator 문법에 대해

HoonDong_K·2025년 5월 11일
3

NestJS

목록 보기
2/3
post-thumbnail

✅ Decorator Pattern

정의 및 예제

데코레이터 패턴(Decorator Pattern) 이란 하나의 객체를 새로운 행동들을 포함한 특수 래퍼 객체들 내에 넣어서 하나에 객체에 다양한 행동들을 연결시키는 구조적 디자인 패턴이다.

하나의 문장을 입력받았을 때, 해당 문장을 파싱하여 반환하는 프로그램을 만들려고 한다.

  • 대문자로 변환
  • 문장 끝에 느낌표(!)를 붙이는 기능
  • 이모지를 문장 앞 뒤에 추가
  • 소문자로 변환
  • ,,,

단순하게 접근해본다면 UpperCaseText, ExclamationText, EmojiText, LowerCaseText 등 여러 클래스를 각각 따로 만들어서 개별적으로 구현할 수 있다. 하지만 이런 기능들을 조합하고 싶을 때, 문제가 발생한다.

  • 대문자로 바꾸고, 이모지를 붙이는 기능: UpperCaseEmojiText
  • 소문자에, 이모지에, 느낌표를 붙이는 기능: LowerCaseEmojiExclamationText

이처럼 각 조합에 해당하는 클래스들도 별도로 만들어야 한다는 문제점이 있다.

이를 해결하기 위해 탄생한 디자인 패턴이 Decorator Pattern이다.

데코레이터 패턴은 말 그대로 "함수를 장식하는" 패턴이다.

구조

  • Component: 모든 객체가 공통적으로 구현해야 하는 인터페이스를 정의하며 getString()과 같은 공통 메서드가 포함

  • Concrete Component: 기본 기능을 제공하는 클래스. PlainText는 기본 텍스트를 반환하며, 데코레이터에 의해 기능이 확장될 수 있음

  • Concrete Decorator: Component에 동적으로 기능을 추가하거나 오버라이드를 통해 동작 부여.

최종적으로는 PlainText 객체에 여러 데코레이터를 조합하여 사용자가 원하는 기능을 가진 Component를 구성할 수 있다.

실습

아래는 JS를 통해 데코레이터 패턴으로 구현한 코드이다.

// component interface
class TextComponent {
  getString() {
    throw new Error('getString() must be implemented');
  }
}

// concrete component
class PlainText extends TextComponent {
  constructor(string) {
    super();
    this.string = string;
  }

  getString() {
    return this.string;
  }
}

// decorator
class TextDecorator extends TextComponent {
  constructor(component) {
    super();
    this.component = component;
  }

  getString() {
    return this.component.getString();
  }
}

// concrete decorator
class UpperCaseDecorator extends TextDecorator {
  getString() {
    return this.component.getString().toUpperCase();
  }
}

class EmojiDecorator extends TextDecorator {
  getString() {
    return `😃 ${this.component.getString()} 😃`;
  }
}

class ExclamationDecorator extends TextDecorator {
  getString() {
    return this.component.getString() + '!';
  }
}
  1. component interface를 통해 공통된 메서드 구현
  2. concrete component를 통해 구현할 객체 클래스 생성
  3. decorator를 통해 각 구현 데코레이터들의 공통된 메서드 및 객체 초기화
  4. concrete decorator를 통해 각 기능을 갖는 데코레이터 클래스 생성
  5. concrete component에 구현하고 싶은 concrete decorator를 합성하여 component 생성
// 결과
const text = new PlainText('hello');
const upperText = new UpperCaseDecorator(text);
const emojiUpperText = new EmojiDecorator(upperText);
const excitedEmojiUpperText = new ExclamationDecorator(emojiUpperText);

console.log(excitedEmojiUpperText.getString()); // 😃 HELLO 😃!

✅ Decorator의 변화

ES5, ES6에서의 데코레이터

  • ES5: 클래스 문법을 제공하지 않아, 실제 함수나 프로토타입을 통해 함수를 바인딩하여 데코레이터를 구현(함수형 데코레이터, Imperative Decorator)
function upperCaseDecorator(fn) {
  return function (string) {
    //함수 바인딩
    return fn.apply(this, [string.toUpperCase()]);
  };
}

function plainText(string) {
  return string;
}

var upperCaseText = upperCaseDecorator(plainText);
console.log(upperCaseText('hello')); // HELLO
  • ES2015(ES6): 클래스 문법이 생기고 객체를 생성할 수 있음에 따라 데코레이터 패턴을 통해 객체에 기능을 동적을 추가할 수 있게 됨. (상위 예제)
    • 객체지향 방식으로 데코레이터 패턴을 구현할 수 있지만 데코레이터 문법은 따로 존재하지 않음
    • 공통 기능을 여러 클래스에 공유하기 어려움
      (각 데코레이터 클래스마다 getString()을 반복적으로 오버라이드해야함)

데코레이터 문법

결론부터 이야기하자면 데코레이터 문법(@Decorator)는 데코레이터 패턴과는 거리감이 조금 있다.

기존 함수를 래핑하여 부가적인 기능을 동적으로 추가한다는 점에서 공통점을 갖지만 데코레이터의 적용 시점이나 목적 등에서 차이가 있다.

즉, 데코레이터 문법은 디자인 패턴인 데코레이터 패턴을 위한 문법은 아닌 것으로,,

  • ES2016+ : @Decorator 문법은 현재 stage 3 에 해당하여 공식적인 표준이라기보다는 실험적인 기능에 해당한다.

데코레이터는 다양한 클래스에서 공통된 기능을 제공하기 위한 문법이며 외부 동작을 변경하지 않고 기능을 추가할 수 있기에 예기치 않은 효과를 방지할 수 있다.

✅ Decorator 사용법

NestJS에서 사용하는 데코레이터는 Typescript에서 제공하는 데코레이터이기에 TypescriptNestJS의 공식문서를 통해 데코레이터의 사용법을 참고하였다.

Decorator

function first() {
  console.log("first(): factory evaluated");
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("first(): called");
  };
}
 
function second() {
  console<.log("second(): factory evaluated");
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("second(): called");
  };
}
 
class ExampleClass {
  @first()
  @second()
  method() {}
}

Decorator은 @expression의 형태를 갖고 expression은 미리 정의된 정보와 함께 런타임에 호출될 함수로 평가된다.

  1. 각 데코레이터는 top-to-bottom 으로 평가되며
  2. bottom-to-top으로 호출된다.

(first ∘ second)(x) = first(second(method))
JS 내부 콜스택에 함수가 스택으로 쌓이고 LIFO 방식으로 함수가 실행되기 때문이다.

first(): factory evaluated
second(): factory evaluated
second(): called
first(): called

Class Decorator

클래스 데코레이터는 클래스 선언 바로 이전에 선언된다. 클래스 데코레이터는 클래스 선언자(constructor)에 적용되며 클래스 정의를 관찰하거나 수정, 다른 클래스로 대체할 수 있다.

function Logger(constructor: Function) {
  console.log('Class was created:', constructor.name);
}

function AddCreatedAt<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class Person extends constructor {
    createdAt: any = new Date();
  };
}

function LoggerInstance<T extends { new (...args: any[]): { name: string } }>(
  constructor: T,
) {
  return class Person extends constructor {
    constructor(...args: any[]) {
      super(...args);
      console.log(`Instance was created: ${this.name}`);
    }
  };
}

// 관찰
@Logger
// 수정
@AddCreatedAt
// 교체
@LoggerInstance
class Person {							 // < Class was created: Person
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

const person1 = new Person('DongHoon');	 // Instance was created: DongHoon
console.log((person1 as any).createdAt); //2025-05-11T02:37:57.590Z
const person2 = new Person('Danny'); 	 //Instance was created: Danny
Class was created: Person
Instance was created: DongHoon
2025-05-11T02:37:57.590Z
Instance was created: Danny

@Logger 데코레이터는 클래스가 생성될 때, 해당 클래스를 관찰하여 클래스 생성자 명을 인자로 받아 출력하는 기능을 수행하는 함수이다.

@AddCreatedAt 데코레이터는 클래스의 인스턴스가 생성될 때 해당 클래스를 수정하는 기능으로, 기존 클래스를 extends하여 createdAt이라는 속성을 추가해주는 함수이다

@LoggerInstance 데코레이터는 클래스의 인스턴스가 생성될 때 해당 클래스를 교체하는 기능으로, 기존 클래스를 extends하여 생성되는 인스턴스의 name을 받아 출력하는 함수이다.

질문

  1. AddCreatedAt이나 LoggerInstance에서 사용되는 <T extends { new (...args: any[]): {} }>의 의미는?
    • T extends {...}: 하위 중괄호의 타입을 확장하는 제네릭 T
    • new (...args: any[]): 어떤 타입의 인자도 받을 수 있는 배열 형태의 인자를 받는 생성자 함수
    • : {}: 생성자의 반환 타입은 객체이다
    • 즉, 아무런 인자도 받을 수 있는 생성자이며 객체를 반환하는 클래스이다.
    • 두 데코레이터에만 제네릭을 통한 타입 제약을 넣는 이유는 해당 constructor를 확장한 클래스를 반환할 것이기 때문이다.
    • 즉, 새로운 클래스를 반환하기 전에 해당 클래스가 조건에 맞는 타입 제약에 해당해야 한다는 것을 명시해주는 것이다.
  2. 왜 데코레이터는 함수인데 괄호(())를 통해 실행시키지 않나?
    • 클래스는 데코레이터를 자동으로 인식하여 인자를 넣지 않아도 타입스크립트가 자동으로 constructor를 전달
// tsc를 통해 실제 TS를 JS로 컴파일
var Person = /** @class */ (function () {
    function Person(name) {
        this.name = name;
    }
    Person = __decorate([
        Logger
        // 수정
        ,
        AddCreatedAt
        // 교체
        ,
        LoggerInstance
    ], Person);
    return Person;
}());
// TS에서 제공하는 __decorate 함수
var __decorate =
  (this && this.__decorate) ||
  function (decorators, target, key, desc) {
    var c = arguments.length,
      r =
        c < 3
    //  클래스 데코레이터
          ? target
          : desc === null
    // 속성 데코레이터
            ? (desc = Object.getOwnPropertyDescriptor(target, key))
    // 메서드 데코레이터
            : desc,
      d;
    
 	// d를 결정
	if (typeof Reflect === 'object' && typeof Reflect.decorate === 'function')
      r = Reflect.decorate(decorators, target, key, desc);
    else
      for (var i = decorators.length - 1; i >= 0; i--)
        if ((d = decorators[i]))
          r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
  };

코드를 해석해보면 타입스크립트는 데코레이터 정의 시점에서 컴파일하여 자바스크립트로 변환할 때, __decorate 함수를 통해 사용된 데코레이터들을 배열로 받고 각 데코레이터의 인자 수에 따라 데코레이터 유형을 결정하는 것으로 보인다.

  • c: 인자 수
  • r: 인자 수에 따른 데코레이터 유형
  • d: 실제 실행되는 데코레이터

반복문을 통해 하위 데코레이터부터 실행시키며 각각의 데코레이터에 인자를 결정하여 실행시킨다.

Method Decorator

메서드 데코레이터는 메서드 선언 이전에 선언된다. 메서드의Property Descriptor에 적용되며 메서드 정의의 관찰, 수정, 교체를 수행한다.

  • Property Descriptor: 자바스크립트 객체의 속성에 대해 기술하는 객체
interface PropertyDescriptor {
  //프로퍼티 삭제, 변경
  configurable?: boolean;
  //루프 가능 여부
  enumerable?: boolean;
  //실제 값
  value?: any;
  //수정 가능
  writable?: boolean;
  //getter
  get?(): any;
  //setterc
  set?(v: any): void;
}
function LogMethod(
  target: any,
  propertyName: string,
  descriptor: PropertyDescriptor,
) {
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`${propertyName} called with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`${propertyName} returned:`, result);
    return result;
  };

  return descriptor;
}

class Calculator {
  @LogMethod
  add(a: number, b: number): number {
    return a + b;
  }

  @LogMethod
  multiply(a: number, b: number): number {
    return a * b;
  }
}

const calc = new Calculator();
calc.add(2, 3);
calc.multiply(4, 5);
add called with args: [ 2, 3 ]
add returned: 5
multiply called with args: [ 4, 5 ]
multiply returned: 20
  • propertyName: 데코레이터가 적용되는 메서드 이름
  • descriptor: PropertyDescriptor 타입을 갖는 객체

위 예제에서는 데코레이터가 적용되는 메서드를 가져와서 해당 객체의 속성 값(descriptor.value)를 직접 수정하여 메서드를 수정해주는 데코레이터를 작성하였다.

각 로그를 작성하고 originalMethod.apply(this, args);를 통해 해당 메서드를 바인딩시켜주어 메서드의 기능을 확장한 데코레이터이다.

  • this: 실행 시점 호출된 인스턴스(calc)
  • Function.prototype.apply(this, args): Function 함수에 this를 유지한 채로 넘겨받은 인자값들을 바인딩시켜주는 함수

Accessor Decorator

접근자 데코레이터는 접근자 선언 이전에 선언된다.
접근자 데코레이터는 접근자 Property Descriptor에 적용되며 접근자 정의를 관찰, 수정, 교체가 가능하다.

function LogAccessor(
  target: any,
  propertyName: string,
  descriptor: PropertyDescriptor,
) {
  const originalGetter = descriptor.get;

  descriptor.get = function () {
    const result = originalGetter?.call(this);
    console.log(`Getting it's price`);
    return result;
  };

  return descriptor;
}

class Calculator {
  private _price: number = 5000;

  @LogAccessor
  get price() {
    return this._price;
  }
}

const calc = new Calculator();
console.log(calc.price);
Getting it's price
5000

위 예제에서는 _price를 가저오는 get 접근자의 데코레이터를 설정하였고 해당 데코레이터는 descriptor.get을 통해 접근자의 객체 속성 중 get을 가져오고 로그를 추가하여 클래스의 접근자 함수를 확장하였다.

  • 전달되는 인자가 없기 때문에 applycall을 통해 this만 객체 속성에 고정

Property Decorator

프로퍼티 데코레이터는 프로퍼티 선언 전에 선언된다.

메서드나 접근자 데코레이터와 달리 Property Descriptor가 인자로 전달되지 않는다.

  • 속성에 대한 값 수정이나 초기화를 수행할 수 없음
  • propertyKey만 전달되기 때문에 속성명만 관찰할 수 있음
function LogProperty(target: any, propertyKey: string) {
  console.log(`property ${propertyKey} was declared`);
}

class Calculator {
  @LogProperty
  _price: number = 5000;
}
property _price was declared

프로퍼티 데코레이터는 클래스가 선언이 되었을 때, 해당 프로퍼티에 대한 로그를 출력한다.

해당 예제만으로는 부실한 것 같아, 타입스크립트 공식문서에 있는 예제를 추가한다.

import "reflect-metadata";
const formatMetadataKey = Symbol('format');

function format(formatString: string) {
  return Reflect.metadata(formatMetadataKey, formatString);
}
function getFormat(target: any, propertyKey: string) {
  return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}

class Greeter {
  @format('Hello, %s')
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    let formatString = getFormat(this, 'greeting');
    console.log(formatString);
    return formatString.replace('%s', this.greeting);
  }
}

const greet = new Greeter('DongHoon');

console.log(greet.greet());

reflect-metadat 라이브러리는 클래스나 속성과 같은 객체에 메타데이터를 붙일 수 있게 해주는 라이브러리다. key-value값을 설정하여 추가 정보의 메타 데이터를 데이터에 부착할 수 있다.

위 예제에서는
1.formatMetadataKey로 메타데이터의 key를 생성한다. (Symbol을 사용하여 key간 충돌 방지)
2. @format() 데코레이터는 target.greeting 속성에 해당 키로 'Hello, %s'값을 메타데이터로 저장한다. 
3. greet() 메서드에서 getFormat은 해당 키 값으로 속성에 대한 값을 불러온다.
4. 그 값에서 replace()메서드를 통해 출력값을 변화시킬 수 있다.

Parameter Decorator

매개변수 데코레이터는 매개변수 선언 전에 사용된다. 매개변수 데코레이터는 클래스 생성자 혹은 메서드 선언 함수에 적용된다.

function LogParam(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`${propertyKey} method's parameter index: ${parameterIndex}`);
}

class Calculator {
  add(@LogParam a: number, @LogParam b: number): number {
    return a + b;
  }
  multiply(@LogParam a: number, @LogParam b: number): number {
    return a * b;
  }
}

const calc = new Calculator();
add method's parameter index: 1
add method's parameter index: 0
multiply method's parameter index: 1
multiply method's parameter index: 0

매개변수 데코레이터 또한 descriptor를 받지 않기 때문에 값을 수정 혹은 교체할 수 없기 때문에 관찰만 가능하다.

해당 메서드의 매개변수를 가져와서 메서드 명과 매개변수 인덱스값을 출력할 수 있다.

데코레이터 응용

타입스크립트에서 제공하는 예제 중 하나를 분석해보았다.

const requiredMetadataKey = Symbol('required');

function required(
  target: Object,
  propertyKey: string | symbol,
  parameterIndex: number,
) {
  let existingRequiredParameters: number[] =
    Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
  existingRequiredParameters.push(parameterIndex);
  Reflect.defineMetadata(
    requiredMetadataKey,
    existingRequiredParameters,
    target,
    propertyKey,
  );
}

function validate(
  target: any,
  propertyName: string,
  descriptor: TypedPropertyDescriptor<Function>,
) {
  let method = descriptor.value!;

  descriptor.value = function () {
    let requiredParameters: number[] = Reflect.getOwnMetadata(
      requiredMetadataKey,
      target,
      propertyName,
    );
    if (requiredParameters) {
      for (let parameterIndex of requiredParameters) {
        if (
          parameterIndex >= arguments.length ||
          arguments[parameterIndex] === undefined
        ) {
          throw new Error('Missing required argument.');
        }
      }
    }
    return method.apply(this, arguments);
  };
}

class BugReport {
  type = 'report';
  title: string;

  constructor(t: string) {
    this.title = t;
  }

  @validate
  print(@required verbose?: boolean) {
    if (verbose) {
      return `type: ${this.type}\ntitle: ${this.title}`;
    } else {
      return this.title;
    }
  }
}

const bug = new BugReport('This is Title');
console.log(bug.print()); 			// << 에러 발생
console.log('===============');
console.log(bug.print(true));
console.log('===============');
console.log(bug.print(false));

메서드 데코레이터인 @validate()와 매개변수 데코레이터인 @required()를 통해 해당 메서드에 들어오는 매개변수의 값을 필수로 지정하는 기능이다.

  1. requiredMetadataKeySymbol('required')로 정의되며, 메타데이터를 식별하기 위한 고유 키 역할을 한다.

  2. BugReport 클래스가 선언될 때, @required()@validate() 데코레이터는 정의된 순서대로 호출되며, 실제 실행은 데코레이터 규칙에 따라 파라미터 데코레이터(@required) → 메서드 데코레이터(@validate) 순으로 이루어진다.

  3. @required 데코레이터는 Reflect.defineMetadata()를 사용해 해당 매개변수의 인덱스를 메타데이터에 저장한다.

  4. @validate 데코레이터는 Reflect.getMetadata()로 미리 저장된 필수 매개변수 정보를 가져와,
    실제 메서드 실행 시 인자 값이 빠져 있는지를 검사하고, 누락된 경우 에러를 발생시킨다.

참고

profile
도움이 될 수 있는 개발자

0개의 댓글