Dependency Injection

어떤 클래스를 사용할 때 다른 클래스가 참조가 필요한 경우가 있다. Car 클래스는 Engine 쿨래스를 참조한다.
즉, Car클래스는 Engine 클래스에 의존한다.
또는 Engine 클래스는 Car 클래스의 종속 항목(디펜던시) 라고 말한다.

특정 클래스가 자신이 의존하고 있는 객체를 얻기 위한 방법은 3가지 방법이 있다.

  1. Car 클래스 안에서 Engine 인스턴스를 생성해 초가화
  2. 다른 곳에서 객체를 가져온다. Android 로 치면 Context, getSystemService() 등에 해당한다.
  3. Car 클래스를 생성할 때 매개변수로 Engine 인스턴스를 전달

세 번째 방법이 Dependency Injection의 방법이다.

의존관계에서 DI를 사용하지 않을 때

class Car() {
    private var engine = Engine()

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

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

문제점

  • CarEngine의 의존성이 너무 강하기 때문에 Èngine 클래스의 하위 클래스인 GasEngine, ElectricEngine 를 사용할 수 없게된다.

의존관계에서 DI를 사용할 때

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

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

main()에서 Engine 인스턴스를 만들고 Car생성자의 매개변수로 넣었다.

장점

  • Car의 재사용성이 높아진다. -> GasEngine과 같은 Engine의 서브 클래스를 유연하게 넘겨 줄 수 있다.
  • Engine의 생성자 등 구현이 변경되도 Car 클래스를 수정하지 않아도 된다.

안드로이드에서 DI 구현 방법

안드로이드에서 DI 구현 방법은 두 가지가 있다.

  • Constructor Injection(생성자 삽입): 위에서 설명한 방법대로, 생성자의 매개변수를 통해 의존성을 주입하는 것이다.
  • Field Injection(필드 삽입): ActivityFragment는 시스템이 인스턴스화 하기 때문에 생성자의 매개변수에 의존성 주입이 불가하다.
class Car() {
    lateinit var engine: Engine

    fun start() {
        engine.start
    }
}

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

DI의 이점

이제 DI를 왜 사용하는지에 대해 알아보자.

  1. 의존성 분리
    클래스(Car)가 디펜던시(Engine)에 관여하지 않기 때문에 디펜던시가 변경 되어도 클래스를 수정하지 않아도 된다(생성자 수정). 리펙토링이 편리해진 것이다.
  2. 클래스 재사용성 증가
    디펜던시의 다양한 서브 타입 클래스의 구현을 수용할 수 있다. (아까 생성자의 매개변수로 Engine 대신 GasEngine를 전달한 것을 볼 수 있다.) 즉, 다양한 곳에서 클래스 재사용성이 높아졌다.
  3. 테스트 편리성
    의존성이 분리되어, 테스트 시 다양한 구현을 전달하여 여러 시나리오를 검증할 수 있다.

Hilt란?

Hilt란 Android에서 종속 항목을 삽입하기 위한 Jetpack의 권장 라이브러리이다.

Hilt는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 실행하는 표준 방법을 정의한다.

*Hilt는 DI 라이브러리인 Dagger를 기반으로 빌드되었다.

종속 항목 수동 삽입

DI를 공부하는데 수동적인 종속 항목 삽입을 이해하는 것이 많은 도움이 될 것이다. 그래서 종속 항목 삽입을 어떻게 사용하는 반복 학습하는 과정을 보일 것이다. 이 과정은 Dagger에서 자동으로 생성하는 것과 아주 유사해지는 지점에 도달할 때까지 계속 개선된다.

SharedPreferences, DataStore 차이

DI Flow

LoginActivityLoginViewModel에 의존(종속)한다. LoginViewModelUserepository에 의존(종속)한다. UserepositoryUserLocalDataSource, UserRemoteDataSource에 의존(종속)한다. UserLocalDataSource, UserRemoteDataSourceRetrofit 서비스에 의존(종속)한다.

RepositoryDataSource 클래스는 다음과 같다.

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

LoginActivity는 다음과 같다.

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // UserRepository의 디펜던시 생성
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // LoginViewModel 생성에 필요한 userRepository 객체 생성
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // userRepository를 이용해 LoginViewModel 생성.
        loginViewModel = LoginViewModel(userRepository)
    }
}

이 접근 방식에서는 다음과 같은 문제가 있다.

  • 보일러 플레이트 코드가 많다.. 또 다른 LoginViewModel 인스턴스를 생성하기 위해 중복된 코드가 발생한다.
  • 종속 항목을 순서대로 선언해야 한다. UserRepository를 만들려면 LoginViewModel 전에 인스턴스화해야 합니다.
  • 객체를 재사용하기 어렵다. 여러 기능에 걸쳐 UserRepository를 재사용하려면 싱글톤 패턴을 따르게 해야 한다. 그러나 싱글톤 패턴을 사용하면, 모든 테스트가 동일한 싱글톤 인스턴스를 공유하므로 테스트가 더 어려워진다.

Container로 디펜던시 관리

객체 재사용 문제를 해결하기 위해, 디펜던시를 가져오기 위해 사용한 Dependency Container 클래스를 만들면 된다.

이 컨테이너에서 제공하는 인스턴스는 외부로 공개될 수 있다. 지금 예시에서는 UserRepository 인스턴스만 있으면 되므로 얘만 public 상태로 둔다.

// Container 인스턴스는 전체 application에 걸쳐 사용된다.
class AppContainer {

    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // userRepository 는 외부에서 사용하니까 public으로 둔다.
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

이러한 디펜던시는 앱 전체에 걸쳐 사용될 수 있으므로 모든 액티비티에서 사용할 수 있는, 즉 Application 클래스에 배치해야 한다.
그러므로 AppContainer 인스턴스를 갖고 있는 Application 클래스를 만들자.

// MyApplication 클래스를 AndroidManifest.xml file에 추가해야한다.
class MyApplication : Application() {
    val appContainer = AppContainer()
}

싱글톤으로 구현하지 않고, 모든 액티비티에게 공유되는 AppContainer 를 통해 UserRepository 를 필요로 하는 모든 액티비티에서 인스턴스를 제공할 수 있게 됐다. -> 객체 재사용성 문제 해결

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // AppContainer 를 통해 userRepository 객체를 얻음
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

이런 식으로는 싱글톤 UserRepository를 얻지 못합니다. 대신 그래프의 객체가 포함된 모든 활동에서 공유된 AppContainer가 있고 다른 클래스에서 사용할 수 있는 이러한 객체의 인스턴스를 만듭니다.

즉, MainActivity에서

class MainActivity: Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer
        val userRepository = LoginViewModel(appContainer.userRepository)
    }
}

마찬가지로 LoginViewModel 도 애플리케이션의 많은 곳에서 재사용된다면 LoginViewModel의 인스턴스를 만드는 곳이 있으면 좋다. 이를 컨테이너로 옮겨보자.

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// LoginViewModel를 만들어주는 Factory 이다.
// UserRepository에 의존하는 LoginViewModel 때문에 LoginViewModelFactory를 통해 LoginViewModel 인스턴스를 만든다.
// LoginViewModel은 파라미터로 UserRepository를 필요로 한다.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

AppContainerLoginViewModelFactory를 포함하여 LoginActivity에서 사용하게 할 수 있다.

// AppContainer는 LoginViewModelFactory를 통해 LoginViewModel 인스턴스를 제공할 수 있다.
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // application 의 AppContainer룰 통해 LoginViewModelFactory를 가져온다.
        // LoginViewModel 인스턴스를 가져온다.
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

이전보다 재사용성은 좋아졌지만 아직 다음과 같은 문제가 있다.

  • AppContainer 를 직접 관리하여 종속항목 인스턴스를 수동으로 만들어줘야 한다.
  • 여전히 보일러 플레이트 코드가 많다. 객체의 재사용에 따라 팩토리나 파라미터를 만들어야 한다.

애플리케이션 흐름에서 종속 항목 관리

지금은 별 문제가 없어보이지만, 프로젝트에서 더 많은 기능을 요구할 때, AppContainer 는 더욱 복잡해져서 다음과 같은 문제가 발생한다.

  1. 흐름이 다양해지면 객체는 해당 흐름의 범위에만 있기를 원할 수 있다.
    예를 들어 로그인 흐름에서만 사용되는 LoginUserData 를 만들 떄 다른 사용자의 이전 로그인 흐름의 데이터를 유지하지 않고 모든 새 흐름에 새 인스턴스를 만들고 싶을 수 있다. 다음 코드 예와 같이 AppContainer 내에 LoginContainer 객체를 만들면 가능합니다.
  2. 플로우에 따라 필요하지 않은 인스턴스를 삭제해야 한다.

예를 들어 LoginActivity에서 여러 프래그먼트(LoginUsernameFragment, LoginPasswordFragment)로 구성된 로그인 흐름을 가정한다.

  1. 로그인 흐름이 지속되는 동안 공유해야 하는 동일한 LoginUserData 인스턴스에 액세스한다.
  2. 로그인 흐름이 다시 시작되면 LoginUserData 의 새 인스턴스를 만듭니다.

LoginContainer 을 통해 위와 같은 사항을 만족시킬 수 있다.
LoginContainer 컨테이너는 로그인 흐름이 끝날 때 까지 유지 되다가 끝나면 삭제되고 로그인 흐름이 다시 시작되면 새로 생성된다.

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer 는 LoginContainer 포함한다.
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // 로그인 플로우에서 유저가 없을 때 LoginContainer 는 null 일 것이다.
    var loginContainer: LoginContainer? = null
}

이제 로그인 플로우를 담당하는 LoginActivity 에서 LoginContainer 의 생성과 삭제를 관리하면 된다. 관리하는 방법으로는 onCreate() 에서 새로운 인스턴스를 만들고 onDestroy() 에서 삭제할 수 있다.

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var loginData: LoginUserData
    private lateinit var appContainer: AppContainer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        // 로그인 플로우 시작. AppContainer속 loginContainer 인스턴스 생성
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
        loginData = appContainer.loginContainer.loginData
    }

    override fun onDestroy() {
        // 로그인 플로우 끝.
        // AppContainer의 loginContainer 인스턴스 삭제
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

LoginActivity 와 마찬가지로 로그인 프래그먼트는 AppContainer 에서 LoginContainer 에 액세스하여 공유 LoginUserData 인스턴스를 사용할 수 있다.

DI 라이브러리 없는 수동 종속 항목 삽입의 문제점

종속 항목 삽입은 확장 및 테스트 가능한 Android 앱을 만드는 데 유용한 기법이다.

지금까지 간단한 예제로 컨테이너와 팩토리를 통해 종속 항목하는 방법을 살펴봤다.

그러나 애플리케이션이 커지면 컨테이너와 팩토를 많이 사용하게 되고 보일러 플레이트 코드가 증가한다. 컨테이너의 범위와 수명 주기를 직접 관리하여 메모리를 확보하기 위해 더 이상 필요하지 않은 컨테이너를 최적화 및 삭제해야 한다. 이 작업을 잘못하면 앱에서 미묘한 버그와 메모리 누수가 발생할 수 있다.

Dagger, Hilt와 같은 DI 라이브러리는 이 프로세스를 자동화해주기에 개발자의 고충을 덜어준다.

Reference

해로
안드로이드 공식 홈페이지

태그:

카테고리:

업데이트: