NestJS DI Scope·Provider 심화

NestJS DI 컨테이너의 동작 원리

NestJS의 Dependency Injection(DI)은 IoC(Inversion of Control) 컨테이너가 런타임에 의존성을 자동으로 해결하고 주입하는 패턴입니다. @Injectable() 데코레이터를 붙이면 NestJS 컨테이너에 등록되고, 생성자 파라미터의 타입 메타데이터를 기반으로 자동 주입됩니다.

이 글에서는 기본 DI를 넘어 커스텀 프로바이더 4가지 패턴, Injection Scope, 순환 참조 해결, Optional/Conditional 주입까지 심화 분석합니다.

커스텀 프로바이더 4가지 패턴

NestJS는 useClass, useValue, useFactory, useExisting 네 가지 프로바이더 패턴을 제공합니다:

// 1. useClass — 인터페이스 기반 다형성
// 환경에 따라 다른 구현체 주입
const paymentProvider = {
  provide: 'PAYMENT_SERVICE',
  useClass:
    process.env.NODE_ENV === 'production'
      ? StripePaymentService
      : MockPaymentService,
};

// 2. useValue — 상수, 설정 객체, 외부 라이브러리 인스턴스
const configProvider = {
  provide: 'APP_CONFIG',
  useValue: {
    apiVersion: 'v2',
    maxRetries: 3,
    timeout: 5000,
    features: { darkMode: true, beta: false },
  },
};

// 3. useFactory — 비동기 초기화, 조건부 생성
const databaseProvider = {
  provide: 'DATABASE_CONNECTION',
  useFactory: async (config: ConfigService): Promise<DataSource> => {
    const dataSource = new DataSource({
      type: 'postgres',
      host: config.get('DB_HOST'),
      port: config.get('DB_PORT'),
      database: config.get('DB_NAME'),
      username: config.get('DB_USER'),
      password: config.get('DB_PASS'),
    });
    return dataSource.initialize();
  },
  inject: [ConfigService], // Factory에 주입할 의존성
};

// 4. useExisting — 기존 프로바이더에 별칭 부여
const aliasProvider = {
  provide: 'LOGGER',
  useExisting: WinstonLoggerService, // WinstonLoggerService를 'LOGGER'로도 접근
};

서비스에서 커스텀 프로바이더를 주입받는 방법:

@Injectable()
export class OrderService {
  constructor(
    @Inject('PAYMENT_SERVICE') private readonly payment: PaymentService,
    @Inject('APP_CONFIG') private readonly config: AppConfig,
    @Inject('DATABASE_CONNECTION') private readonly db: DataSource,
    @Inject('LOGGER') private readonly logger: LoggerService,
  ) {}
}

Injection Scope: DEFAULT vs REQUEST vs TRANSIENT

NestJS의 Injection Scope은 프로바이더 인스턴스의 생명주기를 결정합니다:

// DEFAULT (싱글톤) — 앱 전체에서 하나의 인스턴스
@Injectable() // Scope.DEFAULT가 기본값
export class CacheService {
  private cache = new Map<string, any>();
  // 모든 요청이 같은 cache 인스턴스를 공유
}

// REQUEST — HTTP 요청마다 새 인스턴스
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  private userId: string;
  private tenantId: string;

  setContext(userId: string, tenantId: string) {
    this.userId = userId;
    this.tenantId = tenantId;
  }

  getUserId() { return this.userId; }
  getTenantId() { return this.tenantId; }
}

// TRANSIENT — 주입받을 때마다 새 인스턴스
@Injectable({ scope: Scope.TRANSIENT })
export class TransientLogger {
  private context: string;

  setContext(context: string) {
    this.context = context;
  }

  log(message: string) {
    console.log(`[${this.context}] ${message}`);
  }
}

Scope 전파 규칙: REQUEST 스코프 프로바이더를 주입받는 프로바이더도 자동으로 REQUEST 스코프가 됩니다. 이 “버블링”은 성능에 영향을 주므로 주의가 필요합니다:

// ⚠️ CacheService가 싱글톤이어도,
// RequestContextService(REQUEST)를 주입받으면 REQUEST로 전환됨
@Injectable()
export class UserService {
  constructor(
    private readonly cache: CacheService,        // 싱글톤
    private readonly context: RequestContextService, // REQUEST
  ) {}
  // → UserService도 REQUEST 스코프가 됨!
}

// ✅ 해결: REQUEST 스코프가 필요한 곳만 @Inject(REQUEST) 사용
@Injectable()
export class UserService {
  constructor(
    private readonly cache: CacheService,
    @Inject(REQUEST) private readonly request: Request,
  ) {}
}

순환 참조(Circular Dependency) 해결

A가 B를 주입받고, B도 A를 주입받는 순환 참조는 forwardRef()로 해결합니다:

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    @Inject(forwardRef(() => PostService))
    private readonly postService: PostService,
  ) {}

  async getUser(id: number) {
    const user = await this.userRepo.findOne(id);
    user.recentPosts = await this.postService.getByUser(id);
    return user;
  }
}

// post.service.ts
@Injectable()
export class PostService {
  constructor(
    @Inject(forwardRef(() => UserService))
    private readonly userService: UserService,
  ) {}

  async getPostWithAuthor(id: number) {
    const post = await this.postRepo.findOne(id);
    post.author = await this.userService.getUser(post.authorId);
    return post;
  }
}

모듈 레벨 순환 참조도 동일하게 처리합니다:

// user.module.ts
@Module({
  imports: [forwardRef(() => PostModule)],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

// post.module.ts
@Module({
  imports: [forwardRef(() => UserModule)],
  providers: [PostService],
  exports: [PostService],
})
export class PostModule {}

권장사항: 순환 참조는 설계 문제의 신호입니다. 가능하면 공통 로직을 별도 서비스로 추출하거나, 이벤트 기반 통신으로 디커플링하세요.

Optional 주입과 조건부 프로바이더

// @Optional() — 프로바이더가 없어도 에러 없이 undefined
@Injectable()
export class NotificationService {
  constructor(
    @Optional() @Inject('SLACK_CLIENT')
    private readonly slack?: SlackClient,

    @Optional() @Inject('EMAIL_CLIENT')
    private readonly email?: EmailClient,
  ) {}

  async notify(message: string) {
    if (this.slack) await this.slack.send(message);
    if (this.email) await this.email.send(message);
    if (!this.slack && !this.email) {
      console.log('No notification channel configured:', message);
    }
  }
}

// 조건부 프로바이더 등록
@Module({
  providers: [
    NotificationService,
    // 환경 변수가 있을 때만 등록
    ...(process.env.SLACK_WEBHOOK
      ? [{
          provide: 'SLACK_CLIENT',
          useFactory: () => new SlackClient(process.env.SLACK_WEBHOOK),
        }]
      : []),
  ],
})
export class NotificationModule {}

Abstract Class를 토큰으로 활용

TypeScript 인터페이스는 런타임에 존재하지 않아 DI 토큰으로 사용할 수 없습니다. 대신 추상 클래스를 사용하면 문자열 토큰 없이 타입 안전한 DI가 가능합니다:

// 추상 클래스를 인터페이스 + 토큰으로 활용
export abstract class StorageService {
  abstract upload(file: Buffer, key: string): Promise<string>;
  abstract download(key: string): Promise<Buffer>;
  abstract delete(key: string): Promise<void>;
}

// S3 구현체
@Injectable()
export class S3StorageService extends StorageService {
  async upload(file: Buffer, key: string) {
    // S3 업로드 로직
    return `https://s3.amazonaws.com/bucket/${key}`;
  }
  async download(key: string) { /* ... */ return Buffer.from(''); }
  async delete(key: string) { /* ... */ }
}

// 로컬 구현체
@Injectable()
export class LocalStorageService extends StorageService {
  async upload(file: Buffer, key: string) {
    await fs.writeFile(`/uploads/${key}`, file);
    return `/uploads/${key}`;
  }
  async download(key: string) { return fs.readFile(`/uploads/${key}`); }
  async delete(key: string) { await fs.unlink(`/uploads/${key}`); }
}

// 모듈에서 환경별 바인딩
@Module({
  providers: [
    {
      provide: StorageService,  // 추상 클래스가 토큰
      useClass: process.env.NODE_ENV === 'production'
        ? S3StorageService
        : LocalStorageService,
    },
  ],
  exports: [StorageService],
})
export class StorageModule {}

// 사용 — @Inject() 없이 타입만으로 주입!
@Injectable()
export class FileService {
  constructor(private readonly storage: StorageService) {}
}

ModuleRef: 동적 프로바이더 조회

ModuleRef를 사용하면 런타임에 동적으로 프로바이더를 조회할 수 있습니다:

@Injectable()
export class StrategyExecutor {
  constructor(private readonly moduleRef: ModuleRef) {}

  async execute(strategyName: string, data: any) {
    // 런타임에 전략 패턴 구현체를 동적 조회
    const strategy = this.moduleRef.get(strategyName, { strict: false });

    if (!strategy) {
      throw new Error(`Strategy "${strategyName}" not found`);
    }

    return strategy.execute(data);
  }

  // TRANSIENT/REQUEST 스코프 프로바이더 동적 생성
  async createScopedLogger(context: string) {
    const logger = await this.moduleRef.resolve(TransientLogger);
    logger.setContext(context);
    return logger;
    // resolve()는 호출마다 새 인스턴스 반환 (TRANSIENT)
  }
}

테스트에서의 DI 오버라이드

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        OrderService,
        // 프로바이더 오버라이드
        {
          provide: StorageService,
          useValue: {
            upload: jest.fn().mockResolvedValue('https://mock-url'),
            download: jest.fn().mockResolvedValue(Buffer.from('mock')),
            delete: jest.fn().mockResolvedValue(undefined),
          },
        },
        {
          provide: 'PAYMENT_SERVICE',
          useValue: { charge: jest.fn().mockResolvedValue({ success: true }) },
        },
      ],
    })
    .overrideProvider(ConfigService)
    .useValue({ get: jest.fn((key) => 'mock-value') })
    .compile();

    service = module.get(OrderService);
  });
});

관련 글: NestJS Dynamic Module 설계에서 모듈 수준 DI 패턴을, NestJS Testing 실전 가이드에서 모킹과 테스트 전략을 함께 확인하세요.

마무리

NestJS DI는 단순한 자동 주입을 넘어 커스텀 프로바이더로 유연한 의존성 관리, Injection Scope으로 생명주기 제어, 추상 클래스 토큰으로 타입 안전한 다형성을 구현하는 강력한 시스템입니다. Scope 전파에 의한 성능 영향과 순환 참조는 설계 단계에서 반드시 고려하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux