우리가 앞에서 작성했던 다음 코드를 기억하는가?
export class MessagesController {
messagesService: MessagesService;
constructor() {
this.messagesService = new MessagesService();
//실제 서비스를 만들때는 이런식으로 작성하면 안된다.
}
}
이러한 방식으로 의존성을 주입하는것은 매우 위험한 일이다.
사실,의존성 주입 방법을 설명하는것은 매우 쉽다. 그러나 단순하게 방법을 아는 것 이상으로 이러한 방식의 의존성 주입을 사용하면 안되는 이유를 알아야 한다.
우리가 작성한 MessagesService
는 MessagesRepository
에게 의존을 하고있다. MessageRepository
가 없다면 작동하지 않을 것이다. 이와 마찬가지로 MessagesController
또한 MessagesService
에 의존하고 있다.
근데 만약에 위의 방식으로 의존을 하고있다면, 그 클래스 자체에 의존하게 된다. 의존성이 높아진다는 것은 의존하고 있는 코드가 변경되면 그것에 의존하고 있는 코드 또한 변경하게될 것이다. 이러한 잦은 변동은 버그등을 유발할 수 있다.
이러한 의존역전
에 대한 문제가 발생할때, 인터페이스 혹은 추상클래스를 사용한다면 상대적인 안정적인 코드를 작성할 수 있다!
그렇다면 나쁜코드부터 시작해서 좋은 코드까지 한번 살펴보자.
Bad Case
나쁜 경우이다. MessagesRepository의 클래스를 직접 가져와서 새로 생성하여 주입하고 있다.
이는 직접적으로 클래스를 가져와 객체를 새로 만들기 때문에 의존성이 매우 높다. 여기에서 핵심은 MessagesRepository 그 자체의 복사본을 만든다는 것이다 !
Better Case
이번 경우는 아까보단 나은 버전이다. 직접 복사본 그자체를 받는 것이 아니라. messageRepo를 통하여 거쳐온 Repo를 받는다 ! 하지만 이것보다 더 나은 버전이 있다. 그것을 보자.
Best Case
Best Case다 그 자체의 의존성을 받으면서 MessagesRepository의 복사본을 얻는것이 아니라 새로운 인터페이스를 선언하고 그것을 이용하여 종속성을 주입하였다.
이것이 Best Case인 이유는 바로 우리가 얻으려고 하는 MessagesRepository에 정확하게 의존하는 것이 아니라는 것이다. 음 비유를 하자면 정확하게 우리가 메세지 리포지토리를 집어서 이것에 의존할거야라고 하는 것이 아닌 메세지 리포지토리와 비슷한 구현체를 생성하여 이것에 의존할거야 라고 하는 것과 같다!
이러한 방식으로 의존성 주입을 하는 것은 테스트를 하는데에 있어서도 매우 도움이 된다.
테스트를 할때 우리는 직접 메세지 리포지토리가 나와서 작동하기를 원하지 않는다. 단지 빠르게 내가 작성한 서비스코드가 맞는지가 궁금할뿐이다. 앞에서 보여준 방법처럼 가짜 저장소를 만들어서 실제와 비슷한 구현체를 가졌지만 실제는 매우 다른 이 저장소를 이용하는 것이다. 이것은 실제와 유사하게 구성되었기 때문에 동작 같지만 실제가 아니기 때문에 속도면에서 실제저장소보다 빠를것이다.
앞에서 우리는 의존성을 어떻게 주입하는지, 왜 그러한 방식을 사용해서 주입해야하는지에 대해서 이해했다.이번에는 의존성 주입을 컨테이너가 어떻게 사용하는지에 대해서 의존성 주입에 대해서 폭넓게 알아보려고 한다.
위의 표를 보면 이해가 조금 쉬울 수 있다.
여기에는 Nest Dependency Injection Container 라는 것이 있고, 작성된 코드를 보면서 그것들의 의존성을 확인하는 작업을 한다.
위의 표를 보면 MessageService는 MessagesRepo를 의존하고 있고, MessageRepo는 의존하는 것이 없다. 이러한 의존성을 Container에서 확인을 한 뒤 MessageController 인스턴스가 생성될때 미리 생성된 의존성을 가지고 작동을 하도록 컨테이너가 돕는 것이다.
이러한 것들이 왜 필요한걸까 ? 만약 우리가 의존성으로 리스트업해둔 것이 messageController에서만 사용될것이라는 보장은 없다 뭐, chattingController이라는 것을 만들었는데 거기에서 필요할 수도 있는 노릇이니까. 이럴때 새로운 messageRepository,messageService를 만들어 사용하는 것이 아니라 이미 컨테이너에서 만들어놓은 messageRepository,messageService 를 호출해서 사용하는 것이다.
Dependency Injection 컨테이너의 흐름을 다시 정리해보자
지금까지 종속성 주입 컨테이너가 어떻게 작동하고 종속성 주입이 어떻게 작동하는지 알아보았다. 이 내용을 바탕으로 지금까지 우리가 작성한 코드를 리팩토링해보려고 한다.
messages.service.ts
import { MessagesRepository } from './messages.repository';
export class MessagesService {
//messageRepo: MessagesRepository;
//public 으로 변경하면서 삭제
constructor(public messagesRepo: MessagesRepository) {
//this.messageRepo = messagesRepo;
//public 으로 변경하면서 삭제
//public으로 하면 클래스에 속성으로 자동할당된다.
}
findOne(id: string) {
//이 부분 때문에 서비스와 리포지토리를 나누는것이 이상해보일 수도 있다.
return this.messageRepo.findOne(id);
}
findAll() {
return this.messageRepo.findAll();
}
create(content: string) {
return this.messageRepo.create(content);
}
}
생성자를 자세히 보면 public으로 선언하면서 messagesRepo를 받는다. 이렇게 변경하는 부분에서 this로 그 객체를 받던 부분과 위의 선언부를 삭제할 수 있게 된다 !
왜냐 클래스에 속성으로 종속성이 자동 할당되기 때문이다.
똑같은 맥락으로 컨트롤러의 코드 또한 수정할 수 있다.
messages.controller.ts
import {
Controller,
Get,
Post,
Body,
Param,
NotFoundException,
} from '@nestjs/common';
import { createMessagesDto } from './dtos/create-message.dto';
import { MessagesService } from './messages.service';
@Controller('messages') // class decorator
export class MessagesController {
//messagesService: MessagesService;
constructor(public messagesService: MessagesService) {
//this.messagesService = new MessagesService();
//실제 서비스를 만들때는 이런식으로 작성하면 안된다.
}
@Get() //method decorator
listMessages() {
return this.messagesService.findAll();
}
@Post()
createMessages(@Body() body: createMessagesDto /**argument decorator**/) {
return this.messagesService.create(body.content);
}
//들어오는 데이터가 유효하지 않은지 확인하기 위해 pipe를 구현함
// 유효성 검사 !
@Get('/:id')
async getMessages(@Param('id') id: string) {
const message = await this.messagesService.findOne(id);
if (!message) {
throw new NotFoundException('message not found');
}
return message;
}
}
이렇게 작성한 코드를 모듈에 등록해주어야 한다. 위에서 보이는 첫번째와 두번째를 묶은 저 과정이 바로 그것이다. 모듈에 등록하기 전 데코레이터를 작성해 알려주는것이 먼저 선행되어야 하는데 어떠한 방식으로 하면 되는가?
아주 간단하다 다음 코드를 보자.
messages.service.ts
import { Injectable } from '@nestjs/common';
import { MessagesRepository } from './messages.repository';
@Injectable()
export class MessagesService {
constructor(public messagesRepo: MessagesRepository) {
}
}
이런식으로 작성한다.
messages.repository.ts
import { read } from 'fs';
import { readFile, writeFile } from 'fs/promises';
import { Injectable } from '@nestjs/common';
@Injectable()
export class MessagesRepository {
... //중략
}
Injectable이 필요한 클래스는 바로 종속성 주입으로 들어가는 클래스를 말한다. 그렇기 때문에 controller는 최종 주입을 받는 클래스이므로 Injectable을 해줄 필요가 없다.
이제 마지막 단계이다. 모듈에 종속성을 알려주자
message.module.ts
import { Module } from '@nestjs/common';
import { MessagesController } from './messages.controller';
import { MessagesService } from './messages.service';
import { MessagesRepository } from './messages.repository';
@Module({
controllers: [MessagesController], // 자동 import
providers: [MessagesService, MessagesRepository],
//thingsThatCanBeUsedAsDependenciesForOtherClasses
})
export class MessagesModule {}
여기에 providers에 어떤 종속성이 제공될껀지 작성해주면 된다.
provider를 풀어서 설명하자면, 다른클래스를 위한 종속성으로 사용되는 것 이라고 보면 된다.
우리가 앞에서 작성했던 코드는
Better case에 해당하지 Best case에 해당하지는 않는다. Interface로 우리가 사용하는 모든 종속성이 정의되는 것이 현실적으로 어렵다. 타입스크립트의 몇가지 제약으로 인해,이러한 형식으로 구현하는 것에는 어느정도 어려움이 따르지만 몇가지 트릭을 이용하여 이러한 문제를 해결할 수는 있다. 이 얘기는 추후 하려고한다!
다음은 종속성 컨테이너에 대한 이야기이다. 우리가 종속성을 주입하면 컨테이너는 거기에 대한 종속성 인스턴스 하나만을 생성한다. 만약 우리가 messageService에 대한 인스턴스를 여러개 만들었다고 생각해보자. 그러면 컨테이너는 여러개를 만들어 해당 종속성을 가지도록 하는 것이 아니라 모두에게 똑같은 하나의 인스턴스를 준다.
즉, 인스턴스는 하나만 생성이 되며 해당 인스턴스는 서로 간 공유가 된다는 것!
그러나 이러한 제약사항을 넘어 새로운 인스턴스를 만들어 사용하고 싶다면 그 또한 방법이 있긴하다! 다만 지금은 다룰 주제는 아니기 때문에 이러한 점이 있다고 생각하고 넘어가면 된다.
지금까지 우리는 의존성 주입에 대해서 알아보았다. 아마, 테스트 코드에 관심이 없다면 의존성 주입은 사실 크게 관심을 가지지 않아도 되는 주제이다. 그러나 대부분의 회사들은 테스트 코드가 필수..(!!!)이기 때문에 이번 기회를 통해서 의존성 주입과 Nest를 공부해보았다.