<정리> 명함 앱 만들기 4일차
✔ 요약
Hilt 적용 성공
클린 아키텍처에 맞는 로그인 기능 흐름 구현
파이어베이스를 통한 로그인/회원가입 기능 구현
1. 힘들었던 Hilt 적용기...
왜 그리 적용이 쉽지 않은지...참 여러가지 에러를 만났고 어떻게든 뚫어냈다.
먼저 Hilt는 DI, 의존성 주입 시에 사용하는 Google의 Dagger 기반의 라이브러리다.
(의존성 주입은 함수에 필요한 클래스 또는 참조변수나 객체에 의존하는 것이라고 한다.)
이걸 적용하려고 애를 썼던 것은 코드의 재사용성 향상, 수월한 리팩토링, 의존성 감소, 유연한 코드 등 유지보수에 좋은 영향을 주고 요즘 회사들에서 많이 사용하는 기술이기 때문이다..!!
가장 먼저 한 것은 build.gradle.kts 세팅이다.
프로젝트 단의 build.gradle.kts에서는 hilt 플러그인을 추가해줬다.
plugins {
...
id("com.google.dagger.hilt.android") version "2.50" apply false
}
사용하고 있는 버전에서는 2.41 버전을 추천해주던데 그 버전으로 했을 때 다양한 에러가 계속 터져서 더 최신버전인 2.50 버전까지 올렸다.
버전을 내려도 봤는데 플러그인을 찾지 못하는 문제가 있었다.
모듈 단의 build.gradle.kts에서는 모든 모듈에 적용한 것과 presentation 모듈에만 적용한 게 있다.
모든 모듈에 적용한 것은 자바 버전을 맞추는 것이다.
버전이 필요한 버전보다 낮아서 빌드가 실패했기 때문이다.
그래서 에러 안내문에 나오는 것처럼 8버전에서 17버전으로 올려줬다.
android {
...
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
}
presentation 모듈에만 적용한 것은 플러그인 추가와 의존성 추가한 것이다.
플러그인은 코틀린에서 Annotation 처리를 위한 kotlin-kapt와 hilt를 추가했다.
plugins {
...
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
의존성에는 hilt와 hilt-compiler를 추가해줬다.
hilt는 위에서 말했지만 Android 앱에서 의존성을 자동으로 주입할 수 있게 해주는 라이브러리고 hilt-compiler는 어노테이션 프로세싱을 통해 의존성 주입에 필요한 코드를 자동으로 생성해주는 라이브러리다.
코틀린 코드 상에서 사용되는 @Inject, @HiltAndroidApp 등을 처리하는 라이브러리다.
dependencies {
...
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
}
여기도 아까 플러그인 추가한 버전과 동일하게 2.50 버전으로 맞춰줬다.
이렇게 하면 build.gradle.kts 에서 해야하는 절차는 끝이다.
이제는 코틀린 코드 상에서 직접 사용하면 된다.
가장 먼저 할 거는 Application을 상속받는 클래스를 만들어 @HiltAndoroidApp 어노테이션을 붙여주는 것이다.
이 어노테이션이 모든 작업의 시작점이다.
Hilt는 이 어노테이션을 통해 애플리케이션의 생명주기를 참고하여 컴파일 타입 때 필요한 클래스들을 초기화 해주고 의존성 객체를 제공해준다.
@HiltAndroidApp
class ClipyApplication: Application() {}
그 다음은 의존성을 주입받을 컴포넌트에 @AndroidEntryPoint 어노테이션을 붙여주는 것이다.
Hilt에서 의존성 주입을 받을 수 있는 컴포넌트는 Application, Activity, Fragment, Service, View, BroadcastReceiver, ViewModel이 있다.
주의 사항은 Fragment에서 의존성 주입을 받는다고 Fragment에만 어노테이션을 붙여주면 안되고 Fragment를 띄워주는 Activity에도 붙여줘야한다.
나는 이걸 안 해서 에러를 여러 번 봤다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
...
}
@AndroidEntryPoint
class LoginFragment : DialogFragment() {
...
}
그리고 실제로 사용하는 건 뷰모델이다.
@AndroidEntryPoint가 붙어있는 컴포넌트 내에서 @Inject가 달린 필드에 의존성 주입을 한다.
@HiltViewModel
class LoginViewModel @Inject constructor(
private val progressLogin: ProgressLogin
): ViewModel() {
...
}
의존성 주입을 함으로써 해당 유즈케이스를 싱글톤 패턴으로 뷰모델에서 사용할 수 있다.
해당 유즈케이스는 domain 계층에 있고 인터페이스를 파라미터로 받는 클래스다.
이렇게 생성자를 사용할 수 없는 클래스를 주입하기 위해 @Module 어노테이션을 붙인 클래스를 만들어 그 안에 객체 생성방법을 정의해서 의존성 주입이 가능하도록 했다.
여기에 객체 생성방법을 정의하지 않은채로 @Inject 하려고 하면 빌드 시 에러가 뜨면서 프로그램이 실행되지 않는다.
@Module
@InstallIn(SingletonComponent::class)
class DataModule {
@Provides
@Singleton
fun provideAuthRepository(): AuthRepository = AuthRepositoryImpl()
@Provides
fun provideProgressLogin(authRepository: AuthRepository): ProgressLogin = ProgressLogin(authRepository)
@Provides
fun provideProgressSignup(authRepository: AuthRepository): ProgressSignup = ProgressSignup(authRepository)
}
이걸 구성하는데는 https://github.com/AliAsadi/Android-Clean-Architecture/tree/master 여기의 도움을 많이 받았다.
compose로 화면이 구성되어있긴 하지만 클린 아키텍처가 잘 정리되어 있고 DI도 잘 구성되어있어 공부가 많이 되고 있다.
유즈케이스 구성은 invoke를 사용해서 했다.
여러 기능을 담당하는게 하니라 단 하나의 기능에 대한 것만 진행하기 때문이다.
class ProgressLogin(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(email: String, password: String): Result<FirebaseUser> = authRepository.login(email, password)
}
파라미터로 사용되는 AuthRepository는 인터페이스로 이걸 직접 구현하는건 data 계층의 AuthRepositoryImpl이다.
반환값으로 Result<FirebaseUser>를 반환한다.
interface AuthRepository {
suspend fun login(email: String, password: String): Result<FirebaseUser>
suspend fun signup(email: String, password: String): Result<FirebaseUser>
}
AuthRepositoryImpl은 위에서 말한 것처럼 domain 계층의 AuthRepository를 상속받아 구현된다.
try-catch 문으로 예외처리를 했고 값이 왔는데 비어있는 경우도 처리하기 위해 Throwable을 상속받는 예외 클래스를 만들었다.
class DataNullException: Throwable("Data is Null")
class AuthRepositoryImpl: AuthRepository {
private val auth = FirebaseAuth.getInstance()
override suspend fun login(
email: String,
password: String
): Result<FirebaseUser> {
return try {
val result = auth.signInWithEmailAndPassword(email, password).await()
val user = result.user
if (user != null) {
Result.success(user)
} else {
Result.failure(DataNullException())
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
FirebaseAuth.getInstance()도 뭔가 의존성 주입을 통해 해야할 거 같다는 생각이 들지만 아직 감을 잡지 못해서 좀더 생각하고 서칭을 해봐야할 거 같다.
이러한 과정을 거쳐서 아주 기초적인 Hilt 적용을 해봤다.
이제 계속 쌓아가는 것만 남았다.
아직 제대로 이해가 되지 않는 어노테이션이나 구조가 있는데 이건 매번매번 따로 정리를 하며 채워나갈 것이다.
2. 파이어베이스 인증을 활용한 로그인 기능 구현
현재는 이메일을 이용하는 것만 가능하다.
가장 먼저 하는 파이어베이스와 프로젝트를 연결하는 것은 그냥 패스하겠다.
레이아웃은 다음과 같이 구성했다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
tools:context=".ui.login.LoginFragment">
<ImageView
android:id="@+id/iv_back_arrow_login"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:src="@drawable/ic_arrow_back"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_description_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginTop="32dp"
android:gravity="start"
android:text="Clipy의\n기능을 사용하려면\n로그인이 필요합니다"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/iv_back_arrow_login" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_email_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="32dp"
android:hint="이메일"
app:errorEnabled="true"
app:hintAnimationEnabled="true"
app:hintEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_description_login">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tie_email_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_edittext_for_login_area"
android:inputType="textEmailAddress"
android:maxLength="30" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/til_password_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="4dp"
android:hint="비밀번호"
app:errorEnabled="true"
app:hintAnimationEnabled="true"
app:hintEnabled="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/til_email_login">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tie_password_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_edittext_for_login_area"
android:inputType="textPassword"
android:maxLength="20" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btn_sign_in_login"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginHorizontal="20dp"
android:layout_marginVertical="4dp"
android:backgroundTint="#03A9F4"
android:text="로그인"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/til_password_login" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_additional_func_login"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginHorizontal="20dp"
android:layout_marginVertical="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btn_sign_in_login">
<TextView
android:id="@+id/tv_find_email_btn_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="이메일 찾기"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/materialDivider3"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider3"
android:layout_width="1dp"
android:layout_height="0dp"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/tv_find_password_btn_login"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/tv_find_email_btn_login"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_find_password_btn_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="비밀번호 찾기"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/materialDivider3"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="20dp"
android:layout_marginTop="16dp"
android:background="#EFEFEF"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/cl_additional_func_login">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="24dp"
android:text="이메일이 없으신가요?"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_move_signup_page_login"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/background_move_signup_page_btn"
android:text="회원가입"
android:textColor="#03A9F4"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_line_login"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginHorizontal="32dp"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="@+id/btn_with_google_login"
app:layout_constraintStart_toStartOf="@+id/btn_with_google_login"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout2">
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider2"
android:layout_width="0dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/tv_or_login"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_or_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:text="SNS 계정으로 로그인"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/materialDivider"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/materialDivider2"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.divider.MaterialDivider
android:id="@+id/materialDivider"
android:layout_width="0dp"
android:layout_height="1dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/tv_or_login"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btn_with_kakao_login"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginTop="8dp"
android:background="@drawable/background_social_login_btn"
android:backgroundTint="#FEE712"
android:src="@drawable/background_icon_kakao"
app:layout_constraintEnd_toStartOf="@+id/btn_with_google_login"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/cl_line_login" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btn_with_naver_login"
android:layout_width="50dp"
android:layout_height="50dp"
android:background="@drawable/background_social_login_btn"
android:backgroundTint="#03C75A"
android:src="@drawable/background_icon_naver"
app:layout_constraintBottom_toBottomOf="@+id/btn_with_google_login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/btn_with_google_login"
app:layout_constraintTop_toTopOf="@+id/btn_with_google_login" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/btn_with_google_login"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginHorizontal="16dp"
android:background="@drawable/background_social_login_btn"
android:backgroundTint="#F4F4F4"
android:src="@drawable/background_icon_google"
app:layout_constraintBottom_toBottomOf="@+id/btn_with_kakao_login"
app:layout_constraintEnd_toStartOf="@+id/btn_with_naver_login"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/btn_with_kakao_login" />
</androidx.constraintlayout.widget.ConstraintLayout>
이전까지는 한번도 써보지 않았던 TextInputLayout과 TextInputEditText를 사용해서 구성했다.
이렇게 했을 때 좋았던 점은 힌트의 기본 애니메이션 동작과 에러 처리방식이었다.
레이아웃을 구성하면서 핀터레스트를 적극 활용했는데 생각보다 깔끔하게 나온 거 같아 역시 레퍼런스가 중요하다는 것을 느꼈다.
앱의 주요 기능을 사용하려고 터치할 때 로그인이 되어있지 않다면 해당 화면을 띄워줄 것이지만 일단은 메인 페이지에서 이미지를 눌렀을 때 나타나도록 해놨다.
해당 화면에서 이메일과 비밀번호를 형식에 맞게 다 입력했을 때 뷰모델의 onLoginBtnClicked 함수를 호출했다.
private fun initView() = with(binding) {
btnSignInLogin.setOnClickListener(OnSingleClickListener {
if (tilEmailLogin.error == null && tilPasswordLogin.error == null && !tieEmailLogin.text.isNullOrEmpty() && !tiePasswordLogin.text.isNullOrEmpty()) {
viewModel.onLoginBtnClicked(tieEmailLogin.text.toString(), tiePasswordLogin.text.toString())
} else {
Toast.makeText(requireContext(), "이메일과 비밀번호를 올바르게 입력해주세요", Toast.LENGTH_SHORT)
.show()
}
})
...
}
그럼 뷰모델의 onLoginBtnClicked 함수에서는 코루틴을 이용하여 domain 계층에 있는 ProgressLogin 유즈케이스를 호출한다.
그리고 돌아오는 액션을 보고 uiState의 값을 정해준다.
uiState는 StateFlow로 설정했다.
LiveData는 Android 플랫폼에 종속적이고 UI가 없는 곳에서 LiveData를 사용하기가 어렵다는 점을 듣고 이번에 클린 아키텍처를 적용해 진행할 때는 이 부분을 넘어가보고자 StateFlow를 사용해봤다.
- StateFlow의 장점
Android 플랫폼에 종속적이지 않고 Kotlin 자체에 포함된 요소
Activity/Fragment가 파괴되거나 View가 분리된 경우 데이터 누출을 방지할 수 있다는 것
...(뭔가 더 많은데 아직 와닿지 않고 잘 이해가 되지 않아 나중에 다시 정리해야할듯)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val progressLogin: ProgressLogin
): ViewModel() {
private val _uiState: MutableStateFlow<LoginState> = MutableStateFlow(LoginState.DEFAULT)
val uiState = _uiState.asStateFlow()
fun onLoginBtnClicked(email: String, password: String) {
viewModelScope.launch {
progressLogin(email, password).onSuccess {
_uiState.value = LoginState.SUCCESS
}.onFailure {
_uiState.value = LoginState.FAILURE
}
}
}
}
domain 계층의 ProgressLogin 유즈케이스는 domain 계층의 AuthRepository 인터페이스를 파라미터로 받는 클래스이면서 이름없이 간편히 호출할 수 있는 invoke 함수를 통하여 해당 유즈케이스가 호출됐을 때 AuthRepository의 login 함수가 호출된다.
AuthRepository는 data 계층의 AuthRepositoryImpl에서 상속받아 구현되어 있기 때문에 이쪽으로 흘러간다.
class ProgressLogin(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(email: String, password: String): Result<FirebaseUser> = authRepository.login(email, password)
}
interface AuthRepository {
suspend fun login(email: String, password: String): Result<FirebaseUser>
suspend fun signup(email: String, password: String): Result<FirebaseUser>
}
data 계층의 AuthRepositoryImpl에서는 AuthRepository의 함수들을 오버라이딩하여 구현한다.
try-catch 문으로 감싸져 있고 로그인을 성공했을 때는 Result.success를, 실패하거나 에러가 난 경우에는 Result.failure를 반환했다.
여기서 문제였던 것은 Result.failure를 호출할 때는 에러를 던져야한다는 것이었다.
이걸 해결하기위해 Throwable을 상속받으며 String만 가지는 클래스를 만들어서 넣어줬다.
class AuthRepositoryImpl: AuthRepository {
private val auth = FirebaseAuth.getInstance()
override suspend fun login(
email: String,
password: String
): Result<FirebaseUser> {
return try {
val result = auth.signInWithEmailAndPassword(email, password).await()
val user = result.user
if (user != null) {
Result.success(user)
} else {
Result.failure(DataNullException())
}
} catch (e: Exception) {
Result.failure(e)
}
}
...
}
아직 회원가입 쪽은 진행이 안 됐기 때문에 보여줄 수 없다...
이렇게 흐름이 쭉 흘러갔다가 다시 반대로 가며 로그인이 완료된다.
정말 그냥 로그인 기능만 하려고 한다면 이번에 걸린 시간의 절반이면 될텐데 클린 아키텍처에, DI 다 섞어서 하려니까 정말 오래 걸렸다.
그런데 너무 재밌다..!!
새롭고 짜릿하다...
남은 회원가입이랑 소셜 로그인도 이 기세 그대로 잘 갈 수 있기를~~~