tsyringe
NestJS 같은 프레임워크를 사용하면 의존성 주입(DI)이 기본으로 제공된다. 모듈에 provider를 등록하고, 생성자에 타입만 선언하면 프레임워크가 알아서 인스턴스를 만들어서 넣어준다. 그런데 NestJS 밖에서 동작하는 독립 프로세스를 만들어야 한다면? 예를 들어 메시지 큐를 소비하는 워커 프로세스, 크롤러, CLI 도구처럼 Express나 NestJS 없이 순수 Node.js로 동작하는 프로그램에서도 DI가 필요할 때가 있다.
직접 DI 컨테이너를 만들 수도 있지만, 그건 바퀴를 재발명하는 것이다. tsyringe는 Microsoft가 만든 가벼운 TypeScript DI 컨테이너로, NestJS의 DI와 거의 동일한 패턴을 프레임워크 없이 사용할 수 있게 해준다.
왜 DI 컨테이너가 필요한가
DI 없이 코드를 작성하면 클래스가 자신의 의존성을 직접 생성한다.
class EmailConsumer {
private rabbitmqService: RabbitMQService;
private emailService: EmailService;
constructor() {
this.rabbitmqService = new RabbitMQService();
this.emailService = new EmailService();
}
}
이 방식의 문제는 명확하다.
- 결합도가 높다 —
EmailConsumer가RabbitMQService의 생성 방식을 알아야 한다.RabbitMQService의 생성자가 바뀌면 사용하는 곳을 전부 수정해야 한다. - 테스트가 어렵다 — 테스트에서
RabbitMQService를 모킹하려면 코드를 수정하거나 복잡한 우회가 필요하다. - 싱글톤 관리가 번거롭다 — 앱 전체에서 하나의 DB 연결, 하나의 Redis 연결을 공유해야 하는데, 직접 관리하려면 전역 변수나 별도 팩토리가 필요하다.
DI 컨테이너는 이 세 가지를 한 번에 해결한다. 클래스는 "나는 이것이 필요하다"만 선언하고, 컨테이너가 실제 인스턴스를 만들어서 주입해준다.
tsyringe vs 다른 선택지
TypeScript에서 사용할 수 있는 DI 컨테이너는 여러 개 있다.
| 라이브러리 | 특징 | 단점 |
|---|---|---|
| tsyringe | 가볍고 데코레이터 기반, MS 유지보수 | reflect-metadata 필수 |
| InversifyJS | 기능이 풍부하고 성숙함 | 설정이 복잡하고 보일러플레이트가 많음 |
| typedi | TypeORM과 잘 어울림 | 유지보수가 느림 |
| awilix | 데코레이터 없이 사용 가능 | TypeScript 타입 추론이 약함 |
tsyringe의 장점은 NestJS를 써본 사람이라면 거의 학습 비용 없이 바로 쓸 수 있다는 것이다. @injectable(), @inject() 데코레이터가 NestJS의 @Injectable(), @Inject()와 사실상 동일한 패턴이다.
설치와 설정
npm install tsyringe reflect-metadata
tsyringe는 TypeScript의 데코레이터와 reflect-metadata를 사용한다. tsconfig.json에서 두 가지 옵션을 활성화해야 한다.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
그리고 앱의 진입점(entry point) 최상단에서 reflect-metadata를 import 해야 한다. 이 import는 반드시 다른 모든 import보다 먼저 와야 한다.
import 'reflect-metadata';
import './container'; // DI 등록
import { container } from 'tsyringe';
// 앱 시작
const app = container.resolve(App);
app.start();
순서가 중요한 이유는 reflect-metadata가 데코레이터의 타입 정보를 런타임에 저장하는 폴리필이기 때문이다. 이 폴리필이 로드되기 전에 데코레이터가 실행되면 타입 정보가 누락되어 DI가 제대로 동작하지 않는다.
기본 사용법
@injectable과 @inject
클래스를 DI 컨테이너에서 관리하려면 @injectable() 데코레이터를 붙인다.
import { injectable } from 'tsyringe';
@injectable()
class EmailService {
sendEmail(to: string, subject: string, body: string) {
// 이메일 발송 로직
}
}
다른 클래스에 의존하는 경우, 생성자에 타입을 선언하면 tsyringe가 자동으로 주입한다.
import { injectable } from 'tsyringe';
@injectable()
class NotificationService {
constructor(private readonly emailService: EmailService) {}
notify(userId: string) {
this.emailService.sendEmail(userId, '알림', '새 메시지가 있습니다');
}
}
여기까지는 NestJS와 거의 동일하다. 하지만 인터페이스 기반 주입에서 차이가 생긴다.
왜 인터페이스로 직접 주입이 안 되는가
TypeScript의 인터페이스는 컴파일 타임에만 존재한다. JavaScript로 트랜스파일되면 인터페이스는 완전히 사라진다. 따라서 런타임에 "이 파라미터의 타입이 뭔지" 알아낼 방법이 없다.
// 이렇게 하면 동작하지 않는다
interface MessageQueue {
publish(message: string): Promise<void>;
consume(handler: (msg: string) => void): Promise<void>;
}
@injectable()
class Worker {
// ❌ MessageQueue는 런타임에 존재하지 않으므로 tsyringe가 뭘 주입할지 모른다
constructor(private readonly queue: MessageQueue) {}
}
이 문제를 해결하는 방법이 토큰(Token) 기반 주입이다.
토큰 기반 의존성 주입
tsyringe에서 토큰은 "이 의존성을 식별하는 키"다. 토큰으로는 문자열, Symbol, 클래스 자체를 사용할 수 있다.
문자열 토큰
가장 단순한 방식이다.
import { container, inject, injectable } from 'tsyringe';
@injectable()
class Worker {
constructor(
@inject('MessageQueue') private readonly queue: MessageQueue,
) {}
}
// 등록
container.register('MessageQueue', { useClass: RabbitMQAdapter });
문자열 토큰의 문제는 오타에 취약하다는 것이다. 'MessageQueue'를 'MesageQueue'로 잘못 쓰면 런타임에서야 에러가 발생한다. TypeScript의 타입 안전성을 전혀 활용하지 못한다.
Symbol 토큰
Symbol은 JavaScript의 원시 타입으로, 매번 고유한 값이 생성된다. Symbol을 토큰으로 사용하면 문자열 오타 문제를 해결할 수 있다.
// tokens.ts
export const TOKENS = {
MessageQueue: Symbol.for('MessageQueue'),
DatabaseConnection: Symbol.for('DatabaseConnection'),
RedisConnection: Symbol.for('RedisConnection'),
EmailService: Symbol.for('EmailService'),
};
import { inject, injectable } from 'tsyringe';
import { TOKENS } from './tokens';
@injectable()
class Worker {
constructor(
@inject(TOKENS.MessageQueue) private readonly queue: MessageQueue,
@inject(TOKENS.EmailService) private readonly emailService: EmailService,
) {}
}
TOKENS.MessageQueu라고 오타를 내면 TypeScript 컴파일러가 바로 잡아낸다. 또한 IDE의 자동완성과 "Find All References" 기능을 사용할 수 있어서 어떤 클래스가 어떤 의존성을 사용하는지 추적하기 쉽다.
Symbol() 대신 Symbol.for()를 사용하는 이유는 동일한 문자열로 생성한 Symbol이 항상 같은 값을 반환하기 때문이다. 모듈 시스템에서 같은 Symbol 파일이 여러 번 로드되더라도 토큰이 일치한다.
Symbol('foo') === Symbol('foo'); // false (매번 새로운 Symbol)
Symbol.for('foo') === Symbol.for('foo'); // true (전역 Symbol 레지스트리에서 공유)
InjectionToken 타입
tsyringe는 InjectionToken<T> 타입을 제공한다. 토큰에 타입 정보를 연결해서 더 안전하게 사용할 수 있다.
import { InjectionToken } from 'tsyringe';
const MESSAGE_QUEUE_TOKEN: InjectionToken<MessageQueue> = Symbol.for('MessageQueue');
이렇게 하면 컨테이너에 등록할 때 타입이 맞지 않으면 컴파일 에러가 발생한다.
컨테이너 등록 방식
tsyringe의 container는 전역 싱글톤이다. 의존성을 등록하는 방법은 크게 세 가지다.
register — 매번 새 인스턴스
container.register(TOKENS.EmailService, { useClass: SmtpEmailService });
resolve()할 때마다 새로운 인스턴스가 생성된다. Transient 스코프와 같다.
registerSingleton — 앱 전체에서 하나
container.registerSingleton(TOKENS.DatabaseConnection, MySQLConnection);
container.registerSingleton(TOKENS.RedisConnection, RedisConnection);
처음 resolve()할 때 인스턴스가 생성되고, 이후에는 같은 인스턴스가 반환된다. DB 연결, Redis 연결, 외부 API 클라이언트처럼 상태를 가지는 리소스에 사용한다.
registerInstance — 이미 만들어진 객체 등록
const config = loadConfig();
container.registerInstance(TOKENS.Config, config);
직접 생성한 객체를 그대로 등록한다. 설정 객체, 외부에서 생성된 연결 등을 등록할 때 사용한다.
컨테이너 파일 패턴
실제 프로젝트에서는 모든 등록을 하나의 container.ts 파일에 모아두는 패턴이 일반적이다.
// container.ts
import { container } from 'tsyringe';
import { TOKENS } from './tokens';
import { RabbitMQManager } from './rabbitmq/rabbitmq.manager';
import { RabbitMQService } from './rabbitmq/rabbitmq.service';
import { EmailConsumer } from './email/email.consumer';
import { EmailService } from './email/email.service';
container.registerSingleton<RabbitMQService>(
TOKENS.RabbitMQService,
RabbitMQService,
);
container.registerSingleton<RabbitMQManager>(
TOKENS.RabbitMQManager,
RabbitMQManager,
);
container.registerSingleton<EmailConsumer>(
TOKENS.EmailConsumer,
EmailConsumer,
);
container.registerSingleton<EmailService>(
TOKENS.EmailService,
EmailService,
);
export { container };
이 파일이 import되는 순간 모든 의존성이 등록된다. 진입점에서 이 파일을 import한 후 container.resolve()로 루트 클래스를 꺼내면 의존성 트리 전체가 자동으로 해석된다.
// main.ts
import 'reflect-metadata';
import { container } from './container';
import { TOKENS } from './tokens';
const consumer = container.resolve<EmailConsumer>(TOKENS.EmailConsumer);
await consumer.start();
resolve의 동작 원리
container.resolve()가 호출되면 tsyringe는 다음 순서로 의존성을 해석한다.
- 요청된 토큰에 등록된 클래스를 찾는다
- 해당 클래스의 생성자 파라미터를 reflect-metadata로 조회한다
- 각 파라미터에
@inject()데코레이터가 있으면 해당 토큰으로, 없으면 타입 자체로 재귀적으로 resolve한다 - 모든 의존성이 해석되면 인스턴스를 생성해서 반환한다
이 과정이 재귀적으로 동작하기 때문에 의존성의 의존성도 자동으로 해석된다. A → B → C → D 순서의 의존성 체인이 있어도 resolve(A)만 호출하면 전체가 해석된다.
자식 컨테이너
테스트에서는 특정 의존성만 모킹하고 나머지는 실제 구현을 사용하고 싶을 때가 있다. tsyringe의 createChildContainer()는 이럴 때 유용하다.
// 테스트에서
const childContainer = container.createChildContainer();
// EmailService만 모킹
childContainer.registerInstance(TOKENS.EmailService, {
sendEmail: jest.fn().mockResolvedValue(true),
});
// 나머지 의존성은 부모 컨테이너에서 해석
const consumer = childContainer.resolve<EmailConsumer>(TOKENS.EmailConsumer);
자식 컨테이너는 부모의 등록 정보를 상속한다. 자식에서 같은 토큰으로 등록하면 부모의 등록을 오버라이드한다. 부모 컨테이너에는 영향을 주지 않으므로 테스트 간 격리가 보장된다.
useFactory — 복잡한 생성 로직
의존성을 생성할 때 추가 로직이 필요한 경우 팩토리를 등록할 수 있다.
container.register(TOKENS.DatabaseConnection, {
useFactory: (c) => {
const config = c.resolve<Config>(TOKENS.Config);
return new DatabaseConnection({
host: config.dbHost,
port: config.dbPort,
database: config.dbName,
});
},
});
팩토리 함수는 컨테이너 인스턴스를 인자로 받으므로, 팩토리 안에서 다른 의존성을 resolve할 수 있다. 환경 변수나 설정에 따라 다른 구현체를 반환하는 패턴에도 활용된다.
container.register(TOKENS.Logger, {
useFactory: (c) => {
const config = c.resolve<Config>(TOKENS.Config);
if (config.environment === 'production') {
return new FileLogger('/var/log/my-app.log');
}
return new ConsoleLogger();
},
});
주의사항
reflect-metadata import 순서
가장 흔한 실수다. reflect-metadata는 앱의 최상단에서 import해야 한다. 다른 모듈이 먼저 로드되면 데코레이터의 메타데이터가 누락된다.
// ✅ 올바른 순서
import 'reflect-metadata';
import { container } from 'tsyringe';
// ❌ 잘못된 순서
import { container } from 'tsyringe';
import 'reflect-metadata';
tsc-alias나 module-alias 같은 path alias 해석 도구도 마찬가지다. reflect-metadata보다 먼저 import하면 모듈 해석이 실패할 수 있다.
순환 의존성
A가 B를 주입받고, B가 A를 주입받는 순환 의존성이 발생하면 tsyringe는 무한 루프에 빠진다. 이 경우 delay() 헬퍼를 사용하거나 설계를 리팩터링해서 순환을 제거해야 한다.
import { delay, inject, injectable } from 'tsyringe';
@injectable()
class ServiceA {
constructor(
@inject(delay(() => ServiceB)) private readonly serviceB: ServiceB,
) {}
}
delay()는 해당 의존성의 해석을 지연시켜서 순환을 끊는다. 하지만 이건 임시 방편이고, 순환 의존성 자체가 설계 문제의 신호이므로 가능하면 중간에 인터페이스를 두거나 이벤트 기반으로 분리하는 것이 낫다.
NestJS와의 차이
NestJS의 DI는 모듈 단위로 스코프가 나뉜다. A 모듈에서 등록한 provider는 B 모듈에서 자동으로 사용할 수 없고, exports/imports를 명시해야 한다. 반면 tsyringe의 container는 기본적으로 전역이다. 어디서든 container를 import하면 등록된 모든 의존성에 접근할 수 있다.
이게 단순한 워커 프로세스에서는 장점이지만, 규모가 커지면 의존성 관리가 어려워질 수 있다. 필요하다면 createChildContainer()로 스코프를 수동으로 분리해야 한다.
정리
tsyringe는 NestJS 밖에서 DI가 필요할 때 가장 실용적인 선택이다. NestJS의 DI 패턴을 알고 있다면 학습 비용이 거의 없고, 독립 워커 프로세스나 크롤러처럼 프레임워크 없이 동작하는 프로그램에서도 깔끔한 의존성 관리가 가능하다. 핵심은 세 가지다.
@injectable()+@inject(토큰)데코레이터로 의존성을 선언한다container.ts에서 토큰과 구현체를 매핑한다- 진입점에서
container.resolve()로 루트 객체를 꺼내면 전체 의존성 트리가 자동 해석된다
Symbol 기반 토큰을 사용하면 타입 안전성과 IDE 지원을 모두 확보할 수 있다.