[Hilt 들고 MVVM 정복] 3. UseCase, 그리고 DI

1

Hilt 들고 MVVM 정복

목록 보기
3/4
post-thumbnail

비즈니스 로직은 어떻게 해야 할까

2편에서 화면 UI는 세 가지 구성요소로 나눌 수 있고 이 들중 UI 라이프사이클에 영향받지 않아야 하는 구성요소를 ViewModel로 분리해야 한다고 했습니다.
따라서 Observer나 StateFlow 자료구조를 활용하여 화면 상태정보 데이터를 ViewModel에서 관리하고 View에서는 이를 관찰하여 적절한 UI 로직만 취해주도록 해주었습니다.

그렇다면 비즈니스 로직은 어떻게 처리하면 될까요? 데이터의 경우 라이프사이클에 의해 유실되지 않도록 특별히 ViewModel에서 가지고 있도록 해준 것이지만 사실 로직은 라이프사이클에 크게 영향을 받지 않습니다. 따라서 View에 구현하나 ViewModel에 구현하나 동작에서의 차이는 없을것입니다. 그러나 우리는 단순히 성능개선만을 위해 MVVM 구조를 설계하는게 아니라 유지보수성과 가독성, 그리고 역할의 분리를 위한 Clean Architecture를 달성하기 위해 MVVM 구조를 설계하고 있는거잖아요?

따라서 UseCase라는 개념을 도입하여 View는 오로지 UI 관련로직만을 수행하고 그에 필요한 비즈니스 로직은 UseCase에 구현하여 ViewModel이 수행하도록 할 수 있습니다.

UseCase란?

다시한번 Clean Architecture 다이어그램을 볼까요

UI layer는 2편에서 다뤘는데 이 때 다뤘던 ViewModel을에 Domain layer의 Usecase가 연결되어 있습니다. 이 때 화살표에 의존성 주입이라 되어 있는데 의존성 주입은 아래에서 다시 언급하도록 하겠습니다.
UseCase가 뭐냐면 UI에서 요청하게 되는 각각의 비즈니스 로직에 대해 가장 1차원적인 시각으로 캡슐화한 개념입니다.

회원가입 화면이기에 예를 들어보면

  • 유저가 아이디 중복확인 버튼을 눌러서 중복되는 아이디인지 확인한다.
  • 비밀번호 확인을 입력했을 때 일치하는지 확인한다.
  • 가입하기 버튼을 눌러서 입력한 정보를 바탕으로 회원가입을 수행한다.

와 같은 내용입니다.
위 내용들을 수행하기 위해 네트워크에 연결되고, 각종 클래스를 생성하여 유저 정보를 생성하고, 비밀번호를 제대로 입력했는지 정규식으로 검증하는 등의 비즈니스 로직이 복합적으로 수행됩니다.
그러나 유저가 기대하는 행동은 아이디 중복확인, 가입 요청 등의 UI에 노출되어 있는 단순한 행동들입니다.

따라서 View에서는 이러한 행동을 수행하라는 요청만 ViewModel에게 보내고 구체적인 비즈니스 로직은 UseCase안에서 수행되며 이를 ViewModel이 제어하고 있는 것입니다.

굳이 UseCase를 통해 캡슐화 해야하는 이유?

비즈니스 로직을 분리하다보면 코드 몇 줄로도 충분히 커버할 수 있는 경우도 있을 뿐더러 이들을 매번 UseCase로 분리하여 요청하게 되면 코드양이 늘어나고 비효율적인 것처럼 느껴질 수 도 있습니다.

실제로 Domain layer 관련한 공식문서에서도 이를 선택사항이라고 안내하고 있습니다... 만은 제 개인적인 생각으로 매우 간단한 비즈니스 로직일지라도 UseCase를 꼭 통해서 수행되게 만드는 것이 옳은 방법일 것 같습니다.
그에 대한 근거는 아래와 같습니다.

ViewModel이 비약적으로 커지는 것을 방지

ViewModel이 모든 비즈니스 로직을 구현하고 있다면 코드량이 늘어나는것 뿐만 아니라 의존도 역시 커지게 됩니다. DataSource나 네트워크에 연결하기 위한 모든 Repository들에 의존성을 가지게 되고 해당 로직을 구현하기 위한 코드역시 많이 작성해야 할 것입니다. (지금까지의 내용에서 Data layer에 대해 다루진 않았지만 Repository는 Data를 가져오기 위한 로직의 구현부 혹은 인터페이스 정도로 이해하면 됩니다.)

만약 데이터를 가져오기 위한 특정 비즈니스 로직이 변경되었고, 이러한 로직을 쓰는 ViewModel이 여러개가 있다면 일일이 찾아가서 바꿔주어야 하는데 상상만해도 너무 귀찮습니다

따라서 이러한 로직들을 UseCase들로 묶어서 캡슐화한다면 ViewModel은 Repository의 구현부, 혹은 세부적인 비즈니스 로직 등에 대해 상세하게 알 필요 없이 UseCase 함수 호출만으로 작업을 수행할 수 있으므로 의존도가 낮아지게 됩니다.
(비즈니스 로직에 필요한 모든 Repository에 대해 의존할 필요 없이 사용하는 UseCase들에 대한 의존성만 가지게 됨.)

로직의 변경이 있더라도 모든 ViewModel을 찾아다니며 고칠 필요 없이 UseCase만 변경하면 되니 유지보수성 역시 좋아지겠죠.

비즈니스 로직의 분리

fun validateNameUsecase(nickname : String) : Boolean{
	val containsValidCharacters = nickname.matches(Regex("[a-zA-Zㄱ-ㅎ가-힣0-9]+"))
	return containsValidCharacters
}

위 메서드는 회원가입 과정에서 입력한 닉네임이 형식을 제대로 지켰는지 여부를 정규식으로 검사해서 반환하는 UseCase 예시입니다.
이렇게 간단한 로직들임에도 굳이 UseCase를 통해 호출해야 할까요?

그렇습니다. ViewModel은 어디까지나 UI layer에 속하며 UI state 정보를 관리하며 사용자입력을 처리하는데 의의가 있습니다. 따라서 UI 상태정보를 업데이트하기 위한 최소한의 행동단위를 수행할 뿐 자세한 비즈니스 로직의 모든 내용을 알 필요가 없습니다.

또한 UseCase를 사용할 경우 메서드명으로 어떤 역할을 하는지가 명확히 드러나기 때문에 추후 코드를 수정해야할 일이 생겼을 때 빠르게 찾아서 고칠 수 있습니다.
(예를들어 닉네임 작명 규칙이 변경됐을 경우 ViewModel을 일일이 뒤적거리지 않고도 UseCase를 바로 찾아서 수정할 수 있겠죠.)

단일책임 원칙

위에서 설명한 모든 내용들은 곧 객체지향 방법론과 연결됩니다. 그중 핵심적인 내용을 뽑아보자면 아래와 같습니다.

  • 단일 책임: 클래스가 변경되어야 하는 이유는 하나여야 합니다. 클래스가 여러 책임을 가지면 하나의 변경으로 인해 다른 부분에 영향을 주거나 변경이 필요한 부분이 더 많아질 수 있습니다.

  • 높은 응집도: 클래스의 메서드와 속성은 밀접하게 관련되어야 하며, 하나의 목적을 위해 함께 묶여 있어야 합니다.

  • 낮은 결합도: 클래스 간의 의존성이 낮아야 합니다. 한 클래스의 변경이 다른 클래스에 영향을 미치지 않아야 합니다.

UseCase의 사용은 위 세가지 원칙을 완벽하게 만족시킵니다.

ViewModel의 역할은 "UI State를 관리하는 것" 이므로 비즈니스 로직에 대해 모두 알 필요 없습니다. (높은 응집도) 또한 ViewModel이 모든 Repository들에 의존하는것 대신에 UseCase에 대해서만 의존하고, UseCase가 각각의 비즈니스 로직에 필요한 Repository에 의존하게 됨으로써 클래스간 의존성이 낮아집니다. (낮은 결합도) 마지막으로 해당 UI에 필요한 모든 비즈니스 로직에 대한 책임을 ViewModel이 떠안는 대신, 각각의 UseCase가 필요한 비즈니스 로직만을 구현하고 있다면 비즈니스 로직에 대한 유집수성과 테스트성이 용이해집니다. (단일 책임)

위와 같은 이유들로

ViewModel에서 비즈니스 로직을 다룰 때에는 UseCase를 통해 다루고 정확한 구현부는 UseCase에 숨겨두는 편이 좋습니다. 매우 간단한 로직일 지라도 해당 로직이 한군데에서만 쓰인다는 보장은 없기 때문에 추후 유지보수성을 생각해서라도 모두 UseCase로 캡슐화를 해두는 편이 장기적인 관점에서 좋은 개발 방법이라고 생각합니다.

구현은 어떻게?

세부적인 비즈니스 로직을 UseCase로 빼놓고 ViewModel에서 이를 호출하는식으로 구현하면 되기 때문에 간단합니다.

class SignupUseCase {

    suspend operator fun invoke(){
        // TODO : 실제 회원가입 요청 API 호출
    }

    suspend fun checkId(id : String){
        // TODO : 아이디 중복체크 API 호출
    }

    suspend fun checkNickname(name : String){
        // TODO : 닉네임 중복체크 API 호출
    }

    fun validatePw(pw : String) : Boolean{
        // 비밀번호가 영문자를 포함하는지 확인
        val containsEnglishChars = pw.matches(Regex(".*[a-zA-Z].*"))
        // 비밀번호가 특수문자를 포함하는지 확인
        val containsSpecialChars = pw.matches(Regex(".*[!@#\\\$%].*"))
        // 비밀번호가 숫자를 포함하는지 확인
        val containsNumbers = pw.matches(Regex(".*[0-9].*"))
        // 비밀번호의 길이가 8자에서 20자 사이인지 확인
        val isLengthValid = pw.length in 8..20

        val isValid = containsEnglishChars && containsSpecialChars && containsNumbers && isLengthValid

        return isValid
    }

    fun validateName(nickname : String) : NameValidations{
        val containsValidCharacters = nickname.matches(Regex("[a-zA-Zㄱ-ㅎ가-힣0-9]+"))
        val isLengthValid = nickname.length in 1..10

        val isValid = containsValidCharacters && isLengthValid
        return NameValidations(containsValidCharacters, isLengthValid, isValid)
    }
}

시리즈에서 아직 data layer를 다루지 않았기 때문에 API에 연결하는 매서드는 TODO로 남겨놓았고 data layer에 연결되지 않는 로직만 구현해놓은 모습입니다. data layer 부분 매서드는 어떻게 해야할지 대충 상상을 해보면 아래와 같은 모습일겁니다.

// 필요한 Repository에 대해서만 의존
class SignupUseCase {

	private lateinit var joinRepository : JoinRepository
    
    init{
    	joinRepository = JoinRepository()
    }

    suspend operator fun invoke(userInfo : UserInfo){
        // TODO : 실제 회원가입 요청 API 호출
        joinRepository.requestSignin(userInfo)
    }
}

물론 저게 정답은 아니고 DIP 및 IoC 등에 대해 알아야할 내용이 많으니 나중에 다루자구요

자 이제 UseCase를 구현하여 비즈니스로직을 기존의 Activity에서 분리해 냈으므로 이를 ViewModel에서 호출해야겠죠?
저번 포스팅에서 ViewModel을 대충 구현해보긴 했는데 여러가지 UI state 관련 로직이 추가된 ViewModel을 가져와봅시다.

class JoinViewModel : ViewModel() {
	// ...
    private val _userId = MutableLiveData<String>()
    val userId: LiveData<String> = _userId
    private val _password = MutableLiveData<String>()
    val password: LiveData<String> = _password
    private val _name = MutableLiveData<String>()
    val name: LiveData<String> = _name
    // ...

    init{
    	// ...
        _userId.value = ""
        _password.value = ""
        _name.value = ""
        // ...
    }
    
    fun setUserId(id : String){
        _userId.value = id
        _idCheck.value = DuplicationCheck.PROCEEDING
    }

    fun setPassword(pw : String){
        _password.value = pw
        _pwValidation.value = signUp.validatePw(pw)
    }
    
    // ...
}

자 이렇게 회원가입에 관련된 UI State를 관리하는 ViewModel이 있다면 여기에 비밀번호를 양식에 맞게 입력했는지 검증하는 비즈니스 로직이 수행되야 한다고 가정해봅시다. 해당 로직은 SignupUseCase의 validatePw에 구현되어 있기 때문에 ViewModel에서는 해당 UseCase를 사용해서 비즈니스로직에 접근해야 합니다.

class JoinViewModel : ViewModel() {
	// ...
    private val _userId = MutableLiveData<String>()
    val userId: LiveData<String> = _userId
    private val _password = MutableLiveData<String>()
    val password: LiveData<String> = _password
    // ...
    val signupUsecase = SignupUseCase()	// 새로 추가된 부분

    init{
    	// ...
        _userId.value = ""
        _password.value = ""
        _name.value = ""
        // ...
    }
    
    fun setUserId(id : String){
        _userId.value = id
        _idCheck.value = DuplicationCheck.PROCEEDING
    }

    fun setPassword(pw : String){
        _password.value = pw
        _pwValidation.value = signUp.validatePw(pw)
    }
    
    // ...
    
    // 새로 추가된 부분
    fun validatePw(){
    	val isValid = signupUsecase.validatePw(password.value)
        if (isValid){
        	// 회원가입 버튼 활성화
        } else {
        	// 회원가입 버튼 비활성화
        }
    }
}


// 구현된 UseCase

class SignupUseCase {

    suspend operator fun invoke(){
        // TODO : 실제 회원가입 요청 API 호출
    }

	// ...
    
    fun validatePw(pw : String) : Boolean{
        // 비밀번호가 영문자를 포함하는지 확인
        val containsEnglishChars = pw.matches(Regex(".*[a-zA-Z].*"))
        // 비밀번호가 특수문자를 포함하는지 확인
        val containsSpecialChars = pw.matches(Regex(".*[!@#\\\$%].*"))
        // 비밀번호가 숫자를 포함하는지 확인
        val containsNumbers = pw.matches(Regex(".*[0-9].*"))
        // 비밀번호의 길이가 8자에서 20자 사이인지 확인
        val isLengthValid = pw.length in 8..20

        val isValid = containsEnglishChars && containsSpecialChars && containsNumbers && isLengthValid

        return isValid
    }

	// ...
}

한눈에 보기 쉽게 구현된 UseCase와 전체적인 ViewModel의 형태를 코드로 나타내었는데, ViewModel이 UseCase를 어떻게 사용하는지 간결하게 정리해보면 아래와 같습니다.

class JoinViewModel : ViewModel() {
    val signupUsecase = SignupUseCase()

	fun validatePw(){
    	val isValid = signupUsecase.validatePw(password.value)
        if (isValid){
        	// 회원가입 버튼 활성화
        } else {
        	// 회원가입 버튼 비활성화
        }
    }
}

위 코드에서는 UseCase 객체를 ViewModel이 직접 생성하여 사용하고 있습니다.
결론부터 말하자면 두 ViewModel과 UseCase 두 클래스간 강한 결합도가 생겼기 때문에 좋지 않은 방법입니다.

강한 결합도란?

  1. 직접적인 의존성 : 클래스 A가 클래스 B를 직접 참조하고 있을 때, 클래스 A와 클래스 B는 강한 결합도를 가집니다. 예를 들어, 클래스 A가 클래스 B의 메서드를 호출하고 있다면, 클래스 A는 클래스 B에 강하게 의존하고 있습니다.

  2. 상속 : 서브클래스가 수퍼클래스에 강하게 의존하는 경우가 있습니다. 서브클래스가 수퍼클래스의 내부 구현에 의존하여 수정하기 어렵게 만들 수 있습니다.

  3. 인터페이스 구현 : 클래스가 인터페이스를 구현하고 있는 경우, 해당 클래스는 해당 인터페이스에 강하게 의존하게 됩니다. 이 경우, 인터페이스의 변경이 해당 클래스에 영향을 줄 수 있습니다.

  4. 전역 상태 또는 싱글톤 : 클래스가 전역 변수나 싱글톤 인스턴스에 의존하는 경우, 해당 클래스는 전역 상태나 싱글톤 인스턴스에 강하게 결합됩니다. 이는 유지보수와 테스트를 어렵게 만들 수 있습니다.

  5. 하드 코딩된 값 : 클래스가 하드 코딩된 값에 의존하는 경우, 해당 클래스는 그 값에 강하게 결합됩니다. 이는 유지보수성을 저하시킬 수 있습니다.

위와 같은 예시에 해당하는 경우 두 클래스나 인스턴스간에 강하게 결합되어 있다고 말합니다.

class JoinViewModel : ViewModel() {
    val signupUsecase = SignupUseCase()

	fun validatePw(){
    	val isValid = signupUsecase.validatePw(password.value)
        if (isValid){
        	// 회원가입 버튼 활성화
        } else {
        	// 회원가입 버튼 비활성화
        }
    }
}

위 코드의 경우 1번 직접적인 의존성을 갖는 경우에 해당하는데, 만약 JoinViewModel에서 더이상 SignupUseCase를 사용하지 않고, validatePw 메서드가 하는 역할을 다른 UseCase가 담당하게 되었다면 SignupUseCase를 생성하는 ViewModel들을 모두 찾아다녀야 하기 때문에 유지보수성이 안좋아지게 됩니다.

의존성을 주입해보자

클래스를 직접 생성하는 행위는 해당 클레스에 강하게 의존한다는 의미이므로 결합도가 강해지게 됩니다. 그렇다면 직접 클래스를 생성하는게 아니라 외부에서 파라미터로 받아와 사용하게 되면 결합도가 느슨해지지 않을까요?
주입받은 UseCase를 통해 validatePw라는 비즈니스 로직을 사용하기만 하면 될 뿐 어떤 UseCase가 담당하는지에 대해 알 필요가 없게 되니까요.
즉 변화가 적은 비즈니스 로직에 영향을 미치지 않으면서 관련된 코드의 변경사항에 대해 강건해지게 되는 것입니다.

// 이렇게 말이죠

class JoinViewModel(signupUsecase : SignupUseCase) : ViewModel() {

	fun validatePw(){
    	val isValid = signupUsecase.validatePw(password.value)
        if (isValid){
        	// 회원가입 버튼 활성화
        } else {
        	// 회원가입 버튼 비활성화
        }
    }
}

이제는 JoinViewModel이 직접 클래스를 생성하지 않고 외부에서 JoinViewModel을 생성할 때 파라미터로 SignUpUseCase를 넘겨주게 됩니다. 따라서 JoinViewModel 입장에서는 해당 UseCase에 대한 의존도가 낮아졌다고 볼 수 있겠죠. 이를 외부에서 의존성을 주입해준다 하여 Dependency Injection 줄여서 DI 라 부릅니다.

어...? 근데 어차피 외부에서 주입을 해줘야 한다면 아래 코드처럼 어차피 또 SignUpUseCase를 생성해야 하고 그러면 외부 클래스에서 강한 결합도가 생기는거 아닌가요? 그리고 해당 클래스 인스턴스를 주입해서 사용한다 해도 변화가 생겼을 때 영향을 받는건 별 차이 없는거 아닌가요?

class JoinActivity() : AppCompatActivity(){
	override fun onCreate(){
    	val subClass = SubClass()	// 어차피 외부에서 또 생성해야 함 (다른곳에서 강한 결합 발생)
        val mainClass = MainClass(subClass)	// 의존성 주입을 받아도 SubClass에 변화가 생기면 MainClass 내부에도 영향이 전파됨
    }
}

아래를 봐주세요~

DI 를 통해 강한 결합도 문제를 해결할 수 있나요?
그건 아닙니다. DI는 강한 결합도 문제를 해결하기 위한 수단 중 하나일 뿐 완전히 해결하지 못하고 어느정도 완화시킨다 정도로 생각해주세요. 이러한 결합도와 의존성 문제를 해결하기 위한 방법들 중에는 DI 외에도 의존 역전 (DIP), 제어의 역전(IoC) 와 같은 방법들이 존재하며 이 포스팅의 궁극적 주제인 Hilt 라이브러리와 연관됩니다.
따라서 이번 포스팅에서는 의존성 주입 (DI) 까지만 다루고 ViewModel에 어떻게 의존성을 주입할 수 있는지에 대한 내용까지만 이야기 해 보겠습니다.

ViewModel과 DI

자 이제 의존성을 주입하면서 ViewModel을 생성하는 방법을 한번 살펴보겠습니다. 그 전에 저번 ViewModel 포스팅에서 ViewModel을 생성하는 방법에 대해 한번 다뤘기 때문에 저번 포스팅을 한번 살펴보고 와주세요.


이 사진이 기억 나시나요?
저번 포스팅에서는 첫번째 ViewModelStoreOwner 만 인자로 받는 constructor를 사용했지만 이번엔 아래에 있는 constructor를 사용할 겁니다. Factory라는것을 추가로 받고있네요!

ViewModelStore가 뭐고 ViewModelStoreOwner는 뭐고 Factory는 뭐고 죄다 처음보는것들 투성입니다. 이쯤에서 ViewModel의 생성 및 참조 사이클을 다이어그램으로 살펴볼게요

가장 아래 인간모양이 Activity 혹은 UI 라고 생각해봅시다. UI에서 일어난 상호작용을 처리하기 위해서는 ViewModel을 호출해야 하는데 그 과정에서 다음과 같은 일이 일어납니다.

  1. UI에서 ViewModel 요청
  2. ViewModelProvider가 Factory를 통해 ViewModel 생성
  3. 생성된 ViewModel은 ViewModelStore에 저장됨
  4. ViewModelProvider가 해당 UI와 연관된 ViewModelStoreOwner를 통해 ViewModelStore에 접근하여 ViewModel 객체 반환

ViewModel 하나 생성하고 불러오는데 굉장히 많은 클래스가 엮여있습니다.

ViewModelStore는 생성된 ViewModel들을 Hashmap으로 관리하고 있는 클래스입니다.

ViewModelStoreOwner는 ViewModelStore에 접근하기 위한 인터페이스이며 ComponentActivity가 ViewModelStoreOwner를 구현하고 있습니다.
(AppCompatActivity -> FragmentActivity -> ComponentActivity 순으로 상속하고 있습니다.)

여기에 다 담을 수는 없지만 ComponentActivity의 코드를 살펴보면 ViewModelStore를 선언해서 구현하는 부분과 ViewModelStoreOwner 인터페이스를 구현하여 해당 ViewModelStore에 접근할 수 있도록 구현해놓은 부분이 존재합니다. (바로 ViewModelStore에 접근하지 않고 ViewModelStoreOwner라는 인터페이스를 통해 접근하게 해놓은 것 역시 강한 결합도를 해결하기 위한 의존 역전의 원칙이 관여되어 있습니다.)

뭐 복잡한 내용이 많았는데 정리해보면 ComponentActivity 내부적으로 ViewModelStore에서 ViewModel 객체들을 관리하고 있고 이를 불러오거나 생성하기 위해서는 ViewModelStoreOwner나 Factory인스턴스를 사용해야 한다는 것입니다.

ViewModelFactory?

자 이제 Factory에 대해 다뤄볼게요 Factory 객체는 ViewModelProvider 안에 interface로 존재하고

위에서 보여드렸던 ViewModelProvider 클래스의 constructor에서 Factory를 파라미터로 넣어 객체를 생성하고 있습니다.

빨간 네모박스 영역이 두 constructor간의 유일한 차이점입니다.
첫번째 constructor의 경우 defaultFactor를 자동으로 주입해주고 두번째 constructor의 경우 파라미터로 받은 factory를 주입해주고 있습니다.

defaultFactor의 경우 내부적으로 구현되어 있는 Factory인데 아무런 파라미터도 없는 ViewModel을 생성하는 Factory입니다.

그런데 우리의 ViewModel은 UseCase가 파라미터로 들어가있죠? 그러니까 defaultFactory를 사용하는 것이 아니라 custom Factory를 구현해서 두번째 constructor를 사용해서 주입해주면 됩니다.

Factory 객체는 interface 이므로 우리가 직접 구현해주면 되겠죠.

Custom Factory 구현

바로 코드부터 보시죠

class JoinViewModel(signupUsecase : SignupUseCase) : ViewModel() {

	// ...
    
	fun validatePw(){
    	val isValid = signupUsecase.validatePw(password.value)
        if (isValid){
        	// 회원가입 버튼 활성화
        } else {
        	// 회원가입 버튼 비활성화
        }
    }
    
    // ...
    
    // 뷰모델 의존성 주입을 위한 Factory
    companion object {
        
        @Suppress("UNCHECKED_CAST")
        val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(
                modelClass: Class<T>,
            ): T {
                val signupUseCase = SignupUseCase()

                return JoinViewModel(
                    signupUseCase
                ) as T
            }
        }
    }
}

JoinViewModelFactory 와 같은 이름을 가지는 클래스로 따로 구현해도 되지만 그렇게 하면 파일이 너무 많아지기 때문에 해당 ViewModel의 companion object안에 변수로 구현해두는 편이 가독성이 좋습니다.

아까전에 위에서 Factory의 코드를 보여드렸는데 create()라는 하나의 메서드만 존재하는 interface였습니다. 따라서 Factory 객체를 생성해서 create() 메서드를 override 해서 구현해준 모습입니다.

위 코드가 defaultFactory 의 구현부인데 create() 메서드를 보시면 modelClass.newInstance() 를 통해 생성자 없는 기본 인스턴스를 생성하여 반환하고 있는것을 알 수 있습니다. 클래스에 매개변수가 있는 경우 newInstance()를 이용하여 생성할 수 없기 때문에

val signupUseCase = SignupUseCase()

return JoinViewModel(signupUseCase) as T

직접 이렇게 UseCase에 대한 의존성을 주입해서 객체를 생성해주도록 Factory를 직접 구현해주어야 한다는 점 이해되셨나요?

Custom Factory 활용해서 ViewModel 생성하기

그렇다면 companion object에 구현해둔 Custom Factory 인스턴스를 어떻게 활용해서 ViewModel을 생성해야 할까요?

private val joinViewModel : JoinViewModel by lazy{
	// Factory를 파라미터로 주입해준 모습
	ViewModelProvider(this, JoinViewModel.Factory).get(JoinViewModel::class.java)
}

지금까지의 내용을 모두 이해했다면 바로 아실 수 있다고 생각합니다.
constructor가 두가지 있었죠? viewModelStoreOwner만 주입해주는 버전과 Factory까지 함께 주입해주는 버전.

ViewModel에 의존성주입이 필요없는 경우 첫 번째 버전을 사용하면 되지만 ViewModel에 의존성주입이 필요한 경우 Factory를 직접 구현해서 두 번째 constructor 버전을 사용해주시면 됩니다.

private val joinViewModel : JoinViewModel by viewModels { JoinViewModel.Factory }

이렇게 표현도 가능합니다.

정리해보면

  • ViewModel은 항상 ViewModelProvider.Factory를 통해 인스턴스화 된다.
  • ViewModelProvider는 두가지 버전의 constructor가 있는데 하나는 defaultFactory를 사용하는 버전이고 하나는 customFactory를 사용하는 버전이다.
  • ViewModel은 ViewModelStore에서 HashMap 형태로 관리된다.
  • UI 에서는 ViewModelStoreOwner라는 인터페이스를 통해 ViewModelStore에 접근하여 ViewModel 인스턴스를 얻어온다.
  • 비즈니스 로직은 캡슐화하여 UseCase로 분리하는 것이 유지보수성 및 테스트하기에 용이하다.
  • UseCase는 ViewModel에서 직접 생성하는것보다 외부에서 주입받는 편이 결합도를 낮추기에 좋다 (DI)
  • 그러기 위해서는 UseCase를 생성해서 파라미터로 주입해주도록 직접 ViewModelFactory 인스턴스를 구현해주어야 한다.
profile
안녕하세요, 인공지능에 가치를 느끼는 안드로이드 개발자 입니다.

0개의 댓글