4.4 데코레이터
데코레이터(Decorator)는 클래스, 메서드, 프로퍼티, 파라미터에 @expression 형태로 붙여서 동작을 추가하거나 변형하는 메타프로그래밍 도구다. 코드를 수정하지 않고 외부에서 기능을 주입할 수 있어, NestJS의 @Controller, TypeORM의 @Entity, Angular의 @Component 같은 프레임워크들이 핵심 메커니즘으로 사용한다.
이 장에서는 TypeScript 5.x의 표준 데코레이터(Stage 3 표준)와 레거시 experimentalDecorators 방식의 차이를 명확히 구분하고, 실용적인 데코레이터를 직접 구현하는 방법을 익힌다.
데코레이터란? — Stage 3 vs experimentalDecorators
역사적 배경
TypeScript는 오래전부터 experimentalDecorators: true 옵션으로 데코레이터를 지원해왔다. 이 방식은 TC39 Stage 2 제안을 기반으로 하며, Angular, NestJS, TypeORM이 이 방식을 사용한다.
2023년 TC39는 새로운 표준 데코레이터 제안을 Stage 3로 확정했고, TypeScript 5.0부터 이 표준을 공식 지원한다. 두 방식은 API가 다르고 혼용할 수 없다.
// tsconfig.json — 레거시 방식 (NestJS, Angular 등)
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
// tsconfig.json — 표준 방식 (TypeScript 5.0+)
// experimentalDecorators 옵션 없이 그냥 사용
이 장에서는 TypeScript 5.x 표준 데코레이터를 기준으로 설명하고, 레거시와의 차이를 별도로 짚는다.
클래스 데코레이터
클래스 선언 바로 위에 붙는다. 클래스 자체를 인자로 받아 변형하거나 감쌀 수 있다.
TypeScript 5.x 표준 클래스 데코레이터
// 표준 데코레이터 타입 (TypeScript 5.0+)
type ClassDecorator = (
target: Function,
context: ClassDecoratorContext
) => Function | void;
// 간단한 클래스 데코레이터: 로깅 추가
function Sealed(target: Function, context: ClassDecoratorContext) {
Object.seal(target);
Object.seal(target.prototype);
console.log(`${context.name} 클래스가 봉인되었습니다.`);
}
@Sealed
class Config {
host: string = "localhost";
port: number = 3000;
}
// Config.newProp = "test"; // 오류: 봉인된 객체에 추가 불가
메타데이터 주입 데코레이터
// 클래스에 메타데이터를 주입하는 데코레이터
function Singleton<T extends new (...args: any[]) => any>(
target: T,
context: ClassDecoratorContext
): T {
let instance: InstanceType<T> | null = null;
// 원래 클래스를 감싸는 새 클래스 반환
const Wrapped = class extends target {
constructor(...args: any[]) {
if (instance) return instance;
super(...args);
instance = this as InstanceType<T>;
}
} as T;
return Wrapped;
}
@Singleton
class AppState {
value: number = 0;
increment(): void {
this.value++;
}
}
const s1 = new AppState();
const s2 = new AppState();
s1.increment();
console.log(s1 === s2); // true
console.log(s2.value); // 1 (같은 인스턴스)
메서드 데코레이터
메서드 선언 위에 붙어 메서드의 실행 전후에 로직을 추가하거나 메서드 자체를 교체할 수 있다.
표준 메서드 데코레이터
// 메서드 데코레이터 시그니처 (TypeScript 5.x)
type MethodDecorator<This, Args extends any[], Return> = (
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) => ((this: This, ...args: Args) => Return) | void;
로깅 데코레이터
function Log(
target: Function,
context: ClassMethodDecoratorContext
) {
const methodName = String(context.name);
return function (this: unknown, ...args: unknown[]) {
console.log(`[LOG] ${methodName} 호출 — 인자:`, args);
const start = performance.now();
const result = (target as Function).apply(this, args);
// Promise 처리
if (result instanceof Promise) {
return result
.then((value) => {
const elapsed = (performance.now() - start).toFixed(2);
console.log(`[LOG] ${methodName} 완료 (${elapsed}ms) — 반환:`, value);
return value;
})
.catch((err) => {
console.error(`[LOG] ${methodName} 오류:`, err);
throw err;
});
}
const elapsed = (performance.now() - start).toFixed(2);
console.log(`[LOG] ${methodName} 완료 (${elapsed}ms) — 반환:`, result);
return result;
};
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
@Log
async fetchRate(currency: string): Promise<number> {
// 실제로는 API 호출
await new Promise((r) => setTimeout(r, 100));
return currency === "USD" ? 1300 : 1400;
}
}
const calc = new Calculator();
calc.add(3, 4);
// [LOG] add 호출 — 인자: [3, 4]
// [LOG] add 완료 (0.xx ms) — 반환: 7
메모이제이션(Memoize) 데코레이터
function Memoize(
target: Function,
context: ClassMethodDecoratorContext
) {
const cache = new Map<string, unknown>();
return function (this: unknown, ...args: unknown[]) {
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`[Memoize] 캐시 히트: ${String(context.name)}(${key})`);
return cache.get(key);
}
const result = (target as Function).apply(this, args);
cache.set(key, result);
return result;
};
}
class FibCalculator {
@Memoize
fib(n: number): number {
if (n <= 1) return n;
return this.fib(n - 1) + this.fib(n - 2);
}
}
const fibCalc = new FibCalculator();
console.log(fibCalc.fib(10)); // 55
console.log(fibCalc.fib(10)); // [Memoize] 캐시 히트 — 55
프로퍼티 데코레이터
프로퍼티 선언 위에 붙어 프로퍼티 접근 시 유효성 검사나 변환을 적용한다.
// TypeScript 5.x 프로퍼티 데코레이터
function NonNegative(
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: unknown, initialValue: number) {
let value = initialValue;
// getter/setter를 통해 값을 제어
Object.defineProperty(this, context.name, {
get() { return value; },
set(newValue: number) {
if (newValue < 0) {
throw new RangeError(
`${String(context.name)}은 음수가 될 수 없습니다. 받은 값: ${newValue}`
);
}
value = newValue;
},
enumerable: true,
configurable: true,
});
return initialValue;
};
}
function MaxLength(max: number) {
return function (
target: undefined,
context: ClassFieldDecoratorContext
) {
return function (this: unknown, initialValue: string) {
let value = initialValue.slice(0, max);
Object.defineProperty(this, context.name, {
get() { return value; },
set(newValue: string) {
value = String(newValue).slice(0, max);
},
enumerable: true,
configurable: true,
});
return value;
};
};
}
class Product {
@MaxLength(50)
name: string = "";
@NonNegative
price: number = 0;
@NonNegative
stock: number = 0;
}
const product = new Product();
product.name = "TypeScript 마스터 완전 정복 강의 시리즈 2025 에디션 (무제한 수강권)";
console.log(product.name.length); // 50자로 잘림
product.price = 29900;
// product.price = -100; // RangeError: price은 음수가 될 수 없습니다.
파라미터 데코레이터
메서드 파라미터 앞에 붙어 파라미터에 메타데이터를 주입한다. 주로 의존성 주입 프레임워크에서 사용한다.
// 파라미터 인덱스를 저장하는 메타데이터 심볼
const REQUIRED_PARAMS = Symbol("required_params");
function Required(
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) {
const existingRequired: number[] =
Reflect.getOwnMetadata(REQUIRED_PARAMS, target, propertyKey) || [];
existingRequired.push(parameterIndex);
Reflect.defineMetadata(REQUIRED_PARAMS, existingRequired, target, propertyKey);
}
// 파라미터 검사 메서드 데코레이터
function ValidateParams(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: unknown[]) {
const requiredParams: number[] =
Reflect.getOwnMetadata(REQUIRED_PARAMS, target, propertyKey) || [];
requiredParams.forEach((paramIndex) => {
if (args[paramIndex] === undefined || args[paramIndex] === null) {
throw new Error(
`${propertyKey} 메서드의 ${paramIndex}번 파라미터는 필수입니다.`
);
}
});
return originalMethod.apply(this, args);
};
}
class UserService {
@ValidateParams
createUser(@Required name: string, @Required email: string, age?: number): object {
return { name, email, age };
}
}
데코레이터 팩토리 — 인수를 받는 데코레이터
데코레이터가 인수를 받으려면 데코레이터를 반환하는 함수(팩토리)를 만든다.
// 재시도 데코레이터 팩토리
function Retry(times: number, delayMs: number = 0) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return async function (this: unknown, ...args: unknown[]) {
let lastError: unknown;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await (target as Function).apply(this, args);
} catch (err) {
lastError = err;
console.warn(
`[Retry] ${String(context.name)} 실패 (시도 ${attempt}/${times}):`,
(err as Error).message
);
if (attempt < times && delayMs > 0) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
}
throw lastError;
};
};
}
// 역할(Role) 검사 데코레이터 팩토리
function RequireRole(...roles: string[]) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return function (this: { currentRole?: string }, ...args: unknown[]) {
const userRole = this.currentRole ?? "guest";
if (!roles.includes(userRole)) {
throw new Error(
`권한 부족: ${String(context.name)}은 [${roles.join(", ")}] 역할이 필요합니다.`
);
}
return (target as Function).apply(this, args);
};
};
}
class ApiService {
currentRole: string = "admin";
@Retry(3, 500)
async fetchData(url: string): Promise<string> {
// 네트워크 오류 시뮬레이션
if (Math.random() < 0.7) throw new Error("Network Error");
return `Data from ${url}`;
}
@RequireRole("admin", "superuser")
deleteUser(userId: number): void {
console.log(`사용자 ${userId} 삭제`);
}
@RequireRole("guest")
viewProfile(userId: number): void {
console.log(`사용자 ${userId} 프로필 조회`);
}
}
const api = new ApiService();
api.deleteUser(42); // OK: currentRole은 admin
api.currentRole = "user";
// api.deleteUser(42); // 오류: 권한 부족
NestJS/TypeORM에서의 데코레이터 활용
실제 프레임워크에서 데코레이터가 어떻게 사용되는지 패턴을 이해한다.
// NestJS 스타일 (experimentalDecorators 기반)
// @Injectable: DI 컨테이너에 등록
function Injectable() {
return function (target: Function) {
Reflect.defineMetadata("injectable", true, target);
};
}
// @Controller: HTTP 라우터 등록
function Controller(path: string) {
return function (target: Function) {
Reflect.defineMetadata("path", path, target);
};
}
// @Get: HTTP GET 메서드 등록
function Get(path: string = "") {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const routes = Reflect.getOwnMetadata("routes", target.constructor) || [];
routes.push({ method: "GET", path, handler: propertyKey });
Reflect.defineMetadata("routes", routes, target.constructor);
};
}
// TypeORM 스타일
// @Entity: 데이터베이스 테이블 매핑
function Entity(tableName?: string) {
return function (target: Function) {
Reflect.defineMetadata("entity", { tableName: tableName ?? target.name }, target);
};
}
// @Column: 컬럼 정의
function Column(options?: { type?: string; nullable?: boolean }) {
return function (target: any, propertyKey: string) {
const columns = Reflect.getOwnMetadata("columns", target.constructor) || [];
columns.push({ name: propertyKey, ...options });
Reflect.defineMetadata("columns", columns, target.constructor);
};
}
// @PrimaryGeneratedColumn: 자동 증가 기본키
function PrimaryGeneratedColumn() {
return function (target: any, propertyKey: string) {
Reflect.defineMetadata("primaryKey", propertyKey, target.constructor);
};
}
// 사용 예시 (실제 NestJS/TypeORM과 유사한 구조)
@Entity("users")
class UserEntity {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: "varchar", nullable: false })
name!: string;
@Column({ type: "varchar", nullable: false })
email!: string;
}
실전 예제: @Log, @Memoize, @Validate, @Injectable
// @Validate: 메서드 파라미터 타입 검사
function Validate(schema: Record<string, "string" | "number" | "boolean">) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
return function (this: unknown, params: Record<string, unknown>) {
for (const [key, type] of Object.entries(schema)) {
if (!(key in params)) {
throw new Error(`필수 필드 누락: ${key}`);
}
if (typeof params[key] !== type) {
throw new TypeError(
`${key}의 타입이 잘못됨: ${type} 기대, ${typeof params[key]} 받음`
);
}
}
return (target as Function).apply(this, [params]);
};
};
}
// @Debounce: 연속 호출 억제
function Debounce(ms: number) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
let timer: ReturnType<typeof setTimeout> | null = null;
return function (this: unknown, ...args: unknown[]) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
(target as Function).apply(this, args);
timer = null;
}, ms);
};
};
}
// @Throttle: 일정 시간 내 최대 1회 실행
function Throttle(ms: number) {
return function (
target: Function,
context: ClassMethodDecoratorContext
) {
let lastCall = 0;
return function (this: unknown, ...args: unknown[]) {
const now = Date.now();
if (now - lastCall >= ms) {
lastCall = now;
return (target as Function).apply(this, args);
}
console.log(`[Throttle] ${String(context.name)} 호출 억제됨`);
};
};
}
class SearchService {
@Debounce(300)
search(query: string): void {
console.log(`검색: ${query}`);
}
@Throttle(1000)
trackEvent(event: string): void {
console.log(`이벤트 추적: ${event}`);
}
@Validate({ name: "string", age: "number" })
createProfile(params: { name: string; age: number }): void {
console.log(`프로필 생성: ${params.name}, ${params.age}세`);
}
}
const svc = new SearchService();
svc.createProfile({ name: "Alice", age: 30 }); // OK
// svc.createProfile({ name: "Bob", age: "30" }); // TypeError
고수 팁
데코레이터 실행 순서
클래스에 여러 데코레이터가 있을 때 실행 순서를 이해해야 한다.
function First() {
console.log("First 팩토리 평가");
return function (target: Function, context: ClassDecoratorContext) {
console.log("First 데코레이터 실행");
};
}
function Second() {
console.log("Second 팩토리 평가");
return function (target: Function, context: ClassDecoratorContext) {
console.log("Second 데코레이터 실행");
};
}
@First()
@Second()
class Example {}
// 출력 순서:
// First 팩토리 평가 (위에서 아래로 평가)
// Second 팩토리 평가
// Second 데코레이터 실행 (아래에서 위로 실행)
// First 데코레이터 실행
메서드 데코레이터는 프로퍼티 데코레이터보다 먼저, 클래스 데코레이터는 가장 마지막에 실행된다.
reflect-metadata
reflect-metadata 패키지를 사용하면 타입 정보를 메타데이터로 저장하고 런타임에 조회할 수 있다. emitDecoratorMetadata: true 옵션과 함께 사용한다.
import "reflect-metadata";
function Inject() {
return function (target: any, propertyKey: string) {
const type = Reflect.getMetadata("design:type", target, propertyKey);
console.log(`${propertyKey}의 타입: ${type.name}`);
};
}
class Service {
doWork(): void { console.log("작업 실행"); }
}
class Controller {
@Inject()
service!: Service; // 메타데이터: design:type = Service
}
// 출력: service의 타입: Service
// DI 컨테이너는 이 정보를 활용해 자동으로 Service 인스턴스를 주입
정리
| 데코레이터 종류 | 붙는 위치 | 주요 용도 |
|---|---|---|
| 클래스 데코레이터 | 클래스 선언 | 메타데이터 주입, 클래스 변형, 싱글톤 |
| 메서드 데코레이터 | 메서드 선언 | 로깅, 캐싱, 권한 검사, 재시도 |
| 프로퍼티 데코레이터 | 프로퍼티 선언 | 유효성 검사, 직렬화, 변환 |
| 파라미터 데코레이터 | 파라미터 | 의존성 주입, 필수 파라미터 검사 |
| 데코레이터 팩토리 | 모든 위치 | 인수를 받아 동적 데코레이터 생성 |
| 방식 | 옵션 | 사용처 |
|---|---|---|
| 표준 (TC39 Stage 3) | 기본 지원 (TS 5.0+) | 신규 프로젝트 권장 |
| 레거시 | experimentalDecorators: true | NestJS, Angular, TypeORM |
다음 장에서는 SOLID 원칙을 TypeScript로 적용하는 방법을 배운다. 각 원칙을 위반하는 코드와 올바르게 리팩토링한 코드를 비교하고, 주문 처리 시스템 실전 예제로 5원칙을 통합 적용한다.