한국어

메타데이터 프로그래밍, 관점 지향 프로그래밍, 선언적 패턴을 통한 코드 향상을 위해 타입스크립트 데코레이터의 강력한 기능을 탐색해보세요. 전 세계 개발자를 위한 종합 가이드입니다.

타입스크립트 데코레이터: 견고한 애플리케이션을 위한 메타데이터 프로그래밍 패턴 마스터하기

현대 소프트웨어 개발의 광대한 환경에서 깨끗하고, 확장 가능하며, 관리하기 쉬운 코드베이스를 유지하는 것은 매우 중요합니다. TypeScript는 강력한 타입 시스템과 고급 기능을 통해 개발자에게 이를 달성할 수 있는 도구를 제공합니다. 가장 흥미롭고 혁신적인 기능 중 하나는 데코레이터입니다. 이 글을 작성하는 시점에는 아직 실험적인 기능(ECMAScript의 Stage 3 제안)이지만, 데코레이터는 Angular나 TypeORM과 같은 프레임워크에서 널리 사용되며 디자인 패턴, 메타데이터 프로그래밍, 관점 지향 프로그래밍(AOP)에 대한 우리의 접근 방식을 근본적으로 바꾸고 있습니다.

이 종합 가이드에서는 TypeScript 데코레이터의 메커니즘, 다양한 유형, 실제 적용 사례 및 모범 사례를 깊이 있게 다룰 것입니다. 대규모 엔터프라이즈 애플리케이션, 마이크로서비스, 또는 클라이언트 사이드 웹 인터페이스를 구축하든, 데코레이터를 이해하면 더 선언적이고 유지보수하기 쉬우며 강력한 TypeScript 코드를 작성할 수 있게 될 것입니다.

핵심 개념 이해: 데코레이터란 무엇인가?

핵심적으로 데코레이터는 클래스 선언, 메서드, 접근자, 속성 또는 매개변수에 붙일 수 있는 특별한 종류의 선언입니다. 데코레이터는 꾸며주는 대상에 대해 새로운 값(또는 기존 값을 수정)을 반환하는 함수입니다. 주요 목적은 기본 코드 구조를 직접 수정하지 않고, 부착된 선언에 메타데이터를 추가하거나 동작을 변경하는 것입니다. 코드를 보강하는 이러한 외부적이고 선언적인 방식은 매우 강력합니다.

데코레이터를 코드의 일부에 적용하는 어노테이션이나 레이블이라고 생각할 수 있습니다. 이러한 레이블은 애플리케이션의 다른 부분이나 프레임워크에 의해 종종 런타임에 읽히거나 처리되어 추가적인 기능이나 구성을 제공할 수 있습니다.

데코레이터의 구문

데코레이터는 @ 기호로 시작하며 그 뒤에 데코레이터 함수의 이름이 옵니다. 데코레이터는 꾸미려는 선언 바로 앞에 위치합니다.

@MyDecorator
class MyClass {
  @AnotherDecorator
  myMethod() {
    // ...
  }
}

TypeScript에서 데코레이터 활성화하기

데코레이터를 사용하기 전에, tsconfig.json 파일에서 experimentalDecorators 컴파일러 옵션을 활성화해야 합니다. 또한, 고급 메타데이터 리플렉션 기능(주로 프레임워크에서 사용)을 위해서는 emitDecoratorMetadatareflect-metadata 폴리필도 필요합니다.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "commonjs",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

reflect-metadata도 설치해야 합니다:

npm install reflect-metadata --save
# or
yarn add reflect-metadata

그리고 애플리케이션의 진입점(예: main.ts 또는 app.ts) 최상단에서 이를 가져와야 합니다:

import "reflect-metadata";
// Your application code follows

데코레이터 팩토리: 손쉬운 커스터마이징

기본 데코레이터는 함수이지만, 종종 데코레이터의 동작을 구성하기 위해 인자를 전달해야 할 필요가 있습니다. 이는 데코레이터 팩토리를 사용하여 달성됩니다. 데코레이터 팩토리는 실제 데코레이터 함수를 반환하는 함수입니다. 데코레이터 팩토리를 적용할 때, 인자와 함께 호출하면 TypeScript가 코드에 적용할 데코레이터 함수를 반환합니다.

간단한 데코레이터 팩토리 예제 만들기

다른 접두사로 메시지를 로깅할 수 있는 Logger 데코레이터를 위한 팩토리를 만들어 보겠습니다.

function Logger(prefix: string) {
  return function (target: Function) {
    console.log(`[${prefix}] Class ${target.name} has been defined.`);
  };
}

@Logger("APP_INIT")
class ApplicationBootstrap {
  constructor() {
    console.log("Application is starting...");
  }
}

const app = new ApplicationBootstrap();
// Output:
// [APP_INIT] Class ApplicationBootstrap has been defined.
// Application is starting...

이 예제에서 Logger("APP_INIT")는 데코레이터 팩토리 호출입니다. 이것은 target: Function(클래스 생성자)을 인자로 받는 실제 데코레이터 함수를 반환합니다. 이를 통해 데코레이터의 동작을 동적으로 구성할 수 있습니다.

TypeScript의 데코레이터 유형

TypeScript는 각각 특정 종류의 선언에 적용할 수 있는 다섯 가지 뚜렷한 유형의 데코레이터를 지원합니다. 데코레이터 함수의 시그니처는 적용되는 컨텍스트에 따라 다릅니다.

1. 클래스 데코레이터

클래스 데코레이터는 클래스 선언에 적용됩니다. 데코레이터 함수는 클래스의 생성자를 유일한 인자로 받습니다. 클래스 데코레이터는 클래스 정의를 관찰, 수정하거나 심지어 대체할 수도 있습니다.

시그니처:

function ClassDecorator(target: Function) { ... }

반환 값:

클래스 데코레이터가 값을 반환하면, 제공된 생성자 함수로 클래스 선언을 대체합니다. 이는 믹스인이나 클래스 보강에 자주 사용되는 강력한 기능입니다. 값을 반환하지 않으면 원래 클래스가 사용됩니다.

사용 사례:

클래스 데코레이터 예제: 서비스 주입

클래스를 "주입 가능"으로 표시하고 선택적으로 컨테이너에서 사용할 이름을 제공하는 간단한 의존성 주입 시나리오를 상상해 보세요.

const InjectableServiceRegistry = new Map<string, Function>();

function Injectable(name?: string) {
  return function<T extends { new(...args: any[]): {} }>(constructor: T) {
    const serviceName = name || constructor.name;
    InjectableServiceRegistry.set(serviceName, constructor);
    console.log(`Registered service: ${serviceName}`);

    // Optionally, you could return a new class here to augment behavior
    return class extends constructor {
      createdAt = new Date();
      // Additional properties or methods for all injected services
    };
  };
}

@Injectable("UserService")
class UserDataService {
  getUsers() {
    return [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }];
  }
}

@Injectable()
class ProductDataService {
  getProducts() {
    return [{ id: 101, name: "Laptop" }, { id: 102, name: "Mouse" }];
  }
}

console.log("--- Services Registered ---");
console.log(Array.from(InjectableServiceRegistry.keys()));

const userServiceConstructor = InjectableServiceRegistry.get("UserService");
if (userServiceConstructor) {
  const userServiceInstance = new userServiceConstructor();
  console.log("Users:", userServiceInstance.getUsers());
  // console.log("User Service Created At:", userServiceInstance.createdAt); // If the returned class is used
}

이 예제는 클래스 데코레이터가 어떻게 클래스를 등록하고 심지어 그 생성자를 수정할 수 있는지를 보여줍니다. Injectable 데코레이터는 가상의 의존성 주입 시스템이 클래스를 발견할 수 있도록 만듭니다.

2. 메서드 데코레이터

메서드 데코레이터는 메서드 선언에 적용됩니다. 세 개의 인자를 받습니다: 대상 객체(정적 멤버의 경우 생성자 함수, 인스턴스 멤버의 경우 클래스의 프로토타입), 메서드 이름, 그리고 메서드의 프로퍼티 디스크립터입니다.

시그니처:

function MethodDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

반환 값:

메서드 데코레이터는 새로운 PropertyDescriptor를 반환할 수 있습니다. 만약 반환하면, 이 디스크립터가 메서드를 정의하는 데 사용됩니다. 이를 통해 원래 메서드의 구현을 수정하거나 대체할 수 있어 AOP에 매우 강력합니다.

사용 사례:

메서드 데코레이터 예제: 성능 모니터링

메서드의 실행 시간을 기록하는 MeasurePerformance 데코레이터를 만들어 보겠습니다.

function MeasurePerformance(target: Object, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;

  descriptor.value = function(...args: any[]) {
    const start = process.hrtime.bigint();
    const result = originalMethod.apply(this, args);
    const end = process.hrtime.bigint();
    const duration = Number(end - start) / 1_000_000;
    console.log(`Method "${propertyKey}" executed in ${duration.toFixed(2)} ms`);
    return result;
  };

  return descriptor;
}

class DataProcessor {
  @MeasurePerformance
  processData(data: number[]): number[] {
    // Simulate a complex, time-consuming operation
    for (let i = 0; i < 1_000_000; i++) {
      Math.sin(i);
    }
    return data.map(n => n * 2);
  }

  @MeasurePerformance
  fetchRemoteData(id: string): Promise<string> {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(`Data for ID: ${id}`);
      }, 500);
    });
  }
}

const processor = new DataProcessor();
processor.processData([1, 2, 3]);
processor.fetchRemoteData("abc").then(result => console.log(result));

MeasurePerformance 데코레이터는 원래 메서드를 타이밍 로직으로 감싸서, 메서드 내의 비즈니스 로직을 어지럽히지 않으면서 실행 시간을 출력합니다. 이것은 관점 지향 프로그래밍(AOP)의 전형적인 예입니다.

3. 접근자 데코레이터

접근자 데코레이터는 접근자(getset) 선언에 적용됩니다. 메서드 데코레이터와 유사하게, 대상 객체, 접근자 이름, 그리고 프로퍼티 디스크립터를 받습니다.

시그니처:

function AccessorDecorator(target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { ... }

반환 값:

접근자 데코레이터는 새로운 PropertyDescriptor를 반환할 수 있으며, 이는 접근자를 정의하는 데 사용됩니다.

사용 사례:

접근자 데코레이터 예제: Getter 캐싱

비용이 많이 드는 getter 계산 결과를 캐싱하는 데코레이터를 만들어 보겠습니다.

function CachedGetter(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalGetter = descriptor.get;
  const cacheKey = `_cached_${String(propertyKey)}`;

  if (originalGetter) {
    descriptor.get = function() {
      if (this[cacheKey] === undefined) {
        console.log(`[Cache Miss] Computing value for ${String(propertyKey)}`);
        this[cacheKey] = originalGetter.apply(this);
      } else {
        console.log(`[Cache Hit] Using cached value for ${String(propertyKey)}`);
      }
      return this[cacheKey];
    };
  }
  return descriptor;
}

class ReportGenerator {
  private data: number[];

  constructor(data: number[]) {
    this.data = data;
  }

  // Simulates an expensive computation
  @CachedGetter
  get expensiveSummary(): number {
    console.log("Performing expensive summary calculation...");
    return this.data.reduce((sum, current) => sum + current, 0) / this.data.length;
  }
}

const generator = new ReportGenerator([10, 20, 30, 40, 50]);

console.log("First access:", generator.expensiveSummary);
console.log("Second access:", generator.expensiveSummary);
console.log("Third access:", generator.expensiveSummary);

이 데코레이터는 expensiveSummary getter의 계산이 한 번만 실행되도록 보장하며, 이후 호출은 캐시된 값을 반환합니다. 이 패턴은 속성 접근에 무거운 계산이나 외부 호출이 포함될 때 성능을 최적화하는 데 매우 유용합니다.

4. 프로퍼티 데코레이터

프로퍼티 데코레이터는 속성 선언에 적용됩니다. 두 개의 인자를 받습니다: 대상 객체(정적 멤버의 경우 생성자 함수, 인스턴스 멤버의 경우 클래스의 프로토타입)와 속성 이름입니다.

시그니처:

function PropertyDecorator(target: Object, propertyKey: string | symbol) { ... }

반환 값:

프로퍼티 데코레이터는 어떠한 값도 반환할 수 없습니다. 주요 용도는 속성에 대한 메타데이터를 등록하는 것입니다. 프로퍼티 데코레이터가 실행될 때 속성에 대한 디스크립터가 아직 완전히 정의되지 않았기 때문에, 속성의 값이나 디스크립터를 직접 변경할 수 없습니다.

사용 사례:

프로퍼티 데코레이터 예제: 필수 필드 유효성 검사

속성을 "필수"로 표시하고 런타임에 유효성을 검사하는 데코레이터를 만들어 보겠습니다.

interface ValidationRule {
  property: string | symbol;
  validate: (value: any) => boolean;
  message: string;
}

const validationRules: Map<Function, ValidationRule[]> = new Map();

function Required(target: Object, propertyKey: string | symbol) {
  const rules = validationRules.get(target.constructor) || [];
  rules.push({
    property: propertyKey,
    validate: (value: any) => value !== null && value !== undefined && value !== "",
    message: `${String(propertyKey)} is required.`
  });
  validationRules.set(target.constructor, rules);
}

function validate(instance: any): string[] {
  const classRules = validationRules.get(instance.constructor) || [];
  const errors: string[] = [];

  for (const rule of classRules) {
    if (!rule.validate(instance[rule.property])) {
      errors.push(rule.message);
    }
  }
  return errors;
}

class UserProfile {
  @Required
  firstName: string;

  @Required
  lastName: string;

  age?: number;

  constructor(firstName: string, lastName: string, age?: number) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
  }
}

const user1 = new UserProfile("John", "Doe", 30);
console.log("User 1 validation errors:", validate(user1)); // []

const user2 = new UserProfile("", "Smith");
console.log("User 2 validation errors:", validate(user2)); // ["firstName is required."]

const user3 = new UserProfile("Alice", "");
console.log("User 3 validation errors:", validate(user3)); // ["lastName is required."]

Required 데코레이터는 단순히 중앙 validationRules 맵에 유효성 검사 규칙을 등록합니다. 별도의 validate 함수는 이 메타데이터를 사용하여 런타임에 인스턴스를 확인합니다. 이 패턴은 유효성 검사 로직을 데이터 정의에서 분리하여 재사용 가능하고 깔끔하게 만듭니다.

5. 파라미터 데코레이터

파라미터 데코레이터는 클래스 생성자나 메서드 내의 매개변수에 적용됩니다. 세 개의 인자를 받습니다: 대상 객체(정적 멤버의 경우 생성자 함수, 인스턴스 멤버의 경우 클래스의 프로토타입), 메서드 이름(생성자 매개변수의 경우 undefined), 그리고 함수의 매개변수 목록에서 해당 매개변수의 순서 인덱스입니다.

시그니처:

function ParameterDecorator(target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) { ... }

반환 값:

파라미터 데코레이터는 어떠한 값도 반환할 수 없습니다. 프로퍼티 데코레이터와 마찬가지로, 주요 역할은 매개변수에 대한 메타데이터를 추가하는 것입니다.

사용 사례:

파라미터 데코레이터 예제: 요청 데이터 주입

웹 프레임워크가 파라미터 데코레이터를 사용하여 요청에서 사용자 ID와 같은 특정 데이터를 메서드 매개변수에 주입하는 방법을 시뮬레이션해 보겠습니다.

interface ParameterMetadata {
  index: number;
  key: string | symbol;
  resolver: (request: any) => any;
}

const parameterResolvers: Map<Function, Map<string | symbol, ParameterMetadata[]>> = new Map();

function RequestParam(paramName: string) {
  return function (target: Object, propertyKey: string | symbol | undefined, parameterIndex: number) {
    const targetKey = propertyKey || "constructor";
    let methodResolvers = parameterResolvers.get(target.constructor);
    if (!methodResolvers) {
      methodResolvers = new Map();
      parameterResolvers.set(target.constructor, methodResolvers);
    }
    const paramMetadata = methodResolvers.get(targetKey) || [];
    paramMetadata.push({
      index: parameterIndex,
      key: targetKey,
      resolver: (request: any) => request[paramName]
    });
    methodResolvers.set(targetKey, paramMetadata);
  };
}

// A hypothetical framework function to invoke a method with resolved parameters
function executeWithParams(instance: any, methodName: string, request: any) {
  const classResolvers = parameterResolvers.get(instance.constructor);
  if (!classResolvers) {
    return (instance[methodName] as Function).apply(instance, []);
  }
  const methodParamMetadata = classResolvers.get(methodName);
  if (!methodParamMetadata) {
    return (instance[methodName] as Function).apply(instance, []);
  }

  const args: any[] = Array(methodParamMetadata.length);
  for (const meta of methodParamMetadata) {
    args[meta.index] = meta.resolver(request);
  }
  return (instance[methodName] as Function).apply(instance, args);
}

class UserController {
  getUser(@RequestParam("id") userId: string, @RequestParam("token") authToken?: string) {
    console.log(`Fetching user with ID: ${userId}, Token: ${authToken || "N/A"}`);
    return { id: userId, name: "Jane Doe" };
  }

  deleteUser(@RequestParam("id") userId: string) {
    console.log(`Deleting user with ID: ${userId}`);
    return { status: "deleted", id: userId };
  }
}

const userController = new UserController();

// Simulate an incoming request
const mockRequest = {
  id: "user123",
  token: "abc-123",
  someOtherProp: "xyz"
};

console.log("\n--- Executing getUser ---");
executeWithParams(userController, "getUser", mockRequest);

console.log("\n--- Executing deleteUser ---");
executeWithParams(userController, "deleteUser", { id: "user456" });

이 예제는 파라미터 데코레이터가 필요한 메서드 매개변수에 대한 정보를 수집하는 방법을 보여줍니다. 프레임워크는 이 수집된 메타데이터를 사용하여 메서드가 호출될 때 적절한 값을 자동으로 해석하고 주입하여 컨트롤러나 서비스 로직을 크게 단순화할 수 있습니다.

데코레이터 구성 및 실행 순서

데코레이터는 다양한 조합으로 적용될 수 있으며, 동작을 예측하고 예기치 않은 문제를 피하기 위해 실행 순서를 이해하는 것이 중요합니다.

단일 대상에 대한 여러 데코레이터

하나의 선언(예: 클래스, 메서드, 프로퍼티)에 여러 데코레이터가 적용될 때, 평가는 특정 순서로 실행됩니다: 아래에서 위로, 또는 오른쪽에서 왼쪽으로. 하지만 그 결과는 반대 순서로 적용됩니다.

@DecoratorA
@DecoratorB
class MyClass {
  // ...
}

여기서 DecoratorB가 먼저 평가되고, 그 다음 DecoratorA가 평가됩니다. 만약 이들이 클래스를 수정한다면(예: 새로운 생성자를 반환함으로써), DecoratorA의 수정이 DecoratorB의 수정 위에 래핑되거나 적용됩니다.

예제: 메서드 데코레이터 체이닝

LogCallAuthorization이라는 두 개의 메서드 데코레이터를 생각해 봅시다.

function LogCall(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG] Calling ${String(propertyKey)} with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[LOG] Method ${String(propertyKey)} returned:`, result);
    return result;
  };
  return descriptor;
}

function Authorization(roles: string[]) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const currentUserRoles = ["admin"]; // Simulate fetching current user roles
      const authorized = roles.some(role => currentUserRoles.includes(role));
      if (!authorized) {
        console.warn(`[AUTH] Access denied for ${String(propertyKey)}. Required roles: ${roles.join(", ")}`);
        throw new Error("Unauthorized access");
      }
      console.log(`[AUTH] Access granted for ${String(propertyKey)}`);
      return originalMethod.apply(this, args);
    };
    return descriptor;
  };
}

class SecureService {
  @LogCall
  @Authorization(["admin"])
  deleteSensitiveData(id: string) {
    console.log(`Deleting sensitive data for ID: ${id}`);
    return `Data ID ${id} deleted.`;
  }

  @Authorization(["user"])
  @LogCall // Order changed here
  fetchPublicData(query: string) {
    console.log(`Fetching public data with query: ${query}`);
    return `Public data for query: ${query}`; 
  }
}

const service = new SecureService();

try {
  console.log("\n--- Calling deleteSensitiveData (Admin User) ---");
  service.deleteSensitiveData("record123");
} catch (error: any) {
  console.error(error.message);
}

try {
  console.log("\n--- Calling fetchPublicData (Non-Admin User) ---");
  // Simulate a non-admin user trying to access fetchPublicData which requires 'user' role
  const mockUserRoles = ["guest"]; // This will fail auth
  // To make this dynamic, you'd need a DI system or static context for current user roles.
  // For simplicity, we assume the Authorization decorator has access to current user context.
  // Let's adjust Authorization decorator to always assume 'admin' for demo purposes, 
  // so the first call succeeds and second fails to show different paths.
  
  // Re-run with user role for fetchPublicData to succeed.
  // Imagine currentUserRoles in Authorization becomes: ['user']
  // For this example, let's keep it simple and show the order effect.
  service.fetchPublicData("search term"); // This will execute Auth -> Log
} catch (error: any) {
  console.error(error.message);
}

/* Expected output for deleteSensitiveData:
[AUTH] Access granted for deleteSensitiveData
[LOG] Calling deleteSensitiveData with args: [ 'record123' ]
Deleting sensitive data for ID: record123
[LOG] Method deleteSensitiveData returned: Data ID record123 deleted.
*/

/* Expected output for fetchPublicData (if user has 'user' role):
[LOG] Calling fetchPublicData with args: [ 'search term' ]
[AUTH] Access granted for fetchPublicData
Fetching public data with query: search term
[LOG] Method fetchPublicData returned: Public data for query: search term
*/

순서를 주목하세요: deleteSensitiveData의 경우, Authorization(아래쪽)이 먼저 실행되고, 그 다음 LogCall(위쪽)이 그것을 감쌉니다. Authorization의 내부 로직이 먼저 실행됩니다. fetchPublicData의 경우, LogCall(아래쪽)이 먼저 실행되고, 그 다음 Authorization(위쪽)이 그것을 감쌉니다. 이는 LogCall 관점이 Authorization 관점의 바깥쪽에 위치하게 됨을 의미합니다. 이 차이는 로깅이나 에러 핸들링과 같은 횡단 관심사에서 매우 중요하며, 실행 순서가 동작에 큰 영향을 미칠 수 있습니다.

다른 대상에 대한 실행 순서

클래스, 그 멤버, 그리고 매개변수 모두에 데코레이터가 있을 때, 실행 순서는 명확하게 정의되어 있습니다:

  1. 파라미터 데코레이터가 각 매개변수에 대해 마지막 매개변수부터 첫 번째 매개변수 순으로 먼저 적용됩니다.
  2. 그 다음, 각 멤버에 대해 메서드, 접근자, 또는 프로퍼티 데코레이터가 적용됩니다.
  3. 마지막으로, 클래스 데코레이터가 클래스 자체에 적용됩니다.

각 카테고리 내에서 동일한 대상에 대한 여러 데코레이터는 아래에서 위로 (또는 오른쪽에서 왼쪽으로) 적용됩니다.

예제: 전체 실행 순서

function log(message: string) {
  return function (target: any, propertyKey: string | symbol | undefined, descriptorOrIndex?: PropertyDescriptor | number) {
    if (typeof descriptorOrIndex === 'number') {
      console.log(`Param Decorator: ${message} on parameter #${descriptorOrIndex} of ${String(propertyKey || "constructor")}`);
    } else if (typeof propertyKey === 'string' || typeof propertyKey === 'symbol') {
      if (descriptorOrIndex && 'value' in descriptorOrIndex && typeof descriptorOrIndex.value === 'function') {
        console.log(`Method/Accessor Decorator: ${message} on ${String(propertyKey)}`);
      } else {
        console.log(`Property Decorator: ${message} on ${String(propertyKey)}`);
      }
    } else {
      console.log(`Class Decorator: ${message} on ${target.name}`);
    }
    return descriptorOrIndex; // Return descriptor for method/accessor, undefined for others
  };
}

@log("Class Level D")
@log("Class Level C")
class MyDecoratedClass {
  @log("Static Property A")
  static staticProp: string = "";

  @log("Instance Property B")
  instanceProp: number = 0;

  @log("Method D")
  @log("Method C")
  myMethod(
    @log("Parameter Z") paramZ: string,
    @log("Parameter Y") paramY: number
  ) {
    console.log("Method myMethod executed.");
  }

  @log("Getter/Setter F")
  get myAccessor() {
    return "";
  }

  set myAccessor(value: string) {
    //...
  }

  constructor() {
    console.log("Constructor executed.");
  }
}

new MyDecoratedClass();
// Call method to trigger method decorator
new MyDecoratedClass().myMethod("hello", 123);

/* Predicted Output Order (approximate, depending on specific TypeScript version and compilation):
Param Decorator: Parameter Y on parameter #1 of myMethod
Param Decorator: Parameter Z on parameter #0 of myMethod
Property Decorator: Static Property A on staticProp
Property Decorator: Instance Property B on instanceProp
Method/Accessor Decorator: Getter/Setter F on myAccessor
Method/Accessor Decorator: Method C on myMethod
Method/Accessor Decorator: Method D on myMethod
Class Decorator: Class Level C on MyDecoratedClass
Class Decorator: Class Level D on MyDecoratedClass
Constructor executed.
Method myMethod executed.
*/

정확한 콘솔 로그 타이밍은 생성자나 메서드가 언제 호출되는지에 따라 약간 다를 수 있지만, 데코레이터 함수 자체가 실행되는 순서(따라서 그들의 부수 효과나 반환된 값이 적용되는 순서)는 위의 규칙을 따릅니다.

데코레이터를 사용한 실제 적용 및 디자인 패턴

데코레이터는 특히 reflect-metadata 폴리필과 함께 사용될 때, 메타데이터 기반 프로그래밍의 새로운 영역을 엽니다. 이는 상용구 코드와 횡단 관심사를 추상화하는 강력한 디자인 패턴을 가능하게 합니다.

1. 의존성 주입 (DI)

데코레이터의 가장 두드러진 사용 사례 중 하나는 의존성 주입 프레임워크(Angular의 @Injectable(), @Component() 등 또는 NestJS의 광범위한 DI 사용)입니다. 데코레이터를 사용하면 생성자나 속성에 직접 의존성을 선언할 수 있어, 프레임워크가 자동으로 올바른 서비스를 인스턴스화하고 제공할 수 있게 됩니다.

예제: 단순화된 서비스 주입

import "reflect-metadata"; // Essential for emitDecoratorMetadata

const INJECTABLE_METADATA_KEY = Symbol("injectable");
const INJECT_METADATA_KEY = Symbol("inject");

function Injectable() {
  return function (target: Function) {
    Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
  };
}

function Inject(token: any) {
  return function (target: Object, propertyKey: string | symbol, parameterIndex: number) {
    const existingInjections: any[] = Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) || [];
    existingInjections[parameterIndex] = token;
    Reflect.defineMetadata(INJECT_METADATA_KEY, existingInjections, target, propertyKey);
  };
}

class Container {
  private static instances = new Map<any, any>();

  static resolve<T>(target: { new (...args: any[]): T }): T {
    if (Container.instances.has(target)) {
      return Container.instances.get(target);
    }

    const isInjectable = Reflect.getMetadata(INJECTABLE_METADATA_KEY, target);
    if (!isInjectable) {
      throw new Error(`Class ${target.name} is not marked as @Injectable.`);
    }

    // Get constructor parameters' types (requires emitDecoratorMetadata)
    const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
    const explicitInjections: any[] = Reflect.getMetadata(INJECT_METADATA_KEY, target) || [];

    const dependencies = paramTypes.map((paramType, index) => {
      // Use explicit @Inject token if provided, otherwise infer type
      const token = explicitInjections[index] || paramType;
      if (token === undefined) {
        throw new Error(`Cannot resolve parameter at index ${index} for ${target.name}. It might be a circular dependency or primitive type without explicit @Inject.`);
      }
      return Container.resolve(token);
    });

    const instance = new target(...dependencies);
    Container.instances.set(target, instance);
    return instance;
  }
}

// Define services
@Injectable()
class DatabaseService {
  connect() {
    console.log("Connecting to database...");
    return "DB Connection";
  }
}

@Injectable()
class AuthService {
  private db: DatabaseService;

  constructor(db: DatabaseService) {
    this.db = db;
  }

  login() {
    console.log(`AuthService: Authenticating using ${this.db.connect()}`);
    return "User logged in";
  }
}

@Injectable()
class UserService {
  private authService: AuthService;
  private dbService: DatabaseService; // Example of injecting via property using a custom decorator or framework feature

  constructor(@Inject(AuthService) authService: AuthService,
              @Inject(DatabaseService) dbService: DatabaseService) {
    this.authService = authService;
    this.dbService = dbService;
  }

  getUserProfile() {
    this.authService.login();
    this.dbService.connect();
    console.log("UserService: Fetching user profile...");
    return { id: 1, name: "Global User" };
  }
}

// Resolve the main service
console.log("--- Resolving UserService ---");
const userService = Container.resolve(UserService);
console.log(userService.getUserProfile());

console.log("\n--- Resolving AuthService (should be cached) ---");
const authService = Container.resolve(AuthService);
authService.login();

이 정교한 예제는 @Injectable@Inject 데코레이터가 reflect-metadata와 결합되어 커스텀 Container가 자동으로 의존성을 해결하고 제공하는 방법을 보여줍니다. TypeScript가 (`emitDecoratorMetadata`가 true일 때) 자동으로 내보내는 design:paramtypes 메타데이터가 여기서 매우 중요합니다.

2. 관점 지향 프로그래밍 (AOP)

AOP는 여러 클래스와 모듈에 걸쳐 있는 횡단 관심사(예: 로깅, 보안, 트랜잭션)를 모듈화하는 데 중점을 둡니다. 데코레이터는 TypeScript에서 AOP 개념을 구현하는 데 탁월한 선택입니다.

예제: 메서드 데코레이터를 사용한 로깅

앞서 본 LogCall 데코레이터는 AOP의 완벽한 예입니다. 메서드의 원래 코드를 수정하지 않고 모든 메서드에 로깅 동작을 추가합니다. 이는 "무엇을 할 것인가"(비즈니스 로직)와 "어떻게 할 것인가"(로깅, 성능 모니터링 등)를 분리합니다.

function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[LOG AOP] Entering method: ${String(propertyKey)} with args:`, args);
    try {
      const result = originalMethod.apply(this, args);
      console.log(`[LOG AOP] Exiting method: ${String(propertyKey)} with result:`, result);
      return result;
    } catch (error: any) {
      console.error(`[LOG AOP] Error in method ${String(propertyKey)}:`, error.message);
      throw error;
    }
  };
  return descriptor;
}

class PaymentProcessor {
  @LogMethod
  processPayment(amount: number, currency: string) {
    if (amount <= 0) {
      throw new Error("Payment amount must be positive.");
    }
    console.log(`Processing payment of ${amount} ${currency}...`);
    return `Payment of ${amount} ${currency} processed successfully.`;
  }

  @LogMethod
  refundPayment(transactionId: string) {
    console.log(`Refunding payment for transaction ID: ${transactionId}...`);
    return `Refund initiated for ${transactionId}.`;
  }
}

const processor = new PaymentProcessor();
processor.processPayment(100, "USD");
try {
  processor.processPayment(-50, "EUR");
} catch (error: any) {
  console.error("Caught error:", error.message);
}

이 접근 방식은 PaymentProcessor 클래스가 순전히 결제 로직에만 집중하도록 유지하면서, LogMethod 데코레이터가 로깅이라는 횡단 관심사를 처리하도록 합니다.

3. 유효성 검사 및 변환

데코레이터는 속성에 직접 유효성 검사 규칙을 정의하거나 직렬화/역직렬화 중에 데이터를 변환하는 데 매우 유용합니다.

예제: 프로퍼티 데코레이터를 사용한 데이터 유효성 검사

앞서 @Required 예제가 이미 이를 보여주었습니다. 여기에 숫자 범위 유효성 검사 예제를 추가합니다.

interface FieldValidationRule {
  property: string | symbol;
  validator: (value: any) => boolean;
  message: string;
}

const fieldValidationRules = new Map<Function, FieldValidationRule[]>();

function addValidationRule(target: Object, propertyKey: string | symbol, validator: (value: any) => boolean, message: string) {
  const rules = fieldValidationRules.get(target.constructor) || [];
  rules.push({ property: propertyKey, validator, message });
  fieldValidationRules.set(target.constructor, rules);
}

function IsPositive(target: Object, propertyKey: string | symbol) {
  addValidationRule(target, propertyKey, (value: number) => value > 0, `${String(propertyKey)} must be a positive number.`);
}

function MaxLength(maxLength: number) {
  return function (target: Object, propertyKey: string | symbol) {
    addValidationRule(target, propertyKey, (value: string) => value.length <= maxLength, `${String(propertyKey)} must be at most ${maxLength} characters long.`);
  };
}

class Product {
  @MaxLength(50)
  name: string;

  @IsPositive
  price: number;

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

  static validate(instance: any): string[] {
    const errors: string[] = [];
    const rules = fieldValidationRules.get(instance.constructor) || [];
    for (const rule of rules) {
      if (!rule.validator(instance[rule.property])) {
        errors.push(rule.message);
      }
    }
    return errors;
  }
}

const product1 = new Product("Laptop", 1200);
console.log("Product 1 errors:", Product.validate(product1)); // []

const product2 = new Product("Very long product name that exceeds fifty characters limit for testing purpose", 50);
console.log("Product 2 errors:", Product.validate(product2)); // ["name must be at most 50 characters long."]

const product3 = new Product("Book", -10);
console.log("Product 3 errors:", Product.validate(product3)); // ["price must be a positive number."]

이 설정은 모델 속성에 유효성 검사 규칙을 선언적으로 정의할 수 있게 하여, 데이터 모델이 제약 조건 측면에서 자체 설명적으로 만들어 줍니다.

모범 사례 및 고려 사항

데코레이터는 강력하지만 신중하게 사용해야 합니다. 잘못 사용하면 디버깅하거나 이해하기 더 어려운 코드가 될 수 있습니다.

데코레이터 사용 시기 (그리고 사용하지 말아야 할 때)

성능에 미치는 영향

데코레이터는 컴파일 시점(또는 트랜스파일된 경우 JavaScript 런타임의 정의 시점)에 실행됩니다. 변환이나 메타데이터 수집은 클래스/메서드가 정의될 때 발생하며, 매 호출마다 발생하지 않습니다. 따라서 데코레이터를 *적용*하는 것의 런타임 성능 영향은 미미합니다. 그러나 데코레이터 *내부의 로직*은 성능에 영향을 미칠 수 있으며, 특히 매 메서드 호출마다 비용이 많이 드는 작업을 수행하는 경우(예: 메서드 데코레이터 내의 복잡한 계산) 더욱 그렇습니다.

유지보수성 및 가독성

데코레이터는 올바르게 사용될 때 상용구 코드를 주 로직에서 분리하여 가독성을 크게 향상시킬 수 있습니다. 그러나 복잡하고 숨겨진 변환을 수행하면 디버깅이 어려워질 수 있습니다. 데코레이터가 잘 문서화되고 그 동작이 예측 가능하도록 하세요.

실험적 상태와 데코레이터의 미래

TypeScript 데코레이터가 Stage 3 TC39 제안에 기반한다는 점을 다시 한번 강조하는 것이 중요합니다. 이는 사양이 대체로 안정적이지만 공식 ECMAScript 표준의 일부가 되기 전에 여전히 사소한 변경이 있을 수 있음을 의미합니다. Angular와 같은 프레임워크는 최종적인 표준화를 기대하며 이를 채택했습니다. 이는 어느 정도의 위험을 내포하지만, 널리 채택된 것을 고려할 때 중대한 변경이 발생할 가능성은 낮습니다.

TC39 제안은 진화해 왔습니다. TypeScript의 현재 구현은 제안의 이전 버전을 기반으로 합니다. "레거시 데코레이터"와 "표준 데코레이터"의 구분이 있습니다. 공식 표준이 정해지면 TypeScript는 구현을 업데이트할 가능성이 높습니다. 프레임워크를 사용하는 대부분의 개발자에게 이 전환은 프레임워크 자체에서 관리될 것입니다. 라이브러리 작성자에게는 레거시와 미래 표준 데코레이터 간의 미묘한 차이를 이해하는 것이 필요할 수 있습니다.

`emitDecoratorMetadata` 컴파일러 옵션

이 옵션을 tsconfig.json에서 true로 설정하면 TypeScript 컴파일러가 특정 디자인 타임 타입 메타데이터를 컴파일된 JavaScript에 내보내도록 지시합니다. 이 메타데이터에는 생성자 매개변수의 타입(design:paramtypes), 메서드의 반환 타입(design:returntype), 그리고 속성의 타입(design:type)이 포함됩니다.

이 내보내진 메타데이터는 표준 JavaScript 런타임의 일부가 아닙니다. 일반적으로 reflect-metadata 폴리필에 의해 소비되며, 그런 다음 Reflect.getMetadata() 함수를 통해 접근할 수 있게 됩니다. 이는 컨테이너가 명시적인 구성 없이 클래스가 필요로 하는 의존성의 타입을 알아야 하는 의존성 주입과 같은 고급 패턴에 절대적으로 중요합니다.

데코레이터를 사용한 고급 패턴

데코레이터는 결합하고 확장하여 훨씬 더 정교한 패턴을 구축할 수 있습니다.

1. 데코레이터 데코레이팅 (고차 데코레이터)

다른 데코레이터를 수정하거나 구성하는 데코레이터를 만들 수 있습니다. 이는 덜 일반적이지만 데코레이터의 함수적 특성을 보여줍니다.

// A decorator that ensures a method is logged and also requires admin roles
function AdminAndLoggedMethod() {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Apply Authorization first (inner)
    Authorization(["admin"])(target, propertyKey, descriptor);
    // Then apply LogCall (outer)
    LogCall(target, propertyKey, descriptor);

    return descriptor; // Return the modified descriptor
  };
}

class AdminPanel {
  @AdminAndLoggedMethod()
  deleteUserAccount(userId: string) {
    console.log(`Deleting user account: ${userId}`);
    return `User ${userId} deleted.`;
  }
}

const adminPanel = new AdminPanel();
adminPanel.deleteUserAccount("user007");
/* Expected Output (assuming admin role):
[AUTH] Access granted for deleteUserAccount
[LOG] Calling deleteUserAccount with args: [ 'user007' ]
Deleting user account: user007
[LOG] Method deleteUserAccount returned: User user007 deleted.
*/

여기서 AdminAndLoggedMethod는 데코레이터를 반환하는 팩토리이며, 그 데코레이터 내부에서 두 개의 다른 데코레이터를 적용합니다. 이 패턴은 복잡한 데코레이터 구성을 캡슐화할 수 있습니다.

2. 믹스인을 위한 데코레이터 사용

TypeScript는 믹스인을 구현하는 다른 방법을 제공하지만, 데코레이터를 사용하여 클래스에 기능을 선언적으로 주입할 수 있습니다.

function ApplyMixins(constructors: Function[]) {
  return function (derivedConstructor: Function) {
    constructors.forEach(baseConstructor => {
      Object.getOwnPropertyNames(baseConstructor.prototype).forEach(name => {
        Object.defineProperty(
          derivedConstructor.prototype,
          name,
          Object.getOwnPropertyDescriptor(baseConstructor.prototype, name) || Object.create(null)
        );
      });
    });
  };
}

class Disposable {
  isDisposed: boolean = false;
  dispose() {
    this.isDisposed = true;
    console.log("Object disposed.");
  }
}

class Loggable {
  log(message: string) {
    console.log(`[Loggable] ${message}`);
  }
}

@ApplyMixins([Disposable, Loggable])
class MyResource implements Disposable, Loggable {
  // These properties/methods are injected by the decorator
  isDisposed!: boolean;
  dispose!: () => void;
  log!: (message: string) => void;

  constructor(public name: string) {
    this.log(`Resource ${this.name} created.`);
  }

  cleanUp() {
    this.dispose();
    this.log(`Resource ${this.name} cleaned up.`);
  }
}

const resource = new MyResource("NetworkConnection");
console.log(`Is disposed: ${resource.isDisposed}`);
resource.cleanUp();
console.log(`Is disposed: ${resource.isDisposed}`);

@ApplyMixins 데코레이터는 기본 생성자의 메서드와 속성을 파생 클래스의 프로토타입에 동적으로 복사하여, 효과적으로 기능을 "믹스인"합니다.

결론: 현대 TypeScript 개발 강화하기

TypeScript 데코레이터는 메타데이터 기반 및 관점 지향 프로그래밍이라는 새로운 패러다임을 가능하게 하는 강력하고 표현력이 풍부한 기능입니다. 개발자는 핵심 로직을 변경하지 않고 클래스, 메서드, 속성, 접근자 및 매개변수에 선언적 동작을 향상, 수정 및 추가할 수 있습니다. 이러한 관심사의 분리는 더 깨끗하고, 유지보수하기 쉬우며, 매우 재사용 가능한 코드로 이어집니다.

의존성 주입 단순화 및 견고한 유효성 검사 시스템 구현에서부터 로깅 및 성능 모니터링과 같은 횡단 관심사 추가에 이르기까지, 데코레이터는 많은 일반적인 개발 문제에 대한 우아한 해결책을 제공합니다. 실험적 상태라는 점은 인지해야 하지만, 주요 프레임워크에서의 광범위한 채택은 그들의 실용적 가치와 미래 관련성을 의미합니다.

TypeScript 데코레이터를 마스터함으로써, 여러분은 더 견고하고, 확장 가능하며, 지능적인 애플리케이션을 구축할 수 있는 중요한 도구를 얻게 됩니다. 책임감 있게 사용하고, 그 메커니즘을 이해하며, 여러분의 TypeScript 프로젝트에서 새로운 차원의 선언적 힘을 발휘해 보세요.

타입스크립트 데코레이터: 견고한 애플리케이션을 위한 메타데이터 프로그래밍 패턴 마스터하기 | MLOG