이게 왜 아키텍처 글에 껴있냐하면,
시리즈의 목적인 클린 아키텍처를 이해하기 위해서라면 선행되어야할 개념이기 때문이다.
어려운 개념은 아니다.
함수, 클래스, 모듈 등이 다른 외부 요소에 대해 의존한다는 것이다.
의존한다는게 뭔데? 라고 하면, 어떤 객체가 다른 객체의 기능을 사용하거나 참조한다는 것이다.
class DieselEngine {
fun start() {
// start car
}
}
class Car() {
val engine = DieselEngine()
fun start() {
engine.start()
}
}
fun main(){
val car = Car()
car.start()
}
예시 코드에서, Car는 DieselEngine에 의존한다 라고 할 수 있다.
위 예시처럼 직접적인 참조로 의존성을 갖는 경우의 특징을 살펴보자.
기능을 재사용 할 수 있다. 재사용함으로써 코드가 단축된다.
위에서 Car는 시동을 걸기 위해 단순히 DieselEngine 인스턴스의 함수를 호출만 하면 된다.
또한 직관적 이다.
클래스가 다른 클래스를 직접 참조하는 경우 그 관계가 명확히 드러나고, 후술하겠지만 중간에서 결합도를 낮추게 하는 역할을 하는 코드가 없으므로 빠르고 간단한 구현이 가능하다.
A는 기능 수행을 위해 B 인스턴스를 직접 생성해야하는, 강한 결합도(Coupling)을 가지고 있다.
이로 인해 한 클래스가 수정되면 다른 클래스 역시 많은 수정을 해야하는 문제가 생긴다.
(이를 의존성 전이 라고 한다)
예를 들어, 위 예시 코드에서 같은 차종의 가솔린 엔진 모델을 만들어야 한다고 하자.
class GasolineEngine { // 가솔린 엔진 클래스 추가
fun start() {
// start car
}
}
class DieselEngine {
fun start() {
// start car
}
}
class Car() {
val engine = GasolineEngine() // GasolineEngine으로 변경
fun start() {
engine.start()
}
}
fun main(){
val car = Car()
car.start()
}
기존 Car 클래스는 DieselEngine에 강하게 결합되어 있다.
따라서 새로운 엔진 종류인 GasolineEngine을 추가하려면,
이처럼, 기능 추가가 하나의 엔진 타입을 변경하는 데 그치지 않고 클래스 내 모든 관련 부분을 수정해야 하는 문제가 발생한다. 이로 인해 유지보수성이 떨어지고, 확장성에 제약이 생긴다.
Car의 함수가 잘 동작하는지 테스트하고 싶은 상황에서,
Car의 start()는 결국 engine(가솔린이든 디젤이든)의 start()를 실행하기때문에
Car만을 테스트하는게 아니라 engine 까지 테스트를 해야한다
이로 인해 독립적인 단위 테스트를 수행하는데 불편함이 따른다.
또한 테스트로 버그를 발견해 그 원인을 추적할 때에도 그 원인을 찾는데 어려움이 생길 수 있다.
위 예시야 코드가 간단하지만 만약 Car가 의존하고 있는 객체가 다수라면
Car의 start() 동작 불능 시 여러 클래스를 모두 점검해 원인을 추적해야하는 문제가 있을 수 있다.
객체 간 직접적인 참조로 인해 강한 결합도를 가지는 의존성 관계의 문제에 대해 살펴보았다.
그렇다면 이를 해결할 수 있는 방법에는 무엇이 있을까?
일단 결합도를 약하게 만들어야할 것이다.
그리고 또 하나로는 의존성 주입이 있다.
Dependency Injection
외부에서 객체를 생성해서 사용하려는 객체에게 전달하는 것을 말한다.
말그래도 의존성을 외부에서 주입 하는 것이다.
class DieselEngine {
fun start() {
// start car
}
class Car {
val engine = DieselEngine()
fun start() {
engine.start()
}
}
위 예시는 클래스 내부에서 자체적으로 필요한 인스턴스를 생성하여
의존성을 내부에서 직접 구성하고 있다.
이 때의 문제는 다음과 같다
의존성 주입을 적용하면 어떻게 될까.
말그대로 필요한 객체를 외부에서 생성하여 주입하는 것이다.
이 때 그 방법에도 3가지가 있다.
생성자를 통해 의존하는 객체를 주입받는 방법이다.
class DieselEngine {
fun start() {
// start car
}
class Car(val engine: DieselEngine){ // 생성자
fun start() {
engine.start()
}
}
fun main() {
val dieselEngine = DieselEngine() // 외부에서 필요한 객체 생성
val car = Car(dieselEngine) // 생성자 통해 주입
car.start()
}
필드를 통해 의존 객체를 주입받는 방법이다.
class DieselEngine {
fun start() {
// start car
}
class Car() {
lateinit var engine: DieselEngine
fun start() {
engine.start()
}
}
fun main() {
val car = Car()
car.engine = DieselEngine() // 외부에서 Car의 필드에 주입
car.start()
}
Setter 메소드를 통해 의존 객체를 주입받는 방법이다.
class DieselEngine {
fun start() {
// start car
}
class Car() {
private lateinit var engine : DieselEngine
fun setEngine(val engine: DieselEngine) {
this.engine = engine
}
fun start() {
engine.start()
}
}
fun main() {
val car = Car()
car.setEngine(DieselEngine()) // setter 메소드 호출해 주입
car.start()
}
이 세가지 방법 중 보통 생성자 주입이 가장 많이 사용 된다.
라는 장점이 있기 때문이다.
인터페이스를 사용하여 결합도를 낮추는 방법으로, 느슨한 결합 이라고 한다.
즉, 특정 클래스에 의존하게 하는게 아니라 인터페이스에 의존하게 하는 것이다.
이는 위에서 서술한 의존성 주입과 함께 쓰면 그 효과가 배가 되는데
바로 코드로 살펴보자.
interface Engine {
fun start()
}
class GasolineEngine : Engine {
override fun start() {
// start car
}
}
class DieselEngine : Engine {
override fun start() {
// start car
}
}
먼저 Engine 이라는 인터페이스를 만들고, 디젤과 가솔린이 이를 구현하도록 한다.
class Car(val engine: Engine) { // Engine 타입으로 변경
fun start() {
engine.start()
}
}
fun main() {
val gasolineCar = Car(GasolineEngine())
val dieselCar = Car(DieselEngine())
car.start()
}
Car 클래스는 생성자를 통해 필요한 객체를 주입받는다.
또한 Car 클래스는 가솔린이나 디젤 엔진 클래스에 의존성을 가지는게 아니라,
Engine 인터페이스에 의존하게 함으로써 결합도를 약화시키는 방법이다.
이렇게 된다면 새로운 엔진이 추가되거나 기존 엔진이 변경되어도 Car 클래스에 변화를 줄 필요가 없다
안드로이드 개발 시 이러한 의존성 주입과 그 관리를 서드파티 라이브러리인 Dagger나 Hilt를 사용하게 된다.
이 두 라이브러리에 관해서,
그리고 안드로이드에서 라이브러리 없이 의존성 주입은 어떻게 하는지는
일단 아키텍처 공부를 끝내고 하는게 좋을 것 같다.
https://kotlinworld.com/565
https://mangkyu.tistory.com/226
https://developer.android.com/training/dependency-injection?hl=ko
https://f-lab.kr/insight/understanding-dependency-injection-in-android