회원 탈퇴 기능 개발하기
원티드 안드로이드앱 4.2.2 버전 출시와 함께 회원 탈퇴 기능이 추가 되었습니다. 회원 탈퇴 기능은 유저들이 원하는 기능중 하나였으며, 서비스에 직접 노출되지 않아있던 탓에 cs 메일로 하루에 적을때는 2~3건, 많을 때는 10건 이상 정도로 많이 접수되는 부분이었습니다. 그리고 법적으로도 필요한 기능이었습니다.
AAC에서 소개하고있는 ViewModel을 활용하여 테스트코드를 작성하면서 회원 탈퇴 기능을 개발한 내용을 정리하여 공유해보려고 합니다.
먼저 구글에서 2017년 Android Architecture Component를 소개한 이 후 권장하는 앱 아키텍처 가이드라인을 이해할 필요가 있습니다. 해당 문서에서는 뷰 영역에 해당되는 Activity / Fragment 영역과 뷰 로직을 처리하는 ViewModel, local data 및 API 통신을 위한 Repository의 개념을 소개하고 있습니다.
AAC에서 말하는 ViewModel의 경우 Activity / Fragment 의 라이프사이클로부터 최대한 고통을 덜어낸 채로(?) 데이터를 유지할 수 있는 형태로 제공됩니다. 일반적으로 MVVM에서 소개하는 ViewModel과는 목적이 다르고, 이름만 ViewModel 일 뿐입니다. (좀 더 자세한 내용은 여기를 참조하세요.) 제가 작업한 내용은 AAC ViewModel을 이용한 내용을 바탕으로 정리해 보았습니다.
하나의 뷰모델을 이용하여 개발
회원 탈퇴 프로세스는 매우 간단합니다. 앱 내 설정 - 계정관리 - 회원 탈퇴 를 선택하면 회원 탈퇴 프로세스가 진행됩니다. 회원 탈퇴 화면은 크게 아래와 같이 3개의 화면으로 구성됩니다.
탈퇴 시 주의사항 안내 | 탈퇴 사유 입력 | 탈퇴 완료 확인 |
---|---|---|
![]() |
![]() |
![]() |
각각의 화면은 하나의 Activity로 3개의 Fragment를 활용하여 UI작업을 진행하였습니다. 화면을 보시면 아시겠지만, 회원 탈퇴 프로세스 상에서의 요구사항은 그렇게 많지 않습니다. 대략적으로 정리해 보면 다음과 같습니다.
- 주의 사항 안내에서 이력서 관리, 추천 페이지 를 선택하면 해당 UI로 이동되어야 한다.
- 주의 사항 안내에서 동의하기를 선택하면 탈퇴 사유 입력화면으로 이동된다.
- 탈퇴 사유 입력화면에서는 탈퇴 사유를 선택할 수 있어야 한다. 만약 기타를 선택할 경우 탈퇴 사유를 직접 입력할 수 있어야 한다. 탈퇴 사유의 경우, 입력한 텍스트의 길이를 표시해 줘야 한다.
- 탈퇴 사유 입력화면에서 완료를 선택하면 탈퇴를 진행하고 탈퇴 완료 확인 화면을 표시한다.
뷰 로직 자체는 크게 복잡하지 않다고 판단하여 뷰 모델을 하나만 활용해서 탈퇴 프로세스를 개발 진행하였습니다.(뷰모델을 각 화면단위로 쪼갤 정도로 요구사항이 많지 않았습니다.) AAC에서 일반적으로 뷰모델 객체를 생성은 아래와 같이 생성합니다.
// Activity 에서 호출 시
ViewModelProviders.of(this, ViewModelFactory()).get(ByebyeViewModel::class.java)
ViewModelProviders.of로 activity 혹은 fragment를 전달 할 수 있습니다. 만약 Activity에서 뷰 모델 객체를 생성했다면, 해당 Activity에 이미 attached 되어있는 Fragment에서 아래와 같이 호출할 경우 동일한 ViewModel 객체를 얻어올 수 있습니다.
// Fragment 에서 호출 시
ViewModelProviders.of(requireActivity(), ViewModelFactory()).get(ByebyeViewModel::class.java)
(ByebyeViewModel은 예시입니다. 실제 클래스명이 아닙니다.) ViewModelFactory는 ViewModelProvider.Factory을 구현한 클래스입니다. 뷰 모델 객체 생성방법은 여기를 참조하세요.
위 내용 관련하여 Google I/O 2018 소스코드에서는 이러한 내용을 바탕으로 아래와 같이 Extensions 함수들을 만들어서 사용하고 있습니다.
/**
* For Actvities, allows declarations like
* ```
* val myViewModel = viewModelProvider(myViewModelFactory)
* ```
*/
inline fun <reified VM : ViewModel> FragmentActivity.viewModelProvider(
provider: ViewModelProvider.Factory
) =
ViewModelProviders.of(this, provider).get(VM::class.java)
/**
* Like [Fragment.viewModelProvider] for Fragments that want a [ViewModel] scoped to the Activity.
*/
inline fun <reified VM : ViewModel> Fragment.activityViewModelProvider(
provider: ViewModelProvider.Factory
) =
ViewModelProviders.of(requireActivity(), provider).get(VM::class.java)
사용자 유즈케이스 정리 및 코드 작성
위 요구사항을 바탕으로 사용자 유즈케이스를 정리해 보았습니다. 그중 몇 가지는 아래와 같습니다.
- 탈퇴 사유 진입 시 선택 목록 구성하기
- 탈퇴 사유 입력 화면에서 사용자 행동에 따른 상태 변경
- 탈퇴 프로세스의 각 화면에서 종료 체크
탈퇴 사유 진입 시 선택 목록 구성하기
탈퇴 사유의 경우 API 통신을 통하여 선택 목록을 가져오게 되어있습니다. ViewModel 에서는 API를 이용하여 탈퇴 사유 목록을 가져오기 위한 Repository를 이용하여 View로 실제 탈퇴 사유 목록을 전달하게끔 되어있습니다. 이 부분에 대한 코드는 아래와 같습니다.
interface UsersRepo {
fun getLeaveReasons() : LiveData<ReasonResponse>
}
class LeaveViewModel(private val repository: UsersRepo) : ViewModel() {
val leaveReasons: LiveData<List<String>> = Transformations.map(repository.getLeaveReasons()) {
arrayListOf<String>().apply{
it?.data?.forEach { add(it.text) }
}
}
}
Repository에서는 LiveData를 전달하고 있으며, ViewModel 에서는 이를 Transformations.map 을 활용하여 UI에 필요한 목록으로 변환해서 전달하고 있습니다. UI에서는 leaveReasons를 Observing 하여 Spinner를 구성해 줍니다.
탈퇴 사유 입력 화면에서 사용자 행동에 따른 상태 변경
사용자가 할 수 있게되는 행위와 이를 통해 UI가 업데이트 되어야 할 요소를 나눠보면 아래와 같습니다.
사용자의 행위 | UI 업데이트 요소 |
---|---|
탈퇴 사유 선택 탈퇴 사유 직접 입력 |
완료 버튼 활성화/비활성화 직접 입력 화면 표시/미표시 입력되는 텍스트 길이 표시 |
위 내용을 바탕으로 먼저 뷰 모델이 해야할 일을 정리하였습니다. 대략적으로 그림을 그려보면 아래와 같습니다.
![]() |
이 후, 시나리오를 바탕으로 테스트 케이스를 먼저 작성하였습니다.
@Test
fun `3) 탈퇴사유입력화면에서 탈퇴버튼 활성화 및 직접입력 표시 로직 테스트`() {
viewModel.leaveReasons.observeForever {
val lastItemPosition = it?.size?.dec() ?: 0
var isEnableClick = false
var isEnableInput = false
viewModel.enableLeaveInput.observeForever {
isEnableInput = it ?: false
}
viewModel.enableLeaveClick.observeForever {
isEnableClick = it ?: false
}
// 1. 탈퇴 사유 입력 화면 초기 진입 상태 테스트
Assert.assertEquals(isEnableClick, false)
Assert.assertEquals(isEnableInput, false)
// 2. 탈퇴 사유 3번째 항목을 선택했을 때 테스트
viewModel.setLeaveReasonPosition(3)
Assert.assertEquals(isEnableClick, true)
Assert.assertEquals(isEnableInput, false)
// 3. 탈퇴 사유 기타 의견 항목을 선택했을 때 테스트
viewModel.setLeaveReasonPosition(lastItemPosition)
Assert.assertEquals(isEnableClick, false)
Assert.assertEquals(isEnableInput, true)
// 4. 탈퇴 사유 기타 사유 입력 상태에서 탈퇴 사유를 입력했을 때 테스트
viewModel.setLeaveReasonInput("탈퇴가 하고싶어요")
Assert.assertEquals(isEnableClick, true)
Assert.assertEquals(isEnableInput, true)
// 5. 4에서 탈퇴 사유를 3번으로 바꿨을 때 테스트
viewModel.setLeaveReasonPosition(3)
Assert.assertEquals(isEnableClick, true)
Assert.assertEquals(isEnableInput, false)
// 6. 탈퇴 선택을 하지 않은 상태의 항목을 선택했을 때 테스트
viewModel.setLeaveReasonPosition(0)
Assert.assertEquals(isEnableClick, false)
Assert.assertEquals(isEnableInput, false)
// 7. 6 까지 사용자 행동을 한 상태에서 탈퇴 사유 기타 의견을 선택했을 때 테스트
viewModel.setLeaveReasonPosition(lastItemPosition)
Assert.assertEquals(isEnableClick, true)
Assert.assertEquals(isEnableInput, true)
}
}
최종적으로 위 내용 관련된 뷰 모델 코드는 아래와 같이 정리되었습니다.
/* 탈퇴 사유를 선택한 포지션 */
private val _selLeaveReasonPosition = MutableLiveData<Int>()
/* 탈퇴 사유가 기타일 경우 직접 입력한 텍스트 */
private val _selLeaveReasonInput = MutableLiveData<String>()
/* 뷰에서 탈퇴 사유 선택에 대한 데이터 갱신 */
fun setLeaveReasonPosition(position: Int) {
_selLeaveReasonPosition.value = position
}
/* 뷰에서 탈퇴 사유 직접 입력에 대한 데이터 갱신 */
fun setLeaveReasonInput(text: String) {
_selLeaveReasonInput.value = text
}
/* 입력되는 텍스트 길이 표시 */
val leaveInputLength : LiveData<Int> = Transformations.map(_selLeaveReasonInput) {
it?.length ?: 0
}
/* 탈퇴 사유 입력 화면 표시/미표시 */
val enableLeaveInput : LiveData<Boolean> = Transformations.map(_selLeaveReasonPosition) {
isLastItemSelected(it)
}
/* 탈퇴 버튼 활성화 / 비활성화 버 */
val enableLeaveClick : LiveData<Boolean> = MediatorLiveData<Boolean>().apply {
addSource(_selLeaveReasonPosition) {
value = isEnableLeaveClick(it)
}
addSource(_selLeaveReasonInput) {
value = isEnableLeaveClick(_selLeaveReasonPosition.value)
}
}
UI 상태값을 MutableLiveData로 두고 private로 노출시키지 않았습니다. 그리고 뷰 입장에서 Observing 해야 할 부분은 모두 MediatorLiveData로 작업 하였습니다. Transformations.map 은 파라미터로 전달하는 LiveData가 변경되었을 때 그에 맞춰서 값을 중계해주는데 유용한 유틸리티 함수입니다. (내부적으로는 MediatorLiveData를 이용하고 있습니다.) isLastItemSelected나 isEnableLeaveClick 등의 함수는 UI로부터 입력된 값을 가지고 뷰의 상태를 처리하는 함수(로직)입니다. View에서는 leaveInputLength, enableLeaveInput, enableLeaveClick을 Observing 하여 UI 상태를 업데이트 합니다.
탈퇴 프로세스의 각 화면에서 종료 체크
화면을 종료하는 케이스는 1) Toolbar 의 NavigationIcon 선택 시, 2) backkey 선택 시, 3) 회원 탈퇴 성공 후 하단의 확인 버튼 선택 시 케이스가 있습니다. 먼저 해당 케이스들에 대한 테스트 코드를 아래와 같이 작성하였습니다.
@Test
fun `6) 탈퇴완료안내화면 에서 완료버튼 선택 테스트`() {
var currentScreen = LeaveViewModel.Screen.COMPLETE
viewModel.changeCurrentScreen(currentScreen)
viewModel.finishLeaveEvent.observeForever {
it?.getContentIfNotHandled()?.let {
Assert.assertEquals(it, currentScreen)
}
}
viewModel.onClickComplete()
}
@Test
fun `7) 탈퇴 프로세스에서 각 화면별로 back키 입력 시 테스트`() {
var currentScreen = LeaveViewModel.Screen.GUIDE
viewModel.finishDefaultevent.removeObserver() {
it?.getContentIfNotHandled()?.let {
Assert.assertEquals(currentScreen, it)
}
}
viewModel.finishLeaveEvent.observeForever {
it?.getContentIfNotHandled()?.let {
Assert.assertEquals(currentScreen, it)
}
}
LeaveViewModel.Screen.values().forEach {
currentScreen = it
viewModel.changeCurrentScreen(currentScreen)
viewModel.onBackPressed()
}
}
이를 처리하기 위한 View 및 ViewModel 코드는 아래와 같습니다.
// View(Activity) 에서
override fun onCreate(savedInstanceState: Bundle?) {
// (생략)
// 1) Toolbar 의 NavigationIcon 선택 시
toolbar.setNavigationOnClickListener { viewModel.onBackPressed() }
observeFinishEvents()
// (생략)
}
override fun onBackPressed() {
// 2) backkey 선택 시
viewModel.onBackPressed()
}
private fun observeFinishEvents() {
viewModel.finishDefaultevent.observe(this@LeaveActivity, EventObserver {
finish()
})
viewModel.finishLeaveEvent.observe(this@LeaveActivity, EventObserver {
WantedApp.logout(this@LeaveActivity)
})
}
// View(Fragment) 에서
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 3) 회원 탈퇴 성공 후 하단의 확인 버튼 선택 시
confirm.setOnClickListener {
viewModel.onClickComplete()
}
}
// ViewModel 에서
@VisibleForTesting
internal fun changeCurrentScreen(screen: Screen) {
currentScreen = screen
}
enum class Screen {
GUIDE, INPUT, COMPLETE
}
private var currentScreen = Screen.GUIDE
private val _finishEvent = MutableLiveData<Event<Boolean>>()
// 일반적으로 종료가 된 케이스의 LiveData
val finishDefaultevent = MediatorLiveData<Event<Screen>>().apply {
addSource(_finishEvent) {
if(it == false)
value = Event(currentScreen)
}
}
// 회원탈퇴가 완료 된 이후 종료가 된 케이스의 LiveData
val finishLeaveEvent = MediatorLiveData<Event<Screen>>().apply {
addSource(_finishEvent) {
if(it == true)
value = Event(currentScreen)
}
}
fun onClickComplete() {
onBackPressed()
}
// 1) Toolbar 의 NavigationIcon 선택 시
// 2) backkey 선택 시
fun onBackPressed() {
_finishEvent.value = isFinished()
}
뷰는 finishDefaultevent, finishLeaveEvent의 이벤트를 Observing 하여 해당 값을 수신하였을 때 실제 처리해야할 로직을 수행합니다. 이때 전달되는 스크린 enum 값은 테스트를 위하여 명시적으로 보내도록 하였습니다. changeCurrentScreen은 뷰 모델의 현재 화면 상태를 초기화 하기 위한 함수이고, 테스트만을 위하여 사용하므로 @VisibleForTesting Annotation을 추가해 두었습니다. isFinished()는 회원 탈퇴로 종료가 되었는지, 일반 종료인지를 체크하는 로직(함수) 입니다.
LiveData의 경우 observe를 할 경우 그 즉시 onChanged를 호출하게 되어있습니다. Observing 시점부터 바로 화면을 업데이트 하는 용도로는, 특정 로직을 수행하고 난 후 그 결과(이벤트)만 observing 하는데는 무리가 있습니다.(약간 해깔립니다.) Event 클래스는 Observe 할 때 전달되는 데이터가 이미 onChanged에 의하여 처리 된 상태여부를 확인할 수 있도록 도와줍니다. 관련 이슈를 잘 정리해둔 글은 여기를 참조하세요.
테스트 코드는 잘 동작했을까 ?
위와 같이 탈퇴 프로세스 전반에 필요한 유즈 케이스를 단위로 함수를 만들었습니다. 함수명은 테스트내용이 어떤 것인지 명확히 확인할 수 있도록 Numbering 및 한글로 작성하였습니다. 탈퇴 사유 입력화면에서 텍스트 길이 표시 로직 테스트는 요구사항이 추가 된 내용이서 테스트 코드가 분리되어 있습니다. 모든 내용을 바탕으로 테스트 동작 실행한 결과는 아래와 같습니다.
![]() |
![]() |
(실제 이름은 LeaveViewModel임이 밝혀졌습니다…) Android Studio 에서 Run with Code Coverage 를 통해서 해당 클래스가 얼마나 코드가 커버되었는지, 그리고 해당 파일에서 어디가 테스트가 되었는지 확인이 가능합니다.
회원 탈퇴 기능을 개발하면서 사용자 유즈케이스를 계속 코드화 시키려고 고민한 내용이 결과적으로 코드 자체가 명확해지고, 테스트코드를 작성하기 용이해 지는 현상을 확인할 수 있는 좋은 계기가 되었습니다.
참조