1. DI(Dependency Injection)란?

의존성 관리를 위해 사용되는 디자인 패턴입니다.
DI의 핵심 아이디어는 “제어의 역전(Inversion of Control)“입니다.
일반적으로 객체는 자신이 사용할 의존성을 직접 생성하거나 찾아내는 책임을 갖고 있습니다.
하지만 DI에서는 객체는 의존성을 요청하기 위해 컨테이너 또는 프레임워크에 의존합니다.
이렇게 함으로써 객체는 자신이 사용할 의존성을 외부에서 제공받게 되고, 의존성의 생성과 관리는 외부에 위임됩니다.

DI를 통해 객체 간의 결합도를 낮출 수 있으며, 유닛 테스트 작성과 코드 재사용을 쉽게 할 수 있습니다. 또한 DI 컨테이너를 사용하면 의존성의 라이프사이클 관리와 의존성 간의 관계 설정을 자동화할 수 있습니다. DI 프레임워크로는 Spring, Nest 등이 있습니다.

좋은 DI 설계는 모듈화와 확장성을 개선하며, 코드를 더 테스트 가능하고 유지보수하기 쉽게 만들어줍니다.

2. 예시를 통해 알아보는 DI 장점.

장점?

결합도 감소, 유연성, 재사용성, 테스트 용이성

3. 예시

  • LottoNumber 가 존재한다.
  • 1~45 의 숫자를 가진다.

코드

public class LottoNumber {
    private final int value;

    public LottoNumber(int value) {
        this.value = value;
    }

    @Override
    public boolean equals ...
}

public class LottoService {

    public LottoNumber randomLotto() {
        Random random = new Random();
        int randomNumber = random.nextInt(45) + 1;
        
        return new LottoNumber(randomNumber);
    }
}

테스트 코드


@Test
void 랜덤로또() {
    LottoService lottoService = new LottoService();
    LottoNumber actaul = lottoService.randomLotto();
    LottoNumber expected = new LottoNumber(1);

    assertThat(actaul).isEqualTo(expected);
}

로또 는 랜덤으로 생성되고 있기에 높은 확율로 테스트코드가 실패될 것입니다.
이유는 테스트 하기힘든 Random 내부 의존성 때문입니다.

그렇다면, 테스트 가능하게 변경하려면 어떻게 해야할까요? 🤔
테스트 가능하기 위해 Random의 의존성을 제거해야 할것 같아요.

이를 위해 의존성 주입(Dependency Injection)을 활용하도록 변경해보겠습니다.

DI(Dependency Injection)를 위한 주입 방법에는 다양한 방식이 있습니다.

  1. 생성자 주입
    클래스의 인스턴스 생성 시점에 의존성을 명시적으로 전달하는 방식으로, 코드의 가독성과 테스트 용이성을 높일 수 있습니다.
  2. Setter 주입
    주입할 의존성이 변경될 수 있는 경우에 유연성을 제공합니다. 하지만 외부에서 주입되지 않은 경우에 대비한 기본 처리 방법을 구현해야 합니다.
  3. 필드 주입
    클래스의 필드에 @Autowired 또는 @Inject와 같은 주입 어노테이션을 사용하여 의존성을 주입합니다.
    필드 주입은 클래스의 테스트가 어려워질 수 있습니다. 의존성을 직접 주입하지 않고 필드로 선언하면, 테스트 중에 해당 필드에 적절한 모의 객체 또는 대체 구현체를 주입하기가 어렵습니다.

생성자 주입을 활용해 보겠습니다.

public class RandomNumberGenerator {

    public RandomNumberGenerator() {
    }

    public int generate() {
        Random random = new Random();
        return random.nextInt(45) + 1;
    }
}

public class LottoService {

    private final RandomNumberGenerator randomNumberGenerator;

    public LottoService(RandomNumberGenerator randomNumberGenerator) {
        this.randomNumberGenerator = randomNumberGenerator;
    }

    public LottoNumber randomLotto() {
        return new LottoNumber(randomNumberGenerator.generate());
    }
}

@Test
void 랜덤로또DI() {
    RandomNumberGenerator randomNumberGenerator = new RandomNumberGenerator() {
        @Override
        public int generate() {
            return 1;
        }
    };
    
    LottoService lottoService = new LottoService(randomNumberGenerator);
    LottoNumber actaul = lottoService.randomLotto();
    LottoNumber expected = new LottoNumber(1);

    assertThat(actaul).isEqualTo(expected);
}

RandomNumberGenerator 를 통해 Random의 의존성을 주입하였습니다. LottoServiceRandomNumberGenerator을 통해 숫자를 생성하고 있으므로, 예측가능한 테스트 작성이 가능해졌습니다.

하지만, 확장성면에서 문제가 될 것 같아요.
숫자를 생성하는 컨셉이 다르게 변경 되거나 여러방식의 숫자생성기를 사용하게 된다면 문제가 있는 구조입니다.

RandomNumberGenerator에게 전달하고자 하는 메시지는 무엇을까요?
숫자를 줘 라는 메시지를 전달하고 있습니다.

그렇다면 숫자를 줘 라는 메시지를 전달하는 방식을 추상화 해보겠습니다.

public interface NumberGenerator {
    int generate();
}

public class RandomNumberGenerator implements NumberGenerator {

    public RandomNumberGenerator() {
    }

    @Override
    public int generate() {
        Random random = new Random();
        return random.nextInt(45) + 1;
    }
}

public class LottoService {

    private final NumberGenerator numberGenerator;

    public LottoService(NumberGenerator numberGenerator) {
        this.numberGenerator = numberGenerator;
    }

    public LottoNumber randomLotto() {
        return new LottoNumber(numberGenerator.generate());
    }
}

@Test
void 랜덤로또DI() {
    NumberGenerator numberGenerator = new NumberGenerator() {
        @Override
        public int generate() {
            return 1;
        }
    };
    
    LottoService lottoService = new LottoService(numberGenerator);
    LottoNumber actaul = lottoService.randomLotto();
    LottoNumber expected = new LottoNumber(1);

    assertThat(actaul).isEqualTo(expected);
}

NumberGenerator 를 통해 RandomNumberGenerator의 의존성을 주입하였습니다. LottoServiceNumberGenerator을 통해 숫자를 생성하고 있으므로, 예측가능한 테스트 작성이 가능해졌습니다.

하지만, 이러한 구조는 너무 복잡하지 않나요? 자바 람다 방식으로 변경한다면 훨씬 간단하게 표현 할 수 있습니다.

As-Is

NumberGenerator numberGenerator = new NumberGenerator() {
    @Override
    public int generate() {
        return 1;
    }
};

LottoService lottoService = new LottoService(numberGenerator);

To-Be

@Test
void 랜덤로또DI() {
    LottoService lottoService = new LottoService(() -> 1);
    LottoNumber actaul = lottoService.randomLotto();
    LottoNumber expected = new LottoNumber(1);

    assertThat(actaul).isEqualTo(expected);
}

정리

이제는 테스트 코드도 간단해졌고, 숫자 생성에 대한 의존성도 추상화 되어 추후 어떠한 숫자생성기 컨셉을 사용하더라도 변경이 용이하겠네요 😃

앞서 이야기했던 결합도 감소, 유연성, 재사용성, 테스트 용이성을 다시 살펴보겠습니다.

  1. 결합도 감소
    Random에 대한 결합도가 감소했습니다.
  2. 유연성
    NumberGenerator 추상화를 통해 유연성이 생겼습니다.
  3. 재사용성
    RandomNumberGenerator 사용하고 있었다면, 숫자생성기가 변경 될경우 LottoService의 생성자를 변경하거나 다른 추가 서비스를 만들어야 될텐데 그럴필요가 없겠네요 😃
  4. 테스트 용이성
    자유롭게 랜덤에 대한 예측가능한 테스트 코드를 작성 할 수 있게 되었습니다.