Hilt Custom Component를 활용한 로그인 세션 관리

이태훈·2022년 11월 2일
0

안녕하세요, 오늘은 Hilt의 Custom Component를 통해 로그인 세션을 유지하는 것을 해보겠습니다.

전체적인 구조는 Repository 패턴을 이용하여 유저 정보를 가져오고, 해당 유저 정보가 살아있는 동안 같은 SignInViewModelDelegate를 반환하는 컴포넌트를 생성해서 사용하는 구조가 되겠습니다.

유저 정보는 SignInViewModelDelegate에서 관리하며, 다른 뷰모델들이 해당 ViewModel Delegate에 Delegation을 적용하여 여러 뷰모델에서 유저 정보를 사용할 수 있도록 구조를 짰습니다.

멀티 모듈 클린아키텍처로 구성했기 때문에 레이어별로 코드를 보며 진행하겠습니다.

전체 코드는 링크를 참고해주세요.

Domain Layer

먼저 가장 기본적인 도메인 레이어를 보겠습니다.
도메인 레이어는 별다른 것 없습니다.

data class User(
    val token: String
)

interface UserRepository {
    fun getUser(): Flow<User?>
}

interface UserService {
    fun login(): String
    fun logout()
}

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

위와 같이 User와 UserRepository, UserSerivce 만들어줍니다.

Singleton Scope에서 UserModule의 컴포넌트에 접근하기 위한 Bridged Qualifier도 만들어줍니다.

Data Layer

본격적으로 커스텀 스코프를 설정하고 컴포넌트를 만들어줍니다.

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

@UserScope
@DefineComponent(parent = SingletonComponent::class)
interface UserComponent {

    @DefineComponent.Builder
    interface Builder {
        fun provideUser(@BindsInstance user: User?): Builder
        fun build(): UserComponent
    }
}

커스텀 스코프와 그에 해당하는 컴포넌트를 만들어줍니다.
EntryPoint를 통해 받을 유저 정보를 설정해줍니다.

@Singleton
class UserComponentManager @Inject constructor(
    private val builder: UserComponent.Builder
) : GeneratedComponentManager<UserComponent> {

    private var userComponent = builder
        .provideUser(null)
        .build()

    fun onLogin(user: User) {
        userComponent = builder
            .provideUser(user)
            .build()
    }

    fun onLogout() {
        userComponent = builder
            .provideUser(null)
            .build()
    }

    override fun generatedComponent(): UserComponent {
        return userComponent
    }

}

요구사항에 맞는 행동에 따라 컴포넌트를 생성해주고 그에 맞는 유저를 할당해줍니다.

저 같은 경우는 로그인과 로그아웃할 때 각각 컴포넌트를 재생성하고 그에 따라 유저 값을 할당해줬습니다.

@EntryPoint
@InstallIn(UserComponent::class)
interface UserEntryPoint {
    fun provideUser(): User?
}

엔트리 포인트를 통해 어떤 값을 받을지 설정합니다.

internal class UserRepositoryImpl @Inject constructor(
    private val userComponentManager: UserComponentManager
) : UserRepository {

    override fun getUser(): Flow<User?> = flow {
        val user = EntryPoints
            .get(userComponentManager, UserEntryPoint::class.java)
            .provideUser()
        emit(user)
    }
}

internal class UserServiceImpl @Inject constructor(
    private val userComponentManager: UserComponentManager
) : UserService {
    
    override fun login(): String {
        val token = "token"
        userComponentManager.onLogin(User(token))

        return token
    }

    override fun logout() {
        userComponentManager.onLogout()
    }
}

샘플 앱이라 간단하게 레포지토리에서 유저에 관한 정보를 받고 서비스에서 로그인, 로그아웃을 처리해주도록 했습니다.

@Module
@InstallIn(UserComponent::class)
internal interface UserModule {

    @Binds
    @UserScope
    fun bindUserRepository(userRepository: UserRepositoryImpl): UserRepository

    @Binds
    @UserScope
    fun bindUserService(userService: UserServiceImpl): UserService
}

유저 스코프에 맞게 레포지토리와 서비스를 바인딩해줍니다.

Presentation Layer

interface SignInViewModelDelegate {
    val user: StateFlow<User?>
}

class SignInViewModelDelegateImpl @Inject constructor(
    @Bridged userRepository: UserRepository
) : SignInViewModelDelegate {

    override val user = userRepository.getUser()
        .stateIn(CoroutineScope(Dispatchers.Default), SharingStarted.WhileSubscribed(5000), null)

}

@HiltViewModel
class LoginViewModel @Inject constructor(
    @Bridged private val userService: UserService,
    @Bridged signInViewModelDelegate: SignInViewModelDelegate
) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate {

    fun onLogin() {
        userService.login()
    }
}

여러 뷰모델에서 사용할 Sign In 기능을 가지는 뷰모델 딜리게이트를 만들고, 뷰모델에서 delegation을 적용해 해당 뷰모델을 상속받습니다.

싱글톤 스코프에서 유저스코프에 해당하는 UserSerivce와 SignInViewModelDelegate를 받기 위해 @Bridged Qualifier를 적용한 것을 볼 수 있습니다.

Integration

@Module
@InstallIn(UserComponent::class)
internal interface UserModule {

    @Binds
    @UserScope
    fun bindSignInViewModelDelegate(signInViewModelDelegate: SignInViewModelDelegateImpl): SignInViewModelDelegate
}

@EntryPoint
@InstallIn(UserComponent::class)
internal interface UserComponentBridge {
    fun provideUserService(): UserService
    fun provideUserRepository(): UserRepository
    fun provideSignInViewModelDelegate(): SignInViewModelDelegate
}

SignInViewModelDelegate를 UserScope에 맞게 제공하기 위해 모듈을 만들어줍니다.

그리고 그에 해당하는 UserService, UserRepository, SignInViewModelDelegate를 넘겨주기 위한 EntryPoint도 만들어줍니다.

@Module
@InstallIn(SingletonComponent::class)
object BridgeModule {

    @Bridged
    @Provides
    fun provideUserService(
        userComponentManager: UserComponentManager
    ): UserService {
        return EntryPoints.get(
            userComponentManager, UserComponentBridge::class.java
        ).provideUserService()
    }

    @Bridged
    @Provides
    fun provideUserRepository(
        userComponentManager: UserComponentManager
    ): UserRepository {
        return EntryPoints.get(
            userComponentManager, UserComponentBridge::class.java
        ).provideUserRepository()
    }

    @Bridged
    @Provides
    fun provideSignInViewModelDelegate(
        userComponentManager: UserComponentManager
    ): SignInViewModelDelegate {
        return EntryPoints.get(
            userComponentManager, UserComponentBridge::class.java
        ).provideSignInViewModelDelegate()
    }
}

마지막으로 Signleton Scope에서 User Scope에 해당하는 종속성 항목들을 제공하기 위해 모듈에서 EntryPoints를 통해 뽑아와서 넘겨줍니다.

Conclusion

아래와 같은 시나리오를 통해 제대로 로그인 관리가 이루어지는지 확인해보겠습니다.

  • 로그인 화면에서 유저 확인
  • 메인 화면 진입 후 유저 확인 및 관련 클래스들 재생성 확인
  • 그 다음 화면에 진입 후 유저 확인 및 관련 클래스들이 재생성 안 됐는지 확인

이 시나리오는 젯팩 컴포즈 네비게이션 환경에서 이루어졌습니다. 기본적으로 뷰모델은 해당 화면에 진입할 때 생성되며 네비게이션 스택에서 빠질 때 해제됩니다.

로그인 화면에서 유저 확인

@Composable
fun LoginScreen(
    navController: NavController,
    loginViewModel: LoginViewModel = hiltViewModel()
) {
    val user by loginViewModel.user.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        Text(user?.token?: "null")
        Button(onClick = {
            loginViewModel.onLogin()
            navController.navigate("main")
        }) {
            Text("Login")
        }
    }
}

@HiltViewModel
class LoginViewModel @Inject constructor(
    @Bridged private val userService: UserService,
    @Bridged signInViewModelDelegate: SignInViewModelDelegate
) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate {

    fun onLogin() {
        userService.login()
    }
}

다음과 같이 로그인 화면을 구성하여 유저 정보를 출력해보겠습니다.

로그인 화면에서 유저가 널인 것을 볼 수 있습니다.

메인 화면으로 진입해보겠습니다.

@Composable
fun MainScreen(
    navController: NavController,
    mainViewModel: MainViewModel = hiltViewModel()
) {
    val user by mainViewModel.user.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        Text(user?.token?: "null")
        Button(onClick = {
            mainViewModel.onLogout()
            navController.popBackStack()
        }) {
            Text("Logout")
        }
        Button(onClick = {
            navController.navigate("more")
        }) {
            Text("More")
        }
    }
}

@HiltViewModel
class MainViewModel @Inject constructor(
    @Bridged private val userService: UserService,
    @Bridged signInViewModelDelegate: SignInViewModelDelegate
) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate {

    fun onLogout() {
        userService.logout()
    }
}

메인 화면 진입시 서비스에서 설정한 토큰 값이 제대로 나오는 것을 볼 수 있습니다. 또한, 로그아웃 했을 때 유저 값이 널인 것을 마찬가지로 볼 수 있습니다.

@Composable
fun MoreScreen(
    navController: NavController,
    moreViewModel: MoreViewModel = hiltViewModel()
) {
    val user by moreViewModel.user.collectAsState()

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        Text(user?.token?: "null")
        Button(onClick = {
            navController.popBackStack()
        }) {
            Text("Back")
        }
    }
}

@HiltViewModel
class MoreViewModel @Inject constructor(
    @Bridged signInViewModelDelegate: SignInViewModelDelegate
) : ViewModel(), SignInViewModelDelegate by signInViewModelDelegate {

}

메인화면에서 다음화면으로 넘어 갔을 때 유저가 유지되는 것을 볼 수 있습니다. 마찬가지로 로그아웃 했을 때 유저가 널인 것을 볼 수 있습니다.


References

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

0개의 댓글