Symbol 기반 의존성 토큰
DI 컨테이너에서 의존성을 등록하고 주입할 때, "이 의존성이 어떤 것인지" 식별하는 토큰이 필요하다. 가장 직관적인 방법은 문자열을 쓰는 것이다.
container.register('DatabaseService', { useClass: MySQLService });
// 주입할 때
@inject('DatabaseService') private db: DatabaseService
간단하지만, 프로젝트가 커지면 문제가 생긴다.
문자열 토큰의 문제
1. 오타를 잡을 수 없다
// 등록할 때
container.register('DatabaseService', { useClass: MySQLService });
// 주입할 때 - 오타!
@inject('DataBaseService') private db: DatabaseService
// 런타임에서야 에러 발생: "No provider for DataBaseService"
문자열은 컴파일 타임에 검증되지 않는다. 'DatabaseService'와 'DataBaseService'는 TypeScript 입장에서 둘 다 유효한 string이기 때문에 에러 없이 빌드된다. 실행해봐야 "등록된 의존성이 없다"는 에러를 만나게 된다.
2. 이름 충돌
// auth 모듈에서
container.register('Logger', { useClass: AuthLogger });
// payment 모듈에서
container.register('Logger', { useClass: PaymentLogger });
// 이전 등록을 덮어씌움!
문자열 'Logger'는 전역적으로 하나의 값이다. 서로 다른 모듈에서 같은 문자열을 토큰으로 쓰면, 나중에 등록한 쪽이 이전 등록을 덮어쓴다. 이런 충돌은 프로젝트 규모가 커질수록 추적이 어려워진다.
3. 리팩토링이 어렵다
문자열은 IDE의 "Rename Symbol" 기능이 동작하지 않는다. 'DatabaseService'라는 문자열을 프로젝트 전체에서 찾아 바꾸려면 텍스트 검색에 의존해야 하고, 관련 없는 문자열까지 바뀔 위험이 있다.
Symbol이 해결하는 것
JavaScript의 Symbol은 절대 중복되지 않는 고유한 값을 만든다. 이 특성이 DI 토큰에 딱 맞는다.
const token1 = Symbol('DatabaseService');
const token2 = Symbol('DatabaseService');
console.log(token1 === token2); // false!
같은 설명 문자열을 넘겨도 매번 다른 Symbol이 생성된다. 설명 문자열은 순전히 디버깅용이고, Symbol의 고유성과는 무관하다.
Symbol vs Symbol.for
Symbol을 만드는 방법은 두 가지다.
// Symbol() - 매번 새로운 고유값
const a = Symbol('key');
const b = Symbol('key');
a === b; // false
// Symbol.for() - 전역 레지스트리에서 같은 키면 같은 Symbol 반환
const c = Symbol.for('key');
const d = Symbol.for('key');
c === d; // true
Symbol()은 호출할 때마다 절대 같을 수 없는 값을 만든다. Symbol.for()는 전역 Symbol 레지스트리에서 키를 검색하고, 있으면 기존 Symbol을 반환하고 없으면 새로 등록한다.
DI 컨테이너에서는 보통 Symbol.for()를 사용한다. 이유는 모듈 간에 토큰을 공유해야 하기 때문이다. 등록하는 파일과 주입하는 파일이 다를 때, 같은 키 문자열로 Symbol.for()를 호출하면 같은 Symbol을 얻을 수 있다. Symbol()을 쓰면 import로 직접 참조를 공유해야만 한다.
실전 패턴: 토큰 객체로 중앙 관리
Symbol 토큰을 파일 하나에 모아서 관리하는 것이 핵심 패턴이다.
// src/types/dependency-symbols.ts
export const DEPENDENCY_SYMBOLS = {
DatabaseConnection: Symbol.for('DatabaseConnection'),
RedisConnection: Symbol.for('RedisConnection'),
UserRepository: Symbol.for('UserRepository'),
PostRepository: Symbol.for('PostRepository'),
EmailService: Symbol.for('EmailService'),
CacheWorker: Symbol.for('CacheWorker'),
};
이렇게 하면 문자열 토큰의 모든 문제가 해결된다.
오타 방지
import { DEPENDENCY_SYMBOLS } from './types/dependency-symbols';
// 오타 → 컴파일 에러!
container.resolve(DEPENDENCY_SYMBOLS.DataBaseConnection);
// Property 'DataBaseConnection' does not exist on type '...'
// 올바른 사용
container.resolve(DEPENDENCY_SYMBOLS.DatabaseConnection); // ✅
객체의 프로퍼티로 접근하기 때문에, 존재하지 않는 키를 참조하면 TypeScript가 즉시 잡아준다. 문자열처럼 런타임까지 가지 않아도 된다.
이름 충돌 방지
// 각 모듈의 토큰이 구조적으로 분리됨
const AUTH_SYMBOLS = {
Logger: Symbol.for('Auth.Logger'),
};
const PAYMENT_SYMBOLS = {
Logger: Symbol.for('Payment.Logger'),
};
// 같은 프로퍼티명이지만 다른 Symbol
AUTH_SYMBOLS.Logger === PAYMENT_SYMBOLS.Logger; // false
Symbol.for()의 키에 네임스페이스 접두사를 붙이면 전역 레지스트리에서도 충돌이 없다.
리팩토링 용이
// IDE에서 DEPENDENCY_SYMBOLS.UserRepository를 Rename하면
// 사용하는 모든 곳이 자동으로 변경됨
container.register(DEPENDENCY_SYMBOLS.UserRepository, { useClass: MySQLUserRepo });
// ↓ Rename Symbol → 자동 반영
container.register(DEPENDENCY_SYMBOLS.AccountRepository, { useClass: MySQLUserRepo });
tsyringe에서의 사용
tsyringe는 NestJS 외부에서 사용하는 경량 DI 컨테이너다. Symbol 토큰과 함께 쓰는 전형적인 패턴을 보자.
토큰 정의
// src/types/dependency-symbols.ts
export const DEPENDENCY_SYMBOLS = {
DatabaseConnection: Symbol.for('DatabaseConnection'),
RedisConnection: Symbol.for('RedisConnection'),
UserRepository: Symbol.for('UserRepository'),
PostRepository: Symbol.for('PostRepository'),
ParserManager: Symbol.for('ParserManager'),
XmlParser: Symbol.for('XmlParser'),
JsonParser: Symbol.for('JsonParser'),
BackgroundWorker: Symbol.for('BackgroundWorker'),
};
컨테이너 등록
// src/container.ts
import { container } from 'tsyringe';
import { DEPENDENCY_SYMBOLS } from './types/dependency-symbols';
import { MySQLConnection } from './database/mysql-connection';
import { RedisConnection } from './database/redis-connection';
import { UserRepository } from './repository/user.repository';
import { PostRepository } from './repository/post.repository';
container.registerSingleton<DatabaseConnection>(
DEPENDENCY_SYMBOLS.DatabaseConnection,
MySQLConnection,
);
container.registerSingleton<RedisConnection>(
DEPENDENCY_SYMBOLS.RedisConnection,
RedisConnection,
);
container.registerSingleton<UserRepository>(
DEPENDENCY_SYMBOLS.UserRepository,
UserRepository,
);
container.registerSingleton<PostRepository>(
DEPENDENCY_SYMBOLS.PostRepository,
PostRepository,
);
export { container };
registerSingleton은 해당 토큰에 대해 앱 전체에서 하나의 인스턴스만 만들겠다는 뜻이다. 처음 resolve할 때 인스턴스를 생성하고, 이후에는 같은 인스턴스를 재사용한다.
주입받기
// src/repository/user.repository.ts
import { inject, injectable } from 'tsyringe';
import { DEPENDENCY_SYMBOLS } from '../types/dependency-symbols';
@injectable()
export class UserRepository {
constructor(
@inject(DEPENDENCY_SYMBOLS.DatabaseConnection)
private readonly db: DatabaseConnection,
) {}
async findById(id: number) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
@inject() 데코레이터에 Symbol 토큰을 넘기면, tsyringe가 해당 토큰으로 등록된 인스턴스를 자동으로 주입한다.
엔트리 포인트
// src/main.ts
import 'reflect-metadata';
import './container'; // 등록 코드 실행
import { container } from 'tsyringe';
import { DEPENDENCY_SYMBOLS } from './types/dependency-symbols';
const app = container.resolve<BackgroundWorker>(
DEPENDENCY_SYMBOLS.BackgroundWorker,
);
app.start();
reflect-metadata를 최상단에서 import해야 데코레이터 기반 DI가 동작한다. container 파일을 import하면 등록 코드가 실행되고, 이후 resolve()로 의존성 트리의 루트 객체를 꺼내면 하위 의존성이 자동으로 주입된다.
인터페이스와 Symbol 토큰
TypeScript의 인터페이스는 컴파일 후 사라진다. 그래서 인터페이스를 직접 DI 토큰으로 쓸 수 없다.
interface DatabaseConnection {
query(sql: string, params?: unknown[]): Promise<unknown>;
close(): Promise<void>;
}
// 이건 안 된다 - 인터페이스는 런타임에 존재하지 않음
container.register(DatabaseConnection, { useClass: MySQLConnection }); // ❌
Symbol 토큰이 이 문제를 해결한다. 인터페이스는 타입 체크용으로만 쓰고, 실제 DI 식별은 Symbol이 담당한다.
// 인터페이스 정의
interface DatabaseConnection {
query(sql: string, params?: unknown[]): Promise<unknown>;
close(): Promise<void>;
}
// Symbol 토큰으로 등록
container.registerSingleton<DatabaseConnection>(
DEPENDENCY_SYMBOLS.DatabaseConnection,
MySQLConnection, // DatabaseConnection 인터페이스를 구현한 클래스
);
// 타입은 인터페이스, 식별은 Symbol
@inject(DEPENDENCY_SYMBOLS.DatabaseConnection)
private readonly db: DatabaseConnection
이 패턴 덕분에 구현체를 교체할 때 등록 파일만 수정하면 된다. MySQLConnection 대신 PostgresConnection을 쓰고 싶으면:
// container.ts만 변경
container.registerSingleton<DatabaseConnection>(
DEPENDENCY_SYMBOLS.DatabaseConnection,
PostgresConnection, // 구현체만 교체
);
주입받는 쪽은 인터페이스에만 의존하므로 변경할 필요가 없다.
테스트에서의 활용
Symbol 토큰의 가장 큰 실용적 장점은 테스트에서 목(mock) 객체로 쉽게 교체할 수 있다는 점이다.
// test/setup.ts
import { container } from 'tsyringe';
import { DEPENDENCY_SYMBOLS } from '../src/types/dependency-symbols';
beforeEach(() => {
container.reset(); // 모든 등록 초기화
// 실제 DB 대신 mock 등록
container.register(DEPENDENCY_SYMBOLS.DatabaseConnection, {
useValue: {
query: jest.fn().mockResolvedValue([]),
close: jest.fn(),
},
});
// 실제 Redis 대신 mock 등록
container.register(DEPENDENCY_SYMBOLS.RedisConnection, {
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
},
});
});
container.reset()으로 기존 등록을 모두 지우고, useValue로 mock 객체를 직접 넣는다. 이렇게 하면 테스트가 실제 DB나 Redis에 의존하지 않으면서도, 프로덕션 코드는 한 줄도 수정할 필요가 없다.
NestJS와의 비교
NestJS는 자체 DI 시스템을 가지고 있어서 Symbol 대신 문자열 토큰이나 클래스 자체를 토큰으로 쓸 수 있다. 하지만 NestJS에서도 커스텀 프로바이더를 등록할 때 Symbol을 쓸 수 있다.
// NestJS 방식 - 문자열 토큰
@Module({
providers: [
{
provide: 'DATABASE_CONNECTION',
useClass: MySQLConnection,
},
],
})
export class AppModule {}
// NestJS에서도 Symbol 사용 가능
const DB_TOKEN = Symbol('DATABASE_CONNECTION');
@Module({
providers: [
{
provide: DB_TOKEN,
useClass: MySQLConnection,
},
],
})
export class AppModule {}
NestJS 밖에서 독립 실행되는 워커, 크롤러, CLI 도구 같은 프로세스는 NestJS의 DI를 쓸 수 없다. 이런 경우 tsyringe 같은 경량 DI 컨테이너와 Symbol 토큰 조합이 실용적인 선택이 된다.
정리
| 토큰 방식 | 타입 안전 | 충돌 방지 | 리팩토링 | 런타임 존재 |
|---|---|---|---|---|
| 문자열 | ❌ | ❌ | 어려움 | ✅ |
| 클래스 | ✅ | ✅ | 쉬움 | ✅ |
| Symbol | ✅ (객체 프로퍼티) | ✅ | 쉬움 | ✅ |
| 인터페이스 | ✅ | ✅ | 쉬움 | ❌ (사용 불가) |
Symbol 토큰의 핵심 가치는 인터페이스와 구현체를 분리하면서도 타입 안전한 DI를 구현할 수 있다는 것이다. 인터페이스가 런타임에 사라지는 TypeScript의 한계를 Symbol이 깔끔하게 보완해준다.