본문 바로가기

[Design pattern] DI (Dependency Injection) - 의존성 주입

DI - Dependency Injection

 

 

DI 와 IoC 

DI

  • Dependency Injection(의존성 주입)의 약어
  • 정의
" 프로그래밍에서는 구성요소간의 의존 관계가 소스코드 내부가 아닌

		외부의 설정파일 등을 통해 정의되게 하는 디자인 패턴 중의 하나 "
  • 의존성 주입 ? - 외부에서 의존 객체를 생성하여 넘겨주는 것을 의미

: A Class가 B Class를 의존할 때, B Object가 A를 new(생성자)로 직접 생성하지 않고 외부에서 생성한 객체를 B Object에게 넘겨주면 그것을 DI(의존성 주입)이라고 합니다

(왼쪽) A가 B를 생성 / (오른쪽) 외부에서 B를 생성 후 A에 주입

  • DI Framework : DI를 구현하기 위해서는 객체를 생성하고 넘겨주는 외부 역할

: DI Framework를 스프링에서는 컨테이너, Dagger에서는 Component와 Module이라 명칭

  • 즉, DI는 외부(IoC 컨테이너)에서 객체를 생성한 후 생성된 객체를 다른 객체에 주입하는 방식

    외부에서 생성된 객체들을 한번에 관리 할 수 있다는 장점이 존재합니다
  • DI는 이렇게 의존성이 있는 객체의 제어(생성)을 외부 Framework가 담당하면서 IoC개념을 구현

 

DI가 필요한 이유

MyClass.kt
class MyClass() {
	lateinit var anotherClass : AnotherClass

    init {
    	anotherClass = AnotherClass("테스트")
    }
}

MyClass의 프로퍼티로 anotherClass가 있습니다. AnotherClass를 내부에서 configure했으므로 MyClass는 AnotherClass에 의존성을 지니는 상태라 표현합니다, 즉 AnotherClass가 변경되면 높은 확률로 MyClass 역시 변경해야 한다는 의미가 되고, AnotherClass를 사용하는 클래스가 MyClass1, ... , MyClassN 개가 된다면 N개의 수정이 필요합니다

이런 현상을 피하기위해 DI (의존성 주입)이 필요합니다

MyClass.kt
class MyClass(anotherClass: AnotherClass) {
	var anotherClass : AnotherClass = anotherClass
}

anotherClass를 더 이상 MyClass 내부에서 configure하지 않고, 생성자의 인자로 받아서 선언하게됩니다.

이를 '의존성이 주입되었다' 혹은 '제어가 역전되었다(IoC)' 라고 표현하며, 서로 의존하던 관계를 끊어내 디커플링한 것입니다.

전과 같이 "테스트" 라는 인자로 configure된 anotherClass를 받아야 한다는 사실을 MyClass는 알 필요가 없어집니다
(서로 알 필요가 없다 = 서로 의존하지 않는다)

 

IoC ?

Inversion of Control, 제어의 역전

DI는 의존성이 있는 객체의 제어(생성)을 외부 Framework가 담당하면서 IoC개념을 구현합니다

IoC는 제어가 거꾸로 가는 개념을 의미, 쉽게 말해 외부 컨테이너가 객체를 생성 - 주입하는 경우

IoC 컨테이너에서 객체를 생성한 후 생성된 객체를 다른 객체에 주입하는 것을 의미

즉, DIIoC를 구현하는 방법 중의 하나입니다

 

DI가 필요한 이유

1. Boilerplate Code(보일러 플레이트 코드)를 줄일 수 있습니다

: 의존성 파라미터를 생성자에 작성하지 않고 주입하면서 Boilerplate Code를 줄여 유연한 프로그래밍 가능

2. Interface에 구현체를 쉽게 교체하면서 상황에 따라 적절하게 행동을 정의 가능

: 유닛 테스트 시 간결한 구현을 가능하게 만들어줌  

 

 

간단한 예제

Dagger에서 사용하는 CoffeeMaker 예제를 갖고 DI 개념을 한번 보겠습니다

구조

 1. Heater : Coffee를 내리는데 필요한 열을 가하는 Interface (isHot()으로 Heater가 뜨거운지 체크)

 2. Pump : Coffee를 내리는데 필요한 압력을 가하는 Interface

 3. CoffeeMaker : Heater와 Pump 구현체로 구성된 CoffeeMaker 클래스 

 

Heater

  Interface 인터페이스

interface Heater {

    fun on()
    fun off()

    fun isHot() : Boolean
}

  Impl : 인터페이스 구현체

class A_Heater : Heater {
	// Heater 상태(뜨거운지)를 저장하는 프로퍼티
    private var heating : Boolean = false

	// on(), Heater 켜기
    override fun on() {
        heating = true
        Log.d("coffeMaker", "A_Heater : ~~~ heating ~~~")
    }

    // off(), Heater 끄기
    override fun off() {
        heating = false
        Log.d("coffeMaker", "A_Heater : ~~~ heat Stop ~~~")
    }

	// isHot(), heater 상태체크 함수
    override fun isHot(): Boolean { return heating }
}

 

Pump

  Interface 인터페이스

interface Pump {
    fun pump()
}

  Impl : Pump 인터페이스 구현체

// 생성자 파라미터로 Heater 구현체를 전달
class A_Pump(private val heater: Heater) : Pump {
	// pump(), 압력을 가하는 메서드
    override fun pump() {
    	// heater가 작동 중일 경우에만 압력가하도록 체크
        if (heater.isHot()) {
            Log.d("coffeMaker", "A_Pump -> -> pumping -> ->")
        }
    }
}

 

CoffeeMaker

  : Heater와 Pump를 이용해서 커피를 내려주는 CoffeeMaker 클래스 

class CoffeeMaker(
    private val heater: Heater,	// 생성자 파라미터 heater
    private val pump: Pump	// 생성자 파라미터 pump
) {
	// brew(), Coffee를 내리는 메서드
    fun brew() {
        heater.on()
        pump.pump()
        Log.d("coffeMaker", "[_]P coffee! [_]P")
        heater.off()
    }
}

 

CoffeeMaker의 구조가 위와 같을 때, 사용방법을 한번 보겠습니다

DI 사용하지 않는 상태 (일반적)

DI를 사용하지 않는 일반적인 상태에서 Coffee를 내려보는 예시입니다

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var heater: Heater = A_Heater()	// Heater 인터페이스를 구현한 A_Heater객체 생성
        var pump: Pump = A_Pump(heater)	// Pump 인터페이스를 구현한 A_Pump객체 생성
        var coffeemaker: CoffeeMaker = CoffeeMaker(heater, pump) // Heater, Pump를 갖고 CoffeeMaker객체 생성
        coffeemaker.brew()	// 커피 내리는 메서드
}


위와 같이 Coffee를 내리기 위해서는 CoffeeMaker 객체가 필요한데 CoffeeMaker 객체를 구현하기 위해서는 

Heater 인터페이스 구현체인 A_Heater + Pump 인터페이스 구현체인 A_Pump이 필요합니다

그리고 코드 작성 시 A_Pump객체 생성에는 Heater가 필요하고, CoffeeMaker객체 생성에는 Heater, Pump 모두 필요하기에 코드작성 순서도 바뀌면 안됩니다

코드 작성순서 : Heater객체 생성 -> Pump객체 생성 -> CoffeeMaker 객체 생성

만약 CoffeeMaker를 다른곳에서 사용할 경우 (Activity/Fragment/ViewModel 등), 매번 위 코드를 작성해줘야 합니다

즉, Boilerplate Code(보일러플레이트 코드)가 많이 생성되게 됩니다 

 

DI를 활용한 상태

DI(의존성 주입)는 CoffeeMaker 사용자가 의존성을 모르는 상태 (어떤 HeaterPump를 사용하는지 모르는 상태)에서도 커피를 전과 같이 내릴수 있도록 만들어 줍니다

DI로 CoffeeMaker 객체를 사용자 대신 의존성 주입을 해주는 Injection 클래스를 생성합니다

class Injection {
    companion object {
        @JvmStatic
        fun provideHeater() : Heater { return A_Heater() }

        @JvmStatic
        fun providePump() : Pump { return A_Pump(provideHeater()) }

        @JvmStatic
        fun provideCoffeeMaker() : CoffeeMaker { return CoffeeMaker(provideHeater(), providePump()) }
    }
}

Injection 클래스는 DI(의존성 주입)을 해주는 역할을 하고있습니다

provideHeater() / providePump() 메서드를 통해 인터페이스 구현체를 return 해주고 있습니다 

DI를 구현한 상태에서 CoffeeMaker를 사용하는 방법은 아래와 같습니다

// 방법 1
var coffeeMaker = CoffeeMaker(Injection.provideHeater(), Injection.providePump())
coffeeMaker.brew()

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

// 방법 2
var coffeeMaker = Injection.provideCoffeeMaker()
coffeeMaker.brew()

 

Injection으로부터 Heater와 Pump를 제공받아 사용합니다. 이렇게 되면 사용자는 A_Heater를 사용해야 하는지, B_Heater를 사용해야 하는지 알 필요가 없어지게 됩니다 (의존성 분리)

단지, 그냥 Heater와 Pump를 제공받아서 사용하기만 하면됩니다

이렇게 DI를 사용하면, Heater의 종류를 A_Heater에서 B_Heater로 변경할 경우 Injection 클래스에 Heater를 return하는 로직만 변경하면 됩니다

DI가 아닌, 일반적인 방법으로 만약 N개의 컴포넌트에서 Heater 구현 로직을 작성하였다면 N개의 수정이 필요합니다

아래는 더 간단하게 CoffeeMaker를 구현해서 사용하는 예시입니다

Injection.provideCoffeeMaker().brew()

이 방법은 사용자가 heater나 pump를 아예 모르는 상태에서도 커피를 내릴수 있게 만들어 줍니다

 

이런 형태의 디자인 패턴을 DI 라고 합니다. DI를 사용하면 코드간 커플링을 감소시키며 재사용 및 유지보수를 용이하게 구현해줍니다

또한, 유닛 테스트 시에도 간결한 구현을 가능하게 만들어줍니다.

이런 DI를 쉽고 간결하게 해주는 프레임워크가 바로 Dagger입니다. 아래는 Dagger로 구현한 예시입니다

 

Dagger를 사용한 DI 활용 상태

위에서는 Injection 클래스를 생성하여 의존성 주입 정보(메서드)를 선언해서 사용했습니다.

Dagger는 이런 의존성 주입관계를 Annotation을 이용하여 설정이 가능합니다

아래 예시를 구현하고 Dagger 설명은 [ Dagger2 #1 - 개념 ] 글에서 설명하겠습니다 

 

 CoffeeMakerModule

  : @Inject 어노테이션이 붙은 Method/Field/Constructor에 외부에서 객체를 생성하고 전달할 Module

   Dagger는 @Inject 어노테이션이 붙은 곳의 TypeModule 메서드의 반환Type을 비교하고
   자동으로 의존성 주입을 해줍니다

@Module
class CoffeeMakerModule {
    @Provides
    fun provideHeater() : Heater = A_Heater()

    @Provides
    fun providePump(heater: Heater) : Pump = A_Pump(heater)

    @Provides
    fun provideCoffeeMaker(heater: Heater, pump: Pump) : CoffeeMaker = CoffeeMaker(heater, pump)
}

 AppComponent

  : Dagger의 inject() 메서드를 통해 의존성 주입 대상 컴포넌트를 설정합니다

@Component(modules = [CoffeeMakerModule::class])	// modules로 의존성 주입을 할 Module들을 설정
interface AppComponent {

    fun inject(activity: MainActivity)	// Dagger의 inject() 메서드 파라미터로 MainActivity를 받으면 의존성 주입 실행
}

 MainActivity

class MainActivity : AppCompatActivity() {

    // @Inject 어노테이션으로 의존성주입 대상 설정 (coffeeMaker 프로퍼티)
    @Inject
    lateinit var coffeeMaker: CoffeeMaker	// lateinit으로 늦은 초기화, inject() 함수가 호출 후 의존성 주입이 됩니다

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        /* @Component 애노테이션을 사용한 interface 이름에 Dagger를 붙여 클래스가 자동생성됩니다
        *   Dagger + AppComponent : 클래스명, inject() 함수로 MainActivity 파라미터 전달
        */
        DaggerAppComponent.builder().build().inject(this)

        coffeeMaker.brew()
    }
}