Dependency Injection

개요

하나의 객체가 다른 객체의 의존성을 제공하는 기술. ‘의존성’은 서비스로 사용할 수 있는 객체, ‘주입’은 의존성(서비스)를 사용하려는 객체로 전달하는 것을 의미한다.

어떤 서비스를 호출하려는 클라이언트는 그 서비스가 어떻게 구성되어 있는지 알지 못해야 한다. 클라이언트는 서비스 제공에 대한 책임을 외부 코드(주입자)로 위임한다. 클라이언트는 주입자 코드를 호출할 수 없고, 주입자는 이미 존재하거나 주입자에 의해 구성되었을 서비스를 클라이언트로 주입(전달)한다. 그 후 클라이언트는 서비스를 사용한다.

이것은 클라이언트가 주입자와 서비스 구성 방식 또는 사용중인 실제 서비스에 대해 알 필요가 없음을 의미한다. 클라이언트는 서비스의 사용 방식을 정의하고 있는 서비스의 고유한 인터페이스에 대해서만 알면 된다. 이것은 ‘구성’의 책임으로부터 ‘사용’의 책임을 구분한다.

이러한 의존성 주입은 결합도를 느슨하게 만들고, 의존관계 역전 원칙과 단일 책임 원칙을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다.

이점

위에서 서술한 의존성 주입은 안드로이드 개발에 적합하며, DI 원칙을 따르면 좋은 앱 아키텍처를 위한 토대를 마련할 수 있다.

DI는 다음과 같은 이점을 제공해줄 수 있다.

  • 코드 재사용
  • 리팩터링 편의성
  • 테스트 편의성

방법

한 클래스는 흔하게 다른 클래스를 참조한다. 예를 들어 Car 클래스는 Engine 클래스 참조가 필요할 수 있다. 이 예에서 Car 클래스가 실행되기 위해서 Engine 클래스의 인스턴스가 있어야 햔댜.

클래스가 필요한 객체를 얻는 방법은 세 가지 방법이 있댜.

  1. 클래스가 필요한 종속 항목 클래스를 직접 구성. Car는 자체 Engine 인스턴스를 생성하여 초기화 함
  2. 다른 곳에서 객체를 가져옴. Context getter 및 get SystemService() 와 같은 일부 Android API는 이러한 방식으로 작동
  3. 객체를 매개변수로 제공 받음. App은 클래스가 구성될 때 이러한 종속 항목을 제공하거나 각 종속 항목이 필요한 함수에 전달할 수 있음. Car 생성자는 Engine을 매개변수로 받음

여기서 세 번째 방법이 의존성 주입에 해당하며, 인스턴스가 자체적으로 종속 항목을 얻는 대신 외부에서 클래스의 종속 항목을 받아서 제공한다.

DI가 아닌 경우

class Car {
    private val engine = Engine()
    
    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

위 예시는 Car 클래스가 자체 Engine을 구성하기 때문에 의존성 주입의 예가 아니며 다음과 같은 문제가 일어난다.

  • Car와 Engine이 밀접하게 연결되어 있음. Car 인스턴스는 한 가지 유형의 Engine을 사용하므로 서브 클래스 또는 대체 구현을 사용할 수 없음. 예를 들어 Engine 클래스를 상속 받은 GasEngine, Electric 유형의 엔진을 Car 클래스는 사용할 수 없으며, 다른 유형의 Car 클래스를 새로 생성해야 함.
  • Engine의 종속성이 높은 경우 테스트하기가 어려워짐. Car는 실제 Engine 인스턴스를 사용하므로 다양한 테스트 사례에서 Engine을 수정할 수 없음

DI인 경우

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = Engine() // GasEngine(), ElectricEngine()...
    val car = Car(engine)
    car.start()
}

Car의 각 인스턴스는 초기화 시 Engine 객체를 생성자의 매개변수로 받는다. 또한 다양한 타입의 Engine을 가진 Car 인스턴스를 생성할 수 있다.

이러한 방법은 다음과 같은 이점을 제공한다.

  • Ca의 재사용 가능. 위에서 서술했던 것 처럼 Engine의 다양한 구현을 Car에 전달할 수 있음. Car는 코드의 추가 변경 없이도 계속 작동
  • Car의 테스트 편의성. 테스트 더블을 전달하여 다양한 시나리오를 테스트할 수 있음.

Android에서의 Dependency Injection

Android에서 DI를 실행하는 두 가지 주요 방법은 다음과 같다.

  • Constructor Injection. 위에서 설명한 방법. 클래스의 종속 항목을 생성자에 전달
  • Field Injection (or Setter Injection). Activity, Fragment 와 같은 특정 Android 프레임워크 클래스는 시스템에서 인스턴스화 하므로 Constructor Injection이 불가능함. 이 방법을 사용하면 종속 항목은 클래스가 생성된 후 인스턴스화 됨
// Field Injection의 예
class Car {
    lateinit var engine: Engine

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}

Automated Dependency Injection

이전 예에서는 라이브러리를 사용하지 않고 클래스의 종속 항목을 직접 생성, 제공 및 관리. 이를 dependency injection by hand 혹은 manual dependency injection이라고 한다.

Car 클래스의 예에서는 종속 항목이 하나만 있었으나 그 수가 많아지면 수동으로 삽입하는 작업의 수가 많아진다. 또한 multi-layered architecture 에선 최상위 레이어의 객체를 생성하려면 그 아래에 있는 레이어의 모든 종속 항목을 제공해야 한다. 또 다른 예로 지연 초기화와 같이 종속 항목을 전달하기 전에 구성할 수 없을 때는 메모리에서 종속 항목의 전체 기간을 관리하는 맞춤 컨테이너를 작성하고 유지해야 함.

종속 항목을 생성하고 제공하는 프로세스를 자동화하여 위에서 서술한 문제를 해결하는 라이브러리가 존재하며, 이를 사용하여 개발을 진행할 수 있다.

Hilt

Android에선 DI를 위한 라이브러리가 존재하는데, 과거에는 Dagger를 활용하였으나 현재는Jetpack 권장 라이브러리인 Hilt를 주로 사용함.

Hilt는 Dagger 기반으로 빌드되었고 Dagger가 제공하는 장점을 전부 제공함.

Conclusion

  • 클래스 재사용 가능 및 종속 항목 분리 - 종속 항목 구현을 쉽게 교체할 수 있음. 컨트롤 반전으로 인해 코드 재사용이 개선되었으며 클래스가 더 이상 종속 항목 생성 방법을 제어하지 않지만 대신 모든 구성에서 작동
  • 리팩터링 편의성 - 종속 항목은 API 노출 영역의 검증 가능한 요소가 되므로 구현 세부 정보로 숨겨지지 않고 객체 생성 시간 또는 컴파일 시간에 확인할 수 있음
  • 테스트 편의성 - 클래스는 종속 항목을 관리하지 않으므로 테스트 시 다양한 구현을 전달하여 다양한 모든 사례를 테스트할 수 있음

다음에는 안드로이드에서 hilt를 적용하는 방법에 대해 알아보자.


참고 문헌

0개의 댓글