- Published on
ES2024 Decorators로 DI·로깅 구현과 TS 추론
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드가 커질수록 반복되는 패턴이 늘어납니다. 대표적으로 new 체인으로 이어지는 의존성 생성, 함수마다 붙는 로깅·타이밍 측정·에러 래핑 같은 횡단 관심사(cross-cutting concerns)입니다. 이때 Decorator는 “코드의 의미는 유지하면서, 선언적으로 부가 동작을 입히는” 데 강력합니다.
다만 TypeScript의 기존 experimentalDecorators는 구 문법(legacy) 기반이라, 런타임 동작과 타입 모델이 어긋나거나 reflect-metadata 같은 별도 의존성이 필요해지는 경우가 많았습니다. ES2024+ 표준 Decorators는 이 지점을 크게 개선합니다. 이 글에서는 표준 Decorators를 전제로 다음을 구현합니다.
- ES2024+ Decorators로 DI(Dependency Injection) 구현
- 메서드 로깅·성능 측정(타이밍) Decorator 구현
- TypeScript 타입추론을 최대한 유지하는 패턴(특히 래핑 함수의 시그니처 보존)
관련해서 타입 검증과 추론을 동시에 잡는 패턴은 TS 5.x satisfies로 타입검증·추론 동시 해결 글도 함께 보면 좋습니다. 또한 로깅을 단순 콘솔이 아닌 트레이싱으로 확장하려면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전도 자연스럽게 연결됩니다.
ES2024+ Decorators 핵심만 빠르게
표준 Decorators는 “값을 바꾸거나, 초기화 로직을 추가하는 함수”로 이해하면 편합니다. 특히 메서드 Decorator는 원본 메서드를 다른 함수로 교체할 수 있고, 클래스 Decorator는 생성자나 정적 요소에 영향을 줄 수 있습니다.
중요한 변화는 다음입니다.
- legacy처럼
target,propertyKey,descriptor를 직접 받는 형태가 아니라,value와context를 받습니다. context.addInitializer(fn)로 인스턴스 생성 시점에 실행될 초기화 코드를 등록할 수 있습니다.- 표준 Decorators에는 기본적으로 “디자인 타입 메타데이터”가 없습니다. 즉, 파라미터 타입을 자동으로 뽑아 DI하는 방식은 (별도 설계 없이는) 불가능합니다. 대신 토큰 기반 DI가 깔끔합니다.
프로젝트 설정(현실적인 최소치)
Node.js 런타임과 번들러에 따라 세부 옵션은 달라질 수 있지만, TypeScript에서 표준 Decorators를 쓰려면 보통 아래와 같은 설정 조합을 사용합니다.
tsconfig.json 예시:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"useDefineForClassFields": true
}
}
experimentalDecorators는 legacy 문법을 켜는 옵션이므로, 표준 Decorators만 사용할 계획이라면 혼용을 피하는 편이 안전합니다.- 실행 환경이 Decorators를 직접 지원하지 않는다면, 빌드 단계에서 변환이 필요합니다(예: 최신 Babel/TS 트랜스폼, 또는 프레임워크 빌드 파이프라인).
토큰 기반 DI 설계: 타입추론을 포기하지 않는 방법
표준 Decorators에는 파라미터 타입 리플렉션이 없으므로, ServiceA 생성자 파라미터를 보고 자동 주입하는 방식 대신 “토큰”을 명시합니다. 이때 토큰을 타입 안전하게 만들면 DI 호출부의 타입추론이 유지됩니다.
1) 타입 안전한 Token 만들기
export type Token<T> = {
readonly key: symbol;
readonly description?: string;
};
export function createToken<T>(description?: string): Token<T> {
return { key: Symbol(description), description };
}
이제 Token<UserRepo> 같은 형태로 “런타임 키(symbol) + 컴파일 타임 타입(T)”를 함께 갖게 됩니다.
2) 아주 작은 컨테이너 구현
type Provider<T> = (c: Container) => T;
export class Container {
private providers = new Map<symbol, Provider<any>>();
private singletons = new Map<symbol, any>();
register<T>(token: Token<T>, provider: Provider<T>): void {
this.providers.set(token.key, provider);
}
registerSingleton<T>(token: Token<T>, provider: Provider<T>): void {
this.providers.set(token.key, (c) => {
if (this.singletons.has(token.key)) return this.singletons.get(token.key);
const created = provider(c);
this.singletons.set(token.key, created);
return created;
});
}
resolve<T>(token: Token<T>): T {
const provider = this.providers.get(token.key);
if (!provider) {
throw new Error(`No provider for token: ${token.description ?? token.key.toString()}`);
}
return provider(this);
}
}
여기까지는 단순합니다. 이제 Decorator로 “클래스가 어떤 토큰으로 등록되는지”를 선언적으로 표현해보겠습니다.
ES2024 클래스 Decorator로 @Injectable 만들기
@Injectable은 “이 클래스는 DI 컨테이너에 등록될 수 있다”라는 의도를 드러냅니다. 표준 Decorators에서는 context.addInitializer로 인스턴스 생성 시점 초기화도 가능하지만, 여기서는 등록 자체는 “부트스트랩 코드”에서 수행하고, Decorator는 메타데이터만 남기는 방식이 유지보수에 유리합니다.
1) 메타데이터 저장소
const injectableRegistry = new WeakMap<Function, { token: Token<any> }>();
export function getInjectableToken<T>(ctor: new (...args: any[]) => T): Token<T> | undefined {
return injectableRegistry.get(ctor)?.token;
}
2) @Injectable(token) 구현
export function Injectable<T>(token: Token<T>) {
return function (value: Function, context: ClassDecoratorContext) {
if (context.kind !== "class") return;
injectableRegistry.set(value, { token });
};
}
이제 클래스에 다음처럼 선언할 수 있습니다.
export interface Logger {
info(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
}
export const LOGGER = createToken<Logger>("LOGGER");
@Injectable(LOGGER)
export class ConsoleLogger implements Logger {
info(msg: string, meta?: Record<string, unknown>) {
console.log(msg, meta ?? {});
}
error(msg: string, meta?: Record<string, unknown>) {
console.error(msg, meta ?? {});
}
}
3) 부트스트랩에서 자동 등록
메타데이터를 남겼으니, 애플리케이션 시작 시점에 “스캔 후 등록”이 가능합니다. (실제 서비스에서는 모듈 단위로 명시 등록이 더 예측 가능하지만, 예시는 자동 등록을 보여줍니다.)
export function registerInjectables(container: Container, ctors: Function[]) {
for (const ctor of ctors) {
const token = injectableRegistry.get(ctor)?.token;
if (!token) continue;
container.registerSingleton(token, () => new (ctor as any)());
}
}
여기서 중요한 점은 “생성자 주입을 자동화하지 않았다”는 것입니다. 표준 Decorators만으로 파라미터 타입을 알 수 없기 때문에, 자동 생성까지 욕심내면 결국 별도 메타데이터 시스템을 또 만들어야 합니다. 대신 다음 섹션에서 “필드 주입”을 토큰으로 해결합니다.
필드 Decorator로 @Inject 구현(토큰 기반)
필드 주입은 표준 Decorators의 장점이 잘 드러납니다. context.addInitializer를 이용하면 인스턴스가 만들어질 때 컨테이너에서 값을 꺼내 필드를 채워 넣을 수 있습니다.
먼저 “현재 컨테이너” 접근을 단순화하기 위해, 예제에서는 전역 접근자를 둡니다. (실서비스에서는 요청 스코프, 테스트 격리 등을 위해 AsyncLocalStorage나 명시적 전달이 더 낫습니다.)
let currentContainer: Container | null = null;
export function setCurrentContainer(c: Container) {
currentContainer = c;
}
export function getCurrentContainer(): Container {
if (!currentContainer) throw new Error("Container not set");
return currentContainer;
}
이제 @Inject(token) 구현:
export function Inject<T>(token: Token<T>) {
return function (_: undefined, context: ClassFieldDecoratorContext) {
if (context.kind !== "field") return;
context.addInitializer(function () {
const c = getCurrentContainer();
(this as any)[context.name] = c.resolve(token);
});
};
}
사용 예시:
export interface UserRepo {
findName(id: string): Promise<string>;
}
export const USER_REPO = createToken<UserRepo>("USER_REPO");
export class MemoryUserRepo implements UserRepo {
async findName(id: string) {
return id === "1" ? "Alice" : "Unknown";
}
}
export class UserService {
@Inject(LOGGER)
private logger!: Logger;
@Inject(USER_REPO)
private repo!: UserRepo;
async getUserName(id: string) {
const name = await this.repo.findName(id);
this.logger.info("getUserName", { id, name });
return name;
}
}
부트스트랩:
const c = new Container();
setCurrentContainer(c);
c.registerSingleton(LOGGER, () => new ConsoleLogger());
c.registerSingleton(USER_REPO, () => new MemoryUserRepo());
const svc = new UserService();
await svc.getUserName("1");
이 방식의 장점은 다음입니다.
- 생성자 시그니처를 건드리지 않으므로 기존 코드 영향이 작습니다.
- 토큰이 타입을 품고 있어
resolve결과 타입이 자동 추론됩니다. - 표준 Decorators만으로 동작하며, 별도 리플렉션 의존성이 없습니다.
단점도 명확합니다.
- 전역 컨테이너는 테스트/동시성에 취약합니다.
- 필드 주입은 의존성이 “숨겨져” 가독성이 떨어질 수 있습니다.
실무에서는 “생성자 주입(명시적) + 일부 편의로 필드 주입” 정도로 타협하는 경우가 많습니다.
메서드 로깅 Decorator: 타입 시그니처 보존이 핵심
로깅 Decorator는 원본 메서드를 래핑(wrap)합니다. 이때 타입추론을 망치지 않으려면 this, 파라미터, 반환 타입을 그대로 유지해야 합니다.
1) 범용 함수 래퍼 타입
type AnyFn = (this: any, ...args: any[]) => any;
function wrapMethod<F extends AnyFn>(
original: F,
wrapper: (original: F) => F
): F {
return wrapper(original);
}
F를 그대로 반환하게 강제하면, 데코레이터가 메서드 타입을 깨뜨리는 사고를 줄일 수 있습니다.
2) @Log() 구현
export function Log(options?: { name?: string }) {
return function <F extends AnyFn>(value: F, context: ClassMethodDecoratorContext): F {
if (context.kind !== "method") return value;
const methodName = options?.name ?? String(context.name);
return wrapMethod(value, (original) => {
const wrapped = function (this: any, ...args: any[]) {
const logger = (this as any).logger as Logger | undefined;
logger?.info(`enter:${methodName}`, { argsCount: args.length });
try {
const result = original.apply(this, args);
if (result && typeof (result as any).then === "function") {
return (result as Promise<any>)
.then((v) => {
logger?.info(`exit:${methodName}`);
return v;
})
.catch((e) => {
logger?.error(`error:${methodName}`, { message: String(e) });
throw e;
});
}
logger?.info(`exit:${methodName}`);
return result;
} catch (e) {
logger?.error(`error:${methodName}`, { message: String(e) });
throw e;
}
} as F;
return wrapped;
});
};
}
- 동기/비동기 모두 처리합니다.
this.logger를 사용했는데, 이는 DI로 주입된 로거가 있다는 가정입니다.wrapped as F캐스팅이 보이지만, 래핑 구조상 현실적으로 필요한 부분입니다. 대신F를 유지하도록 설계해 타입 손실을 최소화합니다.
3) 사용 예시
export class UserService {
@Inject(LOGGER)
logger!: Logger;
@Inject(USER_REPO)
repo!: UserRepo;
@Log()
async getUserName(id: string) {
return this.repo.findName(id);
}
}
이제 호출부 타입은 그대로 유지됩니다.
const name = await svc.getUserName("1");
// name: string
성능 측정 Decorator: 운영에서 유용한 형태로
로깅이 단순히 “들어옴/나감”이면 정보가 부족합니다. 최소한 처리 시간을 같이 남기면 장애 분석이 쉬워집니다. 특히 분산 환경에서는 OpenTelemetry 스팬으로 연결하면 더 강력합니다.
간단 타이밍 Decorator:
export function Timed(label?: string) {
return function <F extends AnyFn>(value: F, context: ClassMethodDecoratorContext): F {
if (context.kind !== "method") return value;
const name = label ?? String(context.name);
return wrapMethod(value, (original) => {
const wrapped = function (this: any, ...args: any[]) {
const start = performance.now();
const done = (ok: boolean) => {
const ms = performance.now() - start;
const logger = (this as any).logger as Logger | undefined;
const msg = ok ? `timed:${name}` : `timed_error:${name}`;
logger?.info(msg, { ms: Math.round(ms * 100) / 100 });
};
try {
const result = original.apply(this, args);
if (result && typeof (result as any).then === "function") {
return (result as Promise<any>)
.then((v) => {
done(true);
return v;
})
.catch((e) => {
done(false);
throw e;
});
}
done(true);
return result;
} catch (e) {
done(false);
throw e;
}
} as F;
return wrapped;
});
};
}
적용:
export class UserService {
@Inject(LOGGER)
logger!: Logger;
@Inject(USER_REPO)
repo!: UserRepo;
@Log()
@Timed("UserService.getUserName")
async getUserName(id: string) {
return this.repo.findName(id);
}
}
Decorator 순서에 따라 로그 포맷이 달라질 수 있으니, 팀 규칙으로 “Timed는 Log보다 안쪽”처럼 정해두는 편이 좋습니다.
타입추론을 더 단단하게: 토큰 테이블과 satisfies
토큰이 많아지면 파일이 흩어지고 이름이 충돌하기 쉽습니다. 이때 토큰 테이블을 만들고 satisfies로 타입을 고정하면 “추론은 유지하면서 검증”이 됩니다.
type Tokens = {
LOGGER: Token<Logger>;
USER_REPO: Token<UserRepo>;
};
export const TOKENS = {
LOGGER: createToken<Logger>("LOGGER"),
USER_REPO: createToken<UserRepo>("USER_REPO")
} satisfies Tokens;
이 패턴은 토큰의 타입을 잃지 않고, 키 누락/오타를 컴파일 타임에 잡는 데 유용합니다. 자세한 배경은 TS 5.x satisfies로 타입검증·추론 동시 해결에서 확장해 볼 수 있습니다.
운영 관점: 로깅을 트레이싱으로 확장하는 포인트
단순 로깅 Decorator는 로컬 디버깅에는 좋지만, MSA에서는 요청이 여러 서비스를 거칩니다. 이때는 “로그 라인”보다 “Trace/Span”이 강력합니다.
@Timed에서performance.now()대신 스팬 시작/종료로 바꾸기- 에러 발생 시 스팬 상태를
ERROR로 마킹하고 예외 이벤트 추가 - 요청 스코프 컨테이너(또는 컨텍스트)에 trace id를 함께 보관
이 방향은 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전과 함께 적용하면, Decorator가 “관측 가능성(Observability) 레이어”로 자연스럽게 진화합니다.
정리: ES2024 Decorators를 실무에 쓰는 현실적인 결론
- 표준 Decorators는 legacy 대비 모델이 단순하고,
context.addInitializer같은 기능이 실전적입니다. - DI는 “자동 생성자 주입”보다 “토큰 기반 + 명시적 등록”이 표준 Decorators 환경에서 더 견고합니다.
- 로깅/타이밍 Decorator는 래핑 시그니처를 유지하는 것이 핵심이며,
F extends AnyFn패턴으로 타입추론 손실을 줄일 수 있습니다. - 토큰 테이블에
satisfies를 결합하면 규모가 커져도 타입 안정성과 개발 경험을 함께 가져갈 수 있습니다.
다음 단계로는 (1) 전역 컨테이너를 요청 스코프로 바꾸기, (2) @Timed를 OpenTelemetry 스팬으로 교체하기, (3) Decorator 적용 정책과 순서를 팀 컨벤션으로 고정하기를 추천합니다.