본문 바로가기

Android/랜덤리즘

[랜덤리즘] Fragment와 ViewModel의 책임을 명확하게 하기

 

 

현재 랜덤리즘은 위와 같은 흐름으로 되어있다!

 

각각의 Fragment 에서 ProblemFragment 로 화면이 전환될 때 네트워크 통신할 때 필요한 소스를 ProblemFragment 의  newInstance 메서드를 통해 arguments 로 전달해주고 있었다.

 

// ProblemFragment.kt

fun <T> newInstance(tag: String, value: T): Fragment {
    return ProblemFragment().apply {
        arguments = Bundle().putValue(tag, value)
    }
}

 

아래의 코드는 TagFragment 에서 ProblemFragment 의 newInstance 를 통해 화면을 전환하는 코드이다.

 

// TagFragment.kt

parentFragmentManager.beginTransaction()
    .addToBackStack(ProblemFragment.TAG)
    .setReorderingAllowed(true)
    .replace(
        R.id.container_fragment,
        ProblemFragment.newInstance(ProblemFragment.INSTANCE_TAG, tag, ProblemFragment.INSTANCE_LEVEL, level)
    )
    .commit()

 

 

그런데 마주한 문제는 ProblemFragment 에서 나타났다. (두둥)

 


마주한 문제

1) arguments 를 전역변수로 선언하여 관리하기

// ProblemFragment.kt

class ProblemFragment(...) {
    private var currentTag: String? = null
    private var currentLevel: Int? = null
    private var currentSource: String? = null

    private var currentProblems = emptyList<Problem>()
    private var count: Int = 0
    
    
   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)
       
       arguments?.let {
            currentTag = it.getString(INSTANCE_TAG)
            currentLevel = it.getInt(INSTANCE_LEVEL)
            currentSource = it.getString(INSTANCE_SOURCE)
        }
        
        ...
    }
    
    ...
}

 

TagFragment, LevelListFragment, SourceFragment 에서 전달되는 값들을 ProblemFragment 에서 각각의 전역변수로 선언해주었고, let 스코프를 통해 arguments로 전달된 값들을 설정해주었다.

 

처음에는 let 스코프안에서도 if를 사용하며 현재 전달된 값이 있는지 확인을 하였는데, 기본값을 null로 넣어주었기 때문에(getString, getInt은 해당 값이 존재하지 않으면 null 을 반환하기때문에 값이 넘어오지 않는다면, 기본값과 다른점이 없음!) if문을 사용하지 않아도 되겠다고 생각하여 위의 코드로 수정하였다.

2) arguments 값에 따른 함수 호출

// ProblemFragment.kt

class ProblemFragment(...) {
    private var currentTag: String? = null
    private var currentLevel: Int? = null
    private var currentSource: String? = null

    private var currentProblems = emptyList<Problem>()
    private var count: Int = 0
    
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
       
        currentTag?.let { tag ->
            currentLevel?.let { level ->
                if (level == All_LEVEL) { 
                    problemViewModel.getProblemsByTag(tag) 
                }
                else problemViewModel.getProblemByTagAndLevel(tag, level)
        } ?: currentLevel?.let { level ->
            problemViewModel.getProblemsByLevel(level)
        }

        currentSource?.let { source ->
            problemViewModel.getProblemsBySourceOfProblem(source)
        }
    }
    
    ...
}

 

 

전역변수로 선언해준 상태들(currentTag, currentLevel, currentSource)에 대하여 문제리스트를 가져와야 했다.

그런데 따져봐야할 것들이 많아졌다.

 

 

가장 처음에 개발했던 기능인 단순 알고리즘/레벨에 대한 문제를 가져왔을 때에는 currentTag, currentLevel 이 null 인지아닌지만 파악하면 되었다. (사진의 왼쪽)

현재 기능은 사진의 오른쪽과 같이 나뉘게 되었다. .... 따흑

알고리즘을 선택하고서도 특정 레벨을 선택할 수 있기때문에 arguments 로 tag 와 level 을 전달받아야했고, 전체 레벨과 특정 레벨로 나뉘기 때문에 level 에 대한 조건문도 필요하게 되었다.

3) 같은 기능을 하지만 살짝 다른 함수들

그런데!! 위의 코드가 arguments 를 확인하고 처음 문제를 세팅하는 경우에만 사용되는 것이 아니라는 것이 문제였다.

 

코드를 보면 알 수 있듯이 네트워크 통신으로 가져온 문제리스트(currentProblems) 또한 전역변수로 관리하였었다.

 

// ProblemFragment.kt

class ProblemFragment(...) {
    private var currentTag: String? = null
    private var currentLevel: Int? = null
    private var currentSource: String? = null

    private var currentProblems = emptyList<Problem>()
    private var count: Int = 0
    ...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
        
        problemViewModel.problems.observe(viewLifecycleOwner) {
            currentProblems = it.toList()
            count = 0

            currentTag?.let { tag ->
                currentLevel?.let { level ->
                    if (level == All_LEVEL) {
                        getRandomProblems { 
                            problemViewModel.getProblemsByTag(tag) 
                        }
                    } else {
                        getRandomProblems { 
                            problemViewModel.getProblemByTagAndLevel(tag, level) 
                        }
                    }
                }
            } ?: currentLevel?.let { level ->
                getRandomProblems { 
                    problemViewModel.getProblemsByLevel(level) 
                }
            }

            currentSource?.let { source ->
                getRandomProblems { 
                    problemViewModel.getProblemsBySourceOfProblem(source) 
                }
            }
        }
    }
}

 

 

전달된 문제(problems)에 대해서 관찰자를 등록하고, 문제리스트가 전달되면 전역변수인 currentProblems 에 할당해주었다.

문제를 가져왔으면 바로 화면에 보여주었으면 되는데 현재 전달된 값이랑 동일한지 .. 확인을 하겠다고 함수를 작성했었는데 그 함수 덕분에 중복된 코드가 많이 생겼고, 코드의 양도 많아졌었다.

 

// ProblemFragment.kt

private fun getRandomProblems(block: () -> Unit) {
    if (count >= currentProblems.size) block()
    else if (problemViewModel.hasSavedProblem()) {
        showRandomProblem(problemViewModel.getSavedProblem())
    }
    else showRandomProblem(currentProblems[count++])
}

 

위의 코드에 나와있는 함수였다 .. 상태의 조건에 따라 block 에 해당되는 값을 외부에서 주입해주는 것으로 수정해주긴했다.

 

일급함수로 수정하기 전의 코드

// ProblemFragment.kt

// 선택한 알고리즘 관련 문제리스트
private fun getRandomProblemsByTag(tag: String) {
    if (currentProblems.isNotEmpty() && currentProblems.all { problem -> problem.tags.any { it.key == currentTag } }) {
        if (count >= currentProblems.size) {
            problemViewModel.getProblemsByTag(tag)
        }
        else if (problemViewModel.hasSavedProblem()) {
            showRandomProblem(problemViewModel.getSavedProblem())
        }
        else showRandomProblem(currentProblems[count++])
    }
}

// 선택한 레벨 관련 문제리스트
private fun getRandomProblemsByLevel(level: Int) {
    if (currentProblems.isNotEmpty() && currentProblems.all { it.level.toInt() == currentLevel }) {
        if (count >= currentProblems.size) {
            problemViewModel.getPRoblemsByLevel(level)
        }
        else if (problemViewModel.hasSavedProblem()) {
            showRandomProblem(problemViewModel.getSavedProblem())
        }
        else showRandomProblem(currentProblems[count++])
    }
}

 


문제를 해결하기 위해 한 생각들

알고리즘, 레벨, 출처 외에도 많은 경로를 통해 문제리스트를 가져올텐데 이런 방식으로 코드를 작성해도되나? 하는 의문점이 들었다.

어떻게 수정하면 좋을까? 어떻게 변경해야 할까? 어떻게 코드를 작성해야할까 .. 어떻게 설계를 해야하는걸까?

생각을하다가 객체지향에 대해 다시 공부하고 .. SOLID 원칙에 대해서도 공부를 하게 되었다.

 

그런데 내가 마주한 문제는 'arguments 의 값들을 어디서 관리하느냐' 의 문제였던 것 같다고 생각이 들었다.

 


문제 해결

1) Fragment 와 ViewModel 의 역할을 명확히하기

Fragment 의 역할은 사용자의 action 을 받아서 ViewModel 에 전달한 후, UI 를 업데이트하는 것

ViewModel 의 역할은 전달된 사용자의 action 에 대하여 데이터를 가공한 후, 전달해주는 것이다.

 

그런데 내 Fragment 에는 사용자 action 에 대하여 데이터를 가공하는 부분이 있었고, UI 업데이트와는 상관이 없는 arguments 의 내용들이 있었다. 그래서 해당 부분들을 모두 ViewModel 로 옮겨주었다.

2) 전역변수로 관리했던 arguments 는 data class 로 생성하여 관리하기

// 변경 전

class ProblemFragment(...) {
    private var currentTag: String? = null
    private var currentLevel: Int? = null
    private var currentSource: String? = null
}

// 변경 후
data class ProblemType(
    val tag: String? = null,
    val level: Int? = null,
    val source: String? = null
) : Serializable

 

ProblemFragment 의 newInstance() 함수도 아래와 같이 수정해주었다!

 

fun newInstance(value: ProblemType): Fragment {
    return ProblemFragment().apply {
        arguments = Bundle().apply {
            putSerializable(ARGUMENT_TAG, value)
        }
    }
}

 

// ProblemFragment.kt

class ProblemFragment(...) {
    private var currentTag: String? = null
    private var currentLevel: Int? = null
    private var currentSource: String? = null

    private var currentProblems = emptyList<Problem>()
    private var count: Int = 0
    
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
       
        // 변경 전
        currentTag?.let { tag ->
            currentLevel?.let { level ->
                if (level == All_LEVEL) { 
                    problemViewModel.getProblemsByTag(tag) 
                }
                else {
                    problemViewModel.getProblemByTagAndLevel(tag, level)
                }
        } ?: currentLevel?.let { level ->
            problemViewModel.getProblemsByLevel(level)
        }

        currentSource?.let { source ->
            problemViewModel.getProblemsBySourceOfProblem(source)
        }
        
        // 변경 후
        arguments?.let { bundle ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                bundle.getSerializable(ARGUMENT_TAG, ProblemType::class.java)?.let { type ->
                    problemViewModel.initCurrentProblemType(type)
                }

            } else {
                val type = bundle.getSerializable(ARGUMENT_TAG) as? ProblemType
                type?.let { problemViewModel.initCurrentProblemType(it) }
            }
        }
    }
    
    ...
}

2) arguments 로 전달된 ProblemType 을 ViewModel 에서 처리하기

// ProblemViewModel.kt

class ProblemViewModel(...) : ViewModel {
    ...
   
    fun initCurrentProblemType(problemType: ProblemType) {
        _problemType.value = problemType

        judgeCurrentProblemType()
    }
    
    private fun judgeCurrentProblemType() {
        _problemType.value?.run {
            tag?.let { tag ->
                level?.let { level ->
                    if (level == ALL_LEVEL) getProblemsByTag(tag)
                    else getProblemByTagAndLevel(tag, level)
                } ?: getProblemsByTag(tag)
            } ?: level?.let { level ->
                getProblemsByLevel(level)
            }
            source?.let { source -> getProblemsBySourceOfProblem(source) }
        }
    }
}

 

ProblemType 이 설정되면, judgeCurrentProblemType() 메서드를 통해 현재 타입에 맞는 초기 문제리스트를 가져올 수 있도록 하였다.

 

// ProblemViewModel.kt

class ProblemViewModel(...) : ViewModel {
    ...
   
    fun getProblem() {
        when (hasSavedProblem()) {
            true -> _problem.value = getSavedProblem()
            false -> {
                _problems.value?.let { problems ->
                    if (currentProblemIndex >= problems.size) {
                        judgeCurrentProblemType()
                    }
                    else _problem.value = problems[currentProblemIndex++]
                } ?: { _error.value = ExceptionMessage.NonExistProblem.message }
            }
        }
    }
    
    private fun getProblems(query: String) {
        currentProblemIndex = INIT_PROBLEM_INDEX

        viewModelScope.launch {
            try {
                _loading.value = true
                delay(500)

                _problems.value = getProblemsUseCase.invoke(query)
                getProblem()
            } catch (exception: Exception) {
                _error.value = exception.message
            } finally {
                _loading.value = false
            }
        }
    }
}

 

문제 타입에 맞춰 getProblems 가 호출되고, 1개의 문제를 보여주어야 하기 때문에 getProblem() 메서드를 작성해주었다.

 

// ProblemFragment.kt

class ProblemFragment(...) {
    
    ...
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
       
        problemViewModel.problem.observe(viewLifecycleOwner) {
            showRandomProblem(it)
        }
        
        ...
    }
    
    private fun showRandomProblem(randomProblem: Problem) {
        randomProblem.run {
            binding.tvTitle.text = title
            binding.tvId.text = id.toString()
            binding.tvLevel.text = levels[level.toInt()]
            binding.tvLevel.setBackgroundColor(levelBackgroundColors[level.toInt()])
            showAlgorithmChips(tags)
        }
    }
    ...
}

 

1개의 문제인 problem 에 데이터가 할당되면, ProblemFragment 에서 관찰하고 있다가 UI를 업데이트하도록 하였다!

 


결과

변경 전의 데이터 흐름이 아래와 같았다면

 

변경 후의 데이터 흐름은 Fragment 와 ViewModel 의 책임이 조금 더 명확해졌다!

 


 

이번 글을 작성하며 내가 겪은 문제를 제대로 설명하는 것도 중요하지만 그걸 정리하는 것부터가 시작인 것 같다 ..

너무 횡설수설하지는 않았는지 .. 다른 사람들이 봤을 때 이해하는데 어려움은 없을지 걱정된다 ..

 

처음에 리팩토링해야지!하고 코드를 봤을 땐 너무 커다란 문제같아 보였지만 막상 수정하고보니 내가 마주했던 막막함보단 자그마했던 것 같다. 이게 문제 해결 경험이 되나? 이런것이 ...? 하지만. .. 그럼에도 적어보았다!