이전 글에서는 Depedency, Depedency Injection(DI)에 대한 개념을 알아보았습니다. 이 글에서는 DI를 라이브러리 없이 안드로이드에 구현해보겠습니다. 이를 통해 실제 Dagger와 Hilt 같은 라이브러리에서는 DI가 어떤식으로 구현되는지 이해해보는 것입니다. 만약 Depedency, Depedency Injection 등에 대한 개념을 자세히 알고싶다면 링크를 확인해주세요.
class Car() {
val engine = Engine()
fun driveCar() {
...
}
}
class Engine() {}
이전 글에서 설명하였지만 클래스 내부에서 직접 의존하는 클래스를 생성하면 여러가지 문제가 발생합니다. 따라서 DI를 사용하여 클래스간의 관계를 설정해야 하는데, 안드로이드에서 DI를 구현하는 방법은 크게 두 가지가 존재합니다.
클래스의 의존성을 생성자에 전달합니다.
액티비티나 프래그먼트와 같은 특정한 안드로이드 클래스는 시스템에서 인스턴스화하므로 생성자 삽입이 불가능합니다. 필드 삽입을 사용하면 클래스가 생성된 후에 종속성이 인스턴스화됩니다.
class Car {
// 필드삽입
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
위의 예제는 main(외부)에서 Engine 객체를 생성하고, 이를 사용하여 Car 객체의 engine 필드에 setter를 적용하는 코드입니다. 이와 같은 DI 기반 접근 방법의 이점은 아래와 같습니다.
Car
클래스의 재사용이 가능해집니다. 만약 ElectricEngine
이라는 Engine
클래스의 서브 클래스를 정의했다면, DI에서는 이를 전달하기만 하면 되므로 Car
는 추가 변경을 안해도 됩니다.
Car
클래스의 테스트가 편리해집니다.
여러가지 이점이 있기에 DI를 사용하는데, 요약하자면 아래와 같은 이점이 있습니다.
코드의 재사용
리팩토링의 편의성
테스트의 편의성
코드의 단순화 -> 코드의 가독성이 증가
종속성이 감소 -> 컴포넌트의 종속성이 감소하면, 변경에 민감하지 않아서 코드가 변경되어도 영향을 받지 않습니다.
안드로이드의 권장 앱 아키텍처는 코드를 클래스로 나누는 것을 장려하는데, 이것은 각 클래스가 하나의 정의된 책임을 갖는 원칙(SRP 원칙)입니다. 이렇게 나누어진 클래스를 함께 연결하여 서로의 의존성을 충족해야 합니다.
클래스 간 의존성은 위와 같이 그래프로 표시할 수 있고, 그래프에서 각 클래스는 의존하는 클래스에 연결됩니다. 위의 그림에서 ViewModel(파란색) 클래스가 Repository(주황색) 클래스를 화살표로 가리키고 있습니다. 이는 ViewModel은 Repository에 의존하고, Repository는 ViewModel의 Depedency가 되는 것을 나타냅니다.
이제 안드로이드 앱에서 수동으로 의존성 삽입을 적용하는 방법을 확인해보겠습니다. 그리고 위에서 언급했듯이 이를 통해 Dagger의 구조에 대해 파악하는 것이 목표입니다. 아래의 그림은 로그인 기능의 흐름을 나타내는 그림입니다.
위에서 언급했듯이 그림의 화살표는 의존성을 나타냅니다. LoginActivity
는 LoginViewModel
에 의존하고 LoginViewModel
은 UserRepository
에 의존합니다. 그러면 UserRepository
는 UserLocalDataSource
와 UserRemoteDataSource
에 의존하고, UserRemoteDataSource
는 Retrofit
서비스에 의존합니다.
LoginActivity
는 로그인 흐름의 진입점이며 사용자는 이 액티비티와 상호작용합니다. 따라서 LoginActivity
는 모든 종송 항목이 있는 LoginViewModel
클래스를 생성해야 합니다.
우선 Repository
와 DataSource
클래스는 아래와 같습니다. 의존성 주입을 위해 UserRemoteDataSource
의 생성자에는 Retrofit
클래스를, UserRepository
클래스의 생성자에는 UserLocalDataSourece
와 UserRemoteDataSource
를 선언하였습니다.
class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginRetrofitService
) { ... }
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()
// UserRepository의 생성자로 위에서 선언한 두 객체를 넣어
// LoginViewModel이 필요로 하는 UserRepository 객체 생성
val userRepository = UserRepository(localDataSource, remoteDataSource)
// 마지막으로 LoginViewModel의 생성자에 UserRepository 객체를 넣어 LoginViewModel 객체 생성
loginViewModel = LoginViewModel(userRepository)
}
}
이와 같은 코드는 아래의 문제점이 있습니다.
보일러 플레이트 코드가 많습니다. 코드의 다른 부분에서 LoginViewModel
의 다른 인스턴스를 만들려면 코드에 중복이 생길 수 있습니다.
의존성은 순서대로 선언이 되어야 합니다. UserRepository
를 만들려면 LoginViewModel
전에 인스턴스화해야 하는데, 이는 코드가 너무 길어집니다.
객체를 재사용하기가 어렵습니다. 만약 UserRepository
를 재사용한다면 싱글톤 패턴으로 생성할 수 있지만, 싱글톤 패턴은 모든 테스트가 동일한 싱글톤 인스턴스를 공유하기 때문에 테스트를 더 어렵게 만듭니다.
객체의 재사용 문제를 해결하기 위해서 의존성을 가져오는데 사용하는 컨테이너 클래스를 만들 수 있습니다. 직접 사용하는 인스턴스는 public으로 선언하고, 나머지 인스턴스는 private으로 선언하면 됩니다. 아래의 예제에서는 UserRepository
의 인스턴스만 필요하여 이 인스턴스만 public으로 선언하였습니다.
// 앱에서 공유되는 객체들의 컨테이너
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는 접근자가 private이 아니기에 어디서든 접근 가능
val userRepository = UserRepository(localDataSource, remoteDataSource)
}
이런 의존성은 전체 앱에 걸쳐서 사용되므로 모든 액티비티에서 사용할 수 있는 일반적인 위치, Application 클래스에 배치해야 합니다. 따라서 AppContainer
객체가 포함된 커스텀 Application 클래스를 아래와 같이 작성합니다.
// AndroidManifest.xml 파일에 정의될 커스텀 Application 클래스
class MyApplication : Application() {
// 앱의 모든 액티비티에 의해서 사용될 AppContainer 객체
val appContainer = AppContainer()
}
이제 AppContainer
객체를 application 클래스로부터 얻을 수 있고, 그 객체를 사용해 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
를 생성할 필요가 없고, 모든 액티비티에서 공유되는 Application 객체를 통해 AppContainer
객체에 접근하여 UserRepository
를 필요로 하는 액티비티에서 이를 제공받을 수 있습니다.
만약 LoginActivity
에서 생성한 LoginViewModel
이 앱의 더 많은 장소에서 필요하다면(재사용된다면) 한곳에서 LoginViewModel
의 객체를 만드는 것이 좋습니다. 따라서 LoginViewModel
을 컨테이너 클래스로 이동하고 그 유형의 객체에 대한 factory를 제공할 수 있습니다.
// 한 타입의 객체를 만드는 메소드가 정의된 Factory 인터페이스
interface Factory {
fun create(): T
}
// LoginViewModel을 위한 Factory
// LoginViewModel은 UserRepository에 의존하기 때문에, LoginViewModel 클래스의 객체를 생성하기 위해
// 생성자 파라미터로 UserRepository 객체를 받습니다.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
override fun create(): LoginViewModel {
return LoginViewModel(userRepository)
}
}
이제 AppContainer
클래스에 LoginViewModelFactory
를 포함시키고 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
는 더 복잡해집니다. 즉, 앱이 커지고 다양한 기능을 도입하면 다음과 같은 문제가 발생하게 됩니다.
flow(한 기능의 구성)가 다양하면 객체가 flow의 범위에만 있기를 원할 수 있습니다. 예를 들어 로그인 flow에서만 사용되는 사용자 이름과 비밀번호로 구성되는 LoginUserData
라는 객체를 만들 때 개발자는 다른 사용자의 이전 로그인 flow 데이터를 유지하지 않으려고 합니다. 즉, 새 flow에는 새 객체를 사용해야 할 수도 있습니다.
flow에 따라 필요하지 않은 인스턴스를 삭제해야 합니다.
예를 들어, 하나의 액티비티(LoginActivity)와 두 개의 프래그먼트(LoginUsernameFragment, LoginPasswordFragment)로 구성된 로그인 flow를 가정해보겠습니다. 요구사항은 아래와 같습니다.
로그인 flow가 완료될 때까지 동일한 LoginUserData
를 접근해야 합니다.
flow가 새로 시작하면 새로운 LoginUserData
객체를 생성합니다.
로그인 flow 컨테이너로 이러한 요구사항을 달성할 수 있습니다. 이 컨테이너는 로그인 flow가 시작될 때 만들어지고 flow가 끝날 때 메모리에서 삭제되게끔 하면 됩니다.
아래의 코드 예제에서 LoginContainer
클래스를 추가하겠습니다. 그리고 이를 AppContainer
클래스에 추가합니다.
// UserRepository를 생성자 파라미터로 받습니다.
class LoginContainer(val userRepository: UserRepository) {
val loginData = LoginUserData()
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// AppContainer는 LoginContainer를 포함
class AppContainer {
...
val userRepository = UserRepository(localDataSource, remoteDataSource)
// 사용자가 로그인 flow에 없을 때는 LoginContainer는 null일 것입니다.
var loginContainer: LoginContainer? = null
}
flow와 관련된 컨테이너가 있으면 컨테이너 인스턴스를 언제 만들고 삭제할지 판단해야 하는데, 로그인 flow를 담당하는 LoginActivity
에서 LoginContainer
의 생성과 삭제를 관리하면 됩니다. 따라서 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
// 로그인 flow 시작. AppContainer안의 LoginContainer 객체 넣기
appContainer.loginContainer = LoginContainer(appContainer.userRepository)
loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
loginData = appContainer.loginContainer.loginData
}
override fun onDestroy() {
// Login flow 끝
// AppContainer 안의 LoginContainer 인스턴스를 제거
appContainer.loginContainer = null
super.onDestroy()
}
}
위와 마찬가지로 로그인 프래그먼트도 AppContainer
에서 LoginContainer
에 접근하여 공유된 LoginUserData
객체를 사용할 수 있습니다.
위의 예제에서는 라이브러리를 사용하지 않고 직접 의존성을 생성 및 제공하고 관리했습니다. 이를 dependency injection by hand(직접 의존성 삽입)
또는 manual dependency injection(수동 의존성 삽입)
이라고 합니다. 위의 예제에서는 컨테이너를 사용해 앱의 다양한 부분에서 인스턴스를 공유하는 등, 의존성을 삽입하여 DI를 사용하지 않았을 때보단 재사용성 등을 높였습니다.
다만 이와 같이 직접 의존성을 삽입하면 아래와 같은 문제가 발생합니다.
앱이 커진다면 Container, Factory와 같은 보일러플레이트 코드를 더 많이 작성하게 되는데 이는 오류로 연결되기 쉽습니다.
그리고 Container의 scope와 생명주기를 직접 관리하여 메모리를 확보하기 위해 더 이상 필요하지 않는 Container는 메모리에서 삭제해야합니다. 이를 잘못하면 앱에서 자잘한 버그나 메모리 누수가 발생할 수 있습니다.
Dagger
나 Hilt
와 같은 DI 라이브러리들은 위와 같이 수동으로 의존성을 삽입하는 고충을 덜어주고, 위와 같은 과정을 자동으로 처리합니다. 다음 글에서는 직접 Hilt를 사용하여 DI를 구현해보겠습니다.
참조
Android - DI 개념 & 라이브러리 없이 직접 구현해보기
안드로이드 developer - Depedency Injection
틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!