의존 관계를 가지는 상황에 대한 예시를 들도록 하겠습니다.
class Car {
val engine = Engine()
var isOn = false
fun toggleEngine() {
isOn = !isOn
if(isOn) engine.engineOn()
else engine.engineOff()
}
}
class Engine {
fun engineOn() {
println("Engine On")
}
fun engineOff() {
println("Engine Off")
}
}
위의 소소코드처럼 Car 클래스에서 Engine 클래스를 내부에 변수로 사용하게 됨으로써
Car 클래스는 Engine 클래스에 의존하게 되고, Engine 클래스는 Car의 종속 항목(의존성, Depedency) 이라고 합니다. 다만, 이와 같이 Depedency를 가지게 되면 아래와 같은 문제가 발생합니다.
Car
와 Engine
클래스가 밀접하게 연결되어서 다른 Engine
의 서브클래스 등을 사용할 수 없습니다. 즉, Car
가 자체 Engine
을 구성했기에 GasEngine
나 ElectricEngine
유형의 엔진에 동일한 Car
를 재사용할 수 없고 추가로 두 가지 유형의 Car
를 생성해야 합니다.
하나의 클래스가 변경되면 의존한 다른 클래스까지 변경되어야 합니다. 즉, Engine 클래스가 변경되면 Car 클래스는 Engine 클래스를 강하게 의존하고 있기에 Car 클래스를 변경하지 않는다면 컴파일 오류가 발생하거나 예상치 못한 동작을 하는 등의 영향을 받게 됩니다.
객체 사이의 의존성이 존재하면 Unit Test 작성이 어려워 집니다.
위의 그림과 같이 고차원 모듈이 저차원 모듈을 의존하고 저차원 모듈이 다시 고차원 모듈을 의존하는 것을 의존성 부패라고 합니다. 아래는 이러한 의존성 부패를 없애는 일반적인 디자인 방법인 DIP에 대한 설명입니다.
Depedency Inversion Principle은 class들 간의 의존성 부패를 제거하기 위한 일반적인 디자인 방법입니다. 만약 Car와 Engine 클래스에 DIP를 적용한다면 아래와 같은 순서로 적용됩니다.
class Car {
// Car는 Abstract를 의존
val engine: EngineInterface = GasEngine()
var isOn = false
fun toggleEngine() {
isOn = !isOn
if(isOn) engine.on()
else engine.off()
}
}
// Abstract에 해당
interface EngineInterface {
fun on()
fun off()
}
// Engine은 Abstract를 Inherit
class GasEngine: EngineInterface {
override fun on() {
println("Engine On")
}
override fun off() {
println("Engine Off")
}
}
// Engine은 Abstract를 Inherit
class ElectronicEngine: EngineInterface {
override fun on() {
println("ElectronicEngine On")
}
override fun off() {
println("ElectronicEngine Off")
}
}
위의 코드에서는 Car가 Engine대신 EngineInterface(Abstract)를 참조하도록 수정하였고, 각 GasEngine과 ElectronicEngine이 EngineInterface를 구현하도록 하여 Depedency를 Inversion 하였습니다. 따라서 이제 Car는 Engine과 같은 구현체와 Depedency가 전혀 없음으로 재사용이 가능한 코드가 되었습니다.
다만 DIP가 적용된 코드를 보면 Car와 Engine에 Depedency만 Inversion한다고 의존성 문제가 해결되지 않습니다.
class Car {
val engine: EngineInterface = GasEngine()
...
}
위의 코드를 보면 Car 클래스안에서 concrete class인 GasEngine을 직접 생성하고 있습니다. 의존성을 뒤집어 interface를 참조하도록 하였지만, 아직 class depedency가 남아 있습니다. 이를 고치기 위해 Depedency Injection에 대해 알아보겠습니다.
의존성 주입은 의존관계가 있는 클래스를 직접 클래스 내부에서 생성하는 것이 아니고 외부로부터 가져오는 것을 뜻합니다. 주입하는 방법으로는 생성자와 필드(또는 setter)가 있습니다. 이를 통해 Car 클래스에서 코드의 수정없이 여러가지 Engine 클래스를 받아올 수 있고, 직접 Car 클래스 내부에서 Engine 클래스를 생성할 때 발생한 문제 또한 해결할 수 있습니다.
위의 Car 클래스에 Depedency Injection을 사용한 코드는 아래와 같습니다.
// 생성자를 통해 주입
class Car(val engineHandler: EngineInterface) {
fun engineOn() {
engineHandler.on()
}
}
interface EngineInterface {
fun on()
fun off()
}
class GasEngine(): EngineInterface {
override fun on() {
...
}
override fun on() {
...
}
}
class ElectronicEngine(): EngineInterface {
override fun on() {
...
}
override fun off() {
...
}
}
fun main() {
// Engine을 외부에서 생성
val gasEngine = GasEngine()
// 생성자를 통해 주입
val car = Car(gasEngine)
car.engineOn()
}
위의 예시는 Constructor Injection(생성자 주입)을 적용한 Car 입니다. 이제 Car는 GasEngine등 다른 클래스에 의존하지 않는 독립적인 존재가 되었습니다. 어떠한 depedency가 없으니 외부로부터의 변경사항에 대한 영향도가 매우 적어져서, Car는 어디에서도 수정 없이 곧바로 재사용이 가능한 Component가 되었습니다(Component란 소스 코드의 아무런 수정 없이 다른 곳에서도 바로 재사용이 가능한 수준의 모듈을 의미합니다.).
DIP와 DI가 적용된 Car는 아래와 같이 사용됩니다.
interface EngineInterface {
fun on()
fun off()
}
fun main() {
val exampleCar = Car(engineHandler = GasEngine())
}
위의 예제에서 Client가 GasEngine을 생성해 Car에 주입하고 있습니다. Car는 GasEngine을 모르게 됐지만, Client가 GasEngine을 생성하고 Car와의 관계를 설정하는 이상해진 상황이 됐습니다. 왜냐하면 Client는 GasEngine을 알 이유도 없으며 알아서도 안되기 때문입니다.
IoC가 적용되지 않은 일반적인 프로그램의 흐름은 위와 같이 entry Point(main)에서 사용할 오브젝트를 결정하고, 생성하고, 생성된 오브젝트의 메소드를 호출하고, 그 오브젝트 메소드안에서 또다시 다음에 사용할 것을 결정하고 호출하는 식의 작업이 반복됩니다. 각 오브젝트는 프로그램의 흐름을 결정하거나 사용할 오브젝트를 구성하는 작업에 능동적으로 참여합니다. 즉, Service를 사용하는 Client쪽에서 모든 걸 제어하고 있는 구조입니다. 제어의 역전(IoC)이란 이런 제어의 흐름을 Inversion하는 것을 의미합니다.
위는 역전된 제어의 흐름을 보여줍니다. entry point(main)에서 IoC Container에게 모든 관계 설정에 대한 책임을 위임합니다. 따라서 컴파일 타임의 static한 class depedency가 런타임의 dynamic한 object depedency로 변경된 것을 볼 수 있습니다. 아래는 Car에 IoC 개념이 적용된 모습입니다.
client가 Ioc Container에게 필요한 Object를 요청하면 Ioc Container는 Car가 필요한 object를 생성하고 관계를 wiring 하여 전달합니다. 각 클래스들이 다른 클래스에 대한 depedency가 모두 사라졌으니 이제 모든 클래스들은 component가 될 수 있습니다. 또한 분리된 모든 클래스들은 전부 mock으로 대체될 수 있어 테스트성도 높아졌습니다. IoC만 바뀌면 dynamic하게 전혀 다른 동작을 하는 프로그램이 될 수도 있습니다.
DI는 인터페이스를 통해 다이나믹하게 객체를 주입하여 유연한 프로그래밍을 가능하게 하는 패턴입니다. 또한 DI는 IoC를 구현하는 방법 중 하나이다.
IoC는 객체의 흐름, 생명주기관리 등을 독립적인 제 3자에게 역할과 책임을 위임하는 방식에 프로그래밍 모델이며 범용적인 표현입니다. 즉, 제어가 거꾸로 가는 개념이다.
이 글에서는 Depedency, Dependecny Inversion Pattern, Depedency Injection, Inversion of Control 개념에 대해 알아보았습니다. 다음 내용은 안드로이드에서 Depedency Injection을 수동으로 적용하는 과정을 알아보겠습니다.
참조
제어의 역전(Inversion of Control, IoC) 이란?
IoC와 DI란 무엇일까?
Dependency Injection 이란?
Depedency Injetion(DI)에 대해 알아보자
틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!