[Android] DI 개념 & 라이브러리 없이 직접 구현해보기
Dependency Injection
어떤 클래스는 다른 클래스에 대한 참조가 필요한 경우가 있다. 예를 들어, Car
클래스는 Engine
클래스 참조가 필요할 것이다.
이 때 Car
가 Engine
에 의존하고 있다고 말하고, Engine
을 Car
의 종속 항목 (디펜던시) 이라고 한다.
특정 클래스가 자신이 의존하고 있는 객체를 얻는 방법은 3가지가 있다. (Car
와 Engine
예제 활용)
Car
클래스 안에서Engine
인스턴스를 생성하여 초기화한다.- 다른 곳에서 객체를 가져온다. Android 로 치면
Context
,getSystemService()
등에 해당한다. - 객체를 파라미터로 제공받는다.
Car
의 생성자가Engine
을 파라미터로 받는다.
세 번째 방법이 바로 Dependency Injection 기법 중 하나이다.
의존관계에 있어 DI 를 사용하지 않을 때
DI 없이 코드에서 자체적으로 Engine
을 생성하는 Car
를 나타낸 모습이다.
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
이 코드는 다음과 같은 문제를 갖고 있다.
Car
의Engine
에 대한 의존성이 너무 강하다.Car
클래스가Engine
을 직접 인스턴스화하기
때문에,Engine
의 서브클래스인GasEngine
,ElectricEngine
등을 사용할 수 없게 된다.
또한,Engine
의 생성자가 변경된 경우Car
클래스에서도 수정이 이루어져야 한다.- 이러한 강력한 의존관계는 테스트를 어렵게 만든다.
Engine
의 실제 인스턴스를 사용하기 때문에 다양한 시나리오를 고려하지 못한다. (Unit Test 에 불리함)
의존관계에 있어 DI 를 사용할 때
DI 를 사용한다면 Car
의 각 인스턴스는 초기화할 때 Engine
객체를 생성자 파라미터로 받게 된다.
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
main()
에서 Engine
인스턴스를 생성하고, 이를 활용하여 Car
인스턴스를 만들게 된다.
이렇게 구현하게 되면 다음과 같은 이점을 챙길 수 있다.
Car
의 재사용성이 높아진다. 예를 들어ElectricEngine
과 같은Engine
의 서브클래스를 넘겨주는 등,Engine
의 다양한 구현을Car
에 전달할 수 있다.Engine
의 생성자 등 구현이 변경되어도,Car
클래스를 수정하지 않아도 된다.Car
에 대한 유닛 테스트가 편리해진다. 즉, 다양한 시나리오를 테스트해볼 수 있다. (MockEngine
등)
안드로이드에서의 DI 구현 방법
안드로이드에서 DI 를 구현하는 방법은 크게 두 가지가 있다.
- Constructor Injection (생성자 삽입) : 위에서 설명한 방법대로, 생성자 파라미터를 통해 의존성을 주입해주는 것이다.
- Field Injection (필드 삽입) :
Activity
나Fragment
는 시스템이 인스턴스화하기 때문에 생성자 삽입 기법이 불가능하다.
따라서 다음과 같이 필드 삽입을 사용할 수 있다.
class Car {
lateinit var engine: Engine
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.engine = Engine()
car.start()
}
DI 의 이점
-
의존성 분리
클래스가 더이상 디펜던시의 생성 (인스턴스화) 에 관여하지 않기 때문에, 종속 항목이 변경되어도
(생성자 변경 등) 영향을 받지 않고 유연하게 동작한다. 즉, 리팩토링이 편리해진 것이다. -
클래스 재사용성 증가
의존하는 객체의 구현을 쉽게 갈아끼울 수 있다. 서브 타입 등 다양한 구현을 수용할 수 있고,
때문에 다양한 곳에서 클래스를 재사용할 수 있다. -
테스트 편의성
의존성이 분리되어, 테스트 시 다양한 구현을 전달하여 여러 시나리오를 검증해볼 수 있다.
(즉, Mocking 이 쉬워 진다 : Test Double 이 가능해졌다)
직접 DI 구현해보기
안드로이드 개발자들이 주로 사용하는 Dagger2, Hilt 와 같은 라이브러리들이 있지만, DI 의 원리를 이해하기 위해서는 우선 직접 구현해보는 편이 낫다.
예시로 로그인 플로우를 구현함에 있어 DI 를 직접 구현해보자. 디펜던시 그래프는 다음과 같다.
이 플로우에 있어 Repository
및 DataSource
클래스는 다음과 같다.
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)
}
}
위 코드들을 놓고봤을 때, 아래와 같은 문제들을 발견할 수 있다.
-
보일러플레이트가 너무 많다. 다른 부분에서
LoginViewModel
의 다른 인스턴스를 만들려면
중복된 코드가 발생할 수 있다. -
객체를 재사용하기 어렵다. 여러 군데에서
UserRepository
를 재사용하려면 싱글톤 패턴을 따르게 해야
한다. 그런데 만약 싱글톤으로 구현한다해도, 모든 테스트가 동일한 인스턴스를 공유하므로 다양한 시나리오의 테스트가 어려워지게 된다.
Container 로 Dependency 관리
객체 재사용 문제를 해결하려면, 디펜던시를 가져오기 위해 사용할 자체적인 'Dependency Container' 클래스를 만들면 된다.
이 컨테이너에서 제공하는 인스턴스는 외부로 공개될 수 있다. 지금 예시에서는 UserRepository
인스턴스만 있으면 되므로 얘만 public
상태로 둔다.
// 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)
}
이러한 디펜던시는 앱 전체에 걸쳐 사용될 수 있으므로 모든 액티비티에서 사용할 수 있는, 즉 Application
클래스에 배치해야 한다.
그러므로 AppContainer
인스턴스를 갖고 있는 Application
클래스를 만들자.
// 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
인스턴스를 가져와서 UserRepository
인스턴스를 얻을 수 있다.
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)
}
}
싱글톤으로 구현하지 않고, 모든 액티비티에게 공유되는 AppContainer
를 통해 UserRepository
를 필요로 하는 모든 액티비티에서 인스턴스를 제공할 수 있게 됐다.
만약 LoginViewModel
도 다른 곳에서 재사용되는 경우, LoginViewModel
인스턴스를 만들어주는 곳 역시
있으면 좋다. 마찬가지로 이를 컨테이너로 옮기고, 새 LoginViewModel
객체를 생성하는 팩토리를 만들어주자.
// 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)
}
}
LoginViewModelFactory
를 AppContainer
로 옮겨주고, 이를 LoginActivity
에서 사용해보자.
// 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()
}
}
재사용성을 높였지만, 여전히 다음과 같은 문제들이 남아있다.
AppContainer
를 직접 관리하기 때문에 모든 디펜던시의 인스턴스를 수동으로 만들어줘야 한다.- 여전히 보일러플레이트 코드가 많다. 객체를 다른 곳에서 재사용할지에 따라
팩토리, 파라미터 등을 만들어줘야 한다.
플로우가 더 많아진다면?
지금 예제에서는 별 문제가 없어보이지만, 프로젝트에 기능을 더 많이 포함한다면 AppContainer
는
더더욱 복잡해지고 다음과 같은 문제가 발생한다.
- 만약 또 다른 기능 플로우가 있다면, 객체가 플로우의 범위 내에서만 존재하길 원할 수 있다. 예를 들어, 로그인 플로우에서만 사용하는 'username' 과 'password' 로 이루어진
LoginUserData
라는 객체를 생성할 때, 다른 사용자의 이전 로그인 플로우에서 사용된 데이터를 유지하면 안 될 수 있다. 즉, 새로운 플로우엔 새로운 인스턴스를 사용해야 할 수 있다. - 플로우에 따라 필요하지 않은 인스턴스를 삭제해야 할 수 있다.
예를 들어, LoginActivity
와 LoginUsernameFragment
, LoginPasswordFragment
등의
프래그먼트로 구성된 로그인 플로우를 가정해보자. 아래의 요구사항을 만족시키려 한다.
- 로그인 플로우가 끝날 때까지 동일한
LoginUserData
인스턴스에 액세스한다. - 로그인 플로우가 다시 시작되면, 새로운
LoginUserData
인스턴스를 생성한다.
그렇다면, 오로지 로그인 플로우를 위한 전용 컨테이너로 위와 같은 요구사항을 만족시킬 수 있다.
이 컨테이너는 로그인 플로우가 시작될 때 만들어지고, 끝나면 메모리에서 삭제되게끔 한다.
아래와 같이 LoginConatiner
를 추가하고, 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
}
이제 로그인 플로우를 담당하는 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
// 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()
}
}
이렇게 하면 LoginUsernameFragment
, LoginPasswordFragment
등의 프래그먼트들은 AppContainer
에서 LoginContainer
를 접근하여 액티비티와 공유하는 LoginUserData
의
고유한 인스턴스를 사용할 수 있다.
라이브러리 없이 직접 DI 를 구현했을 때의 고충
지금까지 간략하게 수동으로 DI 를 구현해보았다. DI 를 사용하지 않았을 때보단 코드의 재사용성과 유지보수 효율이 높아졌다. 컨테이너를 사용하여 앱의 다양한 부분에서 클래스 인스턴스를 공유할 수 있게 됐다.
그러나, 앱이 커지면 커질수록 Container, Factory 등 보일러플레이트코드를 많이 작성하게 되고, 그러한 곳들에서 오류가 발생하기 쉽다. 그리고 컨테이너가 더이상 필요하지 않을 때 메모리에서 삭제하는 등 컨테이너의 수명 주기를 직접 관리해야한다. 만일 이러한 곳에서 실수한다면 자잘한 버그와 메모리 릭이 발생할 수 있다.
Dagger
, Hilt
와 같은 DI 라이브러리들은 이러한 고충을 덜어준다. 지금까지의 과정들을 자동화해준다.
다음 포스팅에선, 이러한 라이브러리를 활용하여 DI 를 구현했을 때, 직접 구현하는 방식에 비해 어떤 이점을 챙길 수 있는지에 대해 알아보자.
Author And Source
이 문제에 관하여([Android] DI 개념 & 라이브러리 없이 직접 구현해보기), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@haero_kim/Android-DI-개념-라이브러리-없이-직접-구현해보기저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)