NestJS @Optional DI
NestJS의 DI 컨테이너는 기본적으로 엄격하다. 생성자에 선언된 의존성이 컨테이너에 등록되어 있지 않으면 애플리케이션이 부트스트랩 단계에서 바로 에러를 던진다.
Nest can't resolve dependencies of the NotificationService (?).
Please make sure that the argument SLACK_CLIENT at index [0]
is available in the NotificationModule context.
대부분의 경우 이건 좋은 동작이다. 의존성이 누락되면 런타임에 undefined 참조로 터지는 것보다 시작 시점에 터지는 게 훨씬 낫다. 하지만 "있으면 쓰고, 없으면 말고"라는 요구사항이 분명히 존재한다.
- Slack 알림 모듈: Slack 토큰이 설정되어 있으면 알림을 보내고, 없으면 조용히 넘어간다
- 캐시 레이어: Redis가 연결되어 있으면 캐싱하고, 없으면 캐시 없이 동작한다
- 로깅 전략: 외부 로깅 서비스가 있으면 거기로 보내고, 없으면 콘솔에 찍는다
- 테스트 환경: 프로덕션에서만 필요한 의존성을 테스트에서는 제외하고 싶다
이런 상황에서 @Optional() 데코레이터를 사용한다.
@Optional() 기본 사용법
@nestjs/common에서 가져오는 데코레이터로, 생성자 파라미터에 붙이면 해당 의존성이 컨테이너에 없어도 에러 없이 undefined로 주입된다.
import { Injectable, Optional, Inject } from '@nestjs/common';
@Injectable()
export class NotificationService {
constructor(
@Optional() @Inject('SLACK_CLIENT') private readonly slackClient?: SlackClient,
) {}
async notify(message: string) {
if (this.slackClient) {
await this.slackClient.send(message);
}
// Slack 클라이언트가 없으면 아무것도 안 함
}
}
핵심은 @Optional()이 DI 컨테이너의 해석(resolution) 단계에서 동작한다는 것이다. 컨테이너가 토큰을 찾으러 갔는데 없으면, 보통은 에러를 던지지만 @Optional()이 붙어 있으면 undefined를 반환하고 넘어간다. TypeScript 타입에 ?를 붙이는 건 컴파일 타임 안전성을 위한 것이고, 실제 DI 동작과는 무관하다.
클래스 기반 주입 vs 토큰 기반 주입
클래스 기반
클래스를 직접 타입으로 쓰는 경우, NestJS가 클래스 자체를 토큰으로 사용한다.
@Injectable()
export class ReportService {
constructor(
@Optional() private readonly cacheService?: CacheService,
) {}
}
CacheService가 현재 모듈의 provider로 등록되어 있지 않아도 에러 없이 undefined가 들어온다. @Inject()를 명시하지 않아도 TypeScript의 emitDecoratorMetadata가 클래스 메타데이터를 자동으로 생성해주기 때문에 동작한다.
커스텀 토큰 기반
문자열이나 Symbol 토큰을 사용하는 경우에는 반드시 @Inject()와 함께 써야 한다.
// 토큰 정의
export const CACHE_MANAGER = Symbol('CACHE_MANAGER');
@Injectable()
export class ProductService {
constructor(
@Optional() @Inject(CACHE_MANAGER) private readonly cache?: CacheManager,
) {}
}
@Inject() 없이 @Optional()만 쓰면 NestJS가 어떤 토큰을 찾아야 하는지 모르기 때문에, 의도와 다르게 항상 undefined가 주입될 수 있다.
useFactory에서의 Optional 주입
생성자가 아니라 useFactory 팩토리 함수에서도 optional 주입이 가능하다. 이 경우 inject 배열에서 객체 형태로 지정한다.
const notificationProvider = {
provide: 'NOTIFICATION_SERVICE',
useFactory: (
mailer: MailerService,
slackClient?: SlackClient,
) => {
return new NotificationService(mailer, slackClient);
},
inject: [
MailerService,
{ token: 'SLACK_CLIENT', optional: true },
],
};
inject 배열의 원소가 단순 토큰이면 필수, { token, optional: true } 객체면 선택적이다. 팩토리 함수의 해당 파라미터는 provider가 없을 때 undefined로 들어온다.
이 패턴은 생성자 데코레이터보다 유연하다. 팩토리 안에서 optional 의존성의 존재 여부에 따라 완전히 다른 인스턴스를 만들어 반환할 수 있기 때문이다.
const cacheProvider = {
provide: 'CACHE_SERVICE',
useFactory: (redis?: RedisClient) => {
if (redis) {
return new RedisCacheService(redis);
}
return new InMemoryCacheService();
},
inject: [
{ token: 'REDIS_CLIENT', optional: true },
],
};
Redis 클라이언트가 등록되어 있으면 Redis 기반 캐시를, 없으면 인메모리 캐시를 사용한다. 소비하는 쪽에서는 CACHE_SERVICE 토큰만 주입받으면 되므로 구현이 바뀌어도 영향을 받지 않는다.
조건부 모듈 등록과의 조합
@Optional() DI는 조건부 모듈 등록 패턴과 함께 쓸 때 진가를 발휘한다.
ConditionalModule (NestJS 10+)
NestJS 10부터 @nestjs/config 패키지에 ConditionalModule이 추가되었다.
import { ConditionalModule } from '@nestjs/config';
@Module({
imports: [
ConditionalModule.registerWhen(SlackModule, 'SLACK_ENABLED'),
],
})
export class AppModule {}
SLACK_ENABLED 환경변수가 truthy일 때만 SlackModule이 등록된다. 이 모듈이 SLACK_CLIENT 토큰을 제공한다면, 모듈이 등록되지 않았을 때 해당 토큰을 @Optional()로 주입받는 서비스는 자동으로 undefined를 받게 된다.
직접 조건부 등록
ConditionalModule 없이 동적 모듈로도 같은 패턴을 구현할 수 있다.
@Module({})
export class SlackModule {
static register(): DynamicModule {
const providers: Provider[] = [];
if (process.env.SLACK_TOKEN) {
providers.push({
provide: 'SLACK_CLIENT',
useFactory: () => new SlackClient(process.env.SLACK_TOKEN),
});
}
return {
module: SlackModule,
providers,
exports: providers,
};
}
}
SLACK_TOKEN이 없으면 SLACK_CLIENT provider가 아예 등록되지 않는다. 이 토큰을 @Optional()로 받는 서비스들은 undefined로 처리하면 된다.
Null Object 패턴과의 결합
@Optional()로 undefined를 받으면 사용하는 곳마다 null check가 필요하다. 코드 전체에 if (this.cache) 같은 분기가 퍼지면 지저분해진다. 이걸 해결하는 방법이 Null Object 패턴이다.
// 인터페이스
export interface CachePort {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttl?: number): Promise<void>;
del(key: string): Promise<void>;
}
// 실제 구현
@Injectable()
export class RedisCacheAdapter implements CachePort {
constructor(private readonly redis: Redis) {}
async get(key: string) {
return this.redis.get(key);
}
async set(key: string, value: string, ttl?: number) {
if (ttl) {
await this.redis.set(key, value, 'EX', ttl);
} else {
await this.redis.set(key, value);
}
}
async del(key: string) {
await this.redis.del(key);
}
}
// Null Object — 아무것도 안 하는 구현
@Injectable()
export class NoopCacheAdapter implements CachePort {
async get() { return null; }
async set() { /* noop */ }
async del() { /* noop */ }
}
팩토리에서 조건에 따라 다른 구현을 주입한다:
const cacheProvider: Provider = {
provide: 'CACHE_PORT',
useFactory: (redis?: RedisClient) => {
return redis
? new RedisCacheAdapter(redis)
: new NoopCacheAdapter();
},
inject: [{ token: 'REDIS_CLIENT', optional: true }],
};
이제 소비자 코드에서 null check가 완전히 사라진다:
@Injectable()
export class ProductService {
constructor(
@Inject('CACHE_PORT') private readonly cache: CachePort,
) {}
async getProduct(id: string) {
const cached = await this.cache.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await this.repository.findOne(id);
await this.cache.set(`product:${id}`, JSON.stringify(product), 3600);
return product;
}
}
ProductService는 캐시가 Redis인지 Noop인지 전혀 모른다. 그냥 CachePort 인터페이스에 맞춰 호출하면 된다. Redis가 없는 환경에서는 NoopCacheAdapter가 조용히 아무것도 안 하고, 있는 환경에서는 RedisCacheAdapter가 실제로 캐싱한다.
@Optional()의 내부 동작
@Optional() 데코레이터가 실제로 하는 일은 단순하다. Reflect.defineMetadata로 해당 파라미터 인덱스에 "optional" 메타데이터를 기록한다.
// NestJS 내부 구현 (단순화)
export function Optional(): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const existingOptionals =
Reflect.getMetadata('optional:paramtypes', target) || [];
existingOptionals.push(parameterIndex);
Reflect.defineMetadata('optional:paramtypes', existingOptionals, target);
};
}
DI 컨테이너가 인스턴스를 생성할 때 각 파라미터의 토큰을 해석하는데, 해석에 실패하면 이 메타데이터를 확인한다. optional으로 표시된 파라미터면 undefined를 넣고 계속 진행하고, 아니면 에러를 던진다.
이런 방식 때문에 @Optional()은 반드시 다른 DI 데코레이터(@Inject() 또는 TypeScript의 implicit class token)와 함께 사용해야 의미가 있다. 토큰 자체가 없으면 컨테이너가 무엇을 찾아야 하는지 모르기 때문이다.
ModuleRef를 사용한 런타임 해석
@Optional()은 생성자 주입 시점에 결정된다. 만약 런타임에 동적으로 provider 존재 여부를 확인하고 싶다면 ModuleRef를 사용할 수 있다.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class PluginManager implements OnModuleInit {
private plugins: Plugin[] = [];
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit() {
const pluginTokens = ['PLUGIN_A', 'PLUGIN_B', 'PLUGIN_C'];
for (const token of pluginTokens) {
try {
const plugin = this.moduleRef.get(token, { strict: false });
this.plugins.push(plugin);
} catch {
// provider가 없으면 무시
}
}
}
async executeAll() {
for (const plugin of this.plugins) {
await plugin.execute();
}
}
}
moduleRef.get()은 토큰이 없으면 예외를 던지므로 try-catch로 감싼다. { strict: false }는 현재 모듈뿐 아니라 전체 컨테이너에서 검색하라는 옵션이다.
이 방식은 플러그인 시스템처럼 등록된 provider 목록이 가변적인 상황에서 유용하다. 하지만 일반적인 optional 의존성에는 @Optional() 데코레이터가 더 명확하고 간단하다.
주의할 점
1. @Optional()은 "안전한 실패"가 아니다
@Optional()을 남발하면 의존성 누락을 숨기게 된다. 원래 필수여야 하는 의존성에 @Optional()을 붙이면, 설정 실수로 provider가 누락되었을 때 에러 대신 조용히 undefined가 들어가서 런타임에 예상치 못한 곳에서 터진다.
// 이러면 안 됨 — DB는 필수인데 Optional로 처리
@Injectable()
export class UserService {
constructor(
@Optional() @Inject('DB') private readonly db?: Database,
) {}
findUser(id: string) {
// db가 undefined면 여기서 터짐
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
@Optional()은 정말로 "없어도 동작해야 하는" 의존성에만 써야 한다.
2. 순환 참조와 혼동하지 말 것
forwardRef()와 @Optional()은 전혀 다른 문제를 해결한다. 순환 참조 문제가 발생했을 때 @Optional()로 해결하려고 하면 의존성이 아예 undefined로 들어와서 더 큰 문제가 생긴다.
// 순환 참조 해결은 forwardRef
@Injectable()
export class CatService {
constructor(
@Inject(forwardRef(() => DogService))
private readonly dogService: DogService,
) {}
}
3. 테스트에서의 활용
테스트 환경에서 불필요한 의존성을 제거하고 싶을 때 @Optional()이 유용하다. 하지만 이보다는 Test.createTestingModule()에서 mock을 제공하는 게 더 명확하다.
const module = await Test.createTestingModule({
providers: [
NotificationService,
// SLACK_CLIENT를 제공하지 않음 → @Optional()이므로 undefined
],
}).compile();
이렇게 하면 Slack 연동 없이 NotificationService의 나머지 로직만 테스트할 수 있다.
정리
| 방식 | 사용 시점 | 특징 |
|---|---|---|
@Optional() 데코레이터 | 생성자 주입 | 가장 간단, 없으면 undefined |
inject: [{ token, optional: true }] | useFactory | 팩토리 함수에서 조건 분기 가능 |
ModuleRef.get() + try-catch | 런타임 동적 해석 | 플러그인 시스템 등 가변적 provider |
| Null Object 패턴 | optional + 깔끔한 소비 코드 | null check 제거, 인터페이스 통일 |
@Optional()은 NestJS DI의 엄격함을 필요한 곳에서만 완화하는 도구다. 핵심은 "있으면 쓰고, 없으면 대안을 쓴다"는 전략을 명시적으로 표현하는 것이다. 조건부 모듈 등록, Null Object 패턴과 함께 쓰면 환경에 따라 유연하게 동작하는 모듈을 깔끔하게 설계할 수 있다.