icon

의존성 주입과 IoC 컨테이너


 NestJS의 의존성 주입(DI) 시스템은 애플리케이션의 유연성, 테스트 용이성, 그리고 모듈성을 크게 향상시키는 핵심 기능입니다.

의존성 주입의 작동 원리

 NestJS의 DI 시스템은 클래스의 생성자를 통해 의존성을 주입합니다.

 이는 다음과 같은 과정으로 이루어집니다.

  1. 클래스에 @Injectable() 데코레이터를 사용하여 주입 가능한 서비스임을 선언합니다.
  2. 모듈의 providers 배열에 해당 서비스를 등록합니다.
  3. 컨트롤러나 다른 서비스의 생성자에서 주입받을 의존성을 선언합니다.

 예시

@Injectable()
class UserService {
  // ...
}
 
@Module({
  providers: [UserService],
  // ...
})
class AppModule {}
 
@Controller('users')
class UserController {
  constructor(private userService: UserService) {}
  // ...
}

 이 방식은 느슨한 결합을 가능하게 하여 코드의 재사용성과 테스트 용이성을 높입니다.

IoC 컨테이너

 IoC(Inversion of Control) 컨테이너는 객체의 생성과 생명주기를 관리합니다.

 NestJS에서 IoC 컨테이너는 다음과 같은 역할을 수행합니다.

  1. 의존성 해결 : 클래스가 필요로 하는 의존성을 자동으로 주입합니다.
  2. 객체 생명주기 관리 : 싱글톤, 요청 범위, 임시 인스턴스 등을 관리합니다.
  3. 순환 의존성 해결 : 순환 참조를 감지하고 해결합니다.

프로바이더의 다양한 형태

 NestJS는 다양한 형태의 프로바이더를 지원합니다.

  1. 클래스 프로바이더
@Injectable()
class UserService {}
 
// 모듈에서:
providers: [UserService]
  1. 값 프로바이더
const CONFIG = {
  apiUrl: 'https://api.example.com'
};
 
// 모듈에서:
providers: [
  {
    provide: 'CONFIG',
    useValue: CONFIG
  }
]
  1. 팩토리 프로바이더
const databaseFactory = {
  provide: 'DATABASE',
  useFactory: (config: ConfigService) => {
    return new Database(config.getDatabaseUrl());
  },
  inject: [ConfigService]
};
 
// 모듈에서:
providers: [databaseFactory]

스코프(Scope)

 NestJS는 세 가지 스코프를 제공합니다.

  1. Default (싱글톤) : 애플리케이션 전체에서 하나의 인스턴스만 사용합니다.
  2. Request : 각 요청마다 새로운 인스턴스를 생성합니다.
  3. Transient : 주입될 때마다 새로운 인스턴스를 생성합니다.

 사용 예

@Injectable({ scope: Scope.REQUEST })
class LoggingService {
  // ...
}

 각 스코프는 다음과 같은 상황에 적합합니다.

  • Default : 상태를 공유해야 하는 서비스
  • Request : 요청별로 고유한 상태가 필요한 서비스
  • Transient : 매번 새로운 인스턴스가 필요한 경우 (예 : 유틸리티 클래스)

순환 의존성 해결

 순환 의존성은 두 클래스가 서로를 의존할 때 발생합니다.

 NestJS에서는 이를 @forwardRef() 데코레이터로 해결할 수 있습니다.

@Injectable()
export class ServiceA {
  constructor(
    @Inject(forwardRef(() => ServiceB))
    private serviceB: ServiceB
  ) {}
}
 
@Injectable()
export class ServiceB {
  constructor(
    @Inject(forwardRef(() => ServiceA))
    private serviceA: ServiceA
  ) {}
}

 하지만 가능한 순환 의존성을 피하는 것이 좋습니다.

 대신 공통 서비스를 만들거나 이벤트 기반 통신을 사용하는 것을 고려해볼 수 있습니다.

모듈 간 프로바이더 공유

 모듈에서 프로바이더를 공유하려면 exports 배열에 해당 프로바이더를 추가해야 합니다.

@Module({
  providers: [UserService],
  exports: [UserService]
})
export class UserModule {}

 전역 모듈을 사용할 때는 주의가 필요합니다.

 과도한 사용은 모듈 간 결합도를 높일 수 있습니다.

동적 프로바이더와 비동기 프로바이더

 동적 프로바이더는 런타임에 프로바이더를 구성할 수 있게 해줍니다.

@Module({
  providers: [
    {
      provide: 'DYNAMIC_CONFIG',
      useFactory: () => {
        return process.env.NODE_ENV === 'development'
          ? DevConfig
          : ProdConfig;
      }
    }
  ]
})
export class ConfigModule {}

 비동기 프로바이더는 비동기 작업이 완료된 후 프로바이더를 초기화합니다.

{
  provide: 'ASYNC_CONNECTION',
  useFactory: async () => {
    const connection = await createConnection(options);
    return connection;
  }
}

Best Practices와 주의사항

  1. 단일 책임 원칙 준수 : 각 서비스는 명확히 정의된 하나의 책임을 가져야 합니다.
  2. 인터페이스 기반 프로그래밍 : 구체적인 구현보다는 인터페이스에 의존하도록 설계합니다.
  3. 의존성 최소화 : 필요한 의존성만 주입받아 사용합니다.
  4. 테스트 용이성 고려 : 의존성 주입을 활용하여 단위 테스트를 쉽게 작성할 수 있도록 합니다.
  5. 순환 의존성 주의 : 가능한 순환 의존성을 피하고, 불가피한 경우에만 @forwardRef()를 사용합니다.
  6. 적절한 스코프 선택 : 각 서비스의 특성에 맞는 적절한 스코프를 선택합니다.
  7. 모듈 경계 존중 : 불필요하게 프로바이더를 전역으로 만들지 않습니다.
  8. 팩토리 함수 활용 : 복잡한 초기화 로직이 필요한 경우 팩토리 함수를 사용합니다.
  9. 환경별 구성 관리 : 동적 프로바이더를 활용하여 환경별로 다른 구성을 제공합니다.
  10. 문서화 : 복잡한 의존성 구조는 문서화하여 팀원들의 이해를 돕습니다.

 NestJS의 의존성 주입 시스템은 강력하고 유연한 애플리케이션 구조를 가능하게 합니다.

 개발자는 의존성 주입의 기본 원리를 이해하고, NestJS가 제공하는 다양한 기능을 적절히 활용해야 합니다.

 특히, 프로바이더의 다양한 형태와 스코프를 이해하고 상황에 맞게 사용하는 것이 중요합니다.

 또한 순환 의존성과 같은 잠재적인 문제를 인식하고 이를 해결하기 위한 전략을 숙지해야 합니다.

 모듈 간 프로바이더 공유와 전역 모듈 사용에 있어서는 신중한 접근이 필요하며, 동적 프로바이더와 비동기 프로바이더를 활용하여 더욱 유연한 애플리케이션 구조를 만들 수 있습니다.