개요
프로젝트에서 소셜 로그인을 적용하던 중 늘어나는 메서드와 분기 처리를 하나로 묶고 싶어지는 욕망이 커져갔다..
현재 프로젝트의 MVP 기능에서는 구글 로그인만 있지만 네이버, 애플, 카카오 등 다양한 소셜 로그인을 지원할 전망이기 때문에 이를 대비하여 깔끔하게 짜려고 한다
즉, 내가 해결하고자 하는 문제는
1) 늘어나는 메서드와 그에 의존하는 로직
2) 조건문 제거
3) 확장성 대비
이다.
그래서 사용한 디자인 패턴이 전략 패턴이다
전략 패턴(Strategy Pattern)이란?
전략 패턴은 런타임 중에 알고리즘 전략을 선택하여 객체 동작을 실시간으로 바뀌도록 할 수 있게 하는 행위 디자인 패턴이다.
나의 전략은 '소셜 로그인 방법'이 되는 것이다.
필자가 이해한 전략 패턴의 포인트 아래와 같다.
1) 비슷한 역할을 하는 로직을 정의
2) 해당 로직을 캡슐화
3) 배터리 갈아 끼우듯 교환 가능하도록 설계
4) 클라이언트가 뭘 하든 상관없이 나는 전략을 갈아 끼울 수 있어야 한다.
5) 다양하게 변경할 수 있어야 한다
전략 설계
필자는 아래와 같이 전략을 설계했다.
`~Strategy`와 같은 객체는 소셜 로그인 방법이 되는 것이다.
`BaseStrategy` : 전략 구현체에 대한 공용 인터페이스 역할이다. 소셜 로그인에 대한 것은 로그인, 로그아웃으로 정해져 있기 때문에 로그인, 로그아웃으로 공용 인터페이스를 활용했다.
`AuthContext` : 전략을 실행해야 할 때마다 사용하고자 하는 전략과 연결된 전략 객체의 메서드를 호출한다.
`client` : view라고 생각하여 구성했다 (버튼을 누르는 등에 대한 행위)
기존 코드의 문제점
많이 부끄럽지만.. 기존 코드를 살짝 오픈하자면..
각 소셜 로그인마다 적용되는 `enum class`를 가지고 있으며 그에 맞는 인터페이스를 적용해서 각각 버튼에 대한 아이템 리스너를 할당해 줬다
enum class SocialLoginType(..) {
GOOGLE(..);
interface OnItemClickListener {
fun onClickGoogle()
}
}
그렇게 되면 결과는 아래와 같이 클릭 한 번에 의존하게 되는 일회성 메서드만 주야장천 만들어진다.
// Intent
data class ClickToLogInWithGoogle(val context: Context): LoginIntent()
// viewModel
when(intent) {
is LoginIntent.ClickToSocialLogin -> { onClickToSocialLogin(intent.loginType, intent.context) }
}
// repository
suspend fun loginWithGoogle(context: Context): Result<Unit> { .. }
분기 처리를 도입하려고 했으나, 비슷할 것 같아서 분기를 적용하기 전에 전략 패턴을 채택했다
전략 패턴 적용의 준비의 준비의 준비
전략 패턴의 적용의 준비의 준비를 하려면, 준비의 준비의 준비를 거쳐야 한다.
1. `BaseStrategy` 로직 준비
2. `AuthContext` 로직 준비
일단 이 두 개를 준비하겠다.
BaseStrategy 준비
필요한 메서드는 `login` , `logout`이다. 인터페이스로 적용하면 좋겠지만, 소셜 로그인의 특성상 사용자 측에서 일어나는 예상치 못한 오류도 많고, 경우의 수(e.g. 카카오톡, 카카오계정 로그인)등이 많기 때문에 error를 뱉는 공통 메서드를 작성하고 추상 클래스로 바꾸어줬다.
`login` 메서드는 인가에 필요한 token을 `Result` 객체에 담아서 리턴한다.
`context`는 액티비티를 기반으로 한 context가 들어간다. (다양한 소셜 로그인의 콜백 지옥에서 벗어나는 건 미래의 내가 한다..)
abstract class BaseStrategy {
abstract suspend fun login(context: Context): Result<String>
abstract suspend fun logout(context: Context): Result<Unit>
fun throwUnexpectedError(tag: String = "BaseStrategy", message: String) {
Log.d(tag, message)
throw NofficeError.UnexpectedError
}
}
AuthContext 준비의 준비
`AuthContext`를 준비하기 전에 준비가 필요하다. (즉, 전략 패턴 적용의 준비의 준비의 준비의 준비가 되겠다 ㅋㅋ)
1. 소셜 로그인의 구분에 쓰일 Provider 필요
다음과 같이 enum class로 관리하기 쉽게 선언해 주었다. 여기서 type 파라미터에 string을 바로 때려 박지 않고 변수에 넣어서 전달한 이유는 다음 준비(2번)에 나온다
const val google = "GOOGLE"
enum class Provider(
val type: String
) {
GOOGLE(type = google);
fun getLoginRequest(code: String) = LoginRequest(..)
}
2. 전략 주입을 위한 준비
전략을 동적으로 관리할 수 있는 방법을 생각해 보다가, `Map` 자료구조를 쓰면 좋을 것 같아서 적용하기 위해 hilt를 사용했다.
`@IntoMap` : hilt에서 여러 객체를 `Map`으로 주입할 때 사용하는 어노테이션이다. 이를 사용하면 특정 키와 값의 쌍으로 객체를 `Map`에 넣을 수 있다. 이 `Map`은 주입받은 클래스에서 활용할 수 있다!
`@StringKey` : 이는 `@IntoMap` 과 함께 사용되어 `Map`의 키를 정의할 때 사용한다. 주입되는 `Map`의 각 항목에 고유한 문자열 키를 할당할 수 있다.
1번 준비에서 enum class의 파라미터에 type의 string을 하드코딩으로 넣지 않은 이유가 여기서 key에 대한 일관성을 보장하기 위해서이다.
@Module
@InstallIn(SingletonComponent::class)
object LoginStrategyModule {
@Provides
@IntoMap
@StringKey(google)
fun provideGoogleStrategy(googleStrategy: GoogleStrategy): BaseStrategy = googleStrategy
}
AuthContext 준비
앞서 준비한 것을 가지고 `AuthContext`를 작성해 주었다.
전략들을 `Map`으로 주입받는다.
`setStrategy`에서는 provider를 string으로 받고 해당하는 provider의 전략으로 갈아 끼운다. 여기서 해당하는 provider가 없을 경우 에러를 뱉는다.
그리고 `login`과 `logout`은 설정된 전략을 사용하여 메서드의 동작을 수행한다.
class AuthStrategyContext @Inject constructor(
private val strategies: Map<String, @JvmSuppressWildcards BaseStrategy>
) {
private var currentStrategy: BaseStrategy = strategies[Provider.GOOGLE.name]!!
fun setStrategy(provider: String) {
currentStrategy = strategies[provider] ?: throw IllegalArgumentException("Invalid provider")
}
suspend fun login(context: Context): Result<String> {
return currentStrategy.login(context)
}
suspend fun logout(context: Context): Result<Unit> {
return currentStrategy.logout(context)
}
}
전략 패턴 적용의 준비의 준비
다음은 전략을 하나 세워볼 것이다 (MVP 기능인 구글 로그인)
다음과 같이 `BaseStrategy`를 상속받고 오버라이딩 해주었다. 필자는 `CredentialManager` 를 사용한 구글 로그인을 구현했다.
class GoogleStrategy @Inject constructor(..) : BaseStrategy() {
private val tag = this.javaClass.name
override suspend fun login(context: Context): Result<String> = runCatching {
.. 로그인 로직 ..
}
override suspend fun logout(context: Context): Result<Unit> = runCatching {
.. 로그아웃 로직 ..
}
}
전략 패턴 적용의 준비
이제 repository로 돌아가서, 로그인 부분만 바꾸어주면 된다.
필자의 프로젝트에서는 data 모듈과 feature모듈은 서로 의존 관계가 없기 때문에 provider에 대한 정보가 없다. 그래서 일단 string으로 받고 해당 provider를 찾고 request를 날려야 한다.
class LoginRepositoryIml @Inject constructor(
@Dispatcher(IO) private val dispatcher: CoroutineDispatcher,
private val authStrategyContext: AuthStrategyContext,
private val authService: AuthService,
private val authLocalDataSource: AuthLocalDataSource,
) : LoginRepository {
override suspend fun login(context: Context, provider: String): Result<Unit> = withContext(dispatcher) {
runCatching {
authStrategyContext.setStrategy(provider)
val authCode = authStrategyContext.login(context).getOrThrow()
val request = Provider.valueOf(provider).getLoginRequest(authCode)
val user = authService.login(request).toResult().getOrThrow()
val token = user.token
authLocalDataSource.updateTokens(token.accessToken, refresh = token.refreshToken)
authLocalDataSource.updateAuthProvider(user.provider)
}
}
}
이렇게 되면 usecase도 굉장히 간편해진다. 하나의 클래스만 만들면 되기 때문이다. (기존 코드였다면 `loginWith${type} UseCase`이런 형태의 usecase가 만들어졌을 것이다..)
class LoginUseCase @Inject constructor(
private val loginRepository: LoginRepository
): BaseUseCase<AuthParam, Unit>() {
override suspend fun invoke(param: AuthParam): Result<Unit> =
loginRepository.login(param.context, param.providerName)
}
그리고 로그인 클릭을 처리하는 부분에서도 다음과 같이 선언해 주면 된다.
private fun onClickToSocialLogin(type: SocialLoginType, context: Context) = viewModelScope.launch {
loginUseCase.invoke(AuthParam(context, type.name)).onSuccess {
// FIXME 가입 된 유저 네비게이션 처리
navigateToHome()
}.onFailure {
it.printStackTrace()
}
}
이로써 1) 별도의 분기처리 없이, 2) 소셜 로그인이 추가될 때 최소한의 코드 수정으로 , 3) 클라이언트의 행동에 최대한 의존하지 않고, 소셜 로그인의 로직을 구현했다.
나중에 다양한 소셜 로그인이 추가되면,
1. 전략 클래스 생성 (로그인 로직) -> 이 부분이 제일 독립적
2. provider 추가
3. strategy 모듈에서 key-value 추가만 변경하면 된다.
나는 이제 두려울 게 없다!
해당 코드를 적용한 프로젝트는 여기서 보실 수 있습니다
https://github.com/Team-Notitime/NOFFICE-ANDROID
참고자료
https://ko.wikipedia.org/wiki/%EC%A0%84%EB%9E%B5_%ED%8C%A8%ED%84%B4
'안드로이드' 카테고리의 다른 글
[Android] 코루틴을 활용해 카카오 로그인 콜백 지옥에서 탈출하기 (5) | 2024.10.02 |
---|---|
[Android] IndicationNodeFactory 활용해 터치 인터랙션 적용하기 (0) | 2024.08.17 |
[Android] 네트워크 예외 처리 (에러 핸들링)- Retrofit CallAdapter (0) | 2024.06.16 |
[Android] MVI 패턴을 알아볼까? (1) | 2024.06.07 |
[Android] 메인 화면 성능 개선하기 대작전 (1) | 2024.04.19 |