[Android] 회원가입 코드 수정하기

Sdoubleu·5일 전
0

Android

목록 보기
17/19
post-thumbnail

수정 전 코드의 문제점

Data class만 생성 후 각 입력 칸에 해당하는 조건은 viewModel에서 관리를 하여 다뤘었다.
ui 관련문제니까 fragment나 utils에 따로 관리를 했어야 하지 않았을까 🤔

그래서 코드가 너무 더럽고 확인해야 하는게 너무 번거로웠기 때문에 코드를 전면 수정했다. 실제로 수정하기 까지 내가 작성했던 코드를 이해하기에 번거로웠음


수정 후 코드

Data class

data class SignupAddInfo(
    val nickname: String = "",
    val gender: String = "", // "M" or "F"
    val vegetarianType: String = "",
    val birthYear: Int? = null,
    val height: Int? = null,
    val weight: Int? = null
) {
    companion object {
        private const val MIN_NICKNAME_LENGTH = 2
        private const val MAX_NICKNAME_LENGTH = 10
        private val NICKNAME_REGEX = "[가-힣a-zA-Z0-9]+".toRegex()
    }

    fun validate(): Map<Field, String?> {
        return mapOf(
            Field.NICKNAME to when {
                nickname.isEmpty() -> "닉네임을 입력해주세요"
                !nickname.matches(NICKNAME_REGEX) -> "특수문자는 사용할 수 없습니다"
                nickname.length !in MIN_NICKNAME_LENGTH..MAX_NICKNAME_LENGTH -> "2~10자 이내로 입력해주세요"
                else -> null
            },
            Field.GENDER to if (gender.isEmpty()) "성별을 선택해주세요" else null,
            Field.BIRTH_YEAR to when {
                birthYear == null -> "출생연도를 입력해주세요"
                birthYear !in 1900..2999 -> "유효한 연도를 입력해주세요"
                else -> null
            },
            Field.VEGETARIAN_TYPE to when {
                vegetarianType.isEmpty() -> "비건 타입을 선택해주세요"
                else -> null
            },
            Field.HEIGHT to when {
                height == null -> "키를 입력해주세요"
                height !in 1..200 -> "유효한 키를 입력해주세요"
                else -> null
            },
            Field.WEIGHT to when {
                weight == null -> "몸무게를 입력해주세요"
                weight !in 1..200 -> "유효한 몸무게를 입력해주세요"
                else -> null
            }
        ).filterValues { it != null }
    }

    enum class Field { NICKNAME, GENDER, BIRTH_YEAR, HEIGHT, WEIGHT, VEGETARIAN_TYPE }
}

companion object로 분리한 이유

  1. 재사용성
    지금은 닉네임 길이 조건으로 사용했지만, 다른 곳에서도 필요할 때 동일한 규칙 참조 가능
  2. 가독성
    클래스의 정적 규칙을 명시적으로 그룹화
  3. 변경 용이성
    닉네임 정책 변경 시 한 곳만 수정 가능

fun validate()

fun validate(): Map<Field, String?> {
    return mapOf(
        Field.NICKNAME to when {
            nickname.isEmpty() -> "닉네임을 입력해주세요"
            !nickname.matches(NICKNAME_REGEX) -> "특수문자는 사용할 수 없습니다"
            nickname.length !in MIN_NICKNAME_LENGTH..MAX_NICKNAME_LENGTH -> "2~10자 이내로 입력해주세요"
            else -> null  // 유효한 경우 null 반환
        },
        // ... (다른 필드 검증)
    ).filterValues { it != null }  // 오류만 필터링
}

반환 타입: Map<Field, String?>

  • Field: 검사한 필드 종류(enum)
  • String?: 오류 메세지 (유효하면 null)

동작 방식

  1. 모든 필드에 대해 계단식 검증 수행
  2. filterValues로 오류만 추출

장점 vs 기존 방식 비교

항목기존 방식개선된 방식
검증 로직 위치ViewModel / FragmentData Class 내부
코드 중복많음없음
규칙 일관성낮음높음
유지보수어려움(여러 파일 수정)쉬움(한 곳 수정)
Null 안정성명시적이지 않음null로 미입력 상태 표현

ViewModel

@HiltViewModel
class SignupAddInfoViewModel @Inject constructor(
    private val signupUsecase: SignupUsecase,
    private val sharedPreferences: SharedPreferences
) : ViewModel() {

    private val _signupInfo = MutableStateFlow(SignupAddInfo())
    val signupInfo: StateFlow<SignupAddInfo> = _signupInfo.asStateFlow()

    // 모든 입력 유효성 상태 (true면 버튼 활성화)
    val isAllValid: StateFlow<Boolean> = _signupInfo
        .map { it.validate().isEmpty() }
        .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    // 서버 응답 상태
    private val _signupState = MutableStateFlow<UiState>(UiState.Idle)
    val signupState: StateFlow<UiState> = _signupState.asStateFlow()

    fun updateInfo(update: SignupAddInfo.() -> SignupAddInfo) {
        _signupInfo.update { it.update() }
    }

    fun setVeganType(veganType: String) {
        updateInfo { copy(vegetarianType = veganType) }
    }

    fun submitSignup() {
        _signupState.value = UiState.Loading
        viewModelScope.launch {
            try {
                val request = createSignupRequest()
                val result = signupUsecase.signupAddInfo(request) // suspend 함수 직접 호출
                handleSignupResult(result)
            } catch (e: Exception) {
                _signupState.value = UiState.Error(e.message)
            }
        }
    }

    private fun createSignupRequest(): RequestBody {
        val requestDto = SignupRequest(
            nickname = _signupInfo.value.nickname,
            gender = _signupInfo.value.gender,
            vegetarianType = _signupInfo.value.vegetarianType,
            birthYear = _signupInfo.value.birthYear ?: 0,
            height = _signupInfo.value.height ?: 0,
            weight = _signupInfo.value.weight ?: 0
        )
        return PhotoUtils.createRequestBody(requestDto)
    }

    private fun handleSignupResult(result: ApiResult<ProfileResponse>) {
        _signupState.value = when (result) {
            is ApiResult.Success -> {
                sharedPreferences.edit()
                    .putString("Nickname", result.data.nickname)
                    .apply()
                UiState.Success(result.data.nickname)
            }
            is ApiResult.Error -> UiState.Error(result.description)
            is ApiResult.Exception -> UiState.Error(result.e.message)
        }
    }
}

1. isAllValid: StateFlow 실시간 유효성 검사

val isAllValid: StateFlow<Boolean> = _signupInfo
    .map { it.validate().isEmpty() }
    .stateIn(viewModelScope, SharingStarted.Eagerly, false)
  • 동작 원리
  1. _signupInfo(사용자 입력 데이터)가 변경될 때마다 map 연산자로 유효성 검사 수행
  2. validate().isEmpty()
    • SignupAddInfo의 validate() 함수 호출 -> 오류 메세지가 빈 Map이면 true
  3. stateIn
    최초 값은 false (초기 상태는 무조건 유효하지 않음)
  • 사용처
    뷰에서 가입 버튼 활성화 조건으로 사용
    private fun observeStates() {
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.isAllValid.collect { isValid ->
                    updateNextButtonUI(isValid)
                }
            }
        }
    }

    private fun updateNextButtonUI(shouldEnable: Boolean) {
        binding.btnSignupNext.apply {
            setBackgroundColor(
                ContextCompat.getColor(
                    requireContext(),
                    if (shouldEnable) R.color.base3 else R.color.gray3
                )
            )
            isEnabled = shouldEnable
        }
    }

2. _signupState & signupState: 서버 통신 상태 관리

private val _signupState = MutableStateFlow<UiState>(UiState.Idle) // 내부 상태
val signupState: StateFlow<UiState> = _signupState.asStateFlow() // 외부 노출
  • 설계 목적
    서버 요청의 로딩/성공/실패 상태를 UI에 반영하기 위함
  • 상태 종류 (UiState)
sealed class UiState {
    object Idle : UiState() // 초기 상태
    object Loading : UiState() // 로딩 중
    data class Success(val nickname: String) : UiState() // 성공
    data class Error(val message: String?) : UiState() // 실패
}

사용 예시

    private fun handleSignupState(state: UiState) {
        when (state) {
            is UiState.Success -> {
                navigateToComplete()
            }
            is UiState.Error -> {
                state.message?.let { makeToast(it) }
            }
            UiState.Loading -> {
                // 로딩 UI 처리
            }
            UiState.Idle -> Unit
        }
    }

3. updateInfo: 안전한 데이트 업데이트

fun updateInfo(update: SignupAddInfo.() -> SignupAddInfo) {
    _signupInfo.update { it.update() }
}
  • 함수영 파라미터: SignupAddInfo.() -> SignupAddInfo
    리시버 타입 람다로, this가 현재 SignupAddInfo 객체

  • 동작 방식
    _signupInfo.update 내부에서 현재 값을 it으로 접근 -> update() 확장 함수 실행

  • 예시 호출

// 닉네임 업데이트
viewModel.updateInfo { copy(nickname = "새닉네임") }
  • 장점
  1. 불변성 유지
    copy()로 새 객체 생성 후 상태 업데이트
  2. 스레드 안전
    MutableStateFlow.update()는 원자적 연산

4. setVeganType 특정 필드 업데이트 편의 함수

fun setVeganType(veganType: String) {
    updateInfo { copy(vegetarianType = veganType) }
}
  • 특화된 업데이트
    vegetarianType만 변경하는 경우, updateInfo를 직접 호출하지 않고 간결하게 사용

Data Class와의 연동

1. 유효성 검사 위임

  • ViewModel은 validate()를 호출만 하고, 검증 규칙은 전적으로 Data Class가 관리

2. 불변 데이터 흐름

  • updateInfo -> copy() -> 새 SignupAddInfo 생성 -> _signupInfo 업데이트

3. 상태 일관성

  • isAllValidSignupAddInfovalidate() 결과에 자동 동기화

요약: 왜 이 구조가 좋은가?

1. 단일 책임 원칙

  • SignupAddInfo
    데이터 + 검증 로직
  • ViewModel
    UI 로직 + 상태 관리

2. 반응형 프로그래밍

  • StateFlow로 자동 UI 갱신

3. 안정성

  • 불변 객체 사용 + 스레드 안전한 상태 업데이트

코드

sealed class UiState

data class SignupAddInfo

ViewModel

Fragment


앱을 사용해보고 싶다면? 🤭

google Play - Vegan Life

profile
개발자희망자

0개의 댓글