Data class만 생성 후 각 입력 칸에 해당하는 조건은 viewModel에서 관리를 하여 다뤘었다.
ui 관련문제니까 fragment나 utils에 따로 관리를 했어야 하지 않았을까 🤔
그래서 코드가 너무 더럽고 확인해야 하는게 너무 번거로웠기 때문에 코드를 전면 수정했다. 실제로 수정하기 까지 내가 작성했던 코드를 이해하기에 번거로웠음
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 }
}
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 } // 오류만 필터링
}
filterValues
로 오류만 추출항목 | 기존 방식 | 개선된 방식 |
---|---|---|
검증 로직 위치 | ViewModel / Fragment | Data Class 내부 |
코드 중복 | 많음 | 없음 |
규칙 일관성 | 낮음 | 높음 |
유지보수 | 어려움(여러 파일 수정) | 쉬움(한 곳 수정) |
Null 안정성 | 명시적이지 않음 | null로 미입력 상태 표현 |
@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)
}
}
}
val isAllValid: StateFlow<Boolean> = _signupInfo
.map { it.validate().isEmpty() }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
map
연산자로 유효성 검사 수행validate().isEmpty()
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
}
}
private val _signupState = MutableStateFlow<UiState>(UiState.Idle) // 내부 상태
val signupState: StateFlow<UiState> = _signupState.asStateFlow() // 외부 노출
로딩/성공/실패 상태
를 UI에 반영하기 위함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
}
}
fun updateInfo(update: SignupAddInfo.() -> SignupAddInfo) {
_signupInfo.update { it.update() }
}
함수영 파라미터: SignupAddInfo.() -> SignupAddInfo
리시버 타입 람다로, this가 현재 SignupAddInfo 객체
동작 방식
_signupInfo.update 내부에서 현재 값을 it으로 접근 -> update() 확장 함수 실행
예시 호출
// 닉네임 업데이트
viewModel.updateInfo { copy(nickname = "새닉네임") }
copy()
로 새 객체 생성 후 상태 업데이트MutableStateFlow.update()
는 원자적 연산fun setVeganType(veganType: String) {
updateInfo { copy(vegetarianType = veganType) }
}
직접 호출하지 않고
간결하게 사용1. 유효성 검사 위임
validate()
를 호출만 하고, 검증 규칙은 전적으로 Data Class가 관리2. 불변 데이터 흐름
updateInfo
-> copy()
-> 새 SignupAddInfo
생성 -> _signupInfo
업데이트3. 상태 일관성
isAllValid
는 SignupAddInfo
의 validate()
결과에 자동 동기화1. 단일 책임 원칙
SignupAddInfo
ViewModel
2. 반응형 프로그래밍
StateFlow
로 자동 UI 갱신3. 안정성