[Android] Dependency Injection Part 2. 직접 의존성 주입하기

[Android] Dependency Injection Part 2. 직접 의존성 주입하기

이 글은 Android Developer 가이드 내용을 토대로 작성되었습니다. 예제 코드는 Kotlin만 가져왔으며, Java 코드는 원문에서 확인할 수 있습니다.

직접 의존성 주입하기

Android에서 권장하는 앱 아키텍처는 관심사의 분리를 강조합니다. 이를 위해서는 작은 클래스들로 더욱 쪼갠 후, 실행을 위해 각 디펜던시를 모두 연결해주는 과정이 필요합니다.

MVVM 아키텍처나 Repository 패턴을 사용하는 경우, Anemic Repository를 꼭 읽어보시길 추천합니다!

[그림1. Android app’s application graph]

클래스 간의 의존 관계는 그래프로 나타낼 수 있고, 각 클래스는 자신이 의존하는 클래스와 연결됩니다. 모든 클래스와 의존관계를 그리면 곧 어플리케이션 그래프가 됩니다. 그림1에서 어플리케이션 그래프의 형태를 볼 수 있습니다. 클래스 A(ViewModel)이 클래스 B(Repository)에 의존할 때, A에서 B로 가는 화살표로 의존관계를 표시합니다.

DI는 이런 연결을 편리하게 해주고, 테스트 시 구현체를 쉽게 바꿀 수 있도록 해줍니다. 한 Repository에 의존하는 ViewModel을 테스트하는 경우, Repository의 다른 구현체를 전달하면 됩니다.

직접 의존성 주입의 기본

이 파트에서는 실제 Android 앱 플로우에 의존성 주입을 어떻게 적용할 수 있는지에 대해 설명합니다. 앱에 의존성 주입 적용을 어디부터 시작해야 할지를 나타내고, 나아가 Dagger가 자동으로 어떤 부분들을 생성해주는지를 나타냅니다. Dagger에 대한 자세한 설명은 Part3. Dagger에서 볼 수 있습니다. (참고: 공식 사이트 가이드)

플로우는 한 기능에 대한 화면의 묶음이라고 생각하세요. 로그인, 회원가입과 같은 기능들 모두 플로우의 예시가 될 수 있습니다.

일반적인 Android 앱의 로그인 플로우를 그려보면, LoginActivityLoginViewModel에 의존하고 LoginViewModelUserRepository에 의존합니다. 그리고 UserRepositoryUserLocalDataSourceUserRemoteDataSource에 의존하며, UserRemoteDataSourceRetrofit 서비스에 의존합니다.

LoginActivity가 플로우의 시작점이고, 사용자는 Activity를 통해 기능을 사용합니다. 즉, LoginActivity가 모든 의존관계와 LoginViewModel을 생성해야 합니다.

이 플로우의 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)

	// LoginViewModel의 의존관계를 만족시키기 위해서는
	// 재귀적으로 모든 디펜던시가 필요합니다.
	// 먼저 UserRemoteDataSource의 디펜던시인 retrofit을 생성합니다.
        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)

        // 드디어 LoginViewModel 인스턴스 생성!
        loginViewModel = LoginViewModel(userRepository)
    }
}


이 방법으로는 아래 이슈가 생길 수 있습니다.

  1. 보일러플레이트 코드가 너무 많습니다. 다른 코드에서 LoginViewModel 인스턴스를 또 생성해야 한다면, 코드 중복이 발생합니다.
  2. 의존 관계가 반드시 순서대로 정의되어야 합니다. LoginViewModel 생성 전에 LoginRepository를 먼저 생성해야만 합니다.
  3. 객체 재사용이 어렵습니다. UserRepository를 여러 기능에서 재사용하려면 싱글톤 패턴을 사용해야 합니다. 하지만 싱글톤 패턴은 테스트시 모든 테스트 케이스가 동일한 싱글톤 인스턴스를 참조하기 때문에 테스트가 더 어려워집니다.

하나의 컨테이너로 의존 관계 관리하기

객체 재사용 문제를 해결하기 위해, 디펜던시 컨테이너를 생성합니다. 이 컨테이너를 통해 필요한 의존 관계를 얻습니다. 컨테이너를 통해 얻는 인스턴스는 모두 public 입니다. 예를 들어, UserRepository 인스턴스만 필요하다면 UserRepository 외에 나머지 디펜던시는 private으로 해놨다가 필요할 때 public으로 변경합니다.

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

    // 컨테이너 외부에 userRepository를 공개하기 위해 먼저 필요한 디펜던시를 생성
    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)
}


이런 디펜던시들은 어플리케이션 내에서 전체적으로 사용되기 때문에, 모든 Activity가 접근할 수 있는 공통 공간-Application 클래스-에 있어야 합니다. 따라서 AppContainer 인스턴스를 가지고 있는 커스텀 Application 클래스를 만듭니다.

class MyApplication : Application() {
    val appContainer = AppContainer()
}


AppContainer는 싱글톤 클래스가 아니고, Application 클래스에 저장된 인스턴스를 앱 내부에서 공유합니다. Kotlin에서는 AppContainer가 object가 아니며, Java에서는 Singleton.getInstance() 처럼 접근하는 싱글톤 클래스가 아닙니다.


이제 Application에서 AppContainer 인스턴스를 얻을 수 있고, UserRepository 인스턴스를 가져올 수 있습니다.

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

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

        // Application에 있는 AppContainer로부터 UserRepository 인스턴스를 가져옵니다.
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}


UserRepository를 싱글톤으로 만들지 않고, 모든 Activity에서 AppContainer 인스턴스에 생성된 UserRepository 인스턴스 하나를 공유합니다.

LoginViewModel이 더 많은 곳에서 필요하다면, LoginViewModel을 생성하는 코드를 한 곳에 모을 수 있습니다. 생성 코드를 컨테이너에 옮기면 됩니다. LoginViewModelFactory의 예제 코드입니다:

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

// Factory for LoginViewModel.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}


AppContainerLoginViewModelFactory를 두고, LoginActivity에서 LoginViewModelFactory를 사용하도록 합니다:

// 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. AppContainer를 직접 관리해야 하고, 모든 의존 관계에 해당하는 인스턴스 생성 코드를 직접 추가해야 합니다.
  2. 여전히 많은 보일러플레이트 코드가 존재합니다. 객체 재사용이 필요하느냐에 따라 Factory나 파라미터를 직접 써줘야 합니다.

어플리케이션 플로우 관점에서 의존 관계 관리하기

프로젝트에 기능이 추가될수록 AppContainer도 복잡해집니다. 앱이 커지면 다른 기능 플로우가 생길 수 있고, 그 경우 아래의 문제가 발생할 수 있습니다.

  1. 어떤 객체는 플로우의 scope에서만 유효하게 만들 필요가 생깁니다. 예를 들어 (로그인 플로우에서만 쓰이는 username과 password를 포함하는) LoginUserData를 생성했을 때, 이전 로그인 플로우의 데이터가 계속 유지되지 않아야 합니다. 매번 새로운 플로우마다 새로운 인스턴스가 필요합니다. 해결을 위해 AppContainer 안에 다음 예제 코드에서 볼 수 있는 FlowContainer를 만듭니다.
  2. 어플리케이션 플로우를 최적화하기도 어렵습니다. 플로우를 보고 필요없는 인스턴스를 찾아 삭제해야 합니다.

한 Activity (LoginActivity)와 Fragment들(LoginUsernameFragment, LoginPasswordFragment)로 구성된 로그인 플로우가 있다고 가정해봅시다. 화면의 요구사항은 아래와 같습니다:

  1. 로그인 플로우가 끝나기 전에는 같은 LoginUserData 인스턴스에 접근
  2. 플로우가 다시 시작되면 LoginUserData 인스턴스를 새로 생성

로그인 플로우 컨테이너를 만들어 해결할 수 있습니다. 이 컨테이너는 로그인 플로우가 시작되면 생성되고, 플로우가 끝나면 메모리에서 지워집니다.

예제 코드에 LoginContainer를 추가해봅시다. 여러 LoginContainer가 필요할 수 있으니, 싱글톤으로 만들지 않고 AppContainer에 의존 관계인 클래스로 추가합니다.

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
}


특정 플로우에 한정된 컨테이너는 인스턴스를 언제 생성하고 제거할건지에 대한 정의가 필요합니다. 로그인 플로우는 Activity (LoginActivity)에 포함되기 때문에, Activity가 컨테이너의 라이프사이클을 관리하는 주체가 될 수 있습니다. LoginActivity 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()
    }
}


로그인 관련 Fragment들도 AppContainerLoginContainer를 통해 동일한 LoginUserData를 사용할 수 있습니다.

결론

DI는 확장 가능하고 테스트 가능한 Android 앱을 만드는 데 도움이 됩니다. 컨테이너를 통해 클래스 인스턴스를 공유하고, 인스턴스 생성 코드를 컨테이너에 모아서 사용할 수 있습니다.

앱이 점점 커질수록 많은 양의 Factory 코드와 같은 보일러플레이트 코드가 생기기 시작합니다. 그리고 보일러플레이트 코드는 에러가 발생하기 쉽습니다. 컨테이너의 scope이나 라이프사이클까지 직접 관리해야하고, 메모리 해제를 위해 더이상 사용하지 않는 컨테이너 제거도 해야 합니다. 이런 관리를 잘못 하게되면 잠재적인 버그나 메모리릭이 발생할 수 있습니다.

다음 파트에서는 이런 과정들을 Dagger를 이용해 어떻게 자동화하고, 여기서 직접 썼던 코드들을 어떻게 생성하는지 알아보겠습니다!

Comments