- Published on
TypeScript 5 데코레이터 완전 가이드 - 실무 패턴·함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 공통으로 TypeScript를 쓰다 보면, 횡단 관심사(로깅, 권한, 캐싱, 트레이싱, 입력 검증)를 “비즈니스 로직에서 분리”하고 싶어지는 순간이 옵니다. 예전에는 프레임워크(예: NestJS, Angular)나 실험적 데코레이터에 기대곤 했지만, TypeScript 5부터는 ECMAScript 표준(스테이지 3) 데코레이터를 기반으로 한 새로운 모델이 정착했습니다.
이 글은 “데코레이터를 써도 되는 상황”과 “쓰면 사고 나는 지점”을 실무 중심으로 정리합니다. 특히 TypeScript 5 데코레이터의 시그니처 변화, context 기반의 기능 구성, 그리고 프로덕션에서 자주 겪는 함정(바인딩, this, private 필드, 성능, 번들/트리쉐이킹, 테스트)을 코드로 확인합니다.
참고로 데코레이터는 요청/응답 경로에서 로깅·트레이싱을 넣을 때도 유용합니다. 네트워크 오류를 다루는 흐름은 Node.js fetch ECONNRESET·ETIMEDOUT 해결법 같은 글과 같이 보면 “어디에 관측 코드를 심을지” 감이 더 빨리 옵니다.
TypeScript 5 데코레이터, 무엇이 달라졌나
핵심은 “레거시(실험적) 데코레이터”와 “표준 데코레이터”가 다르다는 점입니다.
- 레거시 방식은
target, propertyKey, descriptor형태가 익숙합니다. - 표준 방식(TypeScript 5)은 데코레이터가 값(value)과 컨텍스트(context) 를 받습니다.
context.addInitializer같은 훅으로 인스턴스 초기화 타이밍에 코드를 주입할 수 있습니다.
실무적으로 중요한 차이는 다음입니다.
- 시그니처가 달라서 기존 코드가 그대로 안 돌아갈 수 있음
reflect-metadata기반 메타데이터 패턴이 예전과 다르게 느껴질 수 있음- 런타임에서 “뭘 바꾸는지”가 더 명확해졌지만, 잘못 쓰면 성능/디버깅이 더 어려워질 수 있음
컴파일 설정: 표준 데코레이터를 켜는 최소 조건
TypeScript 5에서 표준 데코레이터를 쓰려면 tsconfig.json을 점검해야 합니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"experimentalDecorators": false
}
}
- 표준 데코레이터는
experimentalDecorators에 의존하지 않습니다. - 프로젝트에 레거시 데코레이터가 섞여 있으면, 마이그레이션 전략을 먼저 잡는 편이 안전합니다.
표준 데코레이터 기본: 메서드 로깅부터
가장 많이 쓰는 패턴은 “메서드 호출 전후로 로깅/측정”입니다.
function LogCall(label?: string) {
return function (
value: (...args: any[]) => any,
context: ClassMethodDecoratorContext
) {
const name = String(context.name);
return function (this: any, ...args: any[]) {
const tag = label ?? name;
const start = Date.now();
try {
const result = value.apply(this, args);
// Promise도 처리
if (result && typeof (result as any).then === "function") {
return (result as Promise<any>).finally(() => {
const ms = Date.now() - start;
console.log(`[${tag}] done in ${ms}ms`);
});
}
const ms = Date.now() - start;
console.log(`[${tag}] done in ${ms}ms`);
return result;
} catch (e) {
const ms = Date.now() - start;
console.log(`[${tag}] failed in ${ms}ms`);
throw e;
}
};
};
}
class UserService {
@LogCall("getUser")
async getUser(id: string) {
// ...
return { id };
}
}
여기서 중요한 포인트는 value.apply(this, args) 입니다.
- 데코레이터가 원본 메서드를 감싸면서
this바인딩을 깨뜨리기 쉽습니다. apply로 원래 컨텍스트를 유지해야 합니다.
함정 1: this 바인딩 깨짐과 화살표 함수
표준 데코레이터는 “메서드”를 감싸는 형태로 구현하는 경우가 많습니다. 이때 실수로 value(...args)로 호출하면 this가 undefined가 될 수 있습니다.
// 나쁜 예: this가 깨질 수 있음
return function (...args: any[]) {
return value(...args);
};
반드시 아래처럼 호출하세요.
return function (this: any, ...args: any[]) {
return value.apply(this, args);
};
함정 2: private 필드/메서드와 데코레이터
표준 데코레이터는 문법적으로 private 요소에 붙일 수 있지만, 런타임 접근이 제한적이라 “하고 싶은 것”이 안 되는 경우가 많습니다.
#privateField같은 진짜 private는 런타임에서 우회 접근이 불가능합니다.- 데코레이터에서 내부 상태를 건드리려는 설계는 피하는 편이 좋습니다.
대신 “공개 메서드 경계”에 데코레이터를 두고, 내부는 순수 함수/캡슐화로 유지하는 구성이 안전합니다.
함정 3: 데코레이터는 타입 시스템이 아니라 런타임이다
데코레이터는 “타입을 바꾸는 기능”이 아닙니다. 반환 타입/오버로드/제네릭 제약을 마법처럼 강화해주지 않습니다.
- 타입 안전성은 함수 시그니처와 명시적 타입으로 확보
- 데코레이터는 런타임 동작(감싸기, 초기화, 메타데이터 부착)만 담당
즉, 데코레이터로 검증을 넣더라도, 타입은 별도로 지켜야 합니다.
실무 패턴 1: 권한 체크(Authorization) 데코레이터
HTTP 핸들러나 서비스 메서드에서 권한 체크를 반복하는 대신, 데코레이터로 경계를 만들 수 있습니다.
type AuthContext = { roles: string[] };
function RequireRole(role: string) {
return function (
value: (...args: any[]) => any,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
const auth: AuthContext | undefined = this.auth;
if (!auth || !auth.roles.includes(role)) {
throw new Error(`Forbidden: missing role ${role}`);
}
return value.apply(this, args);
};
};
}
class AdminService {
constructor(public auth: AuthContext) {}
@RequireRole("admin")
deleteUser(userId: string) {
return { ok: true, userId };
}
}
이 패턴의 함정:
this.auth같은 의존을 암묵적으로 가정하면 테스트가 어려워집니다.- 해결책은 “명시적 컨텍스트 주입” 또는 “팩토리로 래핑”입니다.
권한/인증 계층에서 403이 반복될 때는, 애플리케이션 로직뿐 아니라 인프라/미들웨어 설정도 함께 보게 됩니다. 예를 들어 Ingress/ALB 단에서 막히는 케이스는 EKS ALB Ingress 403, WAF 아닌 원인 7가지처럼 원인 분리가 중요합니다.
실무 패턴 2: 캐싱(메모이제이션) 데코레이터
동일 입력에 대해 결과가 반복되는 메서드라면 캐싱이 효과적입니다. 단, “키 설계”와 “메모리 누수”가 함정입니다.
function Cached(options?: { ttlMs?: number; key?: (...args: any[]) => string }) {
const ttlMs = options?.ttlMs ?? 5_000;
const keyFn = options?.key ?? ((...args: any[]) => JSON.stringify(args));
return function (
value: (...args: any[]) => any,
context: ClassMethodDecoratorContext
) {
// 인스턴스별 캐시
const cache = new Map<string, { exp: number; v: any }>();
return function (this: any, ...args: any[]) {
const k = keyFn(...args);
const now = Date.now();
const hit = cache.get(k);
if (hit && hit.exp > now) return hit.v;
const v = value.apply(this, args);
cache.set(k, { exp: now + ttlMs, v });
return v;
};
};
}
class ProductService {
@Cached({ ttlMs: 2_000 })
getPrice(productId: string) {
return Math.random();
}
}
주의할 점:
JSON.stringify키는 순서/비직렬화 값에서 문제가 생길 수 있습니다.- 캐시가 인스턴스 생명주기와 함께 사라지지 않으면, 장수 프로세스에서 메모리 누수가 됩니다.
Promise를 캐싱할지, resolve된 값을 캐싱할지 정책을 명확히 해야 합니다.
실무 패턴 3: 재시도/백오프 데코레이터(네트워크 안정성)
외부 API 호출은 ECONNRESET, ETIMEDOUT 같은 오류가 흔합니다. 이때 재시도 로직을 데코레이터로 분리하면 코드가 깔끔해집니다.
function Retry(options?: { retries?: number; delayMs?: number }) {
const retries = options?.retries ?? 2;
const delayMs = options?.delayMs ?? 200;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
return function (
value: (...args: any[]) => Promise<any>,
context: ClassMethodDecoratorContext
) {
return async function (this: any, ...args: any[]) {
let lastErr: unknown;
for (let i = 0; i <= retries; i++) {
try {
return await value.apply(this, args);
} catch (e) {
lastErr = e;
if (i < retries) await sleep(delayMs * (i + 1));
}
}
throw lastErr;
};
};
}
class ApiClient {
@Retry({ retries: 3, delayMs: 150 })
async fetchUser(id: string) {
const res = await fetch(`https://example.com/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
}
여기서 함정:
- 모든 에러에 재시도를 걸면 장애를 증폭시킵니다(특히
4xx). - 재시도 대상 에러를 분류하고, 타임아웃/서킷브레이커와 조합해야 합니다.
네트워크 계층에서 문제가 생길 때는 애플리케이션 재시도만으로는 부족할 수 있습니다. 실제로는 커넥션/프록시/런타임 문제까지 이어지기도 하니, 필요하면 Node.js fetch ECONNRESET·ETIMEDOUT 해결법처럼 원인별로 분해해서 대응하세요.
실무 패턴 4: 트레이싱/상관관계 ID 전파
마이크로서비스에서 “어디서 깨졌는지” 찾는 비용이 너무 큽니다. 데코레이터로 상관관계 ID를 붙여 로그를 구조화하면, 최소한의 침투로 관측 가능성이 좋아집니다.
function WithTraceId() {
return function (
value: (...args: any[]) => any,
context: ClassMethodDecoratorContext
) {
const name = String(context.name);
return function (this: any, ...args: any[]) {
const traceId = this.traceId ?? `t_${Math.random().toString(16).slice(2)}`;
const prev = this.traceId;
this.traceId = traceId;
try {
console.log(`[trace=${traceId}] enter ${name}`);
return value.apply(this, args);
} finally {
console.log(`[trace=${traceId}] exit ${name}`);
this.traceId = prev;
}
};
};
}
이 방식은 단순하지만, 비동기 경계를 넘나들 때는 AsyncLocalStorage 같은 메커니즘이 더 적합합니다. gRPC 환경이라면 인터셉터 기반 전파가 더 자연스럽고, 그때의 함정은 gRPC Interceptor로 분산 트레이싱 전파 오류 잡기에서 다룬 주제와 맞닿아 있습니다.
addInitializer로 인스턴스 초기화 훅 만들기
표준 데코레이터의 강점 중 하나는 context.addInitializer입니다. 예를 들어 “클래스 인스턴스 생성 시 특정 레지스트리에 자동 등록” 같은 패턴이 가능합니다.
const registry = new Set<object>();
function AutoRegister() {
return function (value: Function, context: ClassDecoratorContext) {
context.addInitializer(function () {
// 여기서 this는 인스턴스
registry.add(this);
});
};
}
@AutoRegister()
class Worker {
run() {}
}
new Worker();
console.log(registry.size);
주의할 점:
- 전역 레지스트리는 테스트 간 오염을 만들기 쉽습니다.
- 서버리스/에지 환경에서 인스턴스 생명주기가 짧거나 예측 불가능할 수 있습니다.
데코레이터 도입 체크리스트(실무 함정 요약)
1) 성능: 핫패스에 과도한 래핑 금지
- 데코레이터는 결국 함수 래핑입니다.
- 초당 수만 번 호출되는 경로에서는 오버헤드가 누적됩니다.
- 측정/로깅 데코레이터는 샘플링 옵션을 두거나, 개발/스테이징에서만 활성화하는 토글을 권장합니다.
2) 디버깅: 스택트레이스가 길어지고 원인 추적이 어려움
- 래퍼 함수가 스택에 끼어듭니다.
- 에러 메시지에
context.name과 입력 요약을 넣되, PII는 마스킹해야 합니다.
3) 테스트: 데코레이터가 붙은 메서드는 “행동이 바뀐 것”
- 단위 테스트에서 원본 메서드만 검증하면 놓치는 게 생깁니다.
- 데코레이터 자체를 별도 테스트하거나, 통합 테스트에서 경계 동작을 검증하세요.
4) 트리 쉐이킹/번들: 사이드 이펙트로 취급될 수 있음
- 데코레이터는 런타임 실행을 동반합니다.
- 번들러가 안전하게 제거하지 못하는 코드가 늘 수 있습니다.
5) 프레임워크 혼용: 레거시 데코레이터와 섞이면 지옥
- NestJS/Angular는 레거시 데코레이터 생태계가 큽니다.
- 프로젝트에서 표준/레거시를 동시에 쓰지 않도록 경계를 정하세요.
마이그레이션 전략: 레거시에서 표준으로
레거시 데코레이터가 이미 많은 코드베이스라면, 한 번에 바꾸기 어렵습니다.
- 신규 모듈부터 표준 데코레이터로 시작
- 공통 데코레이터(로깅, 캐싱, 재시도)를 “표준 버전”으로 별도 패키지화
- 레거시 의존(예:
reflect-metadata)이 필요한 구간과 아닌 구간을 분리
특히 DI/메타데이터 중심 설계는 프레임워크 결합도가 높아서, 데코레이터만 바꿔도 동작이 미묘하게 달라질 수 있습니다.
결론: 데코레이터는 ‘규칙’과 ‘경계’에만 쓰면 강력하다
TypeScript 5 표준 데코레이터는 예전보다 명확하고 안전한 모델을 제공합니다. 하지만 “편하니까 여기저기 붙이자”는 순간, 디버깅/성능/테스트 복잡도가 급격히 늘어납니다.
추천하는 사용 원칙은 단순합니다.
- 도메인 로직 내부가 아니라 경계(입출력, 인프라 호출, 권한 체크) 에만 적용
this바인딩과Promise처리 규칙을 데코레이터 유틸에서 통일- 캐싱/재시도/로깅은 옵션(토글, 샘플링, 필터링)을 기본 제공
이 원칙만 지키면, 데코레이터는 코드베이스의 “반복”을 줄이면서도 운영 안정성을 높이는 좋은 도구가 됩니다.