개발일지

[NestJS] DI, DIP에 대해 알아보고 구현해보기 본문

NestJS, Node.js/#01 Project - 투표 커뮤니티

[NestJS] DI, DIP에 대해 알아보고 구현해보기

lyjin 2023. 3. 16.

개요

계층형 아키텍처를 개선하기 위한 방법 중 하나인 SOLID 원칙, 그중에서도 DIP에 대해 알아보고 이를 직접 프로젝트에 적용해보고자 합니다. 이전의 타입스크립트·객체지향 프로그래밍 강의와 원티드 프리온보딩 백엔드에서 배운 내용을 바탕으로 진행했습니다.

 


DI, DIP 알아보기

 

Dependency

쉽게 말해 A를 실행하는데 B가 필요하다면 "A가 B를 의존한다"라고 합니다. 다음의 예시를 보겠습니다.

 

class CoffeeMachine {
  makeCoffee() {
    //... 커피 만들기
  }
}

class CoffeeShop {
  private coffeeMachine;

  constructor() {
    this.coffeeMachine = new CoffeeMachine();
  }

  orderCoffee() {
    return this.coffeeMachine.makeCoffee();
  }
}

 

여기서 CafeLatteMachine은 CoffeeShop의 존재를 모릅니다. 반대로 CoffeeShop에서는 CafeLatteMachine의 메소드로 커피를 만들고 있습니다. 만약 커피 만드는 로직이 변경된다면 이는 CoffeeShop에도 영향을 주게 될 것입니다. 즉 CoffeeShop은 CoffeeMachine에 의존(Dependency)하고 있다고 말할 수 있습니다.


DI (Dependency Injection)

DI란 의존관계의 주입을 의미합니다.

 

class CafeLatteMachine {}

class CoffeeShop {
  private coffeeMachine;

  constructor(private cafeLatteMachine: CafeLatteMachine) {
    this.coffeeMachine = cafeLatteMachine;
  }
}

const cafeLatteMachine = new CafeLatteMachine();
const coffeeShop = new CoffeeShop(cafeLatteMachine);

 

위의 예시를 보면 CoffeeShop는 생성자로부터 의존성을 외부에서 주입받고 있습니다. 이 패턴은 NestJS에서도 흔히 볼 수 있는 패턴입니다.


만약 CafeLatteMachine가 아닌 CafeMochaMachine를 사용하고 싶다면? CoffeeShop 생성자 매개변수 타입을 수정해주면 됩니다. 하지만 프로젝트 규모가 커지고 변경사항이 많아진다면 그때마다 수정하는 건 매우 비효율적인 일입니다. 이처럼 클래스 간 강한 의존 관계를 갖는 것은 좋지 않으며 DIP에 위배되는 내용입니다.

 


DIP (Dependency inversion principle)

여기서 알아야할 원칙이 DIP입니다. DIP는 객체 지향 설계의 다섯가지 기본 원칙(SOLID) 중 하나로 다음과 같습니다.

의존성이 추상에 의존하며 구현체에는 의존하지 않는다.

 

풀어 말해서 첫째, 고수준 모듈(클래스)가 저수준 모듈(클래스)에 의존하지 말아야 하며 모두 추상화에 의존해야합니다. 둘째, 추상화는 세부사항, 즉 구현에 의존해서는 안됩니다. 구현이 변경되더라도 추상화는 변경되어선 안됩니다.


즉 계층 간의 의존성을 약하게 하기위해서는 추상에 의존해야하며 이는 인터페이스를 사용해서 구현할 수 있습니다.


인터페이스로 DIP 구현하기

interface CoffeeMachine {}

class CafeLatteMachine implements CoffeeMachine {}
class CafeMochaMachine implements CoffeeMachine {}

class CoffeeShop {
  private coffeeMachine;

  constructor(private machine: CoffeeMachine) {
    this.coffeeMachine = machine;
  }
}

const coffeeShop1 = new CoffeeShop(new CafeLatteMachine());
const coffeeShop2 = new CoffeeShop(new CafeMochaMachine());

 

CoffeeMachine 인터페이스를 하나만들고 CoffeeShop 생성자 매개변수 타입을 클래스가 아닌 인터페이스로 둡니다. CoffeeMachine 규약에 따르는 클래스라면 어떤 것이든 인수로 사용할 수 있습니다.
이렇게 CoffeeShop가 구체화된 클래스에 의존하는게 아닌 인터페이스에 의존함으로써 CafeLatteMachine 또는 CafeMochaMachine로 클래스가 변경되더라도 CoffeeShop은 그 영향을 받지 않게 되었습니다.


NestJS에서 DIP 적용해보기

이제 실제 프로젝트에 적용해보겠습니다. 처음에는 프리온보딩에서 배운 스프링 예제를 참고하여 작성했습니다.

 

// auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthServiceInterface,
  ) {}
}

// auth.service.ts
@Injectable()
export class AuthService implements AuthServiceInterface {}

 

그러나 의존성을 주입할 수 없다는 에러가 발생했습니다.

 


 

이유는 DI가 의존성을 런타임 시점에 주입시키는 기술이기 때문입니다. TypeScript 인터페이스는 컴파일 시점에만 존재하고 런타임 시점에는 사라집니다. 따라서 런타임 시 인터페이스가 사라지고 IoC 컨테이너는 의존성을 주입시켜주지 못하게 됩니다.


Provider Token

Provider 토큰을 사용해서 해결할 수 있습니다. 사용 방법은 공식문서를 참고해주세요.

 

//auth.module.ts
@Module({
  providers: [
    {
      provide: 'AUTH_SERVICE',
      useClass: AuthService,
    },
  ],
})

// auth.service.ts
@Controller('auth')
export class AuthController {
  constructor(
    @Inject('AUTH_SERVICE') private authService: AuthServiceInterface,
  ) {}
}

 

이렇게 문자열 토큰 등록 후 의존하고 있는 인터페이스에 @Inject('AUTH_SERVICE')를 달아주면 useClass에 명시된 구현체가 주입되게 됩니다.


export하고 싶다면 문자열 토큰 값 그대로 exports에 명시해주면 됩니다.

 

@Module({
  providers: [
    {
      provide: 'AUTH_SERVICE',
      useClass: AuthService,
    },
  ],
  exports: ['AUTH_SERVICE'],
})

 


느낀 점

예전에 처음 DIP를 알게됐을 때가 생각났습니다. 추상에 의존해야한다는 거 알겠고 구현 법도 알겠는데, 그래서 이게 왜 결합도를 낮추는 거고 의존이 역전된다는건 무슨 의미인지 크게 와닿지 않았었습니다. 이번에 개념을 다시 정리해보고 실제 프로젝트에 적용해보면서 그런 의문점들을 해소할 수 있었습니다. 아직은 SOLID를 실제 프로젝트에 녹여낸다는게 너무 어렵지만 이렇게 조금씩 고민해나가다보면 언젠간 무의식적으로도 따르게 되는 그런 날이 올 거라 생각합니다.

 


참고한 글

🔗https://blog.hexabrain.net/395
🔗https://rma7.tistory.com/69
🔗https://velog.io/@leeseonseonje/NestJS-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4%EB%A1%9C-DI%ED%95%98%EA%B8%B0