안녕하세요, 뱅크샐러드 안드로이드팀 이기정입니다.
뱅크샐러드 안드로이드 앱은 클린 아키텍쳐 기반으로 도메인 별 화면과 유틸리티, 이벤트 로깅, 실험 관리 등, 프로그램을 운영하기 위한 수많은 클래스로 이루어져 있습니다. 클래스의 객체 생성에 있어 의존성을 줄이고, 원할한 테스트 코드 작성을 위해 Dependency Injection(의존성 주입, 이하 DI) 도구를 사용하여 문제를 해결해오고 있습니다. 이번 글에서는 뱅크샐러드 안드로이드 프로젝트에서 사용해 오던 DI 도구인 Koin을 어떻게 문제 없이 Dagger Hilt로 마이그레이션 하였는지, 그리고 이 과정에서 느낀 점에 대해 이야기하고자 합니다.
2021년 4월까지 저희 서비스는 아래 코드 구성과 함께 DI 도구로 Koin을 사용해오고 있었습니다.
과거에 저희가 사용해 온 Koin에 대해 간단하게 소개하자면, 아래와 같은 특징을 갖고 있습니다.
Koin으로 의존성 주입을 하는 것을 코드로 표현하자면 아래와 같이 작성이 가능합니다.
Application 시작 시, startKoin
이라는 DSL 함수내에 modules(...)
함수 내에 선언된 Koin Module을 변수로 넣어주면 Application 런타임 시 객체를 할당합니다.
안드로이드 라이프사이클에 해당하는 컴포넌트가 아니라면, 다음과 같이 KoinComponent
인터페이스를 구현하여 모듈에서 컴포넌트 선언을 통해 어디서든 inject()
함수로 주입이 가능합니다.
코드로만 보면 굉장히 간단하게 주입이 가능하다라는 장점이 있었지만, 크리티컬한 단점이 존재했습니다.
Koin은 아래와 같은 장점과 단점을 가지고 있습니다.
koin.get()
함수와 같이 모듈간 의존성에 대해 신경을 쓰지 않고 인스턴스를 사용하는 경우, 추후 멀티모듈로 도입 시 어려움을 겪을 수 있다.뱅크샐러드 안드로이드 앱은 Kotlin 환경에서 작업을 하고 있었고, 무엇보다 러닝커브가 낮다는 장점이 있어 지금까지 DI에 대한 구조를 신경 쓸 생각 없이 개발이 가능했다는 이점이 있었습니다. 하지만, 시간이 지남에 따라 의존성을 분리하기 점점 어려워진다는 문제가 있었습니다.
기존 뱅크샐러드 프로젝트에서는 모듈간 의존성 주입 시, 종종 쉬운 방법으로 koin.get()
함수를 이용하여 많은 곳에서 참조하곤 했습니다.
koin.get()
함수를 사용하는 경우, 우리는 해당 인스턴스가 언제 생성되는지 파악하기가 어렵습니다. Module에 해당 컴포넌트가 정의되어 있는지 확인해야 하는데, 수많은 클래스가 Koin에 정의되었다면, 이를 찾는 것 조차 수고스러운 일입니다.
결국 주입에 대한 Entry Point를 인지하지 않고 개발하게 되면 추후 어디서 어떻게 주입되는지 모른다는 문제를 야기하게 되고, 이는 인스턴스 주입이 필요한 클래스가 많아질수록 런타임 시 객체 주입에 대한 검증을 하기 어려워진다는 문제점으로 이어집니다.
저희는 koin.get()
을 이용한 의존성 주입을 기술 부채로 인식하기 시작하였고, Koin 사용을 유지하면서 개선 방법을 고민하였습니다.
한 가지 방법으로는, koin.get()
함수를 제거하고 각 사용처에 적합하게 Koin 모듈을 생성하여 주입하는 방법을 도입하였습니다. 이렇게 사용하게 되면 무분별하게 모듈에서 의존성과 상관없이 객체를 가져가 쓰는 것을 방지하고, 각 컴포넌트에 트리 구조로 주입받게 되어 어디서 어디로 주입되었는지 유추하는 것이 가능했습니다.
모듈간 의존성이 명확해졌고, 생성자에 koin.get()
을 사용하지 않고 모듈에 정의된 대로 주입이 가능하기 때문에 컴포넌트간 의존성에 가시성이 생기게 되었습니다.
이후 개선 방안을 토대로 가이드라인을 공유하고, Jira를 활용하여 함께 작업할 멤버들과 티켓 기반으로 작업에 대한 담당자와 일정을 조율하였습니다.
이후에 순조롭게 문제들을 해결할 것이라고 기대했으나, 이 해결 방법이 우리의 본질적인 문제를 해결해 주지 않을 것이라는 의견이 나왔습니다.
엎친 데 덮친 격으로 QA에서 걸러지지 못해 DI 문제로 빈번하게 크래시가 발생하게 되었습니다.
뱅크샐러드에서는 실험 문화가 활성화 되어 있고, 이를 통해 실험이 종료되면 사용하지 않는 코드들은 클린업을 진행합니다. 이 과정에서 koin.get()
함수 호출은 컴포넌트가 모듈에 선언되어 있는지 여부를 판단할 수 없기 때문에 컴파일 타임에 검증할 수 없다는 문제가 존재했습니다.
QA에서 테스트 범위를 지정했더라도, 실제로 여러 모듈에 걸쳐 Koin 컴포넌트가 사용되는 경우, QA의 범위를 벗어나기 때문에 메뉴얼 테스팅으로는 한계가 있다는 것을 알게 되었습니다.
뱅크샐러드의 경우 화면의 개수가 수백개를 넘어서 관리가 불가능한 수준 까지 와 있어 이 문제는 큰 난관으로 느껴졌습니다.
이 문제를 더 이상 미루면 안되겠다고 느끼고, 2분기에는 우선순위로 높여 해결과제로 선정하게 되었습니다.
Hilt를 사용하기 앞서, Hilt가 무엇인지 알아봅시다.
Hilt는 2020년 6월에 발표된 Dagger를 좀 더 쉽게 사용하기 위한 Android 용 DI 라이브러리입니다. 기존에 DI도구로 사용되었던 Dagger를 Wrapping하여 더 쉬운 사용성을 제공합니다. Dagger-Android와 비교했을 때 Annotation, Module, Component 관계 Scope 설정을 위한 러닝커브가 높았던 문제를 해결했습니다. Dagger를 사용하시던 분들이나, DI에 대한 기본적인 지식은 있으나 DI에 대한 러닝커브를 느끼시는 분들이라면, 도입하기 좋은 라이브러리입니다. Koin과 Hilt의 특징을 비교한다면, 아래와 같은 특징들이 있다는 것을 알 수 있습니다.
비교 표에서 설명한 대로, 의존성 주입 시점이 서로 다르기 때문에 생기는 차이도 존재합니다.
Hilt에서는 Dagger와 마찬가지로 **KAPT(Kotlin Annotation Processing Tool)**를 통해 Java Stub 파일을 생성하여 주입을 위해 DI Container, 클래스 생성을 위한 보일러플레이트 코드를 생성합니다. 반면에 Koin은 별도의 Stub을 생성하지 않기 때문에 Runtime 시 설정한 Injection Scope에 따라 주입하게 됩니다.
Hilt가 여전히 KAPT를 사용하면서, Java Stub을 생성하여 빌드시간을 더 늘리는 요인이 있었으나, Hilt를 사용하게 됨으로써 갖게되는 이점이 더 크다고 느꼈습니다. 제가 생각하는 Hilt의 이점은 아래 네가지입니다.
Reduced boilerplate
Simplified configuration
Improved testing
No Dependency Cycle
그러면, Hilt와 Koin에서 의존성 주입을 위해 어떻게 코드를 구성하는지 보도록 합시다.
Application 시작 시 Koin의 경우 DSL 함수인 startKoin(...)
Scope안에 Application Context 주입 및 Scope에서 동작할 모듈을 등록합니다.
반면에, Hilt의 경우 어노테이션으로 한번에 해결이 가능합니다. @HiltAndroidApp
은 안드로이드 어플리케이션 클래스에만 오직 적용이 가능하며, 빌드 시 프로세서에서는 @HiltAndroidApp
어노테이션이 있는 경우 코드 생성 프로세스를 트리거하여 하위 컴포넌트 및 주입 대상을 정합니다.
모든 코드를 비교할 수는 없으나, 대표적으로 많이 사용되는 예시 세가지를 비교하겠습니다.
특정 클래스에서 접근하기 어려운 리소스에 접근하기 용이한 Provider를 예시로 들어보겠습니다. 어플리케이션 전역으로 사용이 될 것이기 떄문에 Singleton으로 구성을 할 것입니다.
Koin에서는 컴포넌트를 주입받기 위해 DSL로 module을 구성하여, 코드블록 안에 다음과 같이 single
함수로 생성할 객체를 선언합니다.
Hilt에서는 Dagger와 마찬가지로 @Module
어노테이션을 사용합니다. 다만, 더 쉬운 방법으로 SingletonComponent
로 Scope을 명시하여 어느 시점에 객체를 생성할지를 명시할 수 있습니다.
그리고 마지막으로 제공하기 위한 목적의 @Provides
어노테이션을 제공하는 함수에 명시합니다.
두번째로, Repository 생성 예시를 보겠습니다. Repository 패턴으로 작성된 코드를 보면, 추상화를 통해 결합도를 낮추기 위해 인터페이스로 정의를 합니다. 이런 경우에 객체 주입, 그리고 인터페이스와 그 구현체를 바인딩 하기 위한 고민이 필요합니다. Koin에서는 Generic으로 인터페이스를 명시하지만, Hilt에서는 생성된 인스턴스인 Repository 구현체를 파라미터로 받아 업-캐스팅 하는 것을 볼 수 있습니다.
ViewModel에서 생성자에 파라미터로 주입하는 방법을 보겠습니다.
Koin에서는 viewModel
이라는 DSL함수를 제공하여 return 타입으로 ViewModel을 받도록 되어 있고, koin.get<T>()
방식으로 런타임 시 주입받도록 되어 있습니다. 반면 Hilt에서는 @HiltViewModel
어노테이션을 제공하여 ViewModel Scope에서 타겟이 되는 생성자에 주입합니다.
Hilt를 사용시의 코드를 보셨을 때 Koin과 비교하여 사용성에 있어 간단하다고 느낄 수 있습니다. 아래는 각 DI 도구에 대한 특성을 비교한 표입니다.
각 DI도구의 의존성 주입 방법에 대해 비교를 했으니, 본격적으로 Hilt로 넘어가기 위한 방법을 세웠습니다. 저는 팀원 분들과 함께 합의를 토대로 우리가 해야할 TODO 리스트를 세워 보기로 했습니다.
구체적인 Action Item이 나오니 앞으로 무슨 일을 해야 할지 시야가 트이기 시작했습니다. 그래서 첫 미션으로 코드 작성법에 대한 1-Pager를 작성하여 협업하는 팀원들과의 마이그레이션 계획을 꾸렸습니다.
요약에 적힌 대로, 저희는 모듈을 하나씩 Hilt로 마이그레이션을 하기로 했고, 이에 대한 작업 티켓을 추가했습니다. 에픽에 티켓을 명시하고 각 모듈에 오너십을 가지는 분들께 작업을 할당하게되었습니다.
작업을 할당하고 난 뒤, 저는 코드레벨의 마이그레이션 Plan을 정의하였습니다.
Hilt를 실제 프로덕션에 적용하기 위해 위에서 정의한 계획 중 1번 ~ 6번을 PoC 작업에 반영하였고, 코드레벨의 검증을 진행하게 되었습니다.
PoC의 결과는 성공적이었으며, 이를 통해 작업에 필요한 질문들과 대답들이 오갔습니다. 커뮤니케이션했던 것을 정리하여 가이드라인 문서를 작성하게되었습니다.
이제 계획과 가이드라인 문서가 갖춰 졌으니, 열심히 코드만 작성을 하면 됩니다.
첫번째로, hilt-android-gradle-plugin 추가를 합니다.
두번째로, 안드로이드 모듈에 의존성을 추가합니다.
세번째로, 모듈 작성 기준을 세웠습니다. 이 규칙은 함께 코드를 작성하는 모든 멤버들이 숙지해야 할 사항이었습니다.
Hilt 코드를 작성하기 전 기본적으로 알아야 할 어노테이션을 간단하게 소개합니다.
Hilt에서는 비교적 적은 어노테이션으로 쉽고 빠르게 주입이 가능합니다. 그중, 사용하는 비중이 높을 어노테이션을 소개하겠습니다.
Hilt를 사용하는 어플리케이션은 Application Scope의 컨테이너 역할을 하는 기본 클래스, 그리고 Hilt의 모든 어노테이션이 붙은 모듈을 생성하도록 트리거하는 역할을 합니다.
이후 하위 컴포넌트인 Android Component Class에 대해 보겠습니다.
Application 클래스에 Hilt를 설정하고, Application Scope의 컴포넌트를 사용할 수 있게 되면 Hilt는 @AndroidEntryPoint
가 있는 다른 Android 클래스에 종속 항목을 제공할 수 있습니다.
어노테이션이 추가된 안드로이드 컴포넌트 클래스에 DI 컨테이너를 추가, @HiltAndroidApp
의 설정 후 사용 가능합니다.
컴포넌트의 주입 순서로 보자면, 아래의 순서로 정의할 수 있습니다.
@AndroidEntryPoint
어노테이션을 지원하는 컴포넌트 타입은 총 5가지입니다.
다음은 화면에 대한 데이터 보존을 위해 설계된 ViewModel에서 DI를 사용하는 방법입니다. Hilt에서는 Dagger와 다르게 정식적으로 AAC-ViewModel을 지원하기 때문에, 아래와 같은 방식으로 쉽게 주입이 가능합니다.
또한, SavedStateHandle
이라는 파라미터를 Hilt에서 제공하는데, 이를 통해 Bundle 인스턴스를 핸들링하여 Activity에서는 Intent에서 넘겨받는 Extra, Fragment에서는 Argument로 넘겨받는 Bundle을 관리할 수 있고, 또한 데이터 보존 목적으로써 사용이 가능합니다.
생성자에 Context를 주입하는 방법은 간단합니다. Application, Android Component 타입에 해당하는 어노테이션을 타깃이 되는 파라미터에 명시합니다.
그 외에 Context 주입방법은 Hilt를 사용한 종속 항목 삽입 문서를 참고하시기 바랍니다.
주입이 되는 과정을 이해하려면, 생명주기에 따라 컴포넌트가 어떻게 동작하는지, 계층은 어떻게 되는지 이해할 필요가 있습니다.
위와 같이 코드를 작성하며 하나씩 마이그레이션을 하면서, 발견하게 된 문제들이 하나씩 드러나기 시작했습니다. Koin 내 주입된 인스턴스가 여러곳에서 사용하고 있어 추적이 어려웠는데, 이 이유로 특정 Koin 모듈을 한번에 제거하기 어려운 상황을 맞이하게 되었습니다.
바라던 그림은 Hilt로 선언된 모듈을 통해 깔끔하게 주입 하는 것이었지만, Hilt 모듈내 주입하는 코드를 별도로 더 만들게 되는 경우, 생성 하고자하는 인스턴스가 중복되어 사용되는 문제가 발생하게 되었습니다.
이 문제를 결국 예상치 못한 사이드이펙트가 발생할 확률을 높일 수 있게됩니다.
저희는 이 문제를 @Provides
어노테이션을 통해 해결했습니다. 해결방법에 대한 자세한 설명은 아래주석에 담았습니다.
Fragment에서는 우리가 무의식적으로 Context를 Activity로 타입 캐스팅하여 사용하는 것이 많았습니다. Fragment에서 Context 가져올 때 FragmentContextWrapper
를 사용하는데, 여기서 가져오는 Context는 Actitivy의 Context가 아닙니다.
이러한 문제로, Hilt에서는 이를 인식하지 못하여 런타임 시 크래시가 나는 경우가 발생했습니다.
Hilt에서는 FragmentContextWrapper
를 ViewComponentManager
에서 관리하기 때문입니다.
이것을 통해 context.getBaseContext()
를 통해 Activity를 찾아 가져오는 것이 가장 최상단의 context인 Activity의 Context를 가져올 수 있는 것을 알게되었습니다.
이를 통해, ContextWrapper를 통해 가져오는 Context와 FragmentContextWrapper를 통해 Context를 가져오는 케이스 두가지로 나누어 처리를 했습니다.
Hilt에서는 생성자에 기본값을 명시할 수 없습니다.
의존성 주입에 대한 처리는 Hilt에서 직접 생성한 Injector에서 처리를 하기 때문에, 기본값이 들어가는 파라미터의 경우 생성자에서 클래스의 필드로 빼게되었습니다. 코드를 비교하자면 다음과 같습니다.
Koin과 다르게, Hilt는 Runtime Injection을 쉽게 작성하기가 어렵습니다.
Hilt에서는 ViewModel의 경우 어노테이션 중 @AssistedInject
를 사용하거나 SavedStateHandle
파라미터를 주입 받아 사용이 가능하지만, 또다른 클래스에서는 동적 주입이 어렵습니다. 현재는 단순하게 lateInit
필드로 값을 넣고 있지만, 개선이 필요한 부분입니다.
함께 한 팀원들은 마이그레이션하면서 생긴 문제들을 발견하고, 하나씩 해쳐나갔습니다.
하지만 2분기까지 해결하기에 아직 많은 모듈에 대한 작업이 남아 있었습니다. Koin 의존성을 완전히 덜어냈으나, 이후 실행 시 의존성에 대한 컨플릭트가 1500곳 이상에서 발생했습니다. 좀 더 빠른 해결을 위해 마이그레이션이 필요한 클래스 및 패키지 항목에 대해 시트로 관리를 하였고, 이 덕에 안드로이드 멤버 모든 분들이 함께 문제를 해결해 나갔습니다.
첫 PoC, 이후 각 Base Code 작성, 모듈에 대한 Dependency 그래프를 그리고 각 모듈의 Hilt 마이그레이션 과정에서 총 109개의 PR이 있었으며, 이를 통해 약 30,000라인 이상의 코드 변경점이 있었습니다. 이후, 약 200개에 달하는 클래스에 대한 Hilt 의존성 코드 개선을 통해 마침내 Hilt에 대한 Dependency Graph를 올바르게 구현할 수 있었습니다.
결국 2개월간의 작업 과정을 거쳐 저희는 Koin 의존성을 완전히 덜어내고, Hilt로 성공적으로 마이그레이션 할 수 있었습니다. 그 이후에도 작은 버그들이 QA를 진행하면서 나오게 되었지만, 모든 안드로이드 멤버 분들이 노력해 주신 끝에 성공적으로 마이그레이션을 마쳤습니다.
저희 안드로이드 팀은 2개월이라는 짧지 않은 시간동안 거대한 규모의 마이그레이션을 경험하면서 아래의 6가지 경험을 얻을 수 있었습니다.
지금까지 뱅크샐러드 안드로이드 팀에서 의존성 주입 도구를 변경하게 된 과정, 그리고 어떤 흐름으로 해결해 나갔는지 이야기하였습니다.
만약 여러분들이 Hilt 도입을 고민하고 계시다면, Lesson Learned와 같이 서비스의 규모, 성격에 따라 Hilt라는 도구를 도입하는 것을 신중히 고려하고, 구조적으로 어떻게 가져 갈 것인지 고민이 많이 필요할 것입니다. 뱅크샐러드 서비스는 더 빠른 기능 전달과, 코드 작성 시 안정성을 챙기기 위해 상황에 맞추어 의존성 주입 도구로 Hilt를 택했고, 앞으로 더 효율적으로 의존성을 주입할 수 있을 것이라 기대하고 있습니다. 문제점을 느끼고 해결하는 것을 꿈꾸는 분들, 어떻게든 문제 해결을 위해 끊임없이 고민하는 분들이라면, 저희 뱅크샐러드 안드로이드 팀에서 함께 많은 문제들을 해쳐 나갈 수 있지 않을까 싶습니다. 🚀
긴 글 읽어주셔서 감사합니다. 🙂
보다 빠르게 뱅크샐러드에 도달하는 방법 🚀
지원하기