- Published on
ES2024 Decorators와 TS 타입추론으로 DI 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 어디서든 규모가 커지면 의존성 주입(DI)이 필요한 순간이 옵니다. 문제는 자바/닷넷 스타일의 DI를 그대로 TypeScript에 옮기려 하면 reflect-metadata 같은 런타임 메타데이터에 기대게 되고, 번들/트리셰이킹/ESM 전환 과정에서 발목을 잡기 쉽다는 점입니다.
ES2024에서 표준화된 데코레이터(새 데코레이터 문법)는 “클래스에 메타데이터를 붙여 등록한다”는 DI의 핵심 동작을 더 자연스럽게 만들어 줍니다. 여기에 TypeScript의 타입추론을 섞으면, 런타임에는 최소한의 토큰만 들고도 컴파일 타임에는 꽤 강한 타입 안전성을 확보할 수 있습니다.
이 글에서는 다음 목표로 DI를 구현합니다.
- ES2024 데코레이터로 서비스 등록
- 타입추론 기반의 토큰/팩토리 API로 안전한
resolve제공 - 런타임 리플렉션(파라미터 타입 자동 추출) 없이도 실용적인 주입 구현
- 싱글톤/트랜지언트 스코프, 테스트 대체(override)까지 포함
참고로 Node.js에서 ESM 전환이나 exports 설정을 잘못 잡으면 DI 모듈이 로딩조차 안 되는 상황이 자주 생깁니다. 배포 환경에서 이런 문제를 겪고 있다면 Node.js ESM 전환 후 exports import 오류 해결도 같이 확인해 두는 편이 좋습니다.
ES2024 데코레이터: TS 실험 문법과 무엇이 다른가
TypeScript는 한동안 “레거시 데코레이터”를 제공해 왔고, ES 표준과는 시그니처/동작이 달랐습니다. ES2024 표준 데코레이터는 대략 다음 특징을 가집니다.
- 데코레이터는 값(클래스/메서드/필드 등)과
context를 받음 - 필요하면
context.addInitializer(fn)로 초기화 코드를 등록 가능 - 표준 동작을 따르므로, 장기적으로는 TS/번들러/런타임 간 호환성이 좋아짐
중요한 현실 체크도 있습니다.
- “표준 데코레이터를 TS에서 바로 쓰는 것”은 TS 버전과 옵션 조합에 따라 다릅니다.
- Babel/SWC/tsc 파이프라인에서 데코레이터 트랜스폼 설정을 일관되게 맞춰야 합니다.
이 글은 구현 아이디어와 패턴이 목적이므로, 빌드 설정은 프로젝트 환경에 맞게 조정하세요.
DI 설계의 핵심: 런타임 토큰과 컴파일 타임 타입을 분리
TypeScript 타입은 런타임에 사라집니다. 따라서 DI에서 “의존성을 찾는 키”는 런타임 값이어야 합니다. 전통적으로는 클래스 생성자 자체를 키로 쓰거나, 심볼 토큰을 씁니다.
여기서는 다음 원칙을 택합니다.
- 런타임 키는
Token(심볼 기반) - 컴파일 타임 타입은
Token제네릭으로 연결 - 데코레이터는 “등록”만 담당하고, “주입 대상 파라미터 자동 추론”은 하지 않음
즉, 생성자 파라미터 타입을 리플렉션으로 읽어서 자동 주입하는 프레임워크식 DI 대신, 명시적인 토큰 기반 주입을 채택합니다. 대신 개발 경험을 해치지 않도록 “타입추론”으로 API를 설계합니다.
구현 1: Token과 Provider 타입
먼저 런타임 토큰과 프로바이더 모델을 정의합니다.
// di/token.ts
export type Token<T> = {
readonly id: symbol
readonly description?: string
// 타입 연결용 필드(런타임에는 사용하지 않음)
readonly __type?: T
}
export function createToken<T>(description?: string): Token<T> {
return { id: Symbol(description), description }
}
export type Scope = "singleton" | "transient"
export type Provider<T> =
| { kind: "value"; value: T }
| { kind: "factory"; scope: Scope; factory: (c: Container) => T }
export type Registry = Map<symbol, Provider<unknown>>
export interface Container {
resolve<T>(token: Token<T>): T
}
포인트는 Token<T>가 런타임에서는 그냥 symbol을 들고, 타입 시스템에서는 T를 들고 있다는 점입니다. 이 구조 덕분에 resolve가 Token<T>를 받으면 반환 타입을 T로 정확히 추론합니다.
구현 2: 컨테이너와 스코프(싱글톤/트랜지언트)
싱글톤은 “한 번 만든 인스턴스를 캐시”하면 됩니다.
// di/container.ts
import { Container, Provider, Registry, Token } from "./token"
export class DIContainer implements Container {
private readonly registry: Registry
private readonly singletons = new Map<symbol, unknown>()
constructor(registry?: Registry) {
this.registry = registry ?? new Map()
}
register<T>(token: Token<T>, provider: Provider<T>): this {
this.registry.set(token.id, provider as Provider<unknown>)
return this
}
override<T>(token: Token<T>, provider: Provider<T>): this {
// 테스트에서 주로 사용: 기존 등록을 덮어씀
this.registry.set(token.id, provider as Provider<unknown>)
this.singletons.delete(token.id)
return this
}
resolve<T>(token: Token<T>): T {
const provider = this.registry.get(token.id)
if (!provider) {
throw new Error(`No provider for token: ${token.description ?? "(anonymous)"}`)
}
if (provider.kind === "value") {
return provider.value as T
}
if (provider.scope === "singleton") {
if (this.singletons.has(token.id)) {
return this.singletons.get(token.id) as T
}
const created = provider.factory(this)
this.singletons.set(token.id, created)
return created as T
}
// transient
return provider.factory(this) as T
}
}
여기까지는 “그냥 DI 컨테이너”입니다. 이제 ES2024 데코레이터로 등록을 자동화해 보겠습니다.
구현 3: ES2024 데코레이터로 서비스 등록
목표는 다음과 같은 사용성입니다.
@Service(TOKENS.UserRepo)처럼 클래스에 데코레이터를 붙이면 자동 등록- 등록은 전역 레지스트리에 모아두고, 앱 시작 시 컨테이너를 만들 때 한 번에 로드
전역 레지스트리를 하나 두겠습니다.
// di/global-registry.ts
import { Provider, Registry, Token } from "./token"
const globalRegistry: Registry = new Map()
export function addGlobalProvider<T>(token: Token<T>, provider: Provider<T>): void {
globalRegistry.set(token.id, provider as Provider<unknown>)
}
export function snapshotGlobalRegistry(): Registry {
// 컨테이너별로 복제해서 사용(테스트 격리)
return new Map(globalRegistry)
}
이제 데코레이터를 작성합니다. ES2024 데코레이터는 (value, context) 시그니처를 사용합니다.
// di/decorators.ts
import { addGlobalProvider } from "./global-registry"
import { Scope, Token } from "./token"
type Ctor<T> = new (...args: any[]) => T
export function Service<T>(token: Token<T>, scope: Scope = "singleton") {
return function (value: Ctor<T>, context: ClassDecoratorContext) {
// 클래스가 평가되는 시점에 등록
addGlobalProvider(token, {
kind: "factory",
scope,
factory: () => new value(),
})
// 필요하면 초기화 훅도 가능
context.addInitializer(function () {
// 여기서 this는 클래스(정적 초기화) 컨텍스트가 될 수 있음
// 지금은 데모라서 비워 둠
})
}
}
여기서 눈치 챌 점이 있습니다.
new value()는 “의존성 없는 클래스”만 생성할 수 있습니다.- 결국 DI의 핵심인 “다른 의존성 주입”이 필요합니다.
리플렉션 없이 이를 해결하려면 두 가지 중 하나를 택해야 합니다.
- 생성자에서 컨테이너를 직접 받아
container.resolve(...)로 꺼내기 - 데코레이터 등록 시 팩토리를 명시해서 컨테이너를 통해 생성하기
1)은 편하지만 서비스가 컨테이너에 강결합됩니다. 2)가 더 DI스럽습니다. 여기서는 2)를 선택합니다.
구현 4: 팩토리 기반 등록과 타입추론
서비스 등록을 @Service로만 끝내지 말고, “등록용 함수”를 함께 제공하면 좋습니다.
// di/providers.ts
import { addGlobalProvider } from "./global-registry"
import { Scope, Token } from "./token"
export function provideFactory<T>(
token: Token<T>,
factory: (c: import("./token").Container) => T,
scope: Scope = "singleton",
): void {
addGlobalProvider(token, { kind: "factory", scope, factory })
}
export function provideValue<T>(token: Token<T>, value: T): void {
addGlobalProvider(token, { kind: "value", value })
}
그리고 데코레이터는 “등록 트리거”로만 사용하고, 실제 팩토리는 별도 함수로 등록해도 됩니다. 하지만 클래스에 데코레이터를 붙이는 경험을 살리고 싶다면, 데코레이터에 팩토리를 받을 수 있게 확장합니다.
// di/decorators.ts
import { addGlobalProvider } from "./global-registry"
import { Container, Scope, Token } from "./token"
type Ctor<T> = new (...args: any[]) => T
type ServiceOptions<T> = {
token: Token<T>
scope?: Scope
factory?: (c: Container, ctor: Ctor<T>) => T
}
export function Service<T>(options: ServiceOptions<T>) {
return function (value: Ctor<T>) {
const scope = options.scope ?? "singleton"
const factory = options.factory ?? ((_: Container, ctor: Ctor<T>) => new ctor())
addGlobalProvider(options.token, {
kind: "factory",
scope,
factory: (c) => factory(c, value),
})
}
}
이제 “컨테이너를 통해 의존성을 조합해서 인스턴스 생성”이 가능합니다.
예제: Repository + Service + Controller 조립
토큰을 먼저 정의합니다.
// app/tokens.ts
import { createToken } from "../di/token"
export interface UserRepo {
findNameById(id: string): Promise<string | null>
}
export interface UserService {
getDisplayName(id: string): Promise<string>
}
export const TOKENS = {
UserRepo: createToken<UserRepo>("UserRepo"),
UserService: createToken<UserService>("UserService"),
} as const
UserRepo 구현체를 등록합니다.
// app/user-repo.ts
import { Service } from "../di/decorators"
import { TOKENS, UserRepo } from "./tokens"
@Service({ token: TOKENS.UserRepo, scope: "singleton" })
export class InMemoryUserRepo implements UserRepo {
private readonly data = new Map<string, string>([
["1", "Ada"],
["2", "Linus"],
])
async findNameById(id: string) {
return this.data.get(id) ?? null
}
}
UserService는 UserRepo에 의존합니다. 여기서 팩토리로 의존성을 주입합니다.
// app/user-service.ts
import { Service } from "../di/decorators"
import { TOKENS, UserRepo, UserService } from "./tokens"
class DefaultUserService implements UserService {
constructor(private readonly repo: UserRepo) {}
async getDisplayName(id: string) {
const name = await this.repo.findNameById(id)
return name ? `User:${name}` : "User:Unknown"
}
}
@Service({
token: TOKENS.UserService,
scope: "singleton",
factory: (c) => new DefaultUserService(c.resolve(TOKENS.UserRepo)),
})
export class UserServiceProvider {}
여기서 UserServiceProvider는 “실제 서비스 클래스”가 아니라 “등록 트리거용 클래스”입니다. 이 패턴이 싫다면 DefaultUserService 자체에 데코레이터를 붙여도 되지만, 그러면 factory에서 ctor를 활용하는 방식으로 바꿔야 합니다.
예를 들어 다음처럼도 가능합니다.
// app/user-service-alt.ts
import { Service } from "../di/decorators"
import { TOKENS, UserRepo, UserService } from "./tokens"
@Service({
token: TOKENS.UserService,
factory: (c, ctor) => new ctor(c.resolve(TOKENS.UserRepo)),
})
export class DefaultUserService implements UserService {
constructor(private readonly repo: UserRepo) {}
async getDisplayName(id: string) {
const name = await this.repo.findNameById(id)
return name ? `User:${name}` : "User:Unknown"
}
}
이 방식은 “주입 규칙은 데코레이터에, 구현은 클래스에”라는 균형이 좋습니다.
앱 부팅: 전역 레지스트리 스냅샷으로 컨테이너 만들기
전역 레지스트리에 등록된 프로바이더를 컨테이너로 가져옵니다.
// app/bootstrap.ts
import { DIContainer } from "../di/container"
import { snapshotGlobalRegistry } from "../di/global-registry"
// 중요한 점: 데코레이터가 실행되려면 해당 모듈이 import 되어 평가되어야 함
import "./user-repo"
import "./user-service-alt"
export function createAppContainer() {
return new DIContainer(snapshotGlobalRegistry())
}
이제 다음처럼 사용합니다.
// app/main.ts
import { createAppContainer } from "./bootstrap"
import { TOKENS } from "./tokens"
const c = createAppContainer()
const userService = c.resolve(TOKENS.UserService)
console.log(await userService.getDisplayName("1"))
여기서 resolve(TOKENS.UserService)의 반환 타입은 UserService로 정확히 추론됩니다. 이게 “TS 타입추론으로 DI를 구현한다”의 실질적인 의미입니다.
테스트에서 override로 대체 주입하기
DI의 큰 장점은 테스트에서 의존성을 쉽게 바꿀 수 있다는 점입니다.
// app/user-service.test.ts
import { DIContainer } from "../di/container"
import { snapshotGlobalRegistry } from "../di/global-registry"
import { TOKENS, UserRepo } from "./tokens"
class FakeRepo implements UserRepo {
async findNameById(id: string) {
return id === "1" ? "TestUser" : null
}
}
test("UserService uses repo", async () => {
const c = new DIContainer(snapshotGlobalRegistry())
c.override(TOKENS.UserRepo, { kind: "value", value: new FakeRepo() })
const svc = c.resolve(TOKENS.UserService)
expect(await svc.getDisplayName("1")).toBe("User:TestUser")
})
싱글톤 스코프라면 override 시 싱글톤 캐시를 비워야 “이전 인스턴스가 repo를 물고 있는 문제”를 피할 수 있는데, 위 컨테이너 구현은 그 동작을 포함했습니다.
이 접근의 트레이드오프: 자동 주입이 없는 대신 안정적인 빌드/런타임
리플렉션 기반 DI(생성자 파라미터 타입을 읽어 자동 주입)는 코드가 짧아 보이지만, 다음 비용을 동반합니다.
emitDecoratorMetadata및 메타데이터 폴리필 의존- 번들러/트리셰이킹에서 예상치 못한 사이드이펙트
- ESM/CJS 혼용,
exports설정 문제로 런타임 로딩이 깨질 확률 증가
특히 Node.js 22 전후로 ESM 경계가 더 엄격해지면서, DI 컨테이너 모듈이 require에 묶여 있거나 혼용하면 문제가 커집니다. 관련 이슈는 Node.js 22에서 require·ESM 혼용 에러 해결법에서도 자주 다루는 유형입니다.
반대로 이 글의 방식은 다음 장점이 있습니다.
- 런타임은 심볼 토큰만 알면 됨(리플렉션 불필요)
- 타입 안전성은
Token<T>로 확보 - 데코레이터는 “등록”만 담당하므로 동작이 단순
단점도 명확합니다.
- 의존성 그래프를 자동으로 만들지 않으므로 팩토리를 작성해야 함
- “모듈을 import 해야 등록된다”는 사이드이펙트가 존재
이 단점은 “부팅 파일에서 모든 서비스 모듈을 명시적으로 import”하거나, 기능 단위로 index.ts에서 한 번에 import하는 방식으로 관리합니다.
실전 팁: 모듈 경계와 빌드(Next.js)에서의 주의점
Next.js(App Router 포함)에서는 서버/클라이언트 경계가 분리되어 있고, 모듈이 언제 어디서 평가되는지가 중요합니다.
- 서버에서만 써야 하는 프로바이더(예: DB 커넥션)는 서버 전용 엔트리에서만 import
- 클라이언트 번들에 섞이지 않게 파일을 분리
- 데코레이터로 등록되는 모듈이 “클라이언트 컴포넌트” 경로에서 import되지 않도록 주의
App Router 성능/캐시 최적화 맥락에서 모듈 평가 시점이 문제를 만드는 경우도 있으니, RSC 환경에서 체감 성능을 다루는 글인 Next.js App Router 로딩 느림? RSC 캐시·prefetch 최적화도 함께 보면 도움이 됩니다.
확장 아이디어: 멀티 바인딩, 라이프사이클, 비동기 팩토리
현업에서는 다음 요구가 금방 생깁니다.
- 하나의 토큰에 여러 구현체 등록(멀티 바인딩)
- 요청 스코프(request scope)
- 비동기 초기화(예: DB 연결 생성이
await필요)
이 글의 기본 구조는 확장하기 쉽습니다.
- 멀티 바인딩은
Map값에 배열을 두거나,Token<ReadonlyArray<T>>를 별도로 정의 - 요청 스코프는 컨테이너를 “부모-자식” 구조로 만들어 요청마다 자식 컨테이너를 생성
- 비동기는
resolveAsync를 추가하고Provider에factory: (c) => Promise<T>를 허용
다만 비동기 DI는 호출부 전파가 커지므로, 정말 필요한 토큰에만 제한적으로 적용하는 것을 권합니다.
정리
- ES2024 표준 데코레이터는 “등록” 같은 메타 프로그래밍을 더 정돈된 방식으로 제공
- TypeScript 타입추론은
Token<T>설계만 잘하면resolve타입 안전성을 크게 끌어올림 - 리플렉션 기반 자동 주입을 포기하는 대신, ESM/번들/런타임 호환성이 좋아지고 디버깅이 쉬워짐
다음 단계로는 “요청 스코프”와 “비동기 프로바이더”를 추가해, 웹 서버(예: Fastify, Next.js API route)에서 안전하게 동작하는 DI로 확장해 보세요.