개발

Dependency Injection

yjs3819 2023. 3. 21. 22:23
728x90
  • Nestjs의 프로바이더는 프로바이더 컨테이너에서 프로바이더를 DI받을수있고...
  • Spring의 스프링 컨테이너에서 스프링빈을 DI받을수있고...

개발하면 평소에 자주 접하는 용어이고, 많은 개발블로그에서 다루는 주제이다.
또한, 많은 웹 프레임워크에서 DI를 지원하는데 뭐가 좋길래 지원하는걸까!?

어렵지만 직접 작성하면서 공부하면 이해가 잘 될것이다!

nodejs환경에서, DI가 뭔지 한번 알아보자.
그리고 jest 라이브러리를 이용해 테스트코드도 작성해보자.🐈

주사위 게임

  • 주사위에 값에 따라, 위치를 몇칸움직일지에 대한 게임이다.
export class RandomDice{
    private readonly _min: number;
    private readonly _max: number;

    constructor(_min: number, _max: number){
        this._min = _min;
        this._max = _max;
    }

    public getNumber(){
        return Math.floor(Math.random() * this._max) + this._min;
    }
}
import { RandomDice } from "./dice";

export class Game{
    private _locaiton = 0;
    private readonly _dice: RandomDice;

    constructor(){
        this._dice = new RandomDice(1, 7);
    }

    public move(){
        const number = this._dice.getNumber()
        if(number >= 4){
            this._locaiton += 1;
        }else{
            this._locaiton += 2;
        }
    }

    public get location(){
        return this._locaiton;
    }
}
import { Game } from "./game";

const game = new Game();

game.move()
console.log(game.location)
  • Game 객체는 RandomDice 객체를 의존하고 있고, move라는 행동을 하면, 의존하는 RandomDice 객체가 반환해주는 number에 따라 location 상태가 변경된다.

테스트코드 작성

  • Game객체는 의존하는 RandomDice 객체가 반환하는 number에 따라 move를 잘 하는지, move메서드에 대에 단위 테스트코드를 작성하고 싶다. 어떻게 해야할까?
describe('move unit test', () => {
    test('move시 location 1 증가', () => {
        //given
        const game = new Game();

        //when
        game.move();

        //then
        expect(game.location).toEqual(1);
    })

    test('move시 location 2 증가', () => {
        //given
        const game = new Game();

        //when
        game.move();

        //then
        expect(game.location).toEqual(2);
    })
})
  • test를 돌려보면, RandomDice의 getNumber는 랜덤으로 number를 반환하기에 테스트코드를 작성하기 어렵다.
  • 이는 Game객체가 의존하는 RandomDice의 객체의 의존성을 Game객체 내부에서 Game객체 본인이 생성하여 주입하기 때문이다.
  • 테스트 환경에선, Game객체 외부에서 RandomDice객체를 의존받을수 있으면 RandomDice가 어떤 number를 반환하는지 제어할수 있지 않을까?

Dependency Injection

import { RandomDice } from "./dice";

export class Game{
    private _locaiton = 0;
    private readonly _dice: RandomDice;

    constructor(_dice: RandomDice){
        this._dice = _dice;
    }

    public move(){
        const number = this._dice.getNumber()
        if(number >= 4){
            this._locaiton += 1;
        }else{
            this._locaiton += 2;
        }
    }

    public get location(){
        return this._locaiton;
    }
}
  • Game객체의 생성자를 보면, 생성자 파라미터에서 RandomDice객체의 의존성을 받도록 변경하였다. 이전처럼 생성자 내부에서 RandomDice의존성을 주입받는게 아니라.
  • Game객체의 move메서드에 대한 단위 테스트 코드는 그럼 어떻게 바뀌었을까?
describe('move unit test', () => {
    test('move시 location 1 증가', () => {
        //given
        const game = new Game(new RandomDice(4, 7));

        //when
        game.move();

        //then
        expect(game.location).toEqual(1);
    })

    test('move시 location 2 증가', () => {
        //given
        const game = new Game(new RandomDice(1, 3));

        //when
        game.move();

        //then
        expect(game.location).toEqual(2);
    })
})

(이 단위테스트는 Game객체의 move에대한 테스트이지, RandomDice가 어떤 number를 리턴하는지 검증하고싶은 테스트코드가 아님 주의)

  • Game객체는 이제 외부에서 RandomDice 객체의 의존성을 받을수 있기에, 테스트코드를 작성할 때, 원하는 RandomDice객체의 의존성을 외부 주입하여 원하는 테스트코드를 작성할수 있게되었다.
  • 이게바로 의존성 주입이다.

정리하면
Game객체는 연관관계를 가지는 RandomDice객체의 의존성을 생성자를 통해 외부에서 주입받아서 사용한다.

그리고 Game객체의 move를 실행하는 클라이언트 코드(사용하는 코드)는 이렇게 된다.

import { RandomDice } from "./dice";
import { Game } from "./game";

const game = new Game(new RandomDice(1, 3));

game.move()
console.log(game.location)
  • 근데, 같은 팀에서 이제 게임의 move는 랜덤으로 number를 반환하는 주사위가 아닌, max - min의 number를 반환하는 주사위로 변경한 뒤, 배포해달라는 요구사항을 받았다.

한번 수정해보자

export class StaticDice{
    private readonly _min: number;
    private readonly _max: number;

    constructor(_min: number, _max: number){
        this._min = _min;
        this._max = _max;
    }

    public getNumber(){
        return this._max - this._min;
    }
}
  • 새로운 객체인 StaticDice를 만들었다.
import { StaticDice } from "./dice";

export class Game{
    private _locaiton = 0;
    private readonly _dice: StaticDice;

    constructor(_dice: StaticDice){
        this._dice = _dice;
    }

    public move(){
        const number = this._dice.getNumber()
        if(number >= 4){
            this._locaiton += 1;
        }else{
            this._locaiton += 2;
        }
    }

    public get location(){
        return this._locaiton;
    }
}
  • Game객체가 의존하는 객체를 RandomDice -> StaticDice로 변경하였고
import { StaticDice } from "./dice";
import { Game } from "./game";

const game = new Game(new StaticDice(1, 3));

game.move()
console.log(game.location)
  • Game의 move를 실행하는 클라이언트 코드는 이렇게 수정하였다.
  • 그런데 팀에서, 이번엔 max + min - 5를 반환하는 주사위로 변경해달고 요청했다..
  • 위 과정을 반복해야할까?
  • 추상화된, 인터페이스인 IDice를 만들고, RandomDice, StaticDice는 해당 인터페이스를 implements하자. 그리고 추상화된 인터페이스를 이용해서 Game은 추상화된 IDice만 의존받도록 수정해보자.
import { IDice } from "./dice";

export class Game{
    private _locaiton = 0;
    private readonly _dice: IDice;

    constructor(_dice: IDice){
        this._dice = _dice;
    }

    public move(){
        const number = this._dice.getNumber()
        if(number >= 4){
            this._locaiton += 1;
        }else{
            this._locaiton += 2;
        }
    }

    public get location(){
        return this._locaiton;
    }
}
export interface IDice{
    getNumber(): number;
}

export class RandomDice implements IDice{
    private readonly _min: number;
    private readonly _max: number;

    constructor(_min: number, _max: number){
        this._min = _min;
        this._max = _max;
    }

    public getNumber(){
        return Math.floor(Math.random() * this._max) + this._min;
    }
}

export class StaticDice implements IDice{
    private readonly _min: number;
    private readonly _max: number;

    constructor(_min: number, _max: number){
        this._min = _min;
        this._max = _max;
    }

    public getNumber(){
        return this._max -this._min;
    }
}
  • 추상화를 하면, Game객체가 의존하는 Dice가 RandomDice던, StaticDice던 뭐든, 어떤 구현체간에 변경할 일이 1도 없다.
  • 실제 구현체는 외부에서 주입(의존성 주입)을 해주기 때문에, Game은 추상화된 인터페이스인 IDice만 의존하면 되기 때문이다.
  • 이게 외부에서 의존성을 주입해주는 두번째 장점이다. 의존성 주입을 이용하면, 구현체는 외부에서 지정해서 주입해주니, 추상화에만 의존하면 되고, 변경에 유연한 설계가 가능하다는 것이다.

그러나..

import { StaticDice } from "./dice";
import { Game } from "./game";

const game = new Game(new StaticDice(1, 3));

game.move()
console.log(game.location)
  • Game객체의 move를 실행하는 코드에선 실제 구현체를 입력해 줘야한다.
  • 구현체가 변경될때마다 new StaticDice(1, 3), new RandomDice(1, 7)처럼 변경을 해줘야한다.
  • 또 직접, 의존받을 객체를 new라는 키워드로 생성자에 주입해야한다.

inversify 라이브러리 적용

  • nodejs진영에서 제공하는 DI라이브러리인 inversify를 이용해서 의존성 주입할 인스턴스들을 관리해보자.

yarn add inversify

  • inversify를 이용하면, 의존성 주입할 인스턴스들을 컨테이너라는 박스에 관리하여, 원하는 의존성을 빼서 주입받을수있다.
export const INJECT_TYPES = {
    //base
    MAX: Symbol('Max'),
    MIN: Symbol('Min'),

    // instances
    DICE: Symbol('Dice'),
};
  • inversify 컨테이너 내에 관리할 인스턴스들을 지정할 Symbol을 상수로 선언한다.
import { Container } from "inversify";
import { IDice, RandomDice } from "../dice";
import { INJECT_TYPES } from "./constants";

const container = new Container();

container.bind<number>(INJECT_TYPES.MIN).toConstantValue(1)
container.bind<number>(INJECT_TYPES.MAX).toConstantValue(7)
container.bind<IDice>(INJECT_TYPES.DICE).to(RandomDice)

export default container;
  • inversify컨테이너 내에 관리할 의존성 값들 (상수도 되고, 인스턴스도된다.)을 정의한다.
import { inject, injectable } from "inversify";
import { INJECT_TYPES } from "./container/constants";

export interface IDice{
    getNumber(): number;
}

@injectable()
export class RandomDice implements IDice{
    constructor(
        @inject(INJECT_TYPES.MIN)
        private readonly _min: number, 
        @inject(INJECT_TYPES.MAX)
        private readonly _max: number
    ){}

    public getNumber(){
        return Math.floor(Math.random() * this._max) + this._min;
    }
}

@injectable()
export class StaticDice implements IDice{
    constructor(
        @inject(INJECT_TYPES.MIN)
        private readonly _min: number, 
        @inject(INJECT_TYPES.MAX)
        private readonly _max: number
    ){}

    public getNumber(){
        return this._max -this._min;
    }
}
  • 컨테이너내에 관리할 구현체들에 @injectable이라는 데코레이터를 이용해서 작성해주고 생성자에서 컨테이너에서 주입할 의존성 값은 @inject데코레이터와 의존성을 구별하기 위해 심볼에 대한 상수값을 작성해준다.
import 'reflect-metadata';

import container from "./container/container";
import { INJECT_TYPES } from "./container/constants";
import { IDice } from "./dice";
import { Game } from "./game";

const dice = container.get<IDice>(INJECT_TYPES.DICE);

const game = new Game(dice);

game.move()
console.log(game.location)
  • 그럼 이제 Game객체의 move행동을 요청하는 클라이언트 코드에선, IDice 인터페이스의 구현체가 뭐가 되던, inversify 컨테이너 내에서 IDice인터페이스를 구현한 인스턴스하나를 빼서 의존성 주입해주면된다.
  • inversify를 이용하면, 의존성 주입할 인스턴스, 상수들을 하나의 컨테이너에 관리하여 관리가 용이하고, 직접 new키워드와 함께 생성자에 넣지 않아도 되기에 코드도 깔끔해진다.
  • inversify를 이용해도, 구현체가 변경되면 컨테이너내에 의존하는 구현체는 변경해줘야 하긴 하지만, 의존성주입할 인스턴스나 상수들을 한곳에 모아 관리하기에 관리도 용이할 것이다.
  • 불필요하게 여러개의 의존성 주입할 인스턴스를 만들지 않고 하나의 객체 (싱글턴)으로 관리하기도 용이할 것이다.

inversfiy의 container가 Spring의 의존성 주입 대상인 빈을 관리하는 Spring container, NestJS의 프로바이더를 관리하는 DI container와 같은 역할을 하는군하..

정리

  • 의존성 주입이란 외부에서 연관관계를 갖는 객체의 의존성을 주입받는 것이다.
  • 의존성 주입을 하면, 1. 테스트코드 작성이 용이하고, 2. 추상화에만 의존할수 있어 구현체가 변경되더라도 수정할 필요가 없기에 변경에 유연한 설계가 가능하다.
  • 더 나아가 inversify DI 라이브러리를 이용해 보았는데, 의존성 주입할 인스턴스들을 하나의 컨테이너에 관리하여 관리가 편한걸 느꼈다.

코드 예제 깃헙 저장소

728x90