프로바이더는 Nest의 기본이 되는 개념이다. 이해가 쉽지 않아서 공식문서를 보며 정리하고 있는 중이다. 대부분의 기본 Nest 클래스는 서비스, 리포지토리, 팩토리, 헬퍼등 프로바이더로 분류된다. 이렇게 구분하는 방식 자체가 프로바이더(Provider)인 것 같다.
기본적으로 프로바이더의 중심 컨셉은 종속성으로 주입할 수 있다는 것이다. 주입할 수 있다는 이야기는 @Injectable()
데코레이터가 붙어있다는 것이다. 전체 애플리케이션의 중심에 있는 AppService
에 붙은 @Injectable()
은 Provider화 하기 위한 하나의 방식이다.
이를 통해 NestApp 내부의 객체는 서로 다양한 관계를 만들 수 있으며 객체의 인스턴스를 "연결"하는 기능은 대부분 Nest 런타임 시스템에 의해 짜여져서 애플리케이션으로서 작동한다는 이야기다.
import { Injectable } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
@Injectable()
export class UserService {
private users: UserDto[] = [
new UserDto('kim1', '브라이언 김'),
new UserDto('park1', '존 박'),
];
findAll() : Promise<UserDto[]> {
return new Promise((resolve) =>
setTimeout(
() => resolve(this.users),
100,
),
);
}
findOne(id: string) : UserDto | object {
const foundOne = this.users.filter(user => user.userId === id);
return foundOne.length ? foundOne[0] : { msg: 'nothing' };
}
saveUser(userDto: UserDto) : void {
this.users = [...this.users, userDto];
}
}
3 line에서 @Injectable()
을 선언해서, 이 싱글톤 객체(서비스)의 Dependency가 생성된다. Controller에서 사용할 로직은 Service 영역에서 사용할 수 있도록 짜여져 있고, 선언된 각 Method( ex) findOne, saveUser)
들은 데이터를 조작하고 생성하고 조회하거나 삭제하는(CRUD)의 역할을 담당한다.
위와 같이 서비스 싱글톤 객체를 만들면, 메모리에 떠있기만 한다. 객체가 살아나긴 했는데 어떻게 쓸 수 있게 되는지 알아보자. 따라서 Controller의 소스를 만들어보자!
import { Body, Controller, Get, Param, Post, Res } from '@nestjs/common';
import { UserDto } from './dto/user.dto';
import { UserService } from './user.service';
@Controller('user')
export class UserController {
// 의존성(Dependency) 주입
**constructor(private userService: UserService) {
this.userService = userService;
}**
@Get('list')
findAll(): Promise<UserDto[]> {
return this.userService.findAll();
}
@Get(':userId')
findOne(@Param('userId') id: string): any | object {
return this.userService.findOne(id);
}
@Post()
saveUser(@Body() userDto: UserDto): string {
this.userService.saveUser(userDto);
return Object.assign({
data: { ...userDto },
statusCode: 201,
statusMsg: `saved successfully`,
});
}
}
위의 Controller 코드를 통해서 살펴보자. Controller에는 생성자가 존재하고 해당 생성자에는 UserService
라는 타입의 userService argument를 받아 UserController 내부의 멤버 변수에 주입한다.
생성자를 통해서, 객체 생성될 때 Provider을 넣어주는 것을 아마 주입이라고 표현하는 것 같다.
@Injectable()
은 결국 주입될 수 있도록 의존성(Dependency)를 만들어주는 것이다.
코드만 떼어서 살펴보면 아래와 같다. 결국은 이렇게 하게되면 UserService라는 싱글톤 객체를 Controller에 넣어주게되서, 내부 메서드를 사용할 수 있게 된다. 넣어줄 수 있는 객체 자체를 Providers
이라고 부른다. 넣어지는 것을 의존성 주입(Dependency Injection, DI)
이라고 부른다.
constructor(private userService: UserService) {
this.userService = userService;
}
메소드나 객체의 호출 작업을 개발자가 따로 코드를 작성해서 이루어지는 것이 아니라 Nest의 경우 Application 내부의 독자적인 제어(외부)에서 결정되는 것을 의미한다. 기존 프로그래밍 언어에서 객체나 인스턴스는 new와 같은 생성 지시어를 이용해서 메모리를 할당받는 식으로 직접 수행했다. 하지만 IOC의 개념이 도입된 NestJS에서는 세부적인 설정만을 명시하고 Nest가 인스턴스 lifeCycle을 제어하도록 위임한다는 뜻이다.
정말 쉽게 말해서, 내가 직접 Service 인스턴스나, Controller 인스턴스 혹은 Module 인스턴스같은 것들을 직접 필요에따라 인스턴스를 생성하고 삭제할 필요가 없어지는 것을 의미한다. 어떤 기능을 하는지, 어떻게 주입되는지에 대한 적절한 설정만 해두면 Application이 Runtime에 직접 이를 조정하고 제어한다.
의존성 주입이라는 것은 의존적인 객체(Controller
객체에 Service
객체를 넣어서, Controller
에서 Service
의 메서드를 call
할 수 있도록 넣어서 사용)를 직접 생성하지 않고 외부에서 결정한 뒤 연결하는 것을 의미한다. DI를 내포한 코딩(@Injectable()
)을 통해서 객체 지향성을 보장하며, 모듈간의 결합도를 낮추고, IOC의 구현에 적합한 플랫폼 부품(Provider
)를 작성할 수 있게 된다.
윗 글을 정리해보자면 DI에 의해 주입된 의존성 명시에 의해 NestJS는 IOC를 지원하고 수행한다. 의존성을 가지고 주입될 수 있도록 의존 관계를 명시하고, 의존관계에 묶인 함수, 클래스, API등을 묶어서 처리할 수 있도록 해주므로 서비스의 동작을 추상적으로 연결하는 것을 쉽게 만들어 준다.고 이해했다.
스프링에서 중심개념이 튀어나왔기 때문에 스프링 개념으로 설명을 해야한다. 간단히 요약해보자면,
provide
는 key, 아래있는use...
로 쓴 것이 value로 Nest Application 내부의IoC Container
에 등록된 뒤, DI를 진행(resolve)할 수 있다.
@Injectable()
데코레이터를 발견하면 해당 클래스를 Nest IOC 컨테이너
가 관리할 수 있는 클래스로 선언
한다.Controller
에서 해당 Provider Token
에 대한 의존성을 생성자 주입(DI)과 함께 생성한다.Provider Token
을 해당 Provider
의 클래스와 연결한다.// 코드 작성시
@Module({
controllers: [UsersController],
providers: [UsersService],
})
// 실제 컴파일될 때 코드
@Module({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useClass: UsersService,
},
];
})
위와 같은 종속성 처리는 부트스트랩(main.ts > bootstrap();
)에 의해 발생한다.
NestJS의 Provider에서 좀 어렵기도하고 흥미롭기도 한 부분이 Custom Provider
이부분이다. 원하는 목적과 최적화 성능에 맞춰 개발자가 Custom Provider
를 정의하고 사용할 수 있는데 예시 코드를 작성하며 이해해 보았다.
모형 객체에 실제 구현을 대체
하는데 용이하다.
import { connection ] from './connection'; // 외부 파일의 객체
@Module({
providers: [
{
provide: UsersService, //사용할 provide
useValue: ${대체할 Provider},//실제 provide
},
],
})
export class AppModule {}
혹은 문자열 값 토큰
을 외부 파일에서 가져온 다른 객체와 연결
하는 기능을 제공할 수도 있다.
import { connection } from './connection'; //외부 파일의 connection 객체
@Module({
provide: 'CONNECTION', // 문자열 토큰
useValue: connection, //해당 토큰과 연결할 외부 파일 객체
},
],
});
export class AppModule{ }
위의 패턴을 사용해 문자열 형태의 Token으로 객체를 연결하고 싶다면, 종속성을 클래스 이름으로 선언해야 한다.
@Injectable()
export class UsersRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
동적으로 해결해야 할 필요가 있는 클래스를 결정할 때 쓸 수 있다.
(ex 추상 or 기본 클래스를 환경에 따라 다르게 넣어주고자 하는 다양한 상황에서)
const configServiceProvider = {
provider: ConfigService,
useClass:
process.env.NODE_ENV === 'development' // 환경에 따라 서비스를 정해 연결
? DevelopmentConfigService,
: ProductionConfigService,
};
@Module({
providers: [configServiceProvider],
})
export class AppModule {}
동적인 Provider 생성이 가능하다. 실제 Provider는 정의한 Factory Function의 return값으로 제공된다. Factory의 작업에 다른 Provider를 주입하는 것도 가능하다.
(하지만 이 부분은 주의할 점이 있음, 하단 메커니즘에 표시)
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider], // 팩토리에 optionProvider를 주입하기 위해 필요
};
@Module({
providers: [connectionFactory],
})
export class AppModule {}
동적으로 Provider를 생성한다는 말에서 '동적'이라는 말은 Factory Function
에서 처리된 결과로 반환받은 Provider
클래스를 주입받는 것과 동일하다. useFactory는 개발할 때 사용하는데, 위의 예시코드처럼 Database Config Service를 Database Service에 항상 주입해놓지 않고 필요시에만 저렇게 Factory를 통해 주입할 수 있다.
이렇게 사용하면 Production/dev 환경을 분리하거나 Database를 변경하면서 사용해야할 때 쉽게 Service를 적용할 수 있도록 config(환경)과 database service(비즈니스 로직)이 분리될 수 있어서 유용하다.
기존의 Provider
에 대한 별칭 설정이 가능하다. 동일한 Provider
에 대한 접근하는 방법이 별칭까지 두가지로 늘어난다.(ex 클래스 기반 Provider
와 문자열 토큰 기반 Provider
를 별칭을 통해서 분리 시킴, alias
개념임)
@Injectable() //클래스 기반 Provider
class LoggerService {
// 비즈니스 로직
}
const loggerAliasProvider = { //문자열 토큰 기반 Provider, 별칭 생성
provide: 'AliasedLoggerService',
useExisting: LoggerService, //종속성 설정
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
다른 Provider
와 동일하게 사용자 지정 Provider 또한 선언된 모듈 내부로 범위가 지정
된다. 다른 모듈에 표시하기 위해서는 Export를 통해 내보내기를 수행해야한다. 문자열 토큰을 내보낼수도 있으며, 전체 Provider 객체를 내보낼 수도 있음!
export with Token
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: ['CONNECTION'], // 지정한 토큰만을 export
})
export class AppModule {}
export with the full provider object
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: [connectionFactory], // 전체 Provider 객체를 export
})
export class AppModule {}
대충 이정도면 Providers
라는 개념을 이해하기는 쉬울 것 같다. 이전까지는 Providers:[]
라고 적혀있으면 멘붕이 왔었는데, 이제는 좀 쉽게 이해할 수 있을 것 같다. 결국엔 내부에서 사용될 객체를 나열해서 집어넣는 것이라고 보면 되겠다. 그 집어넣는 과정에서 config(context or 환경)에 따라 다르게 집어넣거나, 집어넣는 것을 다른 이름으로 집어넣거나 하는 방식에 있어서 use...
어노테이션을 활용하는 것이고... 100% 이해는 안됐지만 나름 이해하려고 노력중이다..