- Published on
ES2024+ Decorators DI 구현과 TS 타입추론 함정 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/백엔드 코드에서 DI(Dependency Injection)는 테스트 용이성, 모듈 경계, 런타임 구성(프로덕션/스테이징) 분리를 위해 자주 쓰입니다. 그동안 TS 진영에서는 reflect-metadata 기반의 레거시 데코레이터 실험 기능에 의존하는 경우가 많았는데, ES2024+ 표준 Decorators(이하 “신 데코레이터”)가 자리 잡으면서 런타임 메타데이터 없이도 꽤 괜찮은 DI를 만들 수 있게 됐습니다.
다만, “표준 Decorators로 DI를 만들면 타입도 알아서 잘 추론되겠지”라는 기대는 쉽게 깨집니다. DI는 기본적으로 런타임 레지스트리(토큰 Map) 와 정적 타입(제네릭/조건부 타입) 을 연결하는 작업이고, 이 경계에서 TypeScript의 타입추론이 미끄러지는 지점이 많습니다.
이 글에서는 ES2024+ Decorators로 최소 DI 컨테이너를 구현한 뒤, 실무에서 자주 밟는 TS 타입추론 함정 7가지를 “왜 깨지는지 / 어떻게 고치는지” 관점으로 정리합니다. (비슷한 ‘오해로 터지는’ 패턴 정리 글로는 Rust 라이프타임 static 오해로 터지는 버그 7가지도 참고할 만합니다.)
주의: Next.js 환경에서 신 데코레이터를 쓰려면 TS/번들러 설정이 필요합니다. 또한 본문에서 부등호 문자는 MDX 빌드 이슈를 피하기 위해 모두 인라인 코드로 감쌉니다.
목표: ES2024+ Decorators로 “가벼운 DI” 만들기
핵심 아이디어는 간단합니다.
- 주입 대상은
Token으로 식별한다 Container는Token과provider를 맵으로 들고 있다@inject(token)데코레이터는 필드(또는 accessor)에 게터를 설치해서container.resolve(token)을 호출한다
1) 타입 안전한 Token 정의
문자열 키 대신 타입이 붙은 토큰을 쓰면 DI의 타입 안전성이 크게 좋아집니다.
// token.ts
export type Token<T> = symbol & { readonly __type?: T };
export function createToken<T>(description: string): Token<T> {
return Symbol.for(description) as Token<T>;
}
2) Container 구현
프로바이더는 useValue, useFactory, useClass 정도만 있어도 충분합니다.
// container.ts
import type { Token } from "./token";
type Factory<T> = (c: Container) => T;
type Provider<T> =
| { token: Token<T>; useValue: T }
| { token: Token<T>; useFactory: Factory<T> }
| { token: Token<T>; useClass: new (...args: any[]) => T };
export class Container {
private providers = new Map<symbol, Provider<any>>();
private singletons = new Map<symbol, any>();
register<T>(p: Provider<T>): void {
this.providers.set(p.token, p);
}
resolve<T>(token: Token<T>): T {
if (this.singletons.has(token)) return this.singletons.get(token);
const p = this.providers.get(token);
if (!p) throw new Error(`No provider for token: ${String(token)}`);
let value: any;
if ("useValue" in p) value = p.useValue;
else if ("useFactory" in p) value = p.useFactory(this);
else value = new p.useClass();
// 간단화를 위해 전부 singleton 캐시
this.singletons.set(token, value);
return value;
}
}
export const container = new Container();
3) ES2024+ Decorators로 @inject 구현
표준 Decorators의 필드 데코레이터는 context.addInitializer를 지원합니다. 여기서는 인스턴스가 만들어질 때 주입용 프로퍼티를 정의합니다.
// inject.ts
import { container } from "./container";
import type { Token } from "./token";
export function inject<T>(token: Token<T>) {
return function (_value: undefined, context: ClassFieldDecoratorContext) {
context.addInitializer(function () {
Object.defineProperty(this, context.name, {
configurable: true,
enumerable: true,
get() {
return container.resolve(token);
},
});
});
};
}
사용 예시는 아래처럼 됩니다.
// app.ts
import { createToken } from "./token";
import { container } from "./container";
import { inject } from "./inject";
interface Logger {
info(msg: string): void;
}
const LOGGER = createToken<Logger>("Logger");
class ConsoleLogger implements Logger {
info(msg: string) {
console.log(msg);
}
}
container.register({ token: LOGGER, useClass: ConsoleLogger });
class UserService {
@inject(LOGGER)
declare logger: Logger;
run() {
this.logger.info("hello");
}
}
new UserService().run();
여기까지는 “깔끔해 보이는 DI”입니다. 하지만 조금만 확장하면 타입추론이 급격히 흔들립니다.
TS 타입추론 함정 7가지
아래 함정들은 단순히 “타입이 빨갛게 뜬다” 수준이 아니라, 타입이 조용히 any/unknown으로 무너져 런타임 버그 가능성을 키우는 패턴들입니다.
함정 1) 토큰을 symbol로만 두면 타입이 증발한다
다음처럼 토큰 타입을 그냥 symbol로 쓰면, resolve가 무엇을 반환하는지 TS가 알 길이 없습니다.
// 나쁜 예
type Token = symbol;
class Container {
resolve(token: Token) {
return this.singletons.get(token);
}
}
이 경우 resolve 반환 타입은 대부분 any 또는 unknown으로 흐릅니다. DI의 핵심인 “주입되는 타입의 정적 보장”이 사라집니다.
해결: 본문처럼 Token<T>에 브랜드를 붙이세요.
Token<T>는 런타임에선symbol- 컴파일 타임에만
T를 운반
이 패턴 하나로 이후 함정의 절반이 완화됩니다.
함정 2) createToken() 호출 위치가 달라지면 동일 토큰으로 인식되지 않는다
토큰은 보통 모듈 상수로 두지만, 실수로 함수 안에서 만들면 매번 다른 심볼이 생깁니다.
// 나쁜 예: 호출할 때마다 새 토큰
function getLoggerToken() {
return createToken<Logger>("Logger");
}
container.register({ token: getLoggerToken(), useClass: ConsoleLogger });
container.resolve(getLoggerToken()); // 등록한 것과 다른 토큰
이건 타입추론 문제가 아니라 런타임 레지스트리 문제지만, TS는 이를 잡아주지 못합니다.
해결:
- 토큰은 반드시
const LOGGER = createToken<Logger>(...)처럼 모듈 레벨 단일 인스턴스로 유지 - 심볼 생성은
Symbol.for를 쓰더라도, “동일 description이면 동일 심볼”이라는 규칙에 기대지 말고 상수 공유를 원칙으로 하세요
함정 3) @inject 필드가 declare가 아니면 useDefineForClassFields에 따라 초기화가 꼬인다
TS의 클래스 필드 emit 옵션(useDefineForClassFields)과 데코레이터 초기화 순서가 만나면, 아래처럼 값이 덮일 수 있습니다.
class UserService {
@inject(LOGGER)
logger: Logger; // 초기화 코드가 emit되면 주입 getter가 덮이거나 반대로 덮일 수 있음
}
특히 필드가 “정의만 있고 초기값이 없는” 경우에도 컴파일 결과가 환경에 따라 달라질 수 있습니다.
해결:
- 주입 필드는
declare로 선언만 하고 런타임 필드 초기화를 만들지 않게 합니다.
class UserService {
@inject(LOGGER)
declare logger: Logger;
}
- 또는 accessor 기반으로 강제하는 방식도 있습니다(아래 함정 6 참고).
함정 4) container.register()에서 제네릭이 넓게 추론되면 any가 퍼진다
다음처럼 객체 리터럴을 그대로 넘기면, 상황에 따라 T가 기대보다 넓게 추론될 수 있습니다.
container.register({ token: LOGGER, useClass: ConsoleLogger });
대부분은 잘 되지만, 프로바이더를 배열로 모아 처리하거나, 조건 분기에서 합쳐지면 Provider<any>로 붕괴하는 경우가 생깁니다.
const providers = [
{ token: LOGGER, useClass: ConsoleLogger },
// 다른 토큰들...
];
providers.forEach((p) => container.register(p)); // p가 union으로 넓어지며 any/unknown 위험
해결:
satisfies로 프로바이더 타입을 고정해 추론 붕괴를 막습니다.
import type { Provider } from "./container";
const p1 = {
token: LOGGER,
useClass: ConsoleLogger,
} satisfies Provider<Logger>;
container.register(p1);
- 또는
register를 오버로드로 분리해 “token과 구현 타입의 결합”이 깨지지 않게 합니다.
함정 5) useFactory에서 반환 타입이 암묵적 any로 새는 패턴
useFactory는 DI에서 강력하지만, 반환 타입이 명시되지 않으면 any가 섞이기 쉽습니다.
container.register({
token: LOGGER,
useFactory: (c) => {
const impl = c.resolve(SOME_TOKEN); // 여기서 SOME_TOKEN이 any면 전파
return impl;
},
});
특히 resolve가 unknown을 반환하도록 설계했거나(안전하게 하려고), 토큰 정의가 엉키면 useFactory가 “타입 구멍”이 됩니다.
해결:
- 팩토리 반환 타입을 명시하거나, 토큰 기반으로 강제합니다.
container.register({
token: LOGGER,
useFactory: (c): Logger => new ConsoleLogger(),
});
resolve는 반드시Token<T>를 통해T로 좁혀지게 만들고, 토큰이unknown으로 남지 않게 하세요.
함정 6) 필드 데코레이터만으로는 “읽기 전용/지연 주입” 타입이 자연스럽게 안 맞는다
실무에서는 다음 요구가 자주 나옵니다.
- 주입 필드는 읽기 전용이어야 한다
- 실제로 접근될 때만 resolve하고 싶다(지연)
필드에 getter를 설치하는 방식은 지연 주입에는 맞지만, 타입 선언을 readonly로 만들면 코드 스타일/린트 규칙과 충돌할 수 있습니다.
class UserService {
@inject(LOGGER)
declare readonly logger: Logger; // 선언은 readonly지만 런타임 defineProperty는 writable 개념이 다름
}
여기서 TS는 통과해도, 런타임 프로퍼티 디스크립터를 어떻게 정의하느냐에 따라 “readonly처럼 보이지만 실제로는 재정의 가능”이 됩니다.
해결: accessor(게터) 데코레이터로 형태를 고정하세요.
// inject-getter.ts
import { container } from "./container";
import type { Token } from "./token";
export function injectGetter<T>(token: Token<T>) {
return function (
_value: () => T,
context: ClassGetterDecoratorContext
) {
return function (this: any) {
return container.resolve(token);
};
};
}
class UserService {
@injectGetter(LOGGER)
get logger(): Logger {
// 데코레이터가 구현을 대체
throw new Error("decorated");
}
}
이 방식은 “이 프로퍼티는 getter다”가 코드 구조로 강제되어, 필드 초기화/옵션 영향도 줄고 의도도 명확합니다.
함정 7) 인터페이스 기반 DI에서 “런타임 토큰”과 “컴파일 타임 타입”을 혼동한다
다음은 DI 입문자뿐 아니라 숙련자도 자주 헷갈립니다.
- 인터페이스
Logger는 런타임에 존재하지 않는다 - 따라서 “타입을 토큰으로 쓰는 DI”는 TS 단독으로는 불가능하다
// 불가능한 기대
container.resolve(Logger); // Logger는 값이 아님
레거시 데코레이터 + emitDecoratorMetadata 조합에서는 “디자인 타입”을 리플렉션으로 읽어 생성자 주입을 흉내낼 수 있었지만, 표준 Decorators는 기본적으로 그런 메타데이터를 제공하지 않습니다.
해결:
- 인터페이스는 반드시
Token<Interface>로 대응시키고, 토큰을 런타임 키로 사용 - “토큰을 import해서 주입한다”는 규율을 팀 규칙으로 고정
export interface Logger {
info(msg: string): void;
}
export const LOGGER = createToken<Logger>("Logger");
이 규율이 잡히면, DI 경계가 명확해지고 테스트 더블도 토큰 단위로 깔끔해집니다.
실전 구성: 테스트/환경별 바인딩 교체 패턴
DI를 쓰는 이유 중 하나는 “환경별 구현 교체”입니다.
// bootstrap.ts
import { container } from "./container";
import { LOGGER } from "./tokens";
class JsonLogger {
info(msg: string) {
console.log(JSON.stringify({ level: "info", msg }));
}
}
export function bootstrap(env: "dev" | "prod") {
if (env === "prod") {
container.register({ token: LOGGER, useClass: JsonLogger });
} else {
container.register({ token: LOGGER, useValue: { info: console.log } });
}
}
이때도 함정이 있습니다. useValue: { info: console.log }는 console.log 시그니처가 넓어서 Logger에 맞지 않는 경우가 생깁니다.
대응: 값 주입은 satisfies로 계약을 강제하세요.
const devLogger = {
info: (msg: string) => console.log(msg),
} satisfies Logger;
container.register({ token: LOGGER, useValue: devLogger });
DI를 “타입 안전”하게 유지하는 체크리스트
- 토큰은
Token<T>브랜드로 만든다 - 토큰은 모듈 상수로 단일화한다
- 주입 필드는
declare또는 getter로 강제한다 - 프로바이더 정의는
satisfies Provider<T>로 고정한다 useFactory반환 타입을 명시해 타입 구멍을 막는다- 인터페이스는 런타임에 없다는 전제를 팀 규칙으로 문서화한다
이런 체크리스트는 “어느 순간부터 타입이 다 any가 됐다” 같은 디버깅 시간을 크게 줄여줍니다. 운영 환경에서 원인 추적이 어려운 문제를 줄이는 관점에서는, 인프라 쪽의 진단 체크리스트 글인 systemd 타이머가 안 도는 이유 10가지 진단처럼 ‘재현 가능한 규칙’이 큰 힘을 발휘합니다.
마무리
ES2024+ 표준 Decorators는 DI 구현을 “프레임워크 의존”에서 “언어 기능 기반의 얇은 유틸”로 옮겨오게 해줍니다. 하지만 DI는 본질적으로 런타임 레지스트리와 정적 타입을 연결하는 작업이라, TypeScript 타입추론이 흔들리는 지점이 많습니다.
이 글의 7가지 함정은 대부분 토큰 설계 + 프로바이더 타입 고정 + 필드 초기화 규율로 예방할 수 있습니다. 특히 Token<T>와 satisfies는 작은 비용으로 큰 안정성을 주는 조합이니, DI를 직접 구현하거나 경량 컨테이너를 운영하는 팀이라면 기본 규칙으로 채택하는 것을 권합니다.
추가로 “주입 그래프가 커졌을 때 순환 의존을 어떻게 탐지할지”, “스코프(Transient/Request/Singleton)를 어떻게 타입으로 표현할지”까지 확장하면 또 다른 타입 함정이 나오는데, 원하면 그 주제로도 이어서 정리해볼 수 있습니다.