자바스크립트와 메타프로그래밍

타입스크립트의 데코레이터

2025년 12월 10일12분3

데코레이터

  • 데코레이터는 클래스, 메서드, 프로퍼티 등 메타데이터를 추가하거나 동작을 수정할 수 있게 해주는 함수임

  • 타입스크립트에서 @ 기호와 함께 사용하며 이는 대상의 정의 시점에 실행됨

  • 대상의 동작을 확장하거나 메타데이터를 부착할 수 있음

메타프로그래밍과 연관 관계

  • 메타프로그래밍은 데이터를 다루는게 아닌 코드 자체를 다루는 방법임

  • 데코레이터는 대상을 인자로 받아서 조작하므로 코드를 데이터처럼 다루는 셈임

  • 그래서 데코레이터는 메타프로그래밍의 대표적인 구현 방식 중 하나임

타입스크립트 데코레이터 사용을 위한 설정

  • 데코레이터의 경우 현재 TC39 Stage 3인 상태로 typescript@5 이상의 버전에서는 기본으로 제공하고 있음

  • 하지만 이전까지 사용했던 레거시 형식의 문법이 여전히 많이 사용되고있는 상황임

  • 기술적으로는 가능하지만 주요 라이브러리/프레임워크는 여전히 에코시스템이 따라오지 못하고 있음

  • 구버전 데코레이터 문법을 사용하기 위해서는 experimentalDecorators 옵션 활성화가 필요함

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

데코레이터 종류

클래스 데코레이터

  • 클래스 선언 직전에 붙이는 데코레이터로 생성자 함수를 인자로 받게됨

  • 클래스의 정의를 관찰, 수정 등이 가능하며 완전히 새로운 클래스로 교체도 가능함

  • 데코레이터가 값을 반환하면 원래 클래스의 생성자를 반환된 값으로 대체하게됨

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

@AddTimestamp
class Seat {
  constructor(private seatNumber: string, private isAvailable: boolean) {}
}

const seat = new Seat("123", true);

// Seat {
//   seatNumber: '123',
//   isAvailable: true,
//   createdAt: 2025-12-10T11:05:00.357Z
// }
console.log(seat);

메서드 데코레이터

  • 메서드 데코레이터는 메서드 선언 직전에 붙이는 데코레이터로 런타임에서 3개의 인자와 함께 호출됨

  • 메서드의 PropertyDescriptor에 접근이 가능해서 메서드를 수정하거나 다른 함수로 대체가 가능함

  • 로깅, 성능 측정, 권한 체크 등 메서드 실행 전/후에 공통적인 로직을 삽입할 때 유용함

메서드 데코레이터의 인자

  • target :인스턴스의 경우 클래스의 프로토타입이며 아닌 경우 생성자 함수를 인자로 받음

  • propertyKey: 메서드의 이름

  • propertyDescriptor : 메서드의 프로퍼티 객체

    • value, writeable, enumerable  등 속성을 가지는데 value 내부에 실제 함수가 담겨있음

    • 해당 value를 다른 함수로 교체하면 원본 메서드를 감싸는 래퍼 함수를 만들어서 사용이 가능함

code
interface PropertyDescriptor {
    configurable?: boolean;
    enumerable?: boolean;
    value?: any;
    writable?: boolean;
    get?(): any;
    set?(v: any): void;
}

예제

  • 위에서 설명한 인자처럼 descriptor.value에는 원본 메서드가 들어가있는데 해당 값을 변수에 미리 저장해놓음

  • 메서드가 호출되면 console.log가 호출되고 originalMethod.apply를 통해서 원본 메서드를 호출하게됨

  • 이 때 여기서 function을 사용하는 경우 오류가 발생하는데 이는 this가 결정되는 타이밍이 달라서임

    • arrow function : 정의될 때의 this를 캡쳐 -> 데코레이터가 실행되는 시점의 this임

    • function : 호출될 때의 this를 동적으로 받음 -> 메서드가 속해있는 클래스 인스턴스를 가르킴

code
function LogExecution(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 원본 메서드를 저장
  const originalMethod = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`[${new Date().toISOString()}] ${propertyKey} 호출, 인자:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[${new Date().toISOString()}] ${propertyKey} 완료, 결과:`, result);
    return result;
  };
}

class PaymentService {
  @LogExecution
  processPayment(userId: number, amount: number) {
    return { success: true, userId, amount };
  }
}

const service = new PaymentService();
const paymentResult = service.processPayment(1, 5000);
console.log(paymentResult);

// [2025-01-01T12:00:00.000Z] processPayment 호출, 인자: [1, 5000]
// [2025-01-01T12:00:00.000Z] processPayment 완료, 결과: { success: true, userId: 1, amount: 5000 }
// { success: true, userId: 1, amount: 5000 }

접근자 데코레이터

  • 클래스의 getter, setter 선언 직전에 붙이는 데코레이터로 메서드 데코레이터와 동일한 시그니처를 가짐

  • 접근자의 propertyDescriptor에 접근이 가능해서 메서드처럼 제어가 가능함

  • 주로 값 검증, 값 변환, 접근 제어 등의 용도로 사용하게됨

code
function ValidatePositive(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalSetter = descriptor.set;

  descriptor.set = function (value: number) {
    if (value < 0) {
      throw new Error(`${propertyKey}: 금액은 0 이상이어야 합니다`);
    }

    if (originalSetter) {
      originalSetter.call(this, value);
    }
  };
}

class SeatUsage {
  private _chargedAmount: number = 0;

  get chargedAmount() {
    return this._chargedAmount;
  }

  @ValidatePositive
  set chargedAmount(value: number) {
    this._chargedAmount = value;
  }
}

const usage = new SeatUsage();
console.log(usage); // SeatUsage { _chargedAmount: 0 }

usage.chargedAmount = 3000;
console.log(usage); // SeatUsage { _chargedAmount: 3000 }

usage.chargedAmount = -1000; // Error: chargedAmount: 금액은 0 이상이어야 합니다

프로퍼티 데코레이터

  • 프로퍼티를 선언하기 직전에 붙이는 데코레이터로 target, propertKey 총 2개의 인자를 받음

  • 프로토타입에 멤버 변수를 정의하는 시점에는 인스턴스 프로퍼티 설명이 불가능해서 descriptor를 받지 않음

  • 위 같은 제약으로 인해서 주로 이 프로퍼티가 선언되었음 같은 사실을 기록하기 위한 용도로 사용하게됨

  • 이 경우 메타데이터를 외부에 저장해두고 나중에 활용하는 패턴이 일반적임

code
const columnMetadata: Map<string, any> = new Map();

type ColumnOptions = {
  type: string;
  nullable?: boolean;
};

function Column(options: ColumnOptions) {
  return function (target: any, propertyKey: string) {
    const className = target.constructor.name;
    const key = `${className}.${propertyKey}`;
    columnMetadata.set(key, options);
  };
}

class Member {
  @Column({ type: "varchar", nullable: false })
  name: string;

  @Column({ type: "int", nullable: true })
  remainingTime: number;
}

console.log(columnMetadata.get("Member.name"));
// { type: 'varchar', nullable: false }
console.log(columnMetadata.get("Member.remainingTime"));
// { type: 'int', nullable: true }

파라미터 데코레이터

  • 메서드의 파라미터 선언 직전에 붙이고 target, propertyKey, parameterIndex 3개의 인자를 받음

  • 단독으로는 파라미터의 값을 수정하거나 검증이 불가능하며 메타데이터를 저장해두고 메서드 데코레이터랑 같이 쓰는게 일반적임

  • 데코레이터를 통해서 이 파라미터는 꼭 전달해야함 등 정보를 기록하고 메서드 데코레이터가 실제 검증을 수행하는 느낌임

code
const requiredParams: Map<string, number[]> = new Map();

function Required(target: any, propertyKey: string, parameterIndex: number) {
  const key = `${target.constructor.name}.${propertyKey}`;
  const indices = requiredParams.get(key) || [];
  indices.push(parameterIndex);
  requiredParams.set(key, indices);
}

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  const key = `${target.constructor.name}.${propertyKey}`;

  descriptor.value = function (...args: any[]) {
    const indices = requiredParams.get(key) || [];
    for (const index of indices) {
      if (!args[index]) {
        throw new Error(`매개변수 ${index}번은 필수입니다`);
      }
    }
    return originalMethod.apply(this, args);
  };
}

class SeatService {
  @Validate
  assignSeat(@Required seatNumber: number, @Required memberId: number) {
    return { seatNumber, memberId };
  }
}

const seatService = new SeatService();
seatService.assignSeat(1, 100);

// @ts-ignore
seatService.assignSeat(1, undefined); // Error: 매개변수 1번은 필수입니다

데코레이터 팩토리

  • 위 5가지 데코레이터 예제에서 일부 데코레이터는 직접 인자를 받는 방식으로 동작했음

  • 이처럼 데코레이터에 인자를 전달하기 위해서는 데코레이터 팩토리를 사용해야함

  • 데코레이터 팩토리는 데코레이터를 반환하는 함수로 외부에서 인자를 받아서 데코레이터의 동작 커스텀이 가능해짐

code
/**
 * 일반 데코레이터 방식으로 파라미터를 직접 받을 수 없음
 */
function SimpleLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log(`${propertyKey} 정의됨`);
}

/**
 * 데코레이터 팩토리로 인자를 받을 수 있음
 */
function Route(path: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    if (!target.constructor.routes) {
      target.constructor.routes = [];
    }

    target.constructor.routes.push({ path, method: propertyKey });
  };
}

class SeatController {
  @Route("/seats")
  getAllSeats() {
    return [];
  }

  @Route("/seats/:id")
  getSeatById(id: number) {
    return { id };
  }
}

console.log((SeatController as any).routes);
// [{ path: '/seats', method: 'getAllSeats' }, { path: '/seats/:id', method: 'getSeatById' }]

데코레이터의 실행 순서

  • 하나의 선언에 여러개의 데코레이터 연동이 가능한데 실행 순서의 경우 두 단계로 나뉘게됨

  • 각 데코레이터는 표현식이 위 -> 아래로 평가되고, 그 다음 결과가 아래에서 위로 함수로써 호출하게됨

code
function First() {
  console.log("First 평가");
  return function (target: any, key: string, desc: PropertyDescriptor) {
    console.log("First 실행");
  };
}

function Second() {
  console.log("Second 평가");
  return function (target: any, key: string, desc: PropertyDescriptor) {
    console.log("Second 실행");
  };
}

class SeatReservationService {
  @First()
  @Second()
  createOrder() {}
}

// 출력:
// First 평가
// Second 평가
// Second 실행
// First 실행

댓글 0

ME

댓글을 작성하려면 로그인이 필요합니다

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!