티스토리 뷰
회원가입 UI
회원가입을 하려면 이메일, 이름, 비밀번호를 입력해도록 구현했다.
회원가입 Flow
1. 이메일 입력
2. 인증 요청 버튼 클릭
3. 입력한 이메일과 선언해둔 임시 비밀번호로 임시 계정 생성
4. 파이어베이스는 회원가입 시 자동으로 로그인을 진행함
5. 로그인된 사용자 정보 기반으로 이메일 인증 요청 메일 수신
6. 이메일 인증 완료 혹은 가입 취소시 임시계정 삭제
7. 모든 밸리데이션 통과 후 회원가입 버튼 누르면 실제 계정 생성
8. 로그인 화면으로 라우팅
파이어베이스는 이미 가입된 유저만이 이메일 인증을 할 수 있기에
위와 같은 흐름으로 파이어베이스 이메일 회원가입을 구현할 것이다.
파이어베이스 설정
Authentication 탭에 들어가서 이메일/비밀번호 로그인을 활성화 해준다.
그래야 이메일 인증 요청 메일을 수신할 수 있다.
파이어스토어 설정
유저 정보를 저장할 document를 생성해준다.
파이어베이스는 이메일 회원가입과 구글 로그인 서비스를 지원하기에
소셜 로그인을 구글만 구현할거면 해당 document를 만들 필요가 없지만
본인은 구글, 카카오, 네이버 총 3개의 소셜 로그인을 구현했기에
계정의 고유한 키에 해당하는 이메일 값이 겹치지지 않게 하고,
유저 정보를 편하게 가져오기 하기 위해서 파이어스토어를 사용하였다.
임시 계정 비밀번호 선언
object Secret {
const val TEMP_PASSWORD = "" // 임의의 난수
}
임시 비밀번호는 뭘로 설정해도 상관없다.
...
local.properties
app/src/main/java/com/hamond/escapeanchovy/constants/Secret.kt
해당 상수값은 .gitignore에서 깃허브에 올라가지 않도록 설정해준다.
유틸 함수 선언
object AccountUtils{
fun isValidEmail(email: String): Boolean {
return Patterns.EMAIL_ADDRESS.matcher(email).matches()
}
fun isValidName(name: String): Boolean {
val regex = "^[a-zA-Z0-9\\s가-힣ㄱ-ㅎㅏ-ㅣ]*$".toRegex()
return regex.matches(name)
}
fun isValidPw(password: String): Boolean {
val regex =
"^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"|,.<>/?]).{8,20}$".toRegex()
return regex.matches(password)
}
fun hashPw(pw: String): String {
val data = pw.toByteArray()
val sha256 = MessageDigest.getInstance("SHA-256")
val hashValue = sha256.digest(data)
return hashValue.joinToString("") { "%02x".format(it) }
}
}
회원가입시 쓰이는 유틸 함수들을 정의해준다.
회원가입 상태 정의
sealed class SignUpState {
data object Init : SignUpState()
data object EmailLoading : SignUpState()
data object NameLoading : SignUpState()
data object EmailVerified : SignUpState()
data object NameVerified: SignUpState()
data object SignUp: SignUpState()
data class Error(val error: String?) : SignUpState()
}
코드가 너무 길어지는게 싫어서 로딩 상태명을 좀 줄여서 지었는데
정확히는 EmailVerficationLoading, NameDuplicateCheckLoading에 해당한다.
AuthRepository
class AuthRepositoryImpl @Inject constructor(
private val auth: FirebaseAuth
) : AuthRepository {
override suspend fun createTempAccount(email: String): AuthResult {
try {
return auth.createUserWithEmailAndPassword(email, TEMP_PASSWORD).await()
} catch (e: Exception) {
throw Exception("임시 계정 생성 오류: ${e.message}")
}
}
override fun sendEmailVerification(user: FirebaseUser?) {
try {
user?.sendEmailVerification()
} catch (e: Exception) {
throw Exception("이메일 인증 수신 오류: ${e.message}")
}
}
override fun checkEmailVerification(): Boolean {
try {
val user = auth.currentUser!!
user.reload()
return user.isEmailVerified
} catch (e: Exception) {
throw Exception("이메일 인증 확인 오류: ${e.message}")
}
}
override fun loginTempAccount(email: String) {
try {
auth.signInWithEmailAndPassword(email, TEMP_PASSWORD)
} catch (e: Exception) {
throw Exception("임시계정 로그인 오류: ${e.message}")
}
}
override fun deleteTempAccount() {
try {
auth.currentUser?.delete()
} catch (e: Exception) {
throw Exception("임시 계정 삭제 오류: ${e.message}")
}
}
}
파이어베이스 이메일 인증을 위해 필요한 함수들을 선언한다.
각각의 함수들은 하나의 역할만을 하도록 설계한다.
StoreRepository
class StoreRepositoryImpl @Inject constructor(
private val store: FirebaseFirestore
) : StoreRepository {
override suspend fun saveAccountInfo(user: User) {
try {
store.collection(USER).document(user.email).set(user).await()
} catch (e: Exception) {
throw Exception("유저 정보 저장 에러: ${e.message}")
}
}
override suspend fun isEmailDuplicate(email: String): Boolean {
try {
val query = store.collection(USER)
.whereEqualTo("email", email).limit(1).get().await()
return !query.isEmpty
} catch (e: Exception) {
throw Exception("이메일 중복 체크 에러: ${e.message}")
}
}
override suspend fun isNameDuplicate(name: String): Boolean {
try {
val query = store.collection(USER)
.whereEqualTo("name", name).limit(1).get().await()
return !query.isEmpty
} catch (e: Exception) {
throw Exception("이메일 중복 체크 에러: ${e.message}")
}
}
...
}
데이터베이스에 저장된 유저정보를 기반으로
이메일 및 이름 중복체크를 수행하는 함수를 작성한다.
SignUpViewModel
@HiltViewModel
class SignUpViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val authRepository: AuthRepository,
private val storeRepository: StoreRepository
) : ViewModel() {
private val _signUpState = MutableStateFlow<SignUpState>(SignUpState.Init)
val signUpState: StateFlow<SignUpState> = _signUpState.asStateFlow()
private val _emailValidation = mutableStateOf("")
val emailValidation: State<String> = _emailValidation
private val _nameValidation = mutableStateOf("")
val nameValidation: State<String> = _nameValidation
private val _pwValidation = mutableStateOf("")
val pwValidation: State<String> = _pwValidation
private val _pwCheckValidation = mutableStateOf("")
val pwCheckValidation: State<String> = _pwCheckValidation
private var isEmailVerified = false
private var isNameVerified = false
private var isPasswordValid = false
private var isPasswordCheckValid = false
suspend fun emailValidationBtnAction(email: String) {
when {
email.isBlank() -> {
_emailValidation.value = "이메일을 입력해주세요."
}
!isValidEmail(email) -> {
_emailValidation.value = "올바른 이메일 형식이 아닙니다."
}
isValidEmail(email) -> {
_signUpState.value = SignUpState.EmailLoading
checkEmailDuplicated(email)
}
}
}
private suspend fun checkEmailDuplicated(email: String) {
try {
val isEmailDuplicated = storeRepository.isEmailDuplicate(email)
if (!isEmailDuplicated) {
_emailValidation.value = "이메일 인증 요청을 보내는 중입니다."
sendEmailVerification(email)
_emailValidation.value = "이메일 인증을 진행해주세요."
checkEmailVerification()
if (isEmailVerified) {
_signUpState.value = SignUpState.EmailVerified
_emailValidation.value = "이메일 인증이 완료되었습니다."
}
} else {
_signUpState.value = SignUpState.Init
_emailValidation.value = "이미 사용 중인 이메일입니다."
}
} catch (e: Exception) {
_signUpState.value = SignUpState.Error(e.message)
}
}
private suspend fun sendEmailVerification(email: String) {
val user = authRepository.createTempAccount(email).user
authRepository.sendEmailVerification(user)
saveUserEmail(context, email)
}
private suspend fun checkEmailVerification() {
while (true) {
if (authRepository.checkEmailVerification()) {
isEmailVerified = true
break
}
delay(2000L)
}
}
suspend fun validateName(name: String) {
when {
name.isBlank() -> {
_nameValidation.value = "이름을 입력해주세요."
}
!isValidName(name) -> {
_nameValidation.value = "이름엔 특수문자를 포함할 수 없습니다."
}
else -> {
_signUpState.value = SignUpState.NameLoading
checkNameDuplicated(name)
}
}
}
private suspend fun checkNameDuplicated(name: String) {
try {
val isNameDuplicated = storeRepository.isNameDuplicate(name)
if (isNameDuplicated) {
_signUpState.value = SignUpState.Init
_nameValidation.value = "이미 사용 중인 이름입니다."
} else {
_signUpState.value = SignUpState.NameVerified
_nameValidation.value = "사용 가능한 이름입니다."
isNameVerified = true
}
} catch (e: Exception) {
_signUpState.value = SignUpState.Error(e.message)
}
}
suspend fun signUpSubmitBtnAction(
email: String,
name: String,
pw: String,
pwCheck: String
) {
if (isEmailVerified && isNameVerified) {
if (pw.isEmpty()) {
_pwValidation.value = "비밀번호를 입력해 주세요."
} else if (!isValidPw(pw)) {
_pwValidation.value = "비밀번호 형식이 올바르지 않습니다."
} else {
_pwValidation.value = ""
isPasswordValid = true
}
}
if (isEmailVerified && isNameVerified && isPasswordValid) {
if (pw != pwCheck) {
_pwCheckValidation.value = "비밀번호가 일치하지 않습니다."
} else {
_pwCheckValidation.value = ""
isPasswordCheckValid = true
}
}
if (isEmailVerified && isNameVerified && isPasswordValid && isPasswordCheckValid) {
val user = User(email, name, hashPw(pw))
saveAccountInfo(user)
_signUpState.value = SignUpState.SignUp
}
}
private suspend fun saveAccountInfo(user: User) {
try {
storeRepository.saveAccountInfo(user)
} catch (e: Exception) {
_signUpState.value = SignUpState.Error(e.message)
}
}
fun deleteTempAccount() {
val inputEmail = getUserEmail(context)
if (inputEmail != null) {
try {
authRepository.loginTempAccount(inputEmail)
authRepository.deleteTempAccount()
} catch (e: Exception) {
_signUpState.value = SignUpState.Error(e.message)
}
}
}
fun initSignUpResult() {
_signUpState.value = SignUpState.Init
}
}
이메일 인증 로직이 좀 많이 길어서 단계별로 나눠서 작성하였다.
이메일 인증 요청 메일 수신 후 인증 확인은 롱 폴링 방식으로 진행하였다.
이메일 인증요청을 보내는 시점에서 유저가 작성한 이메일 값을
로컬 저장소에 저장하고 해당 이메일 값을 활용하여 임시계정을 지우도록 하였다.
키보드가 스크롤 이슈
공통적으로 사용할 위젯 구조 선언
@Composable
fun TextAndValidation(
text: String,
validation: String,
isVerified: Boolean = false
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = text,
style = CustomTheme.typography.b4Regular.copy(CustomTheme.colors.text)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = validation, style = CustomTheme.typography.caption1.copy(
color = if (isVerified) CustomTheme.colors.check else CustomTheme.colors.error
)
)
}
}
밸리데이션 메시지는 텍스트 옆에 배치시킨다.
인증 여부에 따라 밸리데이션 메세지의 색깔이 바뀌도록 설정했다.
@Composable
fun TextFieldAndButton(
textField: @Composable () -> Unit,
button: @Composable () -> Unit,
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Box(modifier = Modifier.weight(3f)) { textField.invoke() }
Spacer(modifier = Modifier.width(16.dp))
Box(modifier = Modifier.weight(1f)) { button.invoke() }
}
}
텍스트필드와 버튼은 3:1의 너비를 가지도록 설정했다.
SignUpScreen
@Composable
fun SignUpScreen(navController: NavHostController) {
// 뷰모델 관련 변수 선언
val coroutineScope = rememberCoroutineScope()
val signUpViewModel = hiltViewModel<SignUpViewModel>()
// 밸리데이션 메시지 선언
val emailValidation = signUpViewModel.emailValidation.value
val nameValidation = signUpViewModel.nameValidation.value
val pwValidation = signUpViewModel.pwValidation.value
val pwCheckValidation = signUpViewModel.pwCheckValidation.value
// 비동기 작업 진행 여부
var isEmailLoading by remember { mutableStateOf(false) }
var isNameLoading by remember { mutableStateOf(false) }
// 비동기 작업 완료 여부
var isEmailVerified by remember { mutableStateOf(false) }
var isNameVerified by remember { mutableStateOf(false) }
// 사용자 입력값들
var email by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
var pw by remember { mutableStateOf("") }
var pwCheck by remember { mutableStateOf("") }
// 회원가입 버튼 활성화 조건
val signUpEnable =
isEmailVerified && isNameVerified && pw.isNotEmpty() && pwCheck.isNotEmpty()
// 회원가입 상태별 동작 정의
LaunchedEffect(Unit) {
// 이메일 인증 요청을 보낸 후 가입 취소 버튼을 누르거나 이메일 인증을 완료하지 않았을 때
// 생성된 임시계정에 로그인하여 이메일 인증을 보내기 위해 생성한 임시계정을 제거한다.
signUpViewModel.deleteTempAccount()
signUpViewModel.signUpState.collect { signUpState ->
when (signUpState) {
// 초기 상태 -> 비동기 작업 진행 여부 변수를 초기값으로 초기화
is SignUpState.Init -> {
isEmailLoading = false
isNameLoading = false
}
// 이메일 인증 시도 시
is SignUpState.EmailLoading -> {
isEmailLoading = true
}
// 이름 중복 확인 시
is SignUpState.NameLoading -> {
isNameLoading = true
}
// 이메일 인증 완료 시
is SignUpState.EmailVerified -> {
isEmailLoading = false
isEmailVerified = true
signUpViewModel.deleteTempAccount()
}
// 이름 중복 확인 완료 시
is SignUpState.NameVerified -> {
isNameLoading = false
isNameVerified = true
}
// 회원가입 시
is SignUpState.SignUp -> {
signUpViewModel.initSignUpResult()
val route = "$COMPLETE/sign_up"
navController.navigate(route)
}
// 에러 발생 시
is SignUpState.Error -> {
Log.e("SignUp", "${signUpState.error}")
}
}
}
}
DisposableEffect(navController){
onDispose {
signUpViewModel.deleteTempAccount()
}
}
ContentResizingScreen(contentColumn = {
Spacer(modifier = Modifier.height(48.dp))
SignUpTitleAndExplain()
Spacer(modifier = Modifier.height(28.dp))
TextAndValidation(
text = "이메일",
validation = emailValidation,
isVerified = isEmailVerified
)
Spacer(modifier = Modifier.height(8.dp))
TextFieldAndButton(
textField = {
OutlinedTextField(
value = email,
onValueChange = { email = it },
hint = "이메일 입력",
enabled = !isEmailLoading && !isEmailVerified
)
},
button = {
OutlinedButton(
onClick = { coroutineScope.launch {
signUpViewModel.emailValidationBtnAction(email)
}},
text = "인증 요청",
color = CustomTheme.colors.skyBlue,
enabled = !isEmailLoading && !isEmailVerified
)
}
)
Spacer(modifier = Modifier.height(28.dp))
TextAndValidation(
text = "이름",
validation = nameValidation,
isVerified = isNameVerified
)
Spacer(modifier = Modifier.height(8.dp))
TextFieldAndButton(
textField = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
hint = "이름 입력 (10자 제한)",
maxLength = 10,
enabled = !isNameLoading && !isNameVerified,
)
},
button = {
OutlinedButton(
onClick = { coroutineScope.launch { signUpViewModel.validateName(name) } },
text = "중복 확인",
color = CustomTheme.colors.orange,
enabled = !isNameLoading && !isNameVerified
)
}
)
Spacer(modifier = Modifier.height(28.dp))
TextAndValidation(
text = "비밀번호",
validation = pwValidation
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = pw,
onValueChange = { pw = it },
hint = "비밀번호 입력 (영문, 숫자, 특수문자 포함 8~20자)",
maxLength = 20,
isPassword = true
)
Spacer(modifier = Modifier.height(28.dp))
TextAndValidation(
text = "비밀번호 확인",
validation = pwCheckValidation
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = pwCheck,
onValueChange = { pwCheck = it },
hint = "비밀번호 재입력",
maxLength = 20,
isPassword = true,
isLastField = true
)
}, bottomRow = {
Box(modifier = Modifier.weight(1f)) {
Button(
text = "가입 취소",
onClick = { navController.popBackStack() },
color = CustomTheme.colors.orange
)
}
Spacer(modifier = Modifier.width(16.dp))
Box(modifier = Modifier.weight(1f)) {
Button(
text = "회원가입",
onClick = {
coroutineScope.launch {
signUpViewModel.signUpSubmitBtnAction(
email = email,
name = name,
pw = pw,
pwCheck = pwCheck
)
}
},
color = CustomTheme.colors.skyBlue,
enabled = signUpEnable
)
}
})
}
이메일 인증 및 이름 중복 체크를 할 때 중복되는 요청 및 입력값 변경을 방지하기 위해
로딩 상태 및 인증 여부에 따라 텍스트필드와 버튼의 활성화 상태를 컨트롤 한다.
임시계정 삭제하기
임시계정 삭제 함수를 이메일 인증이 완료되는 시점,
해당 화면에 진입한 시점, 해당 화면이 종료되는 시점에서 실행시킨다.
이로써, 이메일 인증 버튼을 누른 뒤 인증을 진행하지 않은 상태에서
회원가입을 취소해도 임시계정을 삭제하도록 구현할 수 있다.
실행 화면
정말 많은 시행착오가 있었다..
회고
내배캠 팀프로젝트에선 해당 로직을 콜백으로 작성했는데
확실히 suspend 함수로 리팩토링 하니까 훨씬 읽기가 편한 것 같다.
원래는 가입 취소 버튼을 누를 때와 디바이스의 뒤로가기 버튼을 누를 때
이렇게 두 시점마다 따로 임시계정을 삭제하도록 구현했었는데
컴포즈에서는 현재 화면이 종료됨을 감지해주는 DisposableEffect가
존재해 로직을 간결하게 작성할 수 있어 흡족했다.
'멸치탈출 > 개발일지' 카테고리의 다른 글
Compose 파이어베이스 이메일 찾기/비밀번호 재설정 구현 (5) | 2024.11.15 |
---|---|
Compose 로그인 화면 구현 (구글, 카카오, 네이버 소셜 로그인) (2) | 2024.11.11 |
Compose 커스텀 위젯 만들기 (1) | 2024.11.10 |
프로젝트 기본 설정 (2) | 2024.11.07 |
안드로이드 개인 프로젝트 구상 (기술적 의사 결정) (7) | 2024.10.29 |