본문 바로가기

node js/NestJs

[Nest Js] Nest Js 공식 문서 파헤치기 - Providers(프로바이더)

트리스티가 Nest Js를 공부하며 남긴 기록입니다. 틀린 내용은 언제든지 말씀해주세요 ~!

 


 

지난 포스팅에서는 Controller란 무엇이고, Nest js의 Controller에서는 어떤 기능을 사용할 수 있는지를 알아보았습니다. 이번 포스팅에서는 Providers가 뭔지 알아보도록 하겠습니다. 

 

Providers란 무엇일까?

 

📣 Providers란 무엇인가?

 

Providers란 어플리케이션이 제공하는 서비스 기능을 구현하고 수행하는 역할을 맡는 것으로, 지난 포스팅에서 간단하게 설명해드린 MVC 모델의 Model에 해당하는 부분입니다. 예를 들어 중고 물품 거래 플랫폼인 당근 마켓 같은 경우라면 사용자의 위치를 읽고 주변의 거래를 보여주는 서비스를 보여준다던가, 인터넷 방송 플랫폼인 Twitch Tv 같은 경우라면 현재 진행 중인 방송 목록을 보여주는 기능들이 전부 Providers가 될 수 있습니다. 물론, Providers 없이 Controller에서 이러한 비즈니스 로직을 구현할 수 있겠지만 코드가 매우 길어지기 때문에 유지 보수할 때 정말 힘들 것입니다. 그리고 객체 지향 프로그래밍의 SOLID 원칙 중 단일 책임 원칙(SRP: Single Responsibility Principle)에 부합되지 않는 형태가 되기 때문에 더더욱 Controller와 Providers는 분리되어야 합니다. 

 

 

출처: https://docs.nestjs.kr/providers

 

 

 

 

사실 저희는 이미 한번 Providers가 뭔지 간략하게 살펴보았습니다. 바로 app.service.ts가 Providers에 해당하는 부분입니다. Controller에서는 getHello() 기능을 구현하지 않고 그저 Service의 getHello() 기능을 가져다가 사용만하면 됩니다. 

 

 

Service는 Providers에 포함됩니다.

 

Providers는 service, repository, factory, helper 등 여러가지 형태로 구현할 수 있습니다. 따라서 app.service.ts 라던가 Service라고 지칭하는 녀석들은 Providers에 포함된 것이죠. 

 

 

📣 DI(Dependency Injection)란 무엇인가?

 

Nest Js 공식문서의 Providers 항목을 보시면, Providers는 의존성을 주입할 수 있다 라는 문장이 있습니다. 의존성을 주입한다는 것이 대체 무엇일까요?

 

의존성 주입(DI: Dependency Injection)이란 하나의 객체가 다른 객체의 의존성을 제공하는 기술을 말합니다. 코드의 결합도를 느슨하게 하도록하고, 객체의 생성과 사용을 분리하여 코드의 가독성과 재사용성을 높여주죠. 글로만 설명하면 저도 뭔지 모르겠으니까 코드로 봐보겠습니다.

 

의존성(Dependency)이란 간단하게 말하면 하나의 객체에서 다른 객체를 가져다 사용하는 것을 의미합니다. 예를들어  Order 클래스를 통해 원하는 음식을 주문한다고 가정해봅시다. Order 클래스는 Burger 클래스의 makeFood() 메소드를 사용하여 getFood() 메소드를 구성하였습니다. 이때, Order 클래스는 Burger 클래스와 의존 관계에 있다고 표현합니다. Order 클래스는 Burger 클래스 없이는 완성을 못하기 때문이죠!

 

class Order {
	private burger: Burger;

	getFood(): string {
		this.burger = new Burger();
		return this.burger.makeFood();
	}
}

class Burger {

	makeFood(): string {
		return '치즈버거';
	}
}

 

 

그러나 이러한 의존 관계는 한 가지 단점이 있습니다. 만약 햄버거가 아니라 국수가 먹고 싶은 경우라면 어떻게 해야할까요? 그러면 새로운 클래스를 만들고 기능을 만든 다음에 Order 클래스의 getFood() 클래스를 수정해야 합니다. 그렇지 않으면 우리는 햄버거만 먹어야 합니다 ㅠㅠ.

 

지금은 예제 코드이기 때문에 그렇게 큰 문제처럼 보이지는 않지만, 만약 음식 메뉴를 무한정 늘린다면 어떻게 될까요? 그러면 계속해서 Order 클래스의 코드가 늘어날 것이고, 중복 코드도 엄청나게 많아질 것입니다.

 

class Order {
//	private burger: Burger;

//	getFood(): string {
//		this.burger = new Burger();
//		return this.burger.makeFood();
//	}
    
	private noodle: Noodle;

	getFood(): string {
		this.noodle = new Noodle();
		return this.noodle.makeFood();
	}
}

class Noodle {

	makeFood(): string {
		return '쌀국수';
	}
}

 

 

위의 예제처럼 어떤 객체가 다른 객체에 의존성이 있을 때, 의존하는 객체에 변경이 생기거나 다른 객체를 사용해야 하는 경우가 생긴다면 관련 코드를 전부 바꿔야 합니다. 이처럼 기존 로직에 새로운 값이 추가되거나 변경될 때 코드 수정이 많은 코드는 확장성이 적은 코드라고 하고, 객체간의 의존성이 강하기 때문에 결합도가 높은 코드라고 합니다. 아직 다루지는 않았지만 테스트 코드를 작성할 때, 쓸데없이 테스트 코드가 길어진다는 단점도 있습니다. 

 

그렇다면 이러한 의존 관계 문제를 해결하면서 코드량을 줄이기 위해서는 어떻게 해야할까요? 바로 인터페이스를 만들고 그것의 구현체를 만듦으로써 해결합니다. 위의 코드를 밑의 코드처럼 외부에서 객체를 전달받도록 수정하면 중복된 코드 문제도 해결할 수 있고, 결합도를 느슨하게 만들며 확장성이 높은 코드를 만들 수 있습니다. 이처럼 필요한 객체를 직접 생성하는 것이 아니라 외부에서 객체를 전달받아 주입해줌으로써 객체 간의 결합도를 줄이고 유연한 코드를 만드는 방식을 의존성 주입(DI: Dependency Injection)이라고 합니다.

 

interface Food {
	makeFood(): string;
}

class Burger implements Food {

	makeFood(): string {
		return '치즈버거';
	}
}

class Noodle implements Food {

	makeFood(): string {
		return '쌀국수';
	}
}

class Order {
	private food: Food;

	getFood(food: Food): string {
		this.food = food;
		return this.food.makeFood();
	}
}

 

여기서 주의하셔할 점은 단순히 외부에서 객체를 전달받는 것을 의존성 주입이라고 말하지는 않는다는 점입니다.  의존성 주입을 제대로 하기 위해서는 추상화된 객체를 외부에서 받도록 해야 합니다. Food 형태가 아니라 Burger나 Noodle 형태로 의존성 주입을 받도록 한다면 말씀드렸던 문제들은 그대로 발생합니다.  

 

 

 

📣 IOC(Inversion of Control)란 무엇인가?

 

제어의 역전(IOC: Inversion of Control)이란 객체의 생성부터 소멸까지 어플리케이션이 제어권을 갖는 것이 아니라 이런 것들을 대신 관리해주는 컨테이너에게 넘기는 것을 말합니다. 즉, 개발자가 열심히 new 연산자를 사용해서 객체를 생성하고, 객체 간 의존성 맺어주는 등의 귀찮았던 작업들을 컨테이너가 대신해주는 것이죠. 이러면 개발자들은 객체 관리를 컨테이너에게 맡기고 다른 작업들에 힘을 쏟을 수 있고, 코드의 재사용성과 유지보수성을 높여주기 때문에 편하게 개발을 할 수 있습니다. 이러한 컨테이너는 프레임워크에 존재하기 때문에 프레임워크를 사용하면 알아서 객체를 관리해줍니다. 따라서 Nest Js에도 이러한 기능이 존재한답니다.

 

 

 

 

📣 Nest js DI

 

Nest Js에서 의존성 주입(DI: Dependency Injection)는 어떻게 할 수 있는지 한번 보도록 하겠습니다.  지난 포스팅에서 만들어둔 아무런 메소드도 없던 StudyService에 밑의 코드와 같이 공부 모임을 만드는 메소드와 등록된 모든 공부 모임을 찾는 메소드를 만들어 보겠습니다. 그러기 위해서는 study 형태가 필요합니까 따로 interface로 만들어 줍시다. 

 

여기서 Service 파일에는 @Injectable() 데코레이터가 붙어있는것을 볼 수 있는데요. @Injectable() 데코레이터는 해당 클래스가 Providers로 사용될 것임을 Nest js에게 알려줌으로써, Nest js IOC 컨테이너에서 관리할 수 있게 해 줍니다. 이렇게 @Injectable() 데코레이터로 만들어진 클래스는 기본적으로 Nest js 실행 시 싱글턴(Singleton) 객체로 메모리에 존재하게 됩니다.

 

싱글턴(Singleton)이란 어떤 객체에 대해 인스턴스를 단 하나만 만드는 디자인 패턴의 한 방법인데요. 만약 StudyService가 여기저기서 새로운 인스턴스로 생성된다면 하나의 인스턴스만으로도 해결할 수 있는 문제임에도 불구하고 엄청난 메모리 낭비가 발생하게 될 것입니다. 또한 StudyService 내부의 값이 공유되지 못하고 각각 다른 값을 가져버리는 문제점도 발생할 수 있습니다. 그래서 Nest Js에서는 @Injectable() 데코레이터로 만들어진 클래스는 기본적으로 싱글턴 객체로 메모리에 존재하게 됩니다.

 

// study.interface.ts

export interface Study {
	studentName: string;
	subject: string;
}
// study.service.ts

@Injectable()
export class StudyService {
	private readonly study: Study[] = [];

	createStudy(study: Study) {
		this.study.push(study);
	}

	findAll(): Study[] {
		return this.study;
	}
}

 

 

 

Service에 내용을 채워넣었으니 Controller에서 사용할 수 있게끔 해보도록 하겠습니다. StudyService를 사용할 Controller에 다음과 같이 contructor를 사용하여 생성자를 만들고, StudyService를 studyService라는 객체 멤버 변수에 할당해서 사용해줍니다. 이렇게 함으로써 StudyController에 의존성 주입(DI: Dependency Injection)을 할 수 있습니다. Nest js의 의존성 주입은 Angular에서 사용하는 형태를 참고하였다고 합니다. 

 

이렇게 constructor로 의존성을 주입 받으면 StudyController에서는 this 키워드를 사용하여 StudyService에 접근할 수 있게 됩니다.

 

@Controller('study')
export class StudyController {
	constructor(private readonly studyService: StudyService) {}
    
	...
    
	@Post('makeStudy')
	makeStudy(@Body() study: Study) {
		return this.studyService.createStudy(study);
	}

	@Get('findStudy')
	findStudy(): Study[] {
		return this.studyService.findAll();
	}
}

 

 

 

📣 Scopes

 

Providers는 기본적으로 어플리케이션의 life cycle과 같은 범위(Scopes)를 갖습니다. 어플리케이션이 실행되면 Nest Js IOC 컨테이너에 의해 의존성이 주입되고 모든 Providers들이 인스턴스화 됩니다. 어플리케이션이 종료되면 인스턴스화 되었던 Providers들은 삭제됩니다. 이러한 Providers의 범위(Scopes)를 사용자가 설정할 수 있는 방법도 존재하지만, 포스팅이 너무 길어지기 때문에 언젠가 Injection Scopes를 다룰 때 포스팅하겠습니다. 

 

 

 

📣 Custom providers & Optional providers

 

Nest Js는 IOC 컨테이너를 사용해서 Providers들의 관계를 정의하고 의존성을 관리합니다. 지금까지 설명했던 기능들 보다 더 많은 기능을 지원하지만 이 또한 이번 포스팅에서 설명하기에는 너무 길어지기 때문에, 언젠가 Custom providers를 다룰 때 포스팅하겠습니다.

 

 

 

 

 

📣 Property - based injection

지금까지 했던 의존성 주입 방식을 생성자 기반 주입방식이라고 합니다. Providers가 constructor 키워드 즉, 생성자 메소드를 통해 주입되기 때문입니다. 그런데 몇몇 상황에서는 이것과는 다른 속성 기반 주입방식을 사용할 수도 있습니다. 예를 들어 다음과 같이 부모 클래스와 자식 클래스가 있고, 부모 클래스에서는 TestServiceA를 주입받아서 사용하는 상황이라고 가정해보겠습니다. 이 중에서 StudyController에서는 ChildService만 사용할 것이고, ParentService는 어떤 클래스에서도 사용하지 않을 것이기 때문에 @Injectable 데코레이터가 필요하지 않습니다. 

 

// propertyBase.parentService.ts
// parentService를 직접 참조하는 클래스가 없기 때문에 Injectable이 없어도 됩니다.
export class ParentService {
	constructor(private readonly testServiceA: TestServiceA) {}

	testHello(): string {
		return 'hello world';
	}

	parentTest(): string {
		return this.testServiceA.testHello();
	}
}
// // propertyBase.childService.ts
@Injectable()
export class ChildService extends ParentService {
	testHello(): string {
		return this.parentTest();
	}
}
// propertyBase.testServiceA.ts
@Injectable()
export class TestServiceA {
	testHello() {
		return 'hello Test A';
	}
}​

 

 

StudyController에서 @Get() 데코레이터를 사용해서 엔드포인트를 만들었을 때 과연 결과는 어떻게 나올까요? 예상해 봤을 때는 'hello Test A'가 출력될 것 같습니다. 하지만 testHello()를 찾을 수 없다는 오류가 출력됩니다.

 

@Controller('study')
export class StudyController {
	constructor(
		private readonly studyService: StudyService,
		private readonly childService: ChildService
	) {}
    
	...
    
	// Property - based injection 예제
	@Get('propertyBase')
	propertyBaseTest(): string {
		return this.childService.testHello();
	}
    
}

 

 

이유는 바로 ChildService에 있습니다. StudyController에서 ChildService의 testHello()를 사용하기 위해 의존성 주입을 하였으나, ChildService의 부모 클래스인 ParentService는 주입할 수 있는 클래스로 설정이 안 되 있기 때문에 ParentService에서 사용하는 TestServiceA 또한 주입하지 않습니다. 따라서 오류를 해결하기 위해서는 다음과 같이 ChildService에서 super() 키워드를 사용하여 TestServiceA의 인스턴스를 전달해주어야 합니다. (여기서 멤버 변수 이름이 tsA인 이유는 testServiceA 그대로 사용하면 부모 쪽의 멤버 변수와 충돌 나기 때문입니다. ) 

 

 

@Injectable()
export class ChildService extends ParentService {
	constructor(private readonly tsA: TestServiceA) {
		super(tsA);
	}

	testHello(): string {
		return this.parentTest();
	}
}

성공!

 

 

 

그런데 이런 경우가 발생할 때마다 super() 키워드를 사용해서 문제를 해결하기는 너무 불편합니다. 이럴 때 속성 기반 주입 방식을 사용할 수 있습니다. ParentService 클래스의 constructor 부분을 @Inject() 데코레이터로 대체함으로써 속성 기반 Providers를 사용할 수 있습니다. 단! 클래스가 다른 Providers를 확장하지 않거나 상속관계에 있지 않으면 생성자 기반 주입방식을 사용하는 것이 좋습니다.

 

// propertyBase.parentService.ts

// parentService를 직접 참조하는 클래스가 없기 때문에 Injectable이 없어도 됩니다.
export class ParentService {
	// constructor(private readonly testServiceA: TestServiceA) {}
	@Inject(TestServiceA) private readonly testServiceA: TestServiceA;

	testHello(): string {
		return 'hello world';
	}

	parentTest(): string {
		return this.testServiceA.testHello();
	}
}
// // propertyBase.childService.ts
@Injectable()
export class ChildService extends ParentService {
	testHello(): string {
		return this.parentTest();
	}
}
// propertyBase.testServiceA.ts
@Injectable()
export class TestServiceA {
	testHello() {
		return 'hello Test A';
	}
}​

 

 

 

 

 

 

📣 Provider registration

 

Providers를 정의하고 Controller와 Service 간 의존 관계도 정의했으니, 해당 Service를 Nest Js에 등록하는 일만 남았습니다. 밑에 사진처럼 providers에 StudyService에 등록해주시면 Nest Js가 StudyController 클래스의 의존성을 해결해 줄 수 있습니다!

 

 

 

하지만 바깥에 있는 app.module.ts를 사용하지 않고 study.module.ts를 사용하여 보다 간단하게 설정할 수 있지 않을까요? 다음 포스팅에서는 이것과 관련한 Module 부분을 다뤄보도록 하겠습니다.

 

 

아직 사용하지 않은 StudyModule를 사용해서 좀 더 간단하게 설정할 수 있지 않을까?