타입스크립트(TypeScript) 데코레이터(Decorator)는 클래스, 메서드, 속성, 파라미터 등에 기능을 부여하거나 수정할 수 있도록 해주는 기능입니다. 주로 로깅, 권한 검사, 데이터 검증, 캐싱 등 공통적으로 사용되는 기능을 분리(관심사 분리)해 코드를 간결하고 일관성 있게 유지하는데 유용합니다. 데코레이터는 기존 코드에 영향을 주지 않으면서 새로운 기능을 추가하는 데 매우 유용하며, TypeScript에서는 @ 기호로 데코레이터를 선언합니다. (자바, 파이썬도 같은 기호 사용), 마지막 부분에서는 자바와 파이썬에서는 어떻게 사용하는지 같이 살펴보겠습니다.
데코레이터 종류
타입스크립트에서 데코레이터는 여러 종류가 있으면 다음의 몇가지를 살펴보겠습니다.
- 클래스 데코레이터: 클래스에 적용
- 메서드 데코레이터: 클래스 메서드에 적용
- 속성 데코레이터: 클래스 속성에 적용
- 매개변수 데코레이터: 메서드의 매개변수에 적용
- 접근자 데코레이터: 클래스의 getter/setter에 동작을 정의
타입스크립트에서 데코레이터를 사용하려면 experimentalDecorators
옵션을 tsconfig.json
파일에 설정해야 합니다. 현재 시점에서 데코레이터는 https://github.com/tc39/proposal-decorators 스테이지 3 단계입니다. 특별한 이슈가 없는한 정식 채택된다는 뜻으로 생각하면 됩니다. 그래도 현재는 정식 채택이 아니므로, 아래와 같이 설정해 주어야 합니다.
{ "compilerOptions": { "experimentalDecorators": true } }
데코레이터 예제
클래스 데코레이터 예제
클래스 데코레이터는 클래스 선언에 기능을 추가하거나, 클래스인스턴스를 수정할 때 유용합니다.
function Logger(target: Function) { console.log(`${target.name}가 생성되었습니다.`); } @Logger class Car { constructor(public model: string) {} }
위의 예제에서 Logger 데코레이터는 Car 클래스가 생성될 때마다 수행이됩니다.
메서드 데코레이터 예제
메서드 데코레이터는 특정 메서드에 추가 동작을 적용할 수 있습니다.
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function (...args: any[]) { console.log(`Method 호출: ${propertyKey}`); console.log(`Arguments : ${JSON.stringify(args)}`); const result = originalMethod.apply(this, args); console.log(`결과: ${result}`); return result; }; return descriptor; } class Calculator { @Log add(a: number, b: number): number { return a + b; } } const calculator = new Calculator(); calculator.add(2, 3);
위의 예제에서, LogExecution 데코레이터는 Calculator 클래스의 add 메서드가 호출될 때마다 실행됩니다. 위의 예제의 target, propertyKey, descriptor를 눈여겨 보시면 좋습니다.
속성 데코레이터 예제
속성 데코레이터는 클래스의 특정 속성에 기능을 추가하는데 사용됩니다. 예를 들어, 속성값을 자동으로 검증하는 기능 같은 것을 추가할 수 있습니다.
function Required(target: Object, propertyKey: string) { let value: string; const getter = () => value; const setter = (newValue: string) => { if (!newValue) { throw new Error(`${propertyKey}는 필수 항목입니다.`); } value = newValue; }; Object.defineProperty(target, propertyKey, { get: getter, set: setter, }); } class User { @Required name: string = ""; } const user = new User(); user.name = ""; // 오류: name는 필수 항목입니다.
위의 예제에서 속성값을 확인해서, 빈값이 들어오면 에러를 반환할 수 있습니다.
매개변수 데코레이터 예제
특정 매개변수의 값을 검사하거나 처리할 때 사용할 수 있습니다. 이쯤 되면 거의 개념이 잡혔을 것으로 예상됩니다.
function LogParameter(target: any, propertyKey: string | symbol, parameterIndex: number) { console.log(`Parameter decorator called for ${propertyKey} at index ${parameterIndex}`); } class User { greet(@LogParameter message: string) { console.log(`Hello, ${message}`); } } const user = new User(); user.greet("TypeScript"); // 매개변수 데코레이터가 동작하여 매개변수 값을 로깅합니다.
위의 예제에서 LogParameter 는 message 매개 변수에 적용되어서, 이후 절차를 수행합니다. 위에서 계속 보던 예제와 달리, 매개변수 데코레이터에는 return 값이 없습니다
접근자 데코레이터
접근자 데코레이터를 통해 getter 나 setter에 특정 동작을 추가할 수 있습니다.
function AccessAndLimit(max: number) { return function (target: Object, propertyKey: string, descriptor: PropertyDescriptor) { let accessCount = 0; const originalGetter = descriptor.get; const originalSetter = descriptor.set; descriptor.get = function () { accessCount++; console.log(`"${propertyKey}"에 접근한 횟수: ${accessCount}`); return originalGetter ? originalGetter.apply(this) : undefined; }; descriptor.set = function (newValue: number) { if (newValue > max) { console.log(`"${propertyKey}"의 값은 최대 ${max}로 설정됩니다.`); newValue = max; } if (originalSetter) { originalSetter.apply(this, [newValue]); } }; }; } class Product { private _price: number = 0; @AccessAndLimit(100) get price(): number { return this._price; } set price(value: number) { this._price = value; } } const product = new Product(); // 값 설정 (setter 동작) product.price = 120; // 출력: "price의 값은 최대 100로 설정됩니다." // 값 접근 (getter 동작) console.log(product.price); // 출력: "price에 접근한 횟수: 1" 후 "100" console.log(product.price); // 출력: "price에 접근한 횟수: 2" 후 "100"
위와 같이 설정하면, get 과 set 동작시에 동작하도록 할 수 있습니다. 예제에서 100을 최대값으로 지정했고, 120이 들어왔을때, 100으로 지정하도록 했습니다. 타입스크립트에서 get price() 위에만 데코레이터가 있어도, 같은 이름의 get set 접근자 모두에 대해 해당 데코레이터가 동작합니다.
타입스크립트에서 데코레이터를 사용하는 이유
위의 예제에서 보시는 것처럼, 데코레이터를 사용하면, 공통 기능을 분리하여 모듈화할 수 있습니다. 코드의 재사용성과 유지보수성을 크게 향상시킵니다. 특히 다음과 같은 상황에서 유용하게 사용할 수 있습니다. 위의 예시들만 보더라도 무슨 의미인지 감이 오실겁니다.
- 로깅: 코드에 여러 메서드의 호출 로그를 남길 때.
- 데이터 검증: 입력값 검증 로직을 각 메서드마다 작성할 필요 없이 데코레이터로 처리.
- 권한 검사: 사용자가 특정 기능을 실행할 권한이 있는지 검사.
- 캐싱: 동일한 입력값으로 호출된 메서드의 결과를 캐시하여 성능 향상.
데코레이터를 캐싱으로 사용하기
위에서 언급하지 않았던 캐싱 사용하는 예제를 추가로 보여드리겠습니다. 만약에 계산에 비용이 많이 드는 경우가 있다고 할 경우, 입력받은 인자를 키로 사용하여, 캐싱된 값을 넘겨준다면 계산 비용이 많이 줄어 들겠죠? 아래 예제를 먼저 보겠습니다.
function CacheResult(target: Object, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; const cache = new Map<string, any>(); descriptor.value = function (...args: any[]) { const key = JSON.stringify(args); // 인자를 키로 사용 if (cache.has(key)) { console.log(`캐시된 결과 반환: ${key}`); return cache.get(key); } const result = originalMethod.apply(this, args); cache.set(key, result); console.log(`새로운 결과 계산 후 캐싱: ${key}`); return result; }; return descriptor; } class MathOperations { @CacheResult expensiveCalculation(num: number): number { console.log("비용이 많이 드는 계산 수행..."); return num * num; // 예를 들어, 제곱 계산 } } const mathOps = new MathOperations(); console.log(mathOps.expensiveCalculation(5)); // "비용이 많이 드는 계산 수행..." 출력 후 결과 25 console.log(mathOps.expensiveCalculation(5)); // "캐시된 결과 반환" 출력 후 결과 25 console.log(mathOps.expensiveCalculation(3)); // "비용이 많이 드는 계산 수행..." 출력 후 결과 9 console.log(mathOps.expensiveCalculation(3)); // "캐시된 결과 반환" 출력 후 결과 9
위의 예시에서 인자값이 같은 경우, 캐싱된 값을 가져오게됩니다.
유사 언어와의 비교
타입스크립트의 데코레이터는 파이썬과 자바의 어노테이션(Annotation)과 유사한 역할을 합니다.
파이썬의 예
def log(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with {args} and {kwargs}") result = func(*args, **kwargs) print(f"{func.__name__} returned {result}") return result return wrapper @log def add(a, b): return a + b add(2, 3)
자바의 예
사실 자바는 데코레이터라는 개념은 없지만, 어노테이션이라는 비슷한 개념을 가지고 있습니다. 예를 들어 @Override 라는 어노테이션은 자주 쓰이는 것중 하나입니다.
@Override는 부모 클래스나 인터페이스의 메서드를 재정의할 때 사용되는 어노테이션입니다. 이 어노테이션을 사용하면 개발자가 의도한 대로 메서드가 정확히 재정의되었는지 컴파일러가 확인해 줍니다. 만약 메서드 이름이나 매개변수가 맞지 않으면 컴파일 오류를 발생시키므로, 오타나 실수로 인한 오류를 방지할 수 있습니다.
class Animal { public void sound() { System.out.println("Animal sound"); } } class Dog extends Animal { @Override public void sound() { System.out.println("Bark"); } } public class Main { public static void main(String[] args) { Animal dog = new Dog(); dog.sound(); // 출력: Bark } }