Android DI(Dependency Injection)

2024. 1. 5. 16:49Android

안드로이드 개발에 적합한 프로그래밍 기법

장점

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

 

 

DI 작동 방식(예시)

한 클래스가 다른 클래스를 참조하는 경우, 참조에 필요한 클래스를 dependency라고 한다.

Car 클래스 → Engine 클래스( dependency : 종속 항목 )

 

 

3 ways for a class to get an object it needs

(클래스가 필요한 오브젝트를 얻는 방법)

  • 필요한 클래스 내부에서 dependency를 만든다. 즉, Car 클래스에서 Engine 인스턴스(구현된 실제 객체)를 생성하고 initialize한다.
  • class Car {
    
        private val engine = Engine()
    
        fun start() {
            engine.start()
        }
    }
    
    fun main(args: Array) {
        val car = Car()
        car.start()
    }
    
     위와 같은 코드는 다음과 같은 문제가 있다.
    1. Car와 Engine이 서로 밀접하게 연결되어 있어 사용하기 쉽지 않다. 만약 Engine클래스를 상속하는 두개의 클래스 Gas, Electric이 있다면 동일한 Car를 재사용할 수 없고 두 가지 유형의 Car를 새로 만들어야 한다.
    2. 또한 자동화된 다양한 테스트 사례에서 Car가 Engine인스턴스를 사용하므로 Engine을 따로 수정하기 번거로워 테스트가 더욱 어려워진다.
  • 다른 곳에서 dependency 객체를 받아온다. Android API에서 Context를 getter를 통해 가져오거나 getSystemService()를 사용하는 방법과 같다.
  • parameter로 dependency 객체를 받는다. 클래스가 만들어 질때 이런 dependency를 필요한 함수에 전달받는다. → 이것이 DI

즉, 클래스가 자체적으로 dependency를 가지는 대신 dependency를 다른곳에서 제공받는 것이다.

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

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

이 코드를 실행하면 Engine 인스턴스를 먼저 생성한 후 이를 사용해 Car 인스턴스를 생성하게 된다. 위와 같은 코드는 다음과 같은 이점이 있다.

1. Car 재사용: Engine에 새 서브클래스가 정의되어도 그 서브클래스의 인스턴스를 전달하기만 하면 되기때문에 Car는 추가적인 변경 없이 계속 사용할 수 있다.

2. 테스트 더블을 전달해 다양한 시나리오를 테스트 할 수 있다. 예를 들어 FakeEngine이라는 테스트 더블을 생성해 다양한 테스트에 맞게 구성할 수 있다.

 

 

2 ways to do dependency injection in Android

(안드로이드에서 DI를 사용하는 방법)

Constructor Injection

생성자 삽입: 위에 있는 DI 코드 예시와 같다. 클래스의 dependency를 생성자에 전달한다.

Field Injection (or Setter Injection)

필드 삽입: Activity / Fragment(쉽게 말해 UI View Container, UI 구성 기본요소)와 같은 Android 특정 클래스는 시스템 자체에서 인스턴스화 하기 때문에 Constructor Injection이 불가능 하다.

따라서 UI 컴포넌트에서는 Field Injection을 사용하여 클래스가 생성된 후에 dependency를 인스턴스화하여 삽입한다. 코드는 다음과 같다.

class Car {
    lateinit var engine: Engine

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

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

 

 

DI Manual

DI에 대한 완벽한 이해를 돕기위해 라이브러리 없이 매뉴얼을 따라 직접 DI 코드를 작성해 보도록 하자.

Android는 각 클래스가 정의된 하나의 책임을 갖는 관심사 분리 원칙을 앱 아키텍처로 권장하고 있다. 이런식으로 코드를 짜면 클래스가 서로 연결되어 서로의 dpendency를 해결해야 한다.

다음은 클래스 간의 dependency를 그래프로 표시한 것이다. 이 그래프에서 클래스는 종속된 클래스에 연결 된다.

Android 앱 애플리케이션 그래프 모델 예시 위 그림에서 Activity/Fragment는 ViewModel에 종속되며 ViewModel은 Repository에 종속된다.

Android 앱 애플리케이션 그래프 모델 예시 위 그림에서 Activity/Fragment는 ViewModel에 종속되며 ViewModel은 Repository에 종속된다.

Basic of Manual DI

수동적으로 Android 앱 시나리오에서 DI를 적용하는 방법을 알아보자. (이 방식은 Dagger 라이브러리에서 자동으로 DI를 생성하는 것과 유사하다.)

다음의 로그인 flow를 살펴보자.

로그인 flow. LoginActivity는 진입점이며 사용자와 상호작용하는 Activity이다. LoginActivity는 LoginViewModel에 종속되므로 LoginViewModel을 만들어야 한다. LoginViewModel은 UserRepository에 종속된다. UserRepository는 UserLocalDataSource와 UserRemoteDataSource에 종속되고 UserLocalDataSource와 UserRemoteDataSource는 Retrofit 서비스에 종속된다.

이런식으로 코드를 짤 수도 있다.

 

UserRepository, UserRemoteDataSource

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)

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        val retrofit = Retrofit.Builder()
            .baseUrl("<https://example.com>")
            .build()
            .create(LoginService::class.java)

        // Then, satisfy the dependencies of UserRepository
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // Now you can create an instance of UserRepository that LoginViewModel needs
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = LoginViewModel(userRepository)
    }
}

 

그러나 위와 같은 방식은 다음과 같은 문제가 있다.

  1. boilerplate 코드가 많다. 만약 다른 곳에서 LoginViewModel의 인스턴스를 만들면 코드가 중복된다.
  2. dependency의 순서대로 선언해야한다. 즉, LoginViewModel를 만들기전에 UserRepository를 인스턴스화해야 한다.
  3. 재사용이 어려운 object가 된다. 여러 곳에서 UserRepository를 재사용하려면 싱글톤 패턴을 따라야 한다. 그러나 모든 곳에서 동일한 싱글톤 인스턴스를 공유하므로 테스트가 더 어려워진다.

따라서 재사용문제를 해결하기 위해 서는 다음과 같이 해야한다.

 

 

Container로 dependency 관리

dependency를 가져오는 데 사용하는 DI Container 클래스를 만든다. 이 컨테이너에서 가져올 수 있는 모든 인스턴스는 public으로 한다. 필요하지 않은 것들은 private으로 둔다.

 

AppContainer

// Container of objects shared across the whole app
class AppContainer {

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("<https://example.com>")
                            .build()
                            .create(LoginService::class.java)

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

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

이 dependency들은 앱 전체에 걸쳐 사용하므로 모든 Activity에서 사용할 수 있도록 Application 클래스에 배치해야한다.

AppContainer 인스턴스를 포함한 Application 클래스를 만든다.

 

 

MyApplication

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

AppContainer는 Appication 클래스에 있는 앱 전체에서 공유하는 고유한 인스턴스가 있는 일반적인 클래스(싱글톤 패턴)일 뿐이다. 그러나 AppContainer는 싱글톤 패턴을 따르지 않는다. Kotlin에서는 AppContainer가 Object가 아니다.

이제 앱의 어느 Activity에서든 AppContainer를 가져와서 UserRepository를 얻을 수 있다.

 

LoginActivity

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

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

        // Gets userRepository from the instance of AppContainer in Application
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

 

그러나 또 한 가지 문제가 있다. 이런 식으로는 싱글톤 UserRepository를 사용할 수 없다.

그래서 사용할 object가 포함된 모든 Activity에서 사용할 AppContainer가 있고 다른 클래스에서 사용할 수 있는 object의 인스턴스를 만들어야 한다.

즉, LoginViewModel이 앱의 여러 곳에서 필요한 경우, 한 곳(Container)에서 LoginViewModel의 인스턴스를 생성하고 factory를 통해 새 object를 제공하는 것이다.

 

LoginViewModelFactory

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

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

그리고 AppContainer에 LoginViewModelFactory를 넣어 LoginActivity에서 사용한다.

 

 

AppContainer

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
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)

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

이는 이전보다 좋아진 방식이지만 여전히 다음의 문제를 가지고 있다.

  1. AppContanier를 직접 관리하며 모든 dependency가 있는 인스턴스를 수동으로 만들어야 한다.
  2. 여전히 많은 boilerplate 코드. 객체 재사용 여부에 따라 수동으로 factory나 parameter를 만들어야 한다.

 

 

App flow별 DI 관리

기능이 훨씬 많고 복잡한 상용 프로젝트에서 AppContainer 또한 훨씬 복잡해진다. 앱이 커지고 다양한 기능들이 추가됨에 따라 더 많은 문제가 발생한다.

  1. 앱에는 다양한 flow가 있다. object를 특정 flow내 지정된 scope에만 두고 싶을 수 있다. 모든 새 flow마다 각각의 새로운 인스턴스를 만들고 싶을 수 있다.
  2. application graph와 flow container를 최적화 하는 것이 어렵다. flow에 종속된 사용하지 않는 인스턴스를 기억해 일일히 삭제해주어야한다.

flow별로 인스턴스를 만들고 생성하는 일을 기능에 따라 하나하나 관리하는 관리해야한다면 매우 번거로울 것이다. 이를 해결하기 위해서는 FlowContainer를 만들 수 있다.

예를 들어 로그인 flow에서만 사용하는 LoginUserData를 만들 때, AppContainer 내에 FlowContainer를 만들 수있다. 다음과 같이 LoginContainer를 싱글톤으로로 만들지 말고고 인스턴스를 여러 개 만들 수 있는 로그인 flow에 필요한 AppContainer의 DI 클래스로 지정한다.

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

 

flow에 따라 정의된 Container가 있으면 언제 Container의 인스턴스를 만들고 언제 삭제할지 정해주어야 한다. 로그인 flow가 LoginActivity에 포함되어있으므로 LoginActivity가 LoginContainer의 lifecycle을 관리한다. 안드로이드의 기본 패턴에 따라 다음과 같이 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

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

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

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

LoginActivity는 AppContainer에서 LoginContainer에 액세스하여 LoginUserData 인스턴스를 사용할 수 있다. 여기서는 뷰의 lifecycle 로직을 처리하므로lifecycle observation을 사용하는 것이 좋다.

 

위와 같이 DI를 이해하기 위해 Conatiner 패턴을 사용하여 한곳에서 앱 여러곳에서 클래스 인스턴스를 공유하고 한 곳에서 팩토리를 사용하는 클래스 인스턴스를 만들어 보았다.

앱이 커질수록 factory와 같은 boilerplate가 많아질 것이고, 오류가 발생하기 쉬운 코드가 될 것이다.

컨테이너의 범위와 수명 주기를 직접 관리하고 메모리를 확보하기 위해 직접 사용하지 않는 컨테이너를 삭제하는 등 최적화 작업을 인위적으로 하게 된다. 이는 버그와 메모리 누수의 위험을 안고 있다.

따라서 다음엔 Dagger-Hilt와 같은 라이브러리를 사용하여 이 프로세스를 자동화 하는 방법을 알아보도록 한다.


 

참고

Android의 종속 항목 삽입  |  Android 개발자  |  Android Developers

종속 항목 수동 삽입  |  Android 개발자  |  Android Developers