프로그래밍/Android

[Hilt] Custom Component의 활용 - 지역별 DB 생성하기

Lou Park 2024. 1. 1. 10:41

Hilt의 Component와 Scope

Hilt에서는 안드로이드 앱의 다양한 생명주기에 맞는 미리 정의된 컴포넌트 들을 제공한다. 컴포넌트 위의 어노테이션은 해당 컴포넌트의 수명에 대한 바인딩 범위를 지정한다. 이렇게 어노테이션을 붙이면 해당 컴포넌트와 오브젝트는 생명주기를 같이하게 된다.

 

이쯤되면 거의 고전짤

 

바인딩은 ScopedUnscoped 두 가지 유형으로 나누어지는데, 기본적으로는 Unscoped 바인딩이다.

  • Unscoped: 해당 바인딩이 요청될 때마다 새로운 인스턴스가 생성됨
  • Scoped: 범위가 지정된 컴포넌트의 인스턴스 당 한 번만 생성되며, 해당 바인딩에 대한 모든 요청은 동일한 인스턴스를 공유함
@Module
@InstallIn(FragmentComponent::class)
object FooModule {
  // This binding is "unscoped".
  @Provides
  fun provideUnscopedBinding() = UnscopedBinding()

  // This binding is "scoped".
  @Provides
  @FragmentScoped
  fun provideScopedBinding() = ScopedBinding()
}

 

Singleton, Activity, Fragment...등등 Hilt가 제공해주는 Component들로 대부분의 문제는 충분히 해결가능하다. 하지만 이것 이외에도 나만의 Scope를 생성해서 해당 Scope에 바인딩된 객체들이 생성되고 소멸할 수 있다면 어떨까...? 

 

Custom Component가 필요했던 이유

나의 경우에는 앱에서 지원하는 지역의 범위가 늘어나고, 지역별로 다른 서버에서 전혀 다른 데이터들을 다루어야했다. 처음부터 잘 설계했다면 문제가 없었겠지만 확장성은 딱히 생각하지 못했고...지역이 바뀌면 데이터가 꼬이는 문제가 터지기 시작했다. "DB 자체를 지역별로 만들면 해결될 것 같다"는 조언을 받았지만 Singleton Scope의 DB를 주입받아 사용하고 있었으므로, 어떻게 하면 좋을지 아이디어가 전혀 떠오르지 않았다.

 

"지역이 바뀔때 어떻게 새로운 DB 인스턴스를 주입받지??"

 

그러다 명표님의 <Hilt Custom Component 활용하기> 아티클을 보게되었는데 이거다!라는 생각에 바로 행동에 착수했다. Custom Component에 대해 자세히 알고 싶다면 해당 아티클을 참조하시길 바란다.

 

후술할테지만, Custom Component의 분명한 단점들에도 불구하고 Dagger Document에서 말하는 Custom Component가 필요한지 여부를 결정하는 중요 사안들은 다음과 같다.

  • 컴포넌트의 수명이 명확한가?
  • 컴포넌트의 개념이 잘 이해되고 널리 적용가능한가?

 

Region Component 만들기

이제 접속 지역이 바뀌면 컴포넌트 트리가 재구성되는 RegionComponent를 만들어 보도록 하겠다.

@Scope  
@Retention(value = AnnotationRetention.RUNTIME)  
annotation class RegionScope

@RegionScope
@DefineComponent(parent = SingletonComponent::class)
interface RegionComponent {
    @DefineComponent.Builder
    interface Builder {
        fun provideRegion(@BindsInstance region: Region): Builder
        fun build(): RegionComponent
    }
}

 

현재 접속 지역에 대해서 Scope를 설정하기 위해 @RegionComponent라는 컴포넌트를 새로 만들고, @RegionComponent@SingletonComponent의 자식 컴포넌트로 만들어주었다. @BindsInstance는 컴포넌트를 빌드할때 인스턴스를 바인딩하도록 해서 Region을 가져올 수 있게 한다. 어떻게 주입하고 가져오는지는 이어서 보도록하자.

 

@Singleton
class RegionComponentManager @Inject constructor(
    private val localStorage: LocalStorage,
    private val builder: RegionComponent.Builder
) : GeneratedComponentManager<RegionComponent> {


    private var currentRegion: Region = localStorage.region

    private var regionComponent = build(currentRegion)

    fun onRegionChanged(region: Region) {
        if (currentRegion == region) return
        currentRegion = region
        regionComponent = build(region)
    }

    private fun build(region: Region): RegionComponent {
        return builder
            .provideRegion(region)
            .build()
    }

    override fun generatedComponent(): RegionComponent = regionComponent
}

지역 변화에 대한 이벤트를 받아서 지역이 바뀔때마다 새롭게 컴포넌트를 빌드하는 RegionComponentManager다. Builder 인터페이스에 provideRegion() 함수를 통해 Region을 제공하기로 했으므로 빌드하면서 넘겨준다.

 

이렇게 RegionComponentManager를 만듦으로서 @RegionComponent를 사용할 수 있게 되었다. 수명주기는 지역이 바뀔때임이 분명해졌다.

 

 

바뀐 컴포넌트 계층

다소 허접해보이는 주황색 컴포넌트가 방금 우리가 만든 RegionComponent의 위치다.

 

 

@EntryPoint  
@InstallIn(RegionComponent::class)  
interface RegionEntryPoint {  
    fun provideRegion(): Region  
}

 

Hilt에서 기본적으로 제공하는 컴포넌트들은 따로 이런 작업을 해 줄 필요가없었지만, 우리가 만든 RegionComponent의 트리에 접근할 수 있게 하기 위해서는 직접 EntryPoint를 정의해주어야한다.

 

class SomeTestRepositoryImpl @Inject constructor(
  private val regionComponentManager: RegionComponentManager
) : SomeTestRepository {

  fun printCurrentRegion() {
    val region = EntryPoints.get(
      regionComponentManager, RegionEntryPoint::class.java
    ).provideRegion()

    System.out.println("Region: $region")
  }

}

이렇게 하면 RegionComponent의 인스턴스들에 접근할때 RegionComponentManager를 넘겨서 찾을 수 있다. 예시로 Region을 다른 Repository에서 가져오는 방법이다. 매번 EntryPoint를 가져와야한다니...끔찍함은 일단 뒤로하고 원래 목표로했었던 지역별 DB 생성으로 돌아가보자.

 

@Module
@InstallIn(RegionComponent::class)
object RegionScopedDatabaseModule {
    fun RegionComponentManager.getRegion(): Region {
        return EntryPoints.get(
            this, RegionEntryPoint::class.java
        ).provideRegion()
    }

    @RegionScope
    @Provides
    fun provideNotifyDatabase(
        @ApplicationContext context: Context,
        regionComponentManager: RegionComponentManager,
    ): NotifyDatabase {
        return Room.databaseBuilder(
            context,
            NotificationDatabase::class.java,
            "notify.${regionComponentManager.getRegion()}.db"
        )
        .fallbackToDestructiveMigration()
        .build()
    }
}

@RegionScope

  • RegionComponent에 Database를 주입해주는데, @RegionScope라고 아까 만들어두었던 Scope를 추가해주었다. 이 Database는 Region이 바뀔때까지 인스턴스를 유지한다.

RegionComponentManager.getRegion()

  • 앞으로 쓰일일이 많아서 확장함수로 추가해주었는데, 확장함수는 아무래도 상관없고 DB이름에 Region을 둬서 지역별로 다른 DB가 생성될 수 있게 처리한것이 중요하다.
  • "notify.KR.db", "notify.US.db" 처럼 지역별로 DB가 생성된다.

 

자! 이대로 Repository등 다른 컴포넌트에서 주입받으려면 아까처럼 EntryPoint 쌩쇼를 매번 펼쳐야한다. 이것도 마찬가지로 확장함수로 처리할 수도 있지만...아까 잠깐 언급했던 명표님의 글에서는 Qualifier로 이를 해결해서 따라해보았다.

@Qualifier  
@Retention(value = AnnotationRetention.BINARY)  
annotation class Bridged

@Module
@InstallIn(SingletonComponent::class)
object BridgeModule {
    @Bridged
    @Provides
    fun provideNotifyDatabase(
        regionComponentManager: RegionComponentManager
    ): NotifyDatabase {
        return EntryPoints.get(
            regionComponentManager, RegionEntrypoint::class.java
        ).provideNotifyDatabase()
    }
}

@EntryPoint  
@InstallIn(RegionComponent::class)  
interface RegionEntrypoint {
    fun provideRegion(): Region  
    fun provideNotifyDatabase(): NotifyDatabase // 추가됨!
}

모든 컴포넌트들의 루트인 SingletonComponent에서 EntryPointAccessor를 통해 RegionComponent의 인스턴스를 주입받을 수 있도록 "연결"해주는데 이렇게 연결된 것들은 @Bridged 라는 Qualifier로 구분해서 가져오게하는 것이다.

 

그래서 이를 "브릿지"를 만든다라고 표현하셨다.

 

class SomeTestRepositoryImpl @Inject constructor(
  @Bridged private val db: NotifyDatabase
) : SomeTestRepository {
  ...
}

Qualifier덕에 아까처럼 EntryPoints 어쩌고하는 코드를 적지 않고 간단히 NotifyDatabase를 주입받을 수 있다.

 

Custom Component의 단점과 한계

동그란 물체는 동그래서 좋기도 하지만, 동그래서 불편하기도 하다.
이 세상에 좋은점만 존재하는 건 없는 것 같다.
Custom Component도 예외는 아니다.

  • 직접 EntryPoint를 정의해주어야 하므로 귀찮다.
  • 컴포넌트 구성이 복잡해지고, 약간의 오버헤드가 있을 수 있다.
  • 비표준 컴포넌트이므로 외부 라이브러리 공유시 어려움이 있다.
  • CustomComponent는 SingletonComponent의 직/간접적 자식이어야한다는 제약이 있다. 이는 예로들면 ActivityComponent와 FragmentComponent 사이에 위치하는 Custom Component는 만들 수 없음을 말한다.

 

아직 배포까지 해보진 않았지만 직접 느낀 단점으로는...

  • EntryPoint 정의 부분이 역시 귀찮다.
  • 직접 만든 Scope에 대한 충분한 설명(문서)이 필요하다.

 

장점은 다음과 같다.

  • 서비스 비즈니스 로직에 맞는 생명주기를 가지는 컴포넌트를 구성할 수 있었다
  • 불분명한 타이밍 이슈따위가 사라진다.