본문 바로가기

FrontEnd

의존성 역전 원칙과 NestJS(Dependency Inversion Principle with NestJS)

반응형

최근에 Remix를 써보면세 백엔드를 복습하기로 했습니다.

그 일환으로 NestJS를 공부하고 있는데요,

NestJS 창시자가 속해있으면서 풀스택 컨설팅(이라고 하고 SI라 읽는) 회사의 공식 블로그에 양질의 글이 많은걸 발견했습니다.

해당 글의 번역입니다.

https://trilon.io/blog/dependency-inversion-principle

 

Dependency Inversion Principle

This principle from SOLID is the most dependent on our Dependency Injection system. Let's look at how it works with NestJS!

trilon.io

의존성 역전 원칙(DIP)은 다음과 같이 명시합니다.
 
  1. 고수준 모듈은 저수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화(예: 인터페이스)에 의존해야 합니다.
  2. 추상화(인터페이스)는 세부 사항(구체적인 구현 클래스)에 의존해서는 안됩니다. 세부사항(구체적인 구현)은 추상화에 의존해야 합니다.
주 : 결국 구체 클래스를 임포트 하지 말고, 이 경우 중간에 인터페이스를 집어넣으란 말

SOLID의 이 원칙은 DI(Dependency Injection) 시스템과 가장 밀접하게 연결되어 있습니다.

이 패턴(DI)은 의존성 역전 원칙을 따르는 소프트웨어 조각을 동작하는 응용 프로그램으로 결합하는 데 도움이 됩니다.
따라서 하위 수준 모듈에서 상위 수준 소비자까지 특정 구현을 제공합니다.

 

NestJS는 고맙게도 우리에게 정말 좋은 DI 시스템을 제공하며,
이 기사에서는 의존성 역전 원칙을 따를 때 이 시스템을 사용하는 방법을 보여주고자 합니다.

예제

DIP를 구현하는 방법 외에도 DIP를 따르는 것이 유익한 이유를 보여주고 싶습니다. 다음 예를 살펴보겠습니다.

GitHub의 리포지토리에 대한 데이터를 분석할 애플리케이션을 만듭니다.
우리의 첫 번째 작업은 특정 기여자가 검토해야 하는 지정된 리포지토리에 대한 활성 풀 요청을 반환하는 엔드포인트를 구현하는 것입니다.

리뷰어 ID와 레포지토리 ID를 이용해, 리뷰해야 하는 Pull Request 목록 반환

import { Controller, Get, HttpModule, HttpService, Param } from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PullRequest } from 'app/model';

@Controller()
export class RepositoryStatisticsController {
  constructor(private http: HttpService) {}

  @Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
  getReviewerPendingPrs(
    @Param('repositoryId') repositoryId: string,
    @Param('reviewerId') reviewerId: string
  ): Observable<GithubPullRequest[]> {
    return this.http
      .get<{ data: GithubPullRequest[] }>(`https://api.github.com/repos/${repositoryId}/pulls`)
      .pipe(
        map(res => res.data),
        map(prs => prs.filter(pr => pr.reviewers.some(reviewer => reviewer.id === reviewerId)))
      );
  }
}
해당 HTTP 호출을 전용 서비스로 이동하는 것만으로는 구현을 상위 수준 모듈에서 적절하게 분리하는 데 충분하지 않습니다.
우리는 저수준 계층이 Github를 사용한다는 것을 여전히 확실히 알고 있습니다.

컨트롤러와 서비스 분리

import { Controller, Get, HttpService, Param } from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { GithubService } from 'app/infrastructure';

@Controller()
export class RepositoryStatisticsController {
  constructor(private githubService: GithubService) {}

  @Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
  getReviewerPendingPrs(
    @Param('repositoryId') repositoryId: string,
    @Param('reviewerId') reviewerId: string
  ): Observable<GithubPullRequest[]> {
  getReviewerPendingPrs(id: string): Observable<number> {
    return this.githubService.getReviewerPendingPrs(repositoryId, reviewerId);
  }
}
import { HttpService, Injectable } from '@nestjs/common';
import { GithubPullRequest } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class GithubService {
  constructor(private http: HttpService) {}
  getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<GithubPullRequest[]> {
    return this.http
      .get<{ data: GithubPullRequest[] }>(`https://api.github.com/repos/${id}/pulls`)
      .pipe(
        map(res => res.data),
        map(prs => prs.filter(pr => pr.reviewers.some(reviewer => reviewer.id === reviewerId)))
      );
  }
}

의존성 역전 원칙 구현

컨트롤러에서 구현을 적절하게 숨겼다고 말할 수 있으려면 추상화를 도입해야 합니다.
TypeScript를 사용하면 모든 Type을 구현할 수 있으므로 추상 클래스가 될 수 있습니다.

import { Observable } from 'rxjs';
import { PullRequest } from 'app/model';

export abstract class RepositoryDataFetcher {
  abstract getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<PullRequest[]>;
}
대안으로 인터페이스를 사용할 수 있으며, 이 경우 Injection Token으로 사용할 무언가가 필요합니다.
import { Observable } from 'rxjs';
import { PullRequest } from 'app/model';

export RepositoryDataFetcherToken = Symbol('RepositoryDataFetcher');
export interface RepositoryDataFetcher {
  getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<PullRequest[]>;
}​
왜 필요한지 궁금하다면 이 스레드(this thread)에서 자세히 읽어보세요.
참고 : here

주 : 인터페이스는 런타임에 존재하지 않으므로, 값 타입으로 사용할 수 없습니다.
변환 후 인터페이스가 더 이상 존재하지 않아 객체 값이 비어 있습니다.
문자열 키를 값으로 사용하고 데코레이터를 삽입하여 문제에 대한 해결책이 있습니다.
@Module({
    controllers: [ UsersController ],
    components: [
        { provide: 'IUserRepository', useClass: UserRepository }
    ],
})
export class UserController {
    constructor(@Inject('IUserRepository') private userService: IUserRepository) {}
}

추상화는 더 이상 리턴 타입으로 GithubPullRequest에 의존할 수 없습니다.
이것은 원칙의 두 번째와 관련이 있습니다.
PullRequest라는 추상화를 도압합니다.
이제 고수준 모듈에서 이걸 사용할 수 있습니다.
import { Controller, Get, Inject, Param } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { RepositoryDataFetcher, RepositoryDataFetcherToken } from 'app/interfaces';
import { PullRequest } from 'app/model';

@Controller()
export class RepositoryStatisticsController {
  // as an abstract class
  constructor(private repositoryDataFetcher: RepositoryDataFetcher) {}
  // or as an interface
  constructor(
    @Inject(RepositoryDataFetcherToken) private repositoryDataFetcher: RepositoryDataFetcher
  ) {}

  @Get('repository/:repositoryId/reviewer/:reviewerId/pending-prs')
  getReviewerPendingPrs(
    @Param('repositoryId') repositoryId: string,
    @Param('reviewerId') reviewerId: string
  ): Observable<PullRequest[]> {
  getReviewerPendingPrs(id: string): Observable<number> {
    return this.repositoryDataFetcher.getReviewerPendingPrs(repositoryId, reviewerId);
  }
}
물론 실제 구현도 어딘가에 존재해야 합니다.
import { HttpService, Injectable } from '@nestjs/common';
import { GithubPullRequest, GithubPullRequestMapper } from './github-pull-request';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PullRequest } from 'app/model';
import { RepositoryService } from 'app/interfaces';

@Injectable()
export class GithubRepositoryService implements RepositoryDataFetcher {
  constructor(private http: HttpService) {}

  getReviewerPendingPrs(repositoryId: string, reviewerId: string): Observable<PullRequest[]> { {
    return this.http
      .get<{ data: GithubPullRequest[] }>(`https://api.github.com/repos/${id}/pulls`)
      .pipe(
        map(res => res.data),
        map(prs => prs.filter(pr => pr.reviewers.some(reviewer => reviewer.id === reviewerId)),
        map(res => res.data.map(GithubPullRequestMapper.toModel))
      );
  }
}

의존성 주입 시스템과 결합하기

모듈은 고수준 모듈(코드)가 런타임에 제공받을 실제 구현을 지정할 수 있는 빌딩 블록입니다.
먼저, 우리는 모듈의 Provider 배열에 GithubRepositoryService를 추가해야 하지만,
우리의 구현을 추상화와 연결하는 특별한 방식으로 해야 합니다.
 

추상 클래스 사용 버전

import { HttpModule, Module } from '@nestjs/common';
import { RepositoryDataFetcher } from 'app/interfaces';
import { GithubRepositoryService } from './github-repository.service';

@Module({
  imports: [HttpModule],
  providers: [
    GithubRepositoryService,
    { provide: RepositoryDataFetcher, useExisting: GithubRepositoryService }
  ],
  exports: [RepositoryDataFetcher]
})
export class GithubInfrastructureModule {}

인터페이스 사용 버전

단일 useClass 필드를 사용할 수 도 있습니다만,
하지만 GithubRepositoryService가 두 개의 서로 다른 인터페이스가 있는 어댑터가 될 경우,
useExisting이 사용자를 보호하는 경우가 있습니다.
주 : 원치않는 데이터 경합(상태 공유) 방지를 위해서인것 같음
 
각각에 대해 Nest는 종종 원치 않는 동작일 수 있는 서비스의 다른 인스턴스를 생성하는 일을 합니다.
import { HttpModule, Module } from '@nestjs/common';
import { RepositoryDataFetcherToken } from 'app/interfaces';
import { GithubRepositoryService } from './github-repository.service';

@Module({
  imports: [HttpModule],
  providers: [
    GithubRepositoryService,
    { provide: RepositoryDataFetcherToken, useExisting: GithubRepositoryService }
  ],
  exports: [RepositoryDataFetcherToken]
})
export class GithubInfrastructureModule {}
 
마지막으로 해야 할 일은 이 모듈을 고수준 모듈의 import에 넣는 것입니다.
import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { GithubInfrastructureModule } from 'app/infrastructure-github';

@Module({ imports: [GithubInfrastructureModule], controllers: [RepositoryStatisticsController] })
export class RepositoryStatisticsModule {}

다양한 저수준 모듈을 사용할 수 있는 유연성

애플리케이션의 요구 사항이 변경되었다고 가정해 보겠습니다.
Github 외에도, Bitbucket에 저장된 리포지토리를 전용 애플리케이션 인스턴스로 지원해야 합니다.
더 일찍 추가하지 않았다면 이제 필요한 데이터에 대한 적절한 HTTP 호출을 준비하기 위해
서비스 및 컨트롤러에 많은 조건을 추가해야 합니다.
 
우아하게 숨겨진 데이터 소스 레이어를 사용하여 Bitbucket 서비스를 위한 전용 모듈을 만들고
다음과 같이 기능 모델을 적절하게 가져올 수 있습니다.
 
이를 수행하는 방법은 다양하며 개인 취향에 따라 원하는 것을 선택할 수 있습니다.

Hexagonal Architecture를 따를 때 인프라 모듈과 완전히 독립적인 고수준 모듈을 만들고 싶을 수 있습니다.
그런 다음 인프라 모듈을 동적(dynamic)으로 만드는 것을 고려할 수 있습니다.

환경 변수에 따라 다른 인프라 모듈 임포트하기

모듈 import를 구체적인 모듈 임포트에서 로직을 포함한 import로 변경

import { DynamicModule, Module, ModuleMetadata } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';

@Module({
  controllers: [RepositoryStatisticsController]
})
export class RepositoryStatisticsModule {
 static withInfrastructure(
   infrastructure: ModuleMetadata['imports']
 ): DynamicModule {
   infrastructure = infrastructure ?? [];
   return {
     module: RepositoryStatisticsModule,
     imports: [...infrastructure],
   };
 }
}
import { Module } from '@nestjs/common';
import { RepositoryStatisticsModule } from './repository-statistics.module';
import { GithubInfrastructureModule } from 'app/infrastructure-github';
import { BitbucketInfrastructureModule } from 'app/infrastructure-bitbucket';

const infrastructure =
  process.env.provider === 'BITBUCKET'
    ? [BitbucketInfrastructureModule]
    : [GithubInfrastructureModule];

@Module({
  imports: [RepositoryStatisticsModule.withInfrastructure(infrastructure)],
})
export class AppModule {}
이 정도 수준의 독립성이 필요하지 않을 것으로 예상되는 경우 해당 조건을 모듈(@Module) 내부에 넣을 수 있습니다.
(로직을 인라인으로 하여 별도 로직으로 추출하는 것보다 코드를 단순화함, 대신 로직과 import의 독립성은 낮아짐)
import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { GithubInfrastructureModule } from 'app/infrastructure-github';
import { BitbucketInfrastructureModule } from 'app/infrastructure-bitbucket';

@Module({
  imports: [
    ...(process.env.provider === 'BITBUCKET'
      ? [BitbucketInfrastructureModule]
      : [GithubInfrastructureModule]),
  ],
  controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {}
또는 자체적으로 적절한 인프라를 리졸브하는 전용 모듈에 위임합니다.
주 : 구체적인 모듈을 가져오는 로직을 다른 모듈의 정적 메서드로 빼냄
상위 모듈이 아닌 하위 모듈에서 처리
개인적으로 이 패턴이 제일 낫다고 봄
import { Module } from '@nestjs/common';
import { RepositoryStatisticsController } from './repository-statistics.controller';
import { RepositoryStatisticsInfrastructureModule } from './repository-statistics-infrastructure.module';

@Module({
  imports: [RepositoryStatisticsInfrastructureModule.register()],
  controllers: [RepositoryStatisticsController],
})
export class RepositoryStatisticsModule {}

언제 DIP를 사용하나요?

의존성 역전은 SOLID 원칙 중 하나입니다.
우리는 그것을 확실히 베스트 프랙티스로 생각할 수 있습니다.
그러나 나는 당신이 항상 특정한 규칙을 따라야 한다는 생각에 반대합니다.
의존성 역전 원칙과 함께 제공되는 몇 가지 다른 이점을 강조하겠습니다.
그런 다음 프로젝트에 이를 적용할지 여부를 결정할 수 있습니다.

옵션 열어두기(확장성)

Bob 삼촌의 Clean Architecture 책 인용:
"소프트웨어를 유연하게 유지하는 방법은 가능한 한 많은 옵션을 최대한 오랫동안 열어 두는 것입니다. 열어 두어야 할 옵션은 무엇입니까? 세부 사항은 중요하지 않습니다."
인터페이스와 추상화를 사용하여 클래스가 서로 통신하는 방법을 정의할 때
구현 세부 사항을 열린 옵션으로 남겨둡니다.
이를 통해 더 많은 실험을 실행하고 원하는 것을 달성하기 위해 다양한 기본 전략을 시도할 수 있습니다.

테스팅

테스트를 지원하기 위해 많은 모범 사례가 개발되었습니다.
애플리케이션이 안정적이고 런타임에 항상 동일한 서비스를 사용하더라도
단위 테스트는 항상 사용하는 것과 다른 구현을 제공하는 것을 고려할 때의 또 다른 경우입니다.
 
테스트 중인 서비스의 종속성을 흉내내는 데 도움이 되는 멋진 라이브러리가 많이 있다는 것을 알고 있습니다.
그러나 모든 문제에 대해 라이브러리를 사용하고 싶지 않다면
이러한 종속성의 테스트 버전을 제공하는 훨씬 간단한 방법입니다.
간단한 추상화를 일치시키기만 하면 됩니다.

업무 나누기

팀에 데이터베이스나 다른 기술 마스터가 있습니까?
아니면 당신이 비즈니스 로직 전문가이며, 지금 Github의 API를 공부하는 데 지식과 시간을 낭비하고 싶지 않습니까?
이는 합리적입니다!
역할을 수행하고 필요한 인터페이스를 공유하고 테스트를 추가하고 병합하기만 하면 됩니다.
 
이제 당신은 인터페이스를 이용해 미래의(다음) 기능을 사용할 수 있으며 동료가 인터페이스 구현을 제공할 것입니다.
 

정리

IT 산업과 소프트웨어 개발은 ​​오랫동안 시장에 존재해 왔습니다.
이 기간 동안 현명한 사람들은 우리가 일하는 동안 직면할 수 있는 일반적인 문제를 피하는 데 도움이 되는 패턴을 이미 찾았습니다.
오늘날 우리의 역할은 우리가 사용하는 새로운 도구와 함께 이러한 패턴을 능숙하게 사용하는 것입니다.
SOLID 원칙은 객체 지향 프로그래밍의 기본 패턴 중 하나이며
NestJS는 프로젝트에서 SOLID 원칙을 사용할 수 있도록 지원합니다.

 

반응형