250x250
Notice
Recent Posts
Recent Comments
관리 메뉴

탁월함은 어떻게 나오는가?

NestJS로 적용해보는 의존성 주입(dependency injection)과 일반 컴포지션 방식의 차이점에 대해 알아보자 본문

[Snow-ball]server/객체지향

NestJS로 적용해보는 의존성 주입(dependency injection)과 일반 컴포지션 방식의 차이점에 대해 알아보자

Snow-ball 2023. 8. 10. 17:20
반응형

의존성 주입(Dependency Injection)이란?

의존성 주입이란 무엇일까? 위키백과에 의존성 주입을 검색해본다면 한줄로 요약이 가능할 것이다.

 

의존성 주입은 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉이다.

 

 

우리는 위의 한 줄에서 얻어야하는 것은 "테크닉" 이라는 부분이다. 즉, 우리가 코드를 작성할 때 사용하는 테크닉들 중 하나인 것이다. 그러면 우리는 고민을 해봐야하는 것 중 한가지는 어떤 테크닉이라는 것일까?

 

그 답은 바로 컴포지션이다. 즉, 합성(포함)이다. 쉽게말해서, 합성의 테크닉들 중 한가지라고 보면 된다. 그렇다면 역으로 생각해볼 수 있다는것은 Nestjs나 Spring boot 에서 제공하는 의존성 주입은 결국 우리가 흔하게 구현하는 new ClassName() 의 컴포지션 방식으로도 구현이 가능하다는 것을 알게 될 것이다. 

 

그렇다면, 왜 굳이 프레임워크들은 의존성 주입을 제공하는것일까? 의존성 주입은 결국 프로그램의 모듈들이 서로 결합도를 느슨하게 되도록하고 의존관계 역전 원칙(Dependency Inversion Principle)과 단일 책임 원칙(Single Responsibility Principle)을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다. 이론적인 이야기는 접어두고 일단 코드를 보면서 이야기를 해보자.

 

만약, 컴포지션에 대해서 잘 모르겠다면, IS-A(Inheritance, 상속) vs HAS-A(Composition, 포함) 관계 정리 이 글에 대해서 먼저 읽어보길 권장한다.

 

 

 


 

 

 

코드

일단 우리는 Nestjs에서 권장하는 방식으로 의존성 주입을 해보겠다. 이 예제는 철저하게 의존성 주입에 대해서만 공부하기 위해 만든 예제이기 때문에 OOP의 철학과 맞지 않는 부분이 있음을 미리 적어본다.

 

컴포지션 방식을 적용한 의존성 주입을 한 Servcie와 Repository

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Service
@Injectable()
export class AppService {
  private repository: AppRepository;
 
  constructor(param: { readonly domainName: string }) {
    this.repository = new AppRepository({
      domainName: param.domainName,
    });
  }
 
  public create(params: Board): Board {
    return this.repository.create(params);
  }
}
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Repository
@Injectable()
export class AppRepository {
  private save: Map<string, Board> = new Map<string, Board>();
 
  private readonly domainName: string;
 
  constructor(param: { readonly domainName: string }) {
    this.domainName = param.domainName;
  }
 
  public create(params: Board): Board {
    this.save.set(`board${this.domainName}-${params.id}`, {
      id: params.id,
      title: params.title,
      content: params.content,
    });
 
    return this.save.get(`board${this.domainName}-${params.id}`);
  }
}
cs

 

위의 방식을 보면 우리가 흔하게 사용하던 방식의 합성 방식이다. 이렇게 사용해도 무리없이 돌아갈 것이다. 

하지만, Nest.js에서 이 방식을 권하지 않는 이유는 3가지로 정리해볼 수 있을 것 같다.

 

첫째. 로직이 돌아갈 때 마다 new로 인스턴스를 생성해서 메모리에 생성될 것이다. (물론, 우리의 대부분의 로직은 인스턴스가 여러개 생성되도 무리가 없을 정도의 크기일 것이지만 한개만 생성되는것에 비해 비효율적이다.)

둘째. 의존 관계가 컴파일시에 이루어진다.

셋째. 결합도가 높아진다. 사실 이 부분이 제일 중요한 부분일 것이라는 생각이든다. 그럼 실제로 어떤 부분에서 결합도가 높아져서 안좋아지는지 확인을 해보자.

 

 

 

 

 

Repository의 constuctor 부분을  수정해보자!

 

수정 전

1
2
3
  constructor(param: { readonly domainName: string }) {
    this.domainName = param.domainName;
  }
cs

 

수정 후

1
2
3
  constructor(param: { readonly domainName: string; readonly url: string }) {
    this.domainName = param.domainName;
  }
cs

 

param에다가 url을 추가했다. 그러면 repository를 의존하고 있는 모듈들이 터져나가는 걸 확인 할 수 있다. 현재 예제는 Service 만 만들었기 때문에 Service만 수정해주면 되지만, test 코드등 다양하게 의존하고 있다면 모두 수정해줘야 하는 참사가 발생하게 된다.

 

 

 

 

 

하지만 Nest.js에서 권장하는 방식을 사용한다면???

 

1
2
3
4
5
6
7
8
9
// Service
@Injectable()
export class AppService {
  constructor(private repository: AppRepository) {}
 
  public create(params: Board): Board {
    return this.repository.create(params);
  }
}
cs

 

Service 모듈에서만 변경을 위의처럼 해주면 된다. 정말 이렇게 해주면 결합도가 낮아지는걸까? 위의 예시처럼 똑같이 변경을해도 오류가 터지지 않는걸 확인 할 수 있다.

 

오류가 터지지 않는 이유는 Nest.js에서 권장하는 방식을 사용한다면 실체가 아닌 타입이다. 타입이기 때문에 아무런 영향이 없는것이다.  결과를 확인해보면 개인적인 취향이 아닌 결국 생산성 및 협업 등 다양한 이유 때문이라도 Nest.js에서 권장하는 방식을 사용한다는걸 느낀다.

 

그리고 엄밀하게 수정을 하자면 interface를 사용해서 의존성 역전(Dependency Inversion)까지 해줌으로써 상위 계층(고수준 모듈)이 하위 계층(저수준 모듈)에 의존하는 전통적인 의존관계를 역전시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 그리고 추상화 되어있는 interface는 세부 사항들에 의존해서는 안된다.

 

 

 

 

interface를 사용하여 dependency injection 코드

 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
// interface
export interface AppInterface {
  readonly create: (params: {
    readonly id: number;
    readonly title: string;
    readonly content: string;
  }) => {
    readonly id: number;
    readonly title: string;
    readonly content: string;
  };
}
 
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Repository
@Injectable()
export class AppRepository implements AppInterface {
  private save: Map<string, Board> = new Map<string, Board>();
 
  private readonly domainName: string;
 
  constructor(param: { readonly domainName: string }) {
    this.domainName = param.domainName;
  }
 
  public create(params: Board): Board {
    this.save.set(`board${this.domainName}-${params.id}`, {
      id: params.id,
      title: params.title,
      content: params.content,
    });
 
    return this.save.get(`board${this.domainName}-${params.id}`);
  }
}
cs

 

1
2
3
4
5
6
7
8
9
// Service
@Injectable()
export class AppService {
  constructor(private readonly repository: AppInterface) {}
 
  public create(params: Board): Board {
    return this.repository.create(params);
  }
}
cs

 

 

위의 서비스를 보면 Interface가 들어가 있는걸 확인할 수 있다. 이렇게 된다면 추상화되어 있는 interface 내용이 수정이 되질 않는다면 service와 repository가 서로 내부의 로직을 수정한다고 영향을 주지 않게 되는것이다.

 

그렇다면 아까 repository를 사용하는것은 틀린 방법인가?? 개인적으로는 타입으로 사용했기 때문에 틀리다고 할 수는 없다고 본다. 하지만, dependency inversion이 안되어 있어서 완벽하다고 마틴 파울러가 제시한 방법에 완전히 적합하다고 할 수 없어 보인다.

 

 


 

 

 

 

적용 유형

위에서 언급했다싶이 의존성 주입은 한가지로만 제시한게 아니다. 마틴 파울러는 세 가지의 의존성 주입 패턴을 제시하였다.

첫째. 생성자 주입: 필요한 의존성을 모두 포함하는 클래스의 생성자를 만들고 그 생성자를 통해 의존성을 주입한다.

둘째. 세터(Setter)를 통한 주입: 의존성을 입력받는 세터(Setter) 메소드를 만들고 이를 통해 의존성을 주입한다.

셋째. 인터페이스(Interface)를 통한 주입: 의존성을 주입하는 함수를 포함한 인터페이스를 작성하고 이 인터페이스를 구현하도록 함으로써 실행시에 이를 통하여 의존성을 주입한다.

 

 

 

 

 

reference

* 의존성주입 - wiki

* 의존관계 역전 원칙 - wiki

* class - wiki

* Runtime (program lifecycle phase)

* Compile time - wiki

 

 

 

반응형
Comments