NestJS 그리고 SOLID

Jeerryy·2023년 2월 17일
1

NestJS

목록 보기
1/1
post-thumbnail

javascript 혹은 typescript를 사용하는 백엔드 개발자라면 한번쯤은 NestJS에 대해 들어봤을 것이다.
NestJS는 JS 진영의 스프링이라고도 불리는 프레임워크이다.

스프링과 마찬가지로 DI, IoC 등 좋은 객체지향 프로그래밍을 할 수 있도록 도와주고 있다.
NestJS는 스프링에 비해 자료가 많지 않고 더더욱 SOLID를 직접 적용한 예시를 찾기가 힘들어 정리해보려고 한다.

Philosophy

문서를 살펴보면 개발자가 써둔 NestJs 철학이 있는데 내용은 아래와 같다.

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front and backend applications. This has given rise to awesome projects like Angular , React and Vue , which improve developer productivity and enable the creation of fast, testable, and extensible frontend applications. However, while plenty of superb libraries, helpers, and tools exist for Node (and server-side JavaScript), none of them effectively solve the main problem of - Architecture.
Nest provides an out-of-the-box application architecture which allows developers and teams to create highly testable, scalable, loosely coupled, and easily maintainable applications. The architecture is heavily inspired by Angular.

나름대로 내용을 요약하자면 아래와 같다.

최근 Node.js 도입으로 인해 JavaScript는 backend, frontend 모두 훌륭한 웹 공통어 가 되었지만, 어느 것도 쉽고 유지보수가 가능한 Node(Server) 아키텍처에 관한 문제를 해결하지 못했다. 우리는 NestJS를 통해 이러한 아키텍처 문제를 해결한 애플리케이션 아키텍처를 제공하고 싶다.

그렇다면 과연 좋은 객체 지향 프로그래밍은 어떠한 것일까?

객체 지향 프로그래밍

    • 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된, 단위, 즉 객체
      들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다. (협력)
  • 객체 지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.

유연하고 변경이 용이하다는 것은 무슨 뜻일까?
레고 블럭 조립하듯이
키보드, 마우스 갈아 끼우듯이
컴퓨터 부품 갈아 끼우듯이
컴포넌트를 쉽고 유연하게 변경하면서 개발할 수 있는 방법
→ 다형성(Polymorphinsm)

다형성

  • 인터페이스를 구현한 객체 인스턴스를 실행 시점유연하게 변경할 수 있다.
  • 다형성의 본질을 이해하려면 협력이라는 객체 사이의 관계에서부터 시작해야 함.
  • 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.

SOLID

다형성에 대해 알아봤다면 SOLID가 무엇인지 알아보자.
SOLID는 객체지향 프로그래밍(OOP) 및 설계의 5 가지 원칙인 SRP, OCP , LSP, ISP, DIP의 앞글자를 딴 것이다.
각 요소를 한번 알아보자.

이해를 위한 코드는 NestJS 기반의 토이 프로젝트 너나들이 코드를 사용할 것이다.
너나들이는 사용자들에게 하루에 한번씩 글귀를 전송해주는 슬랙봇이다.

SRP

SRP는 Single Responsibility Principle의 약자로 단일 책임 원칙이라고 불린다.

정의
한 클래스하나의 책임만 가지고 있어야 한다.

여기서 하나의 책임이라는 개념이 굉장히 모호한데 변경을 기준을 잡고 변경이 있을 때 파급 효과가 적을 경우 단일 책임 원칙을 잘 지켰다고 볼 수 있다.

아래는 MotivationService 클래스 중 AirTable Tool에 저장된 추천한 글귀를 조회하고 저장 기능 코드 중 일부이다.

// 관리자가 승인한 글귀 조회
const modelList = await this.airtableService.searchConfirmMotivation();

if (modelList.length === 0) {
  this.logger.log('승인된 추천 글귀가 없습니다.');
  return;
}

// motivationEntityList 생성 로직
const makeEntityList = (resultList: MotivationModel[]) => {
 return resultList.map(({ contents, category }) => {
   return this.motivationRepository.create({
     contents: contents,
     category: CategoryType[category],
   });
 });
};

const entityList: Motivation[] = makeEntityList(modelList);
await this.motivationRepository.save(entityList);
this.logger.log(`추천 글귀 추가 성공 (data:${JSON.stringify(entityList)})`);
// 추가된 글귀 업데이트
await this.airtableService.updateMotivationPage(modelList);

MotivationService 클래스는 글귀만 담당하기 때문에
AirTable 내용을 조회하는 this.airtableService.searchConfirmMotivation()
AirTable 내용을 업데이트 하는 this.airtableService.updateMotivationPage() 로직이 어떻게 작동하는 지 알수도 없고 알지도 못한다.
철저하게 책임을 AirtableService 클래스로 전가한 것이다.

만일 책임이 분리되어 있지 않으면 어떻게 될까? 해당 함수의 내용을 그대로 옮겨보겠다.

// 관리자가 승인한 글귀 조회
const response = await this.airtableBase
  .table(this.SUGGEST_TABLE_NAME)
  .select({
    cellFormat: 'json',
    fields: ['글귀', '카테고리'],
    filterByFormula: 'AND({관리자 승인} = 1, {추가됨} = 0)',
  })
  .all();

const modelList = dataList.map((data) => {
  return {
    id: data.id,
    contents: data.get('글귀') as string,
    category: data.get('카테고리') as string,
  };
});

if (modelList.length === 0) {
  this.logger.log('승인된 추천 글귀가 없습니다.');
  return;
}

// motivationEntityList 생성 로직
const makeEntityList = (resultList: MotivationModel[]) => {
 return resultList.map(({ contents, category }) => {
   return this.motivationRepository.create({
     contents: contents,
     category: CategoryType[category],
   });
 });
};

const entityList: Motivation[] = makeEntityList(modeList);
await this.motivationRepository.save(entityList);
this.logger.log(`추천 글귀 추가 성공 (data:${JSON.stringify(entityList)})`);
// 추가된 글귀 업데이트
await this.airtableBase.table(this.SUGGEST_TABLE_NAME).update(id, { 추가됨: true });

이런 코드가 작성된다면 MotivationServiceAirTable를 제어하는 책임까지 맡게 될 것이다.

추가로 MotivationService 생성자를 한번 살펴 보자.

@Injectable()
export class MotivationService {
  private readonly logger: Logger = new Logger(this.constructor.name);
  constructor(
    @InjectRepository(Motivation) private motivationRepository: Repository<Motivation>,
    private readonly userService: UserService,
    private readonly holidayService: HolidayService,
    private readonly slackInteractiveService: SlackInteractiveService,
    private readonly airtableService: AirtableService,
  ) {}
...
}

뭐가 이상한지 모르겠다고 할수도 있지만 관점을 달리 해보면 어떠한 구현체를 사용할 것인지 결정하는 책임도 맡고 있는 것 아닐까?
추후 AirTable이 아닌 다른 툴을 사용한다면?이라는 생각을 해보면 좋을 것 같다.

OCP

OCP는 Open/Closed Principle의 약자로 개방-폐쇄 원칙이라고 불린다.

정의
소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
말로는 쉽게 이해 되지 않을 것이다. 열림과 닫힘은 서로 반대의 개념인데 어떻게 공존하는 것일까?

SRP 파트 마지막에서 잠깐봤던 MotivationService 생성자를 다시 보도록 하자.

@Injectable()
export class MotivationService {
  private readonly logger: Logger = new Logger(this.constructor.name);
  constructor(
    @InjectRepository(Motivation) private motivationRepository: Repository<Motivation>,
    private readonly userService: UserService,
    private readonly holidayService: HolidayService,
    private readonly slackInteractiveService: SlackInteractiveService,
    private readonly airtableService: AirtableService,
  ) {}
...
}

내가 만일 Airtable이 아닌 Notion으로 저장매체를 변경할 경우를 고려해보자. 그렇게 될 경우 해당 클래스 내 AirtableService를 사용하는 모든 코드를 변경해야 한다.

만일 저장매체를 제어하는 역할을 하는 OnlineDatabaseInterfaceService 인터페이스가 존재할 경우 이 인터페이스를 구현하는 구현체만 구현하면 되지 않을까? 그럼 코드는 아래처럼 변경할 수 있을 것이다.

@Injectable()
export class MotivationService {
  private readonly logger: Logger = new Logger(this.constructor.name);
  constructor(
    @InjectRepository(Motivation) private motivationRepository: Repository<Motivation>,
    private readonly userService: UserService,
    private readonly holidayService: HolidayService,
    private readonly slackInteractiveService: SlackInteractiveService,
    private readonly onlineDatabaseService: OnlineDatabaseInterfaceService,
  ) {}
...
}

그렇다면 Notion이 아닌 스프레드시트, NoSql, RDBMS 어떠한 저장매체로 변경한다고 하더라도 역할을 담당하는 OnlineDatabaseInterfaceService 인터페이스에 맞춰 개발할 경우 코드 변경없이 대응이 가능할 것이다.

LSP

LSP는 Liskov Substitution Principle의 약자로 리스코프 치환 원칙으로 불린다.

정의
프로그램의 객체는 프로그램의 정확성을 깨뜨리면서 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

SRP 에서 보았던 추천 글귀 저장 기능 코드에서 OCP를 지킨 코드를 보자.

// 관리자가 승인한 글귀 조회
const response = await this.openDatabaseService.searchConfirmMotivation();

if (modelList.length === 0) {
  this.logger.log('승인된 추천 글귀가 없습니다.');
  return;
}

// motivationEntityList 생성 로직
const makeEntityList = (resultList: MotivationModel[]) => {
 return resultList.map(({ contents, category }) => {
   return this.motivationRepository.create({
     contents: contents,
     category: CategoryType[category],
   });
 });
};

const entityList: Motivation[] = makeEntityList(modelList);
await this.motivationRepository.save(entityList);
this.logger.log(`추천 글귀 추가 성공 (data:${JSON.stringify(entityList)})`);
// 추가된 글귀 업데이트
await this.openDatabaseService.updateMotivationPage(modelList);

현재 추상화 된 this.openDatabaseService.searchConfirmMotivation(); 함수는 관리자가 승인한 글귀를 조회하는 기능을 한다.
하지만 구현 대상이 예를 들어 Airtable 에서 Notion으로 변경되었을 때 관리자가 승인하지 않은 글귀를 조회하도록 기능이 변경된다면 이 인터페이스를 사용하는 클라이언트 입장에서는 해당 인터페이스를 더이상 신뢰할 수 없을 것이다.

쉽게 말해 구현체가 바뀌어도 수행하는 역할이 변경되면 안된다는 원칙이다.

ISP

ISP는 Interface Segregation Principle의 약자로 인터페이스 분할 원칙으로 불린다.

정의
인터페이스가 클라이언트에서 필요하지 않은 메서드를 제공하지 않아야 한다.

OnlineDatabaseInterfaceService 인터페이스 한번 보자

export interface OnlineDatabaseInterfaceService {
  /**
   * 이스터에그 조회
   * @param name
   */
  searchEasterEgg(name: string): Promise<string>;

  /**
   * notionType 에 맞는 database 에 page 저장
   * @param date
   * @param message
   * @param category
   */
  createSuggestRecord(date: string, message: string, category: CategoryType): Promise<boolean>;

  /**
   * 관리자 승인을 받은 추천 글귀 중 추가 안된 글귀 조회
   */
  searchConfirmMotivation(): Promise<MotivationModel[]>;

  /**
   * 추가된 추천 글귀에 체크 표시
   * @param modelList
   */
  updateMotivationRecord(modelList: MotivationModel[]): Promise<boolean>;
}

OnlineDatabaseInterfaceService 인터페이스는 CRU의 메서드만 가지고 있다. 실제로 DELETE 작업을 필요로 하고 있지 않기 때문에 포함되어 있지 않다.
만일 필수적으로 DELETE 메서드를 추가한다면 이를 구현하는 클래스는 불필요한 기능을 구현하게 될 것이고 이는 곧 ISP를 위반하는 것이다.

DIP

DIP는 Dependency Inversion Principle의 약자로 의존 관계 역전 법칙으로 불린다.

정의
프로그래머는 구체화가 아닌 추상화의존해야한다.

OCP 에서 봤던 MotivationService 생성자를 다시 보자.

@Injectable()
export class MotivationService {
  private readonly logger: Logger = new Logger(this.constructor.name);
  constructor(
    @InjectRepository(Motivation) private motivationRepository: Repository<Motivation>,
    private readonly userService: UserService,
    private readonly holidayService: HolidayService,
    private readonly slackInteractiveService: SlackInteractiveService,
    private readonly airtableService: AirtableService,
  ) {}
...
}

만일 AirtableServiceOnlineDatabaseInterfaceService 인터페이스의 구현체라고 할지라도 MotivationService는 구체화에 의존하고 있는 것이다.

AirtableModule을 통해 AirtableService를 철저하게 숨기면 추상화 의존이 가능해진다.

수정 방법은 아래 코드와 같다.

airtable.module.ts

@Module({
	providers:[
		AirtableService,
		{
  			provide: OnlineDatabaseInterfaceService,
  			useExisting: AirtableService,
		};
	],
	exports: [OnlineDatabaseInterfaceService],
})
export class AirtableModule {}

Provider Option 중 useExisting option은 value에 명시된 기존 provider의 alias를 provide value로 변경해주는 option이다.

online-database-interface.module.ts

@Module({
	imports: [AirtableModule],
  exports: [AirtableModule],
})
export class OnlineDatabaseInterfaceModule {}

Module re-exporting을 이용하여 해당 모듈을 Bridge로 사용한 것이다.
ModuleB를 import한 ModuleA 가 있을 때 이 기능을 사용하면 ModuleA을 import한 ModuleC은 ModuleB을 사용할 수 있다.

motivation.module.ts

@Module({
  imports: [
    TypeOrmModule.forFeature([Motivation]),
    UserModule,
    HolidayModule,
    SlackModule,
    OnlineDatabaseInterfaceModule,
  ],
  providers: [MotivationService],
  exports: [MotivationService],
})
export class MotivationModule {}

motivation.service.ts

@Injectable()
export class MotivationService {
  private readonly logger: Logger = new Logger(this.constructor.name);
  constructor(
    @InjectRepository(Motivation) private motivationRepository: Repository<Motivation>,
    private readonly userService: UserService,
    private readonly holidayService: HolidayService,
    private readonly slackInteractiveService: SlackInteractiveService,
    @Inject(AirtableService) private readonly openDatabaseService: OpenDatabaseInterfaceService,
  ) {}
...
}

이렇게 설계할 경우 새로운 구현체인 NotionService를 만들더라도 online-database-interface.module.ts 에서 Module만 교체해주면 된다.

물론 현재 프로젝트가 완벽하게 SOLID 원칙을 만족한 건 아니지만 이번 계기로 꾸준히 리팩토링하게 될 것이고 앞으로 개발에 있어도 많은 노력을 할 것이다.

끝으로 DIP에 관련된 괜찮은 아티클이 있어 공유 해보고자 한다.
Dependency Inversion Principle - Trilon Consulting

profile
다양한 경험을 해보고자 하는 Backend-Engineer. [2023년은 내실 다지기의 해]

0개의 댓글