💡 Intro
레벨 4 첫 미션인 만들면서 배우는 DI도 어느덧 막바지를 향해 가고있다. 1단계, 2, 3단계, 4단계.. 5단계까지 있는 미션이긴하지만 감사하게도 선택사항으로 남겨주셨다. 5단계는 내가 만든 의존성 주입 라이브러리를 걷어내고 힐트로 바꾸는 것이다. 팀에서 진행하고 있는 프로젝트에서 힐트를 사용해볼 수도 있기도 하고, 내가 만든 라이브러리를 걷어낸다면 결국 무엇이 남는단 말인가.. 그래서 5단계는 하지 않을 것 같다. (마감이 오늘까지인데 하나도 안했다. ㅋㅋㅋ)
이번 미션이 끝나고 다음 미션은 어떤 것일지 매우 기대가 되지만 한편으로는 걱정도 조금된다. 이 정도의 난이도가 또 나온다면 나는 프로젝트에 얼마나 시간을 투자할 수 있을 것인가. 실제로 레벨 4 한 달동안 프로젝트의 진전은 굉장히 없었기 때문에 개인적으로 조금 속상하기도 했다. 그래서 개인시간을 좀 더 줄이고 투자해서 프로젝트를 완성도 있게 만드는 것이 내 남은 레벨 4의 목표다.
만들면서 배우는 DI 4단계
4단계 요구 사항을 살펴보면 다음과 같다.
필수 요구 사항
다음 문제점을 해결한다.
CartActivity
에서 사용하는DateFormatter
의 인스턴스를 매번 개발자가 관리해야 한다.- 모든 의존성이 싱글 오브젝트로 만들어질 필요 없다.
CartRepository
는 앱 전체 LifeCycle 동안 유지되도록 구현한다.ProductRepository
는 ViewModel LifeCycle 동안 유지되도록 구현한다.DateFormatter
는 Activity LifeCycle 동안 유지되도록 구현한다.- 내가 만든 DI 라이브러리가 잘 작동하는지 테스트를 작성한다.
선택 요구 사항
DateFormatter
가 Configuration Changes에도 살아남을 수 있도록 구현한다.- Activity, ViewModel 외에도 다양한 컴포넌트(Fragment, Service 등)별 유지될 의존성을 관리한다.
솔직한 심정으로 선택 요구 사항까지 진행하고 싶은 마음이다. 하지만 능력이 부족하기 때문에 거기까지는 욕심내지 않기로 했고, 먼저 필수 요구 사항을 만족하는 것이 목표다.
1️⃣ CartRepository
는 앱 전체 LifeCycle 동안 유지되도록 구현한다.
사실 이 요구사항은 이전 미션에서부터 만족하고 있었다. 앱 전체 LifeCycle 동안 유지되도록 구현한다는 것은 앱을 실행하는동안 싱글톤으로 관리하는 것과 동일하다. 현재 구조는 모든 인스턴스를 싱글톤으로 관리하기 때문에 현재 구조를 그대로 가져가면 이 요구사항은 가볍게 만족할 수 있다.
2️⃣ DateFormatter
는 Activity LifeCycle 동안 유지되도록 구현한다.
레벨 4의 요구사항은 한 마디로 정의할 수 있다.
LifeCycle에 따라 인스턴스들을 알맞게 관리하라.
그러기 위해서는 먼저 Container와 Injector부터 수정해야 했다. Container가 Object이기 때문에 모든 인스턴스가 싱글톤으로 관리되기 때문이다.
다음 사진은 Hilt가 생성하는 구성요소의 계층 구조인데, 참고할 것이 있어서 가져왔다.
힐트는 생명주기에 따라 컴포넌트가 나뉘어져 있다. 생명주기에 따라 인스턴스들을 저장하는 곳이 다르다는 말이다. 그리고 현재 컴포넌트에서 의존성 주입을 필요로 하는 인스턴스를 찾을 수 없는 경우 상위 컴포넌트를 확인하여 가져올 수 있는 구조를 가지고 있으며 이러한 구조를 참고하여 구현하려고 한다.
class Injector(
private val container: Container,
)
class Container(private val parentContainer: Container?) {
private val dependency = mutableMapOf<DependencyType, Any>()
}
원래 Object였던 Injector와 Container를 Class로 변경하였고, Injector가 Container를 갖는 형태로 수정했다.
Injector는 각자의 Container를 가지고 있다. 그리고 상위 컴포넌트를 확인하여 필요한 인스턴스를 가져오기 위해 Container가 ParentContainer를 갖도록 했다. 최상위 컨테이너의 경우 부모 컨테이너가 없기 때문에 ParentContainer 프로퍼티를 nullable하게 만들었다.
상위 컴포넌트를 확인한다는 말이 무엇일까?
Injector는 Application에 하나, Activity마다 하나씩 생긴다. 그리고 각각의 Injector는 ParentContainer와 자신의 Container를 갖고있는 상태다. Container를 생성할 때 자신보다 상위에 있는 컴포넌트의 Container를 가져와 자신의 ParentContainer로 사용한다. 그렇게 함으로써 찾고있는 인스턴스가 현재 Container에서 없는 경우 상위 Container에서 찾아오도록 할 수 있다.
Activity LifeCycle을 만족하기 위해 다음으로 진행한 것은 안드로이드 의존성이 있는 di 모듈(androdi, 오타아님)를 만들었다. 그리고 의존관계 방향을 다음과 같이 만들려고 했다.
하지만 provideFunction을 제공하는 Module 인터페이스는 di 모듈에 정의되어 있으며 provideFunction을 통해 Container에 필요한 인스턴스를 저장해두기 위해서는 app 모듈이 di모듈을 참조해야만 했다. 그래서 완성된 모듈의 의존관계는 다음과 같다.
androdi는 안드로이드 의존성이 있는 모듈이며, 만든 이유는 주입 가능한 Activity와 Application을 만들고 app모듈에서 Activity와 Application을 사용할 때 androdi에 있는 Activity와 Application을 상속 혹은 구현하여 사용하도록 하기 위함이다.
모듈의 의존관계가 내가 생각한 것과 좀 다르게 못생겼지만.. 어쩔 수 없지.🤔
androdi가 가지고 있는 애플리케이션 클래스의 코드는 다음과 같다.
abstract class ApplicationInjectable : Application() {
lateinit var injector: Injector
lateinit var activityInjectorManager: ActivityInjectorManager
override fun onCreate() {
super.onCreate()
injector = Injector(Container(null))
activityInjectorManager = ActivityInjectorManager()
}
fun injectModule(module: Module) {
injector.addModule(module)
}
}
ActivityInjectorManager는 액티비티들이 가지고 있는 Injector를 관리하는 클래스다.
ActivityInjectable액티비티를 구현하는 액티비티는 생성되면 기본적으로 Injector를 가지고 있다. 그리고 액티비티가 파괴되면 ActivityInjectorManager가 가지고 있는 Injector를 삭제함으로써 컨테이너의 생명주기를 액티비티의 생명주기에 맞춰 동작하도록 할 수 있다.
androdi가 가지고 있는 액티비티 클래스의 코드는 다음과 같다.
abstract class ActivityInjectable : AppCompatActivity() {
lateinit var parent: ApplicationInjectable
lateinit var injector: Injector
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
parent = application as ApplicationInjectable
injector = parent.activityInjectorManager.getInjector(this::class.java.name)
?: Injector(Container(parent.injector.getCurrentContainer()))
}
fun injectModule(module: Module) {
injector.addModule(module)
}
override fun onStop() {
super.onStop()
...
}
override fun onPause() {
super.onPause()
...
}
}
onStop과 onPause에서도 어떤 일을 하지만 선택 요구 사항을 만족하기 위한 것이므로 일단 여기서는 설명을 패스하려고 한다.
액티비티는 생성되면 ApplicationInjectable에서 Injector를 가져온다. 만약 없다면 ApplicationInjectable의 컨테이너를 가져와 새로운 Injector를 생성하여 ActivityInjectorManager에 저장한다.
3️⃣ ProductRepository
는 ViewModel LifeCycle 동안 유지되도록 구현한다.
ProductRepository를 사용하는 ViewModel은 다음과 같다.
class MainViewModel(
@Inject
private val productRepository: ProductRepository,
@Database
@Inject
private val cartRepository: CartRepository,
) : ViewModel()
ProductRepository은 MainViewModel이 생성될 때 생성되며, MainViewModel이 사라질 때 함께 사라진다. 그래서 이미 요구사항을 만족하고 있다고 생각했다. (미션을 진행할 때는 매몰돼서 몰랐던 것 같은데 지금와서 다시보니 인스턴스 자체는 MainActivity를 따라가는 것 같다..?)
4️⃣ DateFormatter
가 Configuration Changes에도 살아남을 수 있도록 구현한다.
abstract class ActivityInjectable : AppCompatActivity() {
lateinit var parent: ApplicationInjectable
lateinit var injector: Injector
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
parent = application as ApplicationInjectable
injector = parent.activityInjectorManager.getInjector(this::class.java.name)
?: Injector(Container(parent.injector.getCurrentContainer()))
}
fun injectModule(module: Module) {
injector.addModule(module)
}
override fun onStop() {
super.onStop()
when {
isChangingConfigurations -> {
parent.activityInjectorManager.saveInjector(this::class.java.name, injector)
}
}
}
override fun onPause() {
super.onPause()
when {
isFinishing -> {
parent.activityInjectorManager.removeInjector(this::class.java.name)
}
}
}
}
앞에서 넘어갔던 onStop과 onPause에 코드를 보면 된다.
onStop에서는 액티비티가 파괴되는 시점에 구성변경이 일어나는 것이라면 현재 액티비티가 들고있는 Injector를 ActivityInjectorManager에 등록하는 일을 한다.
onPause 액티비티가 파괴되는 시점에 ActivityInjectorManager에 등록되어 있는 현재 Injector를 삭제하는 일을 한다.
이를 통해 CartActivityModule
이 들고 있는 DateFormatter
는 Configuration Changes에 살아남을 수 있다.
📝 테스트
Robolectric은 가상의 환경에서 안드로이드 애플리케이션을 실행하고 테스트할 수 있도록 도와주기 때문에 생각보다 많은 것들을 테스트할 수 있다.
Robolectric을 사용하여 ActivityInjectable을 테스트하는 방법이다.
class TestApplication : ApplicationInjectable()
class TestActivity : ActivityInjectable() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(androidx.appcompat.R.style.Theme_AppCompat_DayNight_NoActionBar)
}
}
@RunWith(RobolectricTestRunner::class)
@Config(application = TestApplication::class)
class ActivityInjectableTest {
private lateinit var activityController: ActivityController<TestActivity>
private lateinit var testActivity: TestActivity
@Before
fun setup() {
activityController = Robolectric.buildActivity(TestActivity::class.java)
testActivity = activityController.get()
}
}
위 코드는 테스트를 하기 위한 기반 코드다. Robolectric으로 테스트 할 때는 Config
를 통해 어떤 Application을 사용할지 결정할 수 있다. 위에 만들어둔 테스트용 Application을 사용하겠다고 지정했다.
매 테스트를 실행하기 전에 @Before
애너테이션이 붙어있는 setup
메서드가 실행되는데 여기서는 Robolectric으로 TestActivity를 실행하도록 정의했다.
다음은 진행한 세 개의 테스트다. 테스트 명을 통해 어떤 것을 테스트 하려는지 한 눈에 알아볼 수 있도록 작성했다.
@Test
fun `액티비티가 생성되면 Injector가 초기화된다`() {
// when
testActivity = activityController.create().get()
// then
assertNotNull(testActivity.injector)
}
ActivityInjectable의 onCreate에서 Injector를 초기화 하는 코드를 작성해두었기 때문에 액티비티가 생성되면 Injector가 초기화 되어있어야 한다.
@Test
fun `Configration Change가 발생한 경우 같은 injector를 갖는다`() {
// given
testActivity = activityController.create().get()
val originInjector = testActivity.injector
// when
activityController.configurationChange()
val expected = testActivity.injector
// then
assertSame(expected, originInjector)
}
ActivityInjectable의 onStop에서 Configuration Change에 대응했고, 이에 대한 테스트를 작성했다.
정말 신기한 것은 ActivityController
의 configurationChange()
메서드를 활용하면 바로 Configuration Change을 발생시킬 수 있는 것이다. 와웅..
Configuration Change가 발생하기 전에 가지고 있던 Injector와 Configuration Change가 발생한 후 Injector가 같은 인스턴스임을 확인하여 Configuration Change에 잘 대응했음을 테스트할 수 있었다.
@Test
fun `Configration Change발생이 아니라면 다른 injector를 갖는다`() {
// given
testActivity = activityController.create().get()
val originInjector = testActivity.injector
// when
activityController.recreate()
testActivity = activityController.get()
val expected = testActivity.injector
// then
assertNotSame(expected, originInjector)
}
Configuration Change를 발생시키는 것이 아니라 정말 액티비티를 다시 시작되게 하고 싶다면 ActivityController
의 recreate()
메서드를 사용할 수 있다.
Configuration Change가 발생한 것이 아닐 때는 Configuration Change가 발생하기 전의 Injector와 발생한 후의 Injector가 서로 다른 인스턴스임을 확인하여 Configuration Change가 아닐 때 잘 대응했음을 확인할 수 있었다.