Hilt Custom Component 활용하기
Hilt 를 활용한 로그인 스코프 관리하기
안녕하세요. NaverFinancial 앱 개발팀의 심명표 입니다.
Disclaimer❗️
이 글은 Hilt나 Dagger의 기본 사용법에 대해 다루지는 않지만 관련해서 참고할 링크를 본문에 기록하였습니다.
DI 가 무엇인지 이미 알고 있고 Custom Component의 사용에 대해서 바로 보고 싶으신 분은 Custom Component로 바로 스크롤 해도 됩니다.
DI (Dependency Injection)
의존성 주입(DI)이란 무엇이며 왜 사용할까요? 우리가 객체지향으로 어떤 프로그램을 디자인한다고 생각해 봅시다. 아마도 모든 기능을 제공하는 거대한 단일 클래스를 만들어 프로그램을 완성할 수도 있지만 누구도 이런 식으로 프로그램을 만들지는 않습니다. 이런 식의 설계는 본인과 동료 그리고 후임자를 괴롭게 만드는 행동입니다 (물론 오직 나만 다룰 수 있는 프로그램을 만들어 조직에서 대체불가의 직원이 될 수도 있겠죠. #유지 보수하기 어렵게 코딩하는 방법 😁).
그래서 우리는 나와 내 동료의 오후와 행복한 휴일을 위해서 객체지향 디자인 원칙에 따라서 적절하게 클래스를 설계하고 클래스 간의 관계를 정의합니다 (SOLID 원칙에 대해 그림으로 설명하는 재미있는 미디엄 글이 있습니다).
현실 세계를 모델링 한 객체지향은 프로그램 세계에는 맞지 않으며 실패한 패러다임이라는 의견도 있지만 이는 지금 이야기하고자 하는 것과는 다른 주제이며, 이 의견 역시 코드를 한 덩어리로 만들어도 된다는 말은 당연히 아닙니다. 😅
여러분이 각 클래스를 역할에 따라 세분화하여 디자인하면 클래스 간에는 참조 관계가 생깁니다. 우리는 이것을 의존성이라 부릅니다. 여러분이 프로그램으로 냉온 정수기를 디자인한다고 가정해 봅시다. 정수기 클래스는 물을 데우기 위한 가열기, 물을 냉각하기 위한 냉각기, 그리고 본연의 기능인 물을 정화하기 위한 필터 등의 내부 부속품들이 필요합니다. 이 부속품들은 모두 냉온 정수기의 의존 객체라고 할 수 있습니다.
이렇게 한 객체(정수기)가 의존하는 객체들(부속)을 개별적으로 정의하고, 각 부속의 기능을 인터페이스로 정의하면 우리는 매우 유연하고 테스트가 용이한 정수기 클래스를 갖게 됩니다. 예를 들면 다음과 같이 정수기 클래스 WaterPurifier 클래스를 정의하면 우리는 다양한 타입의 가열기, 냉각기, 필터 등을 갖는 정수기를 생성할 수 있습니다.
위 샘플 코드에서 WaterTank, Heater, Cooler 등은 모두 각 기능이 정의된 인터페이스이므로 어떤 구현체라도 프로토콜(인터페이스)만 지키면 생성자로 전달될 수 있습니다. 물론 아주 팬시한 구현체가 전달되면 정수기의 성능이 좋아지겠지만 가격 또한 올라가겠죠 😅? 그리고 이렇게 인터페이스를 클래스 생성자로 전달받도록 구현하여 얻게 된 유연성은 이 클래스를 테스트 친화적으로 만들어 줍니다. 클래스를 테스트할 때 각종 부속품 인터페이스의 구현체로 Mock 객체를 전달하여 다양한 상황에서 정수기의 상태를 테스트해 볼 수 있습니다.
이 정수기 클래스를 생성하면서 각종 부속품의 구현체들을 직접 생성하여 주입하는 방식을 수동 주입(Manual Injection)이라고 합니다. 이처럼 의존성 주입을 하기 위해서 특정 툴이 필요한 것은 아니며 원한다면 직접 구현하는 것도 가능합니다. 하지만 의존성 관리 도구를 사용하면 보다 편리하고 안정적으로 의존성을 주입하고 관리할 수 있습니다. 의존성 관리 도구를 사용하여 얻을 수 있는 장점으로는 손쉬운 의존 오브젝트들의 수명(Scope) 관리를 예로 들 수 있습니다. 여러분이 의존성 주입 방식으로 프로그램을 만들 때, 어떤 의존성들은 애플리케이션과 동일한 스코프를 갖지만 다른 의존성들은 그렇지 못할 수 있습니다. 이런 의존성들의 스코프 관리를 수동으로 하는 것은 다소 번거롭고 실수로 인한 에러가 발생할 수도 있습니다. 의존성 관리 도구를 이용하면 도구에서 정의한 방식에 따라서 손쉽게 의존 오브젝트의 수명을 정의할 수 있습니다.
이번 포스팅에서는 이러한 의존성 관리 도구들 중에서 안드로이드 플랫폼에서 최근 많이 이용되고 있는 Hilt에 대해 알아보려고 합니다. Hilt는 의존성 관리 도구 중 하나인 Dagger를 보다 편리하게 사용할 수 있도록 지원해 주는 도구입니다. 이 글은 Dagger나 Hilt의 기본적인 사용법은 알고 있다고 가정하고 작성되어 있습니다. Hilt에 대한 보다 자세한 내용은 공식 사이트나 안드로이드 가이드에서 확인하실 수 있습니다.
이번 글에서는 Hilt가 제공하는 다양한 기능 중에서도 조금은 고급(?) 주제라고 할 수 있는 Custom Component에 대해 알아보려고 합니다.
Custom Component
Dagger에는 Component라는 개념이 있습니다. Component는 동일을 스코프를 갖는 오브젝트들로 이루어진 의존성 트리입니다. 동일한 스코프를 갖는다는 것은 해당 트리에 있는 오브젝트들은 동일한 생명주기를 갖는다는 것을 의미합니다. 또한 Component들 간에는 부모-자식 계층 구조를 가질 수 있습니다.
위 그림을 보면 Parent Component에 다수의 오브젝트들의 의존 관계를 나타내는 트리가 정의되어 있습니다. 그리고 Parent Component를 상속하는 Child Component에도 여러 오브젝트들의 의존성 트리가 정의되어 있습니다. Child Component의 오브젝트들은 Parent Component의 오브젝트들을 참조 가능하지만 그 역은 불가능합니다.
Android 앱 개발 시 Dagger의 Component를 이용하여 의존 오브젝트들이 적절한 스코프를 갖도록 정의하기 위해서는 많은 양의 보일러 플레이트 코드가 필요합니다. 이를 인지한 Android 프레임워크 팀에서는 Android에 필요한 다양한 Dagger Component 들과 유용한 기능들을 묶어 Hilt라는 툴을 제공하였습니다. Hilt에 정의된 컴포넌트들을 잘 활용하면 의존 오브젝트의 생명주기를 세밀하게 관리함으로써 보다 효율적으로 메모리 관리를 할 수 있습니다(Dagger 가 칼의 종류 중 하나이고 Hilt는 칼집을 의미하므로 이번 작명 센스는 정말 놀라웠습니다 😍).
안드로이드 공식 사이트의 Hilt 관련 페이지에서는 다음과 같이 미리 정의된 컴포넌트들과 스코프들을 소개하고 있습니다.
안드로이드를 개발하지 않는 분이라면 SingletonComponent가 애플리케이션 스코프의 컴포넌트이고, ActivityComponent는 각각의 화면 스코프, FragmentComponent는 화면 내부의 보다 작은 영역의 스코프(예를 들어 탭 구조의 화면에서 각 탭의 콘텐츠 영역)라고 생각하시면 될 것 같습니다.
예를 들어 Singleton Component에 컴포넌트 스코프(Singleton)로 위치한 오브젝트들은 애플리케이션이 종료될 때까지는 계속 동일할 것이고, Activity Component(UI)에 컴포넌트 스코프(ActivityScope)로 위치한 오브젝트들은 해당 화면이 종료되기 전까지만 동일할 것입니다.
그런데 만약 Hilt가 제공하지 않는 스코프가 필요한 상황이 생긴다면 어떻게 해야 할까요? 다행히 Hilt는 Custom Component를 정의할 수 있는 방법을 제공하고 있으며 다음과 같이 정의할 수 있습니다.
Custom Component를 정의할 때는 다음의 두 가지 제약이 있습니다.
- SingletonComponent 혹은 그 하위 컴포넌트를 상속해야 함
- Hilt에 미리 정의된 컴포넌트들 중간에 위치할 수 없음
이런 Custom Component는 어떤 경우 필요할까요? 대표적인 예로는 로그인 한 사용자의 스코프에 대응되는 컴포넌트가 필요할 경우입니다. 다음의 컴포넌트 트리를 살펴봅시다.
이미 알고 있듯이 Singleton, Activity, Fragment 컴포넌트들은 Hilt 가 미리 정의하여 제공하는 컴포넌트들입니다. Singleton Component, Activity Component, Fragment Component는 각각 애플리케이션, Activity UI, Fragment UI의 시작부터 종료까지의 스코프를 갖는 의존성 트리들입니다. 왼쪽에 User Component는 사용자 정의 스코프를 갖는 의존성 트리로 앞서 언급한 로그인을 예로 들자면 로그인 된 사용자를 스코프로 갖는 트리라고 할 수 있습니다. 이 의존성 트리는 사용자 A가 로그인되어 있으면 A를 위한 객체를 주입할 것이고, 사용자 B로 사용자가 변경되면 의존성 트리를 다시 만들어 사용자 B를 위한 객체를 주입할 것입니다. 물론 아무도 로그인되어 있지 않다면 유효하지 않은 객체를 주입하여 오류를 발생시킬 것입니다.
여기서 잠시 한 가지 헷갈릴 수 있는 부분을 짚고 넘어가겠습니다. Singleton Component에 의존성을 제공하는 모듈이라고 해서 모듈이 제공하는 모든 의존성이 Singleton인 것은 아닙니다. 예를 들어 다음과 같이 어떤 모듈이 Singleton Component에 설치(Install) 된 경우 MyRepository1
은 @Singleton으로 제공되므로 Singleton Component 의존성 트리에서 유일하게 존재하게 되지만 MyRepository2
은 호출될 때마다 새로운 객체가 생성되어 주입됩니다.
의존성 트리가 다음과 같이 Activity Component였다면 어떨까요?
MyRepository1
은 @Singleton으로 제공되지만 이는 어디까지나 Activity Component 의존성 트리에서 Singleton인 것입니다. Activity 가 종료되고 다시 시작하면 새로운 MyRepository1
객체를 주입받게 됩니다. MyRepository2
의 경우에는 마찬가지로 항상 새로운 객체를 주입받게 됩니다.
이제 실제로 이러한 Custom Component를 이용하여 로그인 된 사용자 스코프의 컴포넌트를 정의하여 사용하는 예제를 만들어 보겠습니다. 예제는 아주 단순한 할 일 관리 앱(ToDo)이며 Mock API로 로그인 된 사용자의 할 일 목록을 조회, 추가, 삭제하는 기능을 제공합니다. 아래는 샘플 앱의 몇 가지 화면들입니다.
A : 로그인 되지 않은 화면
B : test1
사용자로 로그인 된 화면
C : test1
사용자 할 일 입력 화면
D : test2
사용자 할 일 화면
이 샘플 앱의 전체 코드는 아래 Git Repository에서 확인하실 수 있습니다.
(https://github.com/myungpyo/HiltCustomComponentSample)
이 샘플 앱은 Dagger와 Hilt를 이용한 DI를 적용할 것이며, 특히 사용자의 할 일 데이터를 관리하는 레이어는 로그인 된 사용자마다 자신의 데이터를 관리할 수 있어야 하기 때문에 사용자 정의 컴포넌트인 AuthUserComponent
를 정의하여 사용할 것입니다. 다음 이미지는 이 샘플 앱의 대략적인 컴포넌트 관계를 보여줍니다.
앞서 언급했듯이 Singleton, Activity, Fragment Component는 Hilt가 자체적으로 정의하여 제공하는 컴포넌트입니다. 샘플 앱에서는 사용자 로그인 상태를 나타내는 UserAuth 클래스를 Sealed class로 다음과 같이 정의하였습니다.
그리고 이 데이터 클래스를 관리하기 위한 UserAuthSession이라는 클래스를 정의하고 Singleton Component에 위치시킵니다. login()
함수가 호출되면 로그인을 수행하고 currentAuth
상태를 Authenticated로 변경하고, logout()
함수가 호출되면 currentAuth
상태를 Unauthenticated로 변경합니다. currentAuth
속성을 구독 중인 Flow Collector들은 로그인, 로그아웃 상태 변경에 따라 이를 수신합니다.
다음 그림은 샘플 프로젝트에서 UserAuthSession에 로그인, 로그아웃 이벤트가 발생함에 따라 AuthUserComponent(Custom Component)를 다시 빌드 하여 관련된 오브젝트들이 재생성 되도록 하는 모습을 나타냅니다.
가운데 샘플 앱의 화면에서 시작해 봅시다.
가장 위에는 사용자 정보를 표시하는 뷰가 있습니다. 이 뷰는 왼쪽에 표시된 UserAuthSession을 구독하여 로그인, 로그아웃 이벤트에 따라 상태를 표시합니다.
그 아래는 사용자의 할 일 목록을 보여주는 프래그먼트(MainFragment)가 있습니다. 이 프래그먼트는 ViewModel을 통해서 현재 로그인 된 사용자의 할 일 데이터를 가져와 보여줍니다. ViewModel은 ToDoRepository를 이용하는데 이 Repository는 다양한 DataSource로부터 사용자 할 일 데이터를 가져옵니다. 만약 다른 사용자가 로그인하면 MainFragment를 포함한 빨간색 박스 영역이 모두 변경된 사용자에 맞추어 재생성 됩니다 (AuthUserComponentManager는 잠시 뒤에 알아봅시다).
우리는 ToDoRepository 및 다양한 DataSource들을 로그인 된 사용자 스코프로 주입되도록 할 것입니다. 다시 말하면 앱이 시작되어 특정 사용자로 로그인되면 계속 동일한 ToDoRepository 및 DataSource 오브젝트들을 사용하다가 로그아웃 혹은 다른 사용자로 로그인되면 새로운 사용자를 위한 오브젝트들을 사용하도록 할 것입니다.
그러기 위해서 먼저 ToDoRepository와 DataSource들이 제공될 Custom Component를 만들어 봅시다. 먼저 우리가 정의할 컴포넌트를 나타낼 스코프를 정의합니다 (필수는 아닙니다).
그리고 위에서 생성한 스코프를 이용하는 Custom Component를 다음과 같이 정의합니다 (Builder는 별도의 파일에 생성하는 것이 권장 사항입니다).
이제 Repository와 DataSource들이 Custom Component에 제공될 수 있도록 모듈을 생성해 봅시다 (샘플 앱에서는 간결하게 하고자 InMemoryDataSource만을 사용하고 있습니다).
ToDoModule은 앞서 정의한 Custom Component인 AuthUserComponent에 설치(@InstallIn) 되고 있습니다. 이 모듈은 DataSource와 Repository를 바인딩 형식으로 정의하는데 Repository에는 @AuthUserScope가 추가되어 있습니다. 이 경우 두 바인더의 차이는 무엇일까요? @AuthUserScope가 붙은 Repository는 AuthUserComponent 의존성 트리가 소멸할 때까지 유지되지만 DataSource는 주입 시마다 재생성 됩니다. 예제에서 ViewModel은 데이터를 조작하기 위해서 Repository로만 접근할 것이고 Repository는 내부적으로 DataSource들을 이용하므로 이렇게 해도 괜찮지만 만약 DataSource가 여러 곳에서 주입되어 이용되는 상황에서 로그인 된 사용자 당 하나의 객체만 유지하고 싶다면 @AuthUserScope를 사용해야 합니다.
자, 이제 UI 코드 레벨에서 AuthUserComponent에 정의된 오브젝트들을 주입 받아야 할 차례입니다. Hilt에서 기본적으로 제공하는 Singleton, Activity, Fragment 등의 컴포넌트에서 제공하는 의존성들은 별다른 처리 없이 각각의 스코프에서 제공받을 수 있지만 Custom component인 AuthUserComponent에 접근하기 위해서는EntryPoint
가 필요합니다.
AuthUserComponent 의존성 트리에서 ToDoRepository를 제공받을 수 있는 EntryPoint를 정의하였습니다. 이제 UI에서 다음과 같이 ToDoRepository를 주입받을 수 있습니다. EntryPoints.get()
함수의 첫 번째 파라미터로는 컴포넌트 또는 @AndroidEntryPoint로 표시된 클래스 오브젝트를 제공해야 하는데, 여기서는 authUserComponentManager를 이용하여 EntryPoint에 접근 후 ToDoRepository를 주입받고 있습니다.
EntryPointAccessor를 이용하면 Android Entry Point를 가져올 때 보다 타입에 안전하게 가져올 수 있습니다.
AuthUserComponent는 다음과 같이 구현되어 있습니다. EntryPoint가 AuthUserComponent에 접근할 수 있는 것은 AuthUserComponentManager가 GeneratedComponentManager<AuthUserComponent>를 구현하고 있기 때문입니다. 이 인터페이스를 구현한 부분은 31번 라인뿐이며 generatedComponent()가 호출되면 현재 authUserComponent를 반환합니다.
AuthUserComponentManager는 한 가지 기능을 더 제공하는데 그것은 주입받은 userAuthSession을 구독하다가 로그인, 로그아웃 이벤트 발생 시 AuthUserComponent를 재생성하는 것 입니다 (14~24 라인). 추가적으로 원하는 시점에 언제든 rebuildComponent() 함수를 호출하면 컴포넌트를 재생성합니다.
그런데 UI에서 EntryPoint를 통해 접근하는 것이 영 불편하네요. 다음과 같이 AuthUserCompoent로의 브리지를 생성하면 애플리케이션 스코프에서 언제든 현재 사용자 스코프의 오브젝트들을 주입받을 수 있습니다.
저는 실제 프로젝트에서는 UseCase 클래스들에서 Repository 접근을 하도록 설계하고 UseCase 클래스에서 AuthUserComponent의 EntryPoint에 접근하도록 하고 UseCase 구현 클래스들은 @Inject 어노테이션을 통해 필요한 객체를 주입받아 사용할 수 있도록 하였습니다.
Custom Component는 Hilt에서 지원되지 않는 스코프를 정의하여 의존성 그래프를 좀 더 세밀하게 다룰 수 있게 해주는 장점이 있지만, 컴포넌트가 추가되면서 전체 의존성 그래프를 복잡하게 만들고 그로 인해 성능을 약간 떨어뜨리는 단점도 있습니다. 또한, 비표준 컴포넌트이므로 해당 컴포넌트로 외부 라이브러리와의 연동은 어려울 수 있습니다.
꼭 필요한 부분에 제한적으로 사용한다면 오브젝트들이 불필요하게 메모리를 차지하지 않도록 더 잘 관리할 수 있게 됩니다.
끝. 😁