본문으로 건너뛰기
Advertisement

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: trueNestJS, Angular, TypeORM

다음 장에서는 SOLID 원칙을 TypeScript로 적용하는 방법을 배운다. 각 원칙을 위반하는 코드와 올바르게 리팩토링한 코드를 비교하고, 주문 처리 시스템 실전 예제로 5원칙을 통합 적용한다.

Advertisement