의존성 주입(Dependency Injection, DI)이란

의존성 주입이란, 의존성을 클래스 밖에서 생성해서 내부로 전달하는 프로그래밍 방식이다.

의존성은 클래스 A가 클래스 B의 객체를 참조할 때, A가 B에 의존한다 또는 A가 B에 대한 의존성을 갖는다고 한다.

따라서, 의존성 주입은 어떤 클래스의 내부에서 사용되는 객체를 클래스 외부에서 생성해서 전달하는 것이다.


객체를 내부에서 생성할 때 발생하는 문제

왜 굳이 객체를 클래스 안에서 생성하지 않고 밖에서 생성하려는 걸까? 객체를 내부에서 생성하는 예시는 다음과 같다.

class Bread() {
    private val milk: Milk = SeoulMilk()
}

이러한 방식의 문제는 특정한 클래스와 강하게 결합한다는 점이다. 어떤 타입의 구현 클래스가 당장 하나 뿐이면 괜찮을 수도 있다. 하지만 여러 타입의 Milk가 존재한다면? 혹은 앞으로 Milk 타입의 다른 구현 클래스가 추가된다면?

interface Milk
class SeoulMilk() : Milk
class MaeilMilk() : Milk
class KonkukMilk() : Milk

빵집마다 사용하는 Milk가 다를 수 있으므로 Milk마다 별도의 Bread 클래스를 정의해야 할 것이다.

// 내부에서 객체를 생성하는 방식
class SeoulMilkBread() : Bread { ... ]
class MaeilMilkBread() : Bread { ... }
class KonkukMilkBread() : Bread { ... }

하지만 빵을 만들기 위한 재료(의존성)는 Milk 외에도 여러 가지가 있다. 따라서, 이러한 방식은 엄청나게 많은 클래스를 만들게 된다.

반면, 외부에서 의존성을 전달하면 하나의 Bread 클래스를 상황에 따라 재사용 할 수 있게 된다.

// 의존성 주입 (생성자 주입)
class Bread(private val milk: Milk) { 
    // ... 
}

val bread1 = Bread(SeoulMilk())
val bread2 = Bread(MaeilMilk())
val bread3 = Bread(KonkukMilk())

// 프로퍼티(setter) 주입 방식
class Bread() {
	lateinit var milk: Milk
}
val bread = Bread()
bread.milk = SeoulMilk()
bread.milk = MaeilMilk()

의존성 주입의 장점

위에서 살펴본 DI의 장점은 클래스를 재사용 할 수 있다는 것이었다. (클래스 재사용성 or 유연성)

의존할 타입의 구체 클래스의 종류가 하나임이 확실하다면, DI의 이점은 없는 것일까? 당연히 다른 장점이 있다!

1. 생성자의 변경으로부터 보호할 수 있다

class SeoulMilk(val a: T1, val b: T2, val c: T3) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(T1(), T2(), T3())
}

// 생성자 시그니쳐 변경
class SeoulMilk(val a: S1, val b: T2, val c: W1, val d: S2) : Milk
class Bread() {
    private val milk: Milk = SeoulMilk(S1(), T2(), W1(), S2())
}

구현 클래스의 생성자는 변경될 가능성이 높다. 따라서, 클래스 안에서 객체를 생성할 경우 생성자가 변경되면 생성 코드를 일일이 수정해야 한다. 사용하는(클라이언트) 클래스가 하나라면 모르겠지만, 여러 클래스에서 의존하고 있다면 매번 함께 변경해줘야 할 것이다.

반면, DI를 사용하면 객체를 생성하는 코드를 별도의 클래스에서 관리한기 때문에 생성 코드를 한 번만 변경하면 된다. DI 프레임워크를 사용하면 변경하지 않아도 되는 경우도 있다.

2. 단위 테스트가 가능하다.

단위 테스트를 할 때는 특정 시나리오에서 클래스가 잘 동작하는지 확인하기 위해 테스트 용 임시 객체를 사용한다. 그런데, 객체를 내부에서 생성하면 테스트 환경에서 만든 임시 객체를 사용할 수 없다. 따라서, 클라이언트 클래스를 인터페이스에 의존하게 하고 외부에서 구현체를 주입하도록 바꾸면, 테스트 용 객체를 전달해서 단위 테스트를 할 수 있게 된다.

3. 객체 생성 코드가 분리되어 클래스가 간결해진다 (부가적인 요소)

Bread와 Milk 예시에서는 생성 방식이 간결하지만, 실제 개발 과정에서는 객체 생성 코드가 복잡한 경우도 있다. 이럴 때, 객체의 생성과 사용 코드를 분리하면 클라이언트 클래스의 코드 간결해진다. 하지만, 이런 DI 적용을 고려할 때 장점이 이것 하나인 상황이라면, DI를 굳이 적용하지 않아도 된다고 생각한다.


장점 정리

  • 클래스를 재사용 할 수 있다. (재사용성, 유연성)
  • 단위 테스트를 할 때 훨씬 편하다 (테스트 편의성)
  • 구현 클래스의 변경으로부터 보호할 수 있다. (ex: 생성자 시그니처 변경)
  • 객체 생성 코드가 분리되어 클래스가 간결해진다 (부수적 장점)

참조

+ Recent posts