TypeScript 데코레이터: 클래스·메서드·프로퍼티·매개변수 데코레이터와 데코레이터 팩토리
이 글의 핵심
experimentalDecorators 설정부터 데코레이터 종류별 시그니처와 실행 순서, 인자를 받는 데코레이터 팩토리, 로깅·검증·권한 체크 같은 실전 활용을 정리합니다.
들어가며
데코레이터란?
데코레이터(Decorator)는 클래스·메서드·프로퍼티에 추가 정보를 붙이거나, 선언 시점에 공통 로직을 감싸 넣는 특별한 문법입니다. 횡단 관심(로깅, 권한 등)을 본문과 분리할 때 사용됩니다.
데코레이터는 결국 함수입니다. @log라고 쓰면 TypeScript가 컴파일할 때 클래스를 정의한 직후 log(Calculator.prototype, 'add', descriptor) 같은 호출 코드를 만들어 넣습니다. 즉 데코레이터는 메서드가 호출될 때가 아니라 클래스가 정의될 때 한 번 실행되며, 그 시점에 메서드를 다른 함수로 바꿔치기하거나 메타데이터를 기록해 두는 방식으로 동작합니다. 이 점을 이해하면 이후 예제들이 왜 descriptor.value를 교체하는지 자연스럽게 읽힙니다.
먼저 알아 둘 중요한 사실이 있습니다. TypeScript에는 두 종류의 데코레이터가 공존합니다. 이 글에서 다루는 것은 experimentalDecorators 옵션으로 켜는 레거시(실험적) 데코레이터이고, NestJS, TypeORM, Angular, class-validator가 모두 이 방식을 씁니다. TypeScript 5.0부터는 옵션 없이 쓸 수 있는 표준(TC39 Stage 3) 데코레이터도 지원하는데, 시그니처가 (value, context) 형태로 완전히 다르고 매개변수 데코레이터와 emitDecoratorMetadata를 지원하지 않습니다. 두 방식의 데코레이터는 서로 호환되지 않으므로, 쓰려는 프레임워크가 어느 쪽을 요구하는지 먼저 확인해야 합니다.
tsconfig.json에서 데코레이터 켜기
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
experimentalDecorators를 켜지 않고 @문법을 쓰면, TypeScript 5.0 이상에서는 표준 데코레이터로 해석되어 아래 예제들의 (target, propertyKey, descriptor) 시그니처와 맞지 않는다는 타입 에러가 납니다. 5.0 미만에서는 Experimental support for decorators is a feature that is subject to change 경고(TS1219)가 납니다. 에러 메시지가 “Unable to resolve signature of method decorator when called as an expression”처럼 모호하게 나오는 경우가 많은데, 이때는 이 옵션부터 확인하면 됩니다.
emitDecoratorMetadata는 데코레이터가 붙은 선언의 타입 정보(매개변수 타입, 반환 타입)를 design:paramtypes 같은 메타데이터로 출력하게 합니다. NestJS의 의존성 주입이 생성자 매개변수 타입을 보고 어떤 서비스를 넣을지 결정하는 원리가 이것입니다. 이 메타데이터를 읽으려면 reflect-metadata 패키지를 설치하고 앱 진입점에서 한 번 import 'reflect-metadata'를 해야 합니다. 빠뜨리면 Reflect.getMetadata is not a function 에러가 납니다. 또 esbuild처럼 타입 정보 없이 트랜스파일하는 도구는 이 메타데이터를 만들지 못하므로, Vite나 tsx로 NestJS를 실행하면 의존성 주입이 undefined로 들어오는 문제가 생길 수 있습니다.
클래스 데코레이터와 로깅 예제
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@sealed
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
클래스 데코레이터는 생성자 함수 하나를 인자로 받습니다. sealed는 생성자와 프로토타입을 Object.seal로 잠가 이후에 속성을 추가하거나 삭제할 수 없게 만듭니다. 인스턴스 자체를 봉인하는 것은 아니므로 new User()로 만든 객체에는 여전히 속성을 추가할 수 있습니다. 데코레이터가 값을 반환하지 않으면 원래 클래스가 그대로 쓰이고, 새 생성자를 반환하면 그것이 원래 클래스를 대체합니다. 다음 예제가 그 방식입니다.
인스턴스 생성 로깅
function logger<T extends { new(...args: any[]): {} }>(constructor: T) {
return class extends constructor {
constructor(...args: any[]) {
super(...args);
console.log(`${constructor.name} 인스턴스 생성됨`);
}
};
}
@logger
class User {
constructor(public name: string) {}
}
const user = new User("홍길동");
// 출력: User 인스턴스 생성됨
T extends { new(...args: any[]): {} }는 “생성자로 호출할 수 있는 타입”이라는 제약입니다. 데코레이터는 원래 클래스를 상속한 익명 클래스를 반환하고, 그 생성자에서 super(...args)로 원래 생성자를 호출한 뒤 로그를 남깁니다. 결과적으로 User라는 이름은 이 익명 서브클래스를 가리키게 됩니다.
이 방식에는 알아 둘 한계가 있습니다. 반환한 클래스에 새 메서드를 추가해도 TypeScript의 User 타입에는 반영되지 않습니다. 데코레이터는 타입을 바꾸지 못하기 때문에, 데코레이터로 추가한 메서드를 호출하면 런타임에는 동작해도 컴파일 에러가 납니다. 또 상속 체인이 한 단계 늘어나므로, 디버거에서 생성자 이름이 익명 클래스로 표시되거나 instanceof 검사는 통과하지만 constructor.name에 의존하는 코드가 기대와 다르게 동작할 수 있습니다. 클래스를 교체하는 데코레이터는 꼭 필요한 경우에만 쓰고, 대부분은 메타데이터만 기록하는 편이 부작용이 적습니다.
메서드 데코레이터와 실행 시간 측정
function log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`${propertyKey} 호출됨:`, args);
const result = originalMethod.apply(this, args);
console.log(`${propertyKey} 결과:`, result);
return result;
};
return descriptor;
}
class Calculator {
@log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(10, 20);
// 출력:
// add 호출됨: [10, 20]
// add 결과: 30
메서드 데코레이터의 세 인자는 순서대로 프로토타입(정적 메서드라면 생성자), 메서드 이름, 속성 서술자입니다. descriptor.value가 원래 메서드 함수이므로, 이를 저장해 두고 새 함수로 바꿔 끼우는 것이 기본 패턴입니다. 교체 함수를 function으로 쓰고 originalMethod.apply(this, args)로 호출하는 이유는 FAQ에 정리했듯 this를 호출 시점의 인스턴스로 유지하기 위해서입니다.
target이 인스턴스가 아니라 프로토타입이라는 점이 자주 혼동됩니다. 데코레이터가 실행될 때는 아직 인스턴스가 하나도 없으므로, 데코레이터 안에서 target.someField를 읽어도 인스턴스 필드 값은 없습니다. 인스턴스별 상태가 필요하다면 교체 함수 안의 this를 통해 접근해야 합니다.
성능 측정 데코레이터
function measure(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
const start = performance.now();
const result = await originalMethod.apply(this, args);
const end = performance.now();
console.log(`${propertyKey} 실행 시간: ${(end - start).toFixed(2)}ms`);
return result;
};
return descriptor;
}
class DataService {
@measure
async fetchData() {
await new Promise(resolve => setTimeout(resolve, 1000));
return { data: "결과" };
}
}
const service = new DataService();
await service.fetchData();
// 출력: fetchData 실행 시간: 1001.23ms
이 데코레이터는 교체 함수를 async로 만들었기 때문에, 동기 메서드에 붙이면 그 메서드가 Promise를 반환하게 바뀝니다. @measure를 붙인 calculate()가 숫자 대신 Promise를 돌려주어 NaN이나 [object Promise]가 출력되는 버그로 이어지는데, 타입 선언은 여전히 number라서 컴파일러가 잡아 주지 못합니다. 동기·비동기 모두에 쓰려면 결과가 Promise인지 확인해 분기하는 편이 안전합니다.
const result = originalMethod.apply(this, args);
if (result instanceof Promise) {
return result.finally(() => console.log(`${propertyKey}: ${(performance.now() - start).toFixed(2)}ms`));
}
console.log(`${propertyKey}: ${(performance.now() - start).toFixed(2)}ms`);
return result;
예제 마지막의 await service.fetchData()는 최상위 await라서 ES 모듈("module": "ES2022" 이상 또는 "type": "module")에서만 동작합니다. CommonJS 환경이라면 async 함수 안에서 호출해야 합니다.
프로퍼티 데코레이터와 값 검증
프로퍼티 데코레이터는 서술자 없이 (target, propertyKey) 두 인자만 받습니다. 클래스 필드는 인스턴스가 만들어질 때 생성되는데, 데코레이터는 인스턴스가 없는 클래스 정의 시점에 실행되기 때문입니다. 그래서 프로퍼티 데코레이터로 할 수 있는 일은 사실상 메타데이터 기록이나 프로토타입에 접근자(getter/setter) 정의뿐입니다.
function readonly(target: any, propertyKey: string) {
Object.defineProperty(target, propertyKey, {
set(value: any) {
// 첫 할당 때 인스턴스에 쓰기 불가 속성을 만든다
Object.defineProperty(this, propertyKey, { value, writable: false, enumerable: true });
},
configurable: true
});
}
class User {
@readonly
id: string = "U001";
name: string = "홍길동";
}
const user = new User();
console.log(user.id); // U001
// user.id = "U002"; // 에러 (strict 모드)
처음 이 데코레이터를 만들 때 흔히 쓰는 형태는 프로토타입에 { writable: false }만 정의하는 것인데, 이러면 기대와 다르게 동작합니다. 필드 초기화 id = "U001"이 생성자 안의 this.id = "U001" 할당으로 컴파일되고, 프로토타입에 쓰기 불가 속성이 있으면 인스턴스에 할당하는 것도 막히므로 new User() 자체가 TypeError: Cannot assign to read only property 'id'로 실패합니다. 위 코드는 프로토타입에 setter를 두어 첫 할당 때 인스턴스에 쓰기 불가 속성을 만들고, 이후의 할당이 막히도록 했습니다.
여기서 또 하나의 함정이 useDefineForClassFields 옵션입니다. target이 ES2022 이상이거나 ESNext면 이 옵션의 기본값이 true가 되어, 클래스 필드가 할당이 아니라 Object.defineProperty로 인스턴스에 직접 정의됩니다. 이 경우 프로토타입의 setter를 거치지 않으므로 위 데코레이터와 다음 검증 데코레이터가 아무 효과도 없게 됩니다. 이 글의 예제처럼 프로토타입 접근자에 의존하는 데코레이터를 쓴다면 tsconfig에서 "useDefineForClassFields": false를 명시해야 하며, TypeORM·class-validator 같은 라이브러리 문서가 이 설정을 요구하는 이유도 같습니다.
setter로 값 검증하기
function validate(validationFn: (value: any) => boolean) {
return function(target: any, propertyKey: string) {
let value: any;
Object.defineProperty(target, propertyKey, {
get() {
return value;
},
set(newValue: any) {
if (!validationFn(newValue)) {
throw new Error(`${propertyKey} 검증 실패`);
}
value = newValue;
}
});
};
}
class User {
@validate((value) => value.length >= 2)
name!: string;
@validate((value) => value >= 0 && value <= 150)
age!: number;
}
const user = new User();
user.name = "홍길동"; // ✅
user.age = 25; // ✅
// user.name = "a"; // ❌ 에러
// user.age = 200; // ❌ 에러
validate는 인자로 검증 함수를 받는 데코레이터 팩토리(아래에서 설명)이고, 프로토타입에 getter/setter를 정의해 값을 넣을 때마다 검증합니다. 동작은 확인할 수 있지만 이 코드에는 실무에서 치명적인 버그가 있습니다. 값을 저장하는 let value가 데코레이터 실행 시점, 즉 클래스당 한 번 만들어지는 클로저 변수라서 모든 인스턴스가 같은 값을 공유합니다. user1.name = "홍길동" 후 user2.name = "김철수"를 하면 user1.name도 "김철수"가 됩니다. 인스턴스별로 값을 저장하려면 WeakMap을 쓰는 것이 정석입니다.
const store = new WeakMap<object, any>();
// get() { return store.get(this); }
// set(v) { if (!validationFn(v)) throw ...; store.set(this, v); }
또 검증 함수 value.length >= 2는 name에 undefined나 숫자가 들어오면 검증 실패 대신 TypeError를 던집니다. 실제 검증 로직을 직접 만들기보다는 class-validator처럼 데코레이터로 규칙만 기록하고 validate(obj) 호출 시점에 한꺼번에 검사하는 라이브러리를 쓰는 편이, 여러 에러를 모아서 보고할 수 있고 위의 공유 상태 문제도 없습니다.
매개변수 데코레이터
function required(
target: any,
propertyKey: string,
parameterIndex: number
) {
console.log(`${propertyKey}의 ${parameterIndex}번째 매개변수는 필수입니다`);
}
class User {
greet(@required name: string) {
console.log(`안녕하세요, ${name}님!`);
}
}
매개변수 데코레이터는 세 번째 인자로 매개변수의 위치(0부터 시작)를 받지만, 반환값은 무시되고 메서드를 바꿀 수도 없습니다. 위 예제는 클래스 정의 시점에 메시지를 출력할 뿐 greet()를 인자 없이 호출해도 아무것도 막지 않습니다. 매개변수 데코레이터의 실제 용도는 “이 메서드의 몇 번째 인자는 필수다” 같은 정보를 Reflect.defineMetadata로 기록해 두고, 메서드 데코레이터가 그 메타데이터를 읽어 호출 시점에 검사하는 것입니다. NestJS의 @Body(), @Param('id')가 이런 방식으로, 컨트롤러 메서드의 몇 번째 인자에 요청 본문이나 URL 파라미터를 넣을지 기록해 둡니다.
데코레이터가 여러 종류 섞여 있을 때의 실행 순서도 알아 두면 좋습니다. 인스턴스 멤버(프로퍼티, 메서드, 그 매개변수)가 소스 순서대로 먼저 적용되고, 그다음 정적 멤버, 생성자 매개변수, 마지막으로 클래스 데코레이터가 적용됩니다. 한 선언에 데코레이터가 여러 개 붙어 있으면 팩토리 호출(@a()의 a() 부분)은 위에서 아래로, 반환된 데코레이터의 적용은 아래에서 위로 이루어집니다. 수학의 함수 합성 a(b(method))와 같은 순서입니다.
인자를 받는 데코레이터 팩토리
매개변수를 받는 데코레이터를 만듭니다.
function log(prefix: string) {
return function(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log(`[${prefix}] ${propertyKey} 호출됨`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class UserService {
@log("USER")
createUser(name: string) {
console.log(`사용자 생성: ${name}`);
}
@log("AUTH")
login(email: string) {
console.log(`로그인: ${email}`);
}
}
const service = new UserService();
service.createUser("홍길동");
// 출력:
// [USER] createUser 호출됨
// 사용자 생성: 홍길동
service.login("[email protected]");
// 출력:
// [AUTH] login 호출됨
// 로그인: [email protected]
팩토리는 “데코레이터를 반환하는 함수”라서 @log("USER")처럼 괄호와 함께 호출합니다. 인자가 없는 팩토리를 괄호 없이 @log로 쓰면 TypeScript가 log를 데코레이터 자체로 보고 프로토타입을 prefix 자리에 넘기므로, 데코레이터가 적용되지 않거나 시그니처 에러가 납니다. 팩토리로 만들지 일반 데코레이터로 만들지는 설정값이 필요한지로 판단하고, 팀 안에서 “데코레이터는 항상 괄호를 붙인다”처럼 규칙을 정해 두면 이런 실수가 줄어듭니다. NestJS가 @Injectable()처럼 인자가 없어도 괄호를 쓰게 하는 것도 같은 이유입니다.
예제: 권한 체크와 캐싱 데코레이터
예제 1: 권한 체크
function authorize(roles: string[]) {
return function(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
const userRole = getCurrentUserRole(); // 현재 사용자 역할
if (!roles.includes(userRole)) {
throw new Error("권한이 없습니다");
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
function getCurrentUserRole(): string {
return "admin"; // 실제로는 세션에서 가져옴
}
class AdminService {
@authorize(["admin"])
deleteUser(id: string) {
console.log(`사용자 삭제: ${id}`);
}
@authorize(["admin", "moderator"])
banUser(id: string) {
console.log(`사용자 차단: ${id}`);
}
}
const service = new AdminService();
service.deleteUser("U001"); // ✅ admin이므로 성공
권한 규칙이 메서드 선언 바로 위에 붙어 있어서, 어떤 메서드에 어떤 역할이 필요한지 코드만 보고 알 수 있다는 것이 이 패턴의 장점입니다. 약점은 getCurrentUserRole()처럼 “현재 사용자”를 전역에서 가져와야 한다는 점입니다. 서버에서 요청마다 사용자가 다른데 전역 변수에 현재 사용자를 두면 동시 요청 사이에 값이 섞입니다. Node.js라면 AsyncLocalStorage로 요청 컨텍스트를 전달하거나, NestJS처럼 데코레이터는 필요한 역할만 메타데이터로 기록하고 실제 검사는 요청 객체를 받는 Guard가 하도록 분리하는 것이 안전한 구조입니다.
예제 2: 캐싱
function cache(ttl: number = 60000) {
const cacheStore = new Map<string, { value: any; expiry: number }>();
return function(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
const key = `${propertyKey}_${JSON.stringify(args)}`;
const cached = cacheStore.get(key);
if (cached && Date.now() < cached.expiry) {
console.log("캐시에서 반환");
return cached.value;
}
console.log("새로 계산");
const result = await originalMethod.apply(this, args);
cacheStore.set(key, { value: result, expiry: Date.now() + ttl });
return result;
};
return descriptor;
};
}
class DataService {
@cache(5000) // 5초 캐싱
async fetchUser(id: string) {
await new Promise(resolve => setTimeout(resolve, 1000));
return { id, name: "홍길동" };
}
}
const service = new DataService();
await service.fetchUser("U001"); // 새로 계산
await service.fetchUser("U001"); // 캐시에서 반환
cacheStore는 팩토리가 호출될 때, 즉 @cache(5000)이 붙은 메서드마다 하나씩 만들어집니다. 따라서 같은 클래스의 모든 인스턴스가 캐시를 공유하는데, 인스턴스가 서로 다른 설정(예: 다른 API 서버 주소)을 가진다면 한 인스턴스의 결과가 다른 인스턴스에 반환됩니다. 키에 JSON.stringify(args)를 쓰므로 인자에 함수나 순환 참조 객체가 있으면 키가 제대로 만들어지지 않거나 에러가 나고, 객체 속성 순서만 다른 인자는 다른 키가 됩니다.
만료된 항목을 지우는 코드가 없어서 인자 종류가 다양하면 Map이 계속 커지는 메모리 누수가 생긴다는 점도 운영에서 문제가 됩니다. 또 첫 호출이 끝나기 전에 같은 인자로 두 번째 호출이 들어오면 둘 다 “새로 계산”으로 가서 같은 요청이 두 번 나갑니다. 결과 대신 Promise 자체를 캐시에 넣으면 동시 호출이 같은 Promise를 기다리게 되어 중복 요청이 사라지고, 대신 Promise가 실패하면 캐시에서 지우는 처리를 추가해야 합니다. 이런 세부 사항이 쌓이기 때문에, 데코레이터는 캐시 적용 지점을 선언하는 역할만 맡고 저장소는 검증된 LRU 캐시 라이브러리에 맡기는 편이 실무에서 흔한 구성입니다.
데코레이터 요약
- 클래스 데코레이터: 클래스 수정
- 메서드 데코레이터: 메서드 동작 수정
- 프로퍼티 데코레이터: 프로퍼티 제어
- 매개변수 데코레이터: 매개변수 메타데이터
- 데코레이터 팩토리: 매개변수 받기
다음 단계
- TypeScript 고급 패턴
- TypeScript 실전 프로젝트
- NestJS 가이드 (데코레이터 기반 프레임워크)
같이 보면 좋은 글
- TypeScript 시작하기 | 설치, 설정, 기본 문법
- TypeScript 유틸리티 타입 | Partial, Pick, Omit, Record
- TypeScript 고급 패턴 | 조건부 타입, 템플릿 리터럴 타입
- TypeScript 실전 프로젝트 | REST API 서버 만들기
자주 묻는 질문 (FAQ)
Q. 메서드 데코레이터에서 descriptor.value를 바꿀 때 화살표 함수를 쓰면 안 되는 이유는 무엇인가요?
A. 화살표 함수는 자신만의 this를 갖지 않아서, 교체한 함수 안에서 원래 메서드를 호출할 때 인스턴스가 아닌 데코레이터 정의 시점의 this가 잡힙니다. 그래서 descriptor.value는 일반 function으로 감싸고 originalMethod.apply(this, args)로 원래 메서드에 this와 인자를 그대로 넘겨야 합니다. @log(“USER”)처럼 인자를 받는 경우에는 데코레이터를 반환하는 팩토리 함수로 한 번 더 감싸야 합니다.