본문 바로가기

안드로이드 기술 공유

안드로이드 MVVM 아키텍처 (Android Apps Using MVVM Architecture)

"아키텍처" 라는 주제는 사실 쉽게 이해하기도 힘들며, 완벽한 답안이 있는 게 아니다 보니 어떤 원칙대로 설계를 해야 하는지에 대한 모호함과 어려움이 있어서 쉽게 놓치곤 합니다.   

 

하지만, 안드로이드 프로젝트에 적합한 아키텍처를 정의하지 않으면 코드 베이스가 커지고 팀이 확장됨에 따라 유지보수가 어려워집니다.

(유지보수 : 유지하고 보수하는 것뿐만이 아니라, 가독성, 테스트, 기능 추가, 변경, 삭제, 버그 수정, 인수인계...)

그렇기 때문에, 괜찮은 방식의 아키텍처를 정하는 것에 대해 고민할 수밖에 없고 좋은 아키텍처는 개발자에게 명확한 프로젝트 가독성과 더 큰 효율과 생산성을 가져다준다고 생각합니다.

 

여기서 설명드릴 MVVM 아키텍처는 우리에게 가장 좋은 래퍼런스가 되는 Google Android Architecture 를 참고하여 MVVM 아키텍처가 어떤 구조이며, 어떻게 분리되고 사용되는지에 대해 정리해보려고 합니다.

MVVM (Model-View-ViewModel)

MVVM은 비즈니스 로직과 UI 뷰(e.g. Activity, Fragment, CustomView, Dialog...) 를 분리합니다.

즉, 화면에 보여주는 UI와 실제 데이터가 처리되는 비즈니스 로직이 완전히 분리되기 때문에, 코드를 유지 보수하고 테스트하기가 매우 쉽습니다.

아래는, MVVM의 기본 구조를 다이어그램으로 표현한 것입니다. 

MVVM은 단방향 의존 구조를 가지고 있습니다.

 

 

참고 : https://docs.microsoft.com/en-us/xamarin/xamarin-forms/enterprise-application-patterns/mvvm

 

그렇다면, ViewModel은 View를 알지 못하는데 어떻게 View의 UI를 업데이트 할 수 있을까요?

그건 바로, Observing과 DataBinding을 하기 때문입니다!

View가 ViewModel을 Observing과 Binding하고 있기 때문에, ViewModel은 단순히 자기 값만 바꾸면, View가 자동으로 업데이트됩니다.

결과적으로, ViewModel이 View를 알지 못하게 하여, 의존성에서 벗어날 수 있게 되는 것이죠.

그렇기 때문에, MVVM 아키텍처에서 Observing과 DataBinding 기술은 필수적입니다!

View

View는 사용자에 보여지는 UI 영역이며, ViewModel에게 필요한 데이터를 요청하고 옵저빙을 합니다.

그렇기 때문에, 직접 UI를 바꿔주지 않아도 자동으로 갱신이 되며, 뷰 모델을 통해 데이터를 갱신하기 때문에 생명주기에 자유롭습니다.

액티비티가 destroyed 된 후, 재구성되어도 뷰 모델이 데이터를 유지하고 있기 때문에 전혀 영향을 받지 않게 되는 것이며, 액티비티 생명주기에 따르다 보니 활성화된 경우에만 작동하기 때문에 불필요한 메모리 사용도 줄일 수 있습니다.

ViewModel

ViewModel은 비즈니스 로직을 담당하고 있으며, View에 보여질 데이터를 Model로부터 가져와서 알맞게 데이터를 가공하여 값을 바꾸는 역할을 담당합니다.

가공을 완료한 후 자신의 변수를 업데이트하게 되면, ViewModel을 옵저빙 하고 있던 View는 값의 상태 변경을 감지하여 화면을 갱신하게 됩니다.

Model

프로그램에서 사용되는 실제 데이터이며, Repository, DataSource, Data Class 가 이에 해당합니다.

Local, Remote(Server API...)에서 데이터를 조회하거나 업데이트하는 로직을 담당하고 있습니다.

 

MVVM 장점

1. View가 데이터를 실시간으로 감지합니다.

LiveData를 이용해 데이터를 옵저빙하기 때문에 데이터에 맞게 자동으로 UI를 갱신하게 됩니다.

그렇기 때문에, 직접 View를 바꿔주는 번거로움도 없어질뿐더러 데이터의 일치가 명확해집니다.

 

2. View가 활성화 되어있는 경우에만 동작하기 때문에, 메모리 릭을 방지할 수 있습니다.

View에서는 ViewModel을 통해 데이터를 참조하기 때문에, ViewModel은 생명주기와는 독립적입니다.

예를 들면, 화면 회전과 같이 View가 파괴된 후 재구성되어도 ViewModel이 데이터를 가지고 있기 때문에 전혀 영향을 받지 않고,

View가 활성화 되어있는 경우에만 동작하기 때문에 불필요한 메모리 사용이 없어 메모리 릭을 방지할 수 있습니다.

 

3. 기능별로 모듈화되어 있어 역할 분리를 할 수 있고, 그렇기 때문에 유지보수와 재사용성이 좋으며 유닛 테스트에 용이합니다.

 

샘플 프로젝트로 쉽게 알아보기

그렇다면, 간단한 샘플 프로젝트를 통해서 MVVM이 어떤 식으로 이루어지는지 살펴보도록 하겠습니다!

샘플 프로젝트로는, "로또 정보 조회하기" 버튼을 누르면, 로또 정보 API를 통해 해당 회차의 로또 당첨 정보를 가져와서, 화면에 표현해주는 간단한 로또 당첨 정보 조회 프로젝트입니다.

 

 

아래에서 설명드릴 MVVM의 구조는, 좀 더 개선된 방식의 MVVM구조로, 위의 그림 Model에 Repository 패턴을 적용하여 데이터 소스(DataSource)로부터 Model을 가져오는 것을 추상화함으로써, 각 레이어(Layer)끼리의 의존성을 더 낮추고, 수정과 변경에 유연할 수 있도록 하였습니다.

 

Package 구조

MVVM을 적용하기 위해 막상 시작하려다 보면, M이 Model인건 알겠고, V는 View인건 알겠고, VM이 ViewModel인건 알겠는데 그럼 도대체 어떻게 시작해야 하며, 클래스를 어떻게 분리해야 하는 건지 도통 감이 안 올 수도 있습니다.

그렇기 때문에, 먼저 패키지 구조부터 MVVM에 맞게 나눠본 후에 각각 패키지에 맞는 클래스들을 생성하여 MVVM을 완성시켜보도록 하겠습니다.

 

패키지 구조는 Google Android Architecture 와 Clean Architecture, Android App Package Guidelines를 참고하여 가장 이상적이고 선호되는 패키지 구조로 구성하였습니다.

 

 

data = Model : 프로그램에 사용되는 실제 데이터를 담당하기 때문에, "data"의 패키지로 분리하였습니다.

  • model : 데이터 클래스(Entity)들의 패키지로써, 추후 난독화에서 제외되어야 하는 대상이기 때문에, 난독화의 편의를 위해 model 패키지로 따로 분리하였습니다.
  • repository : Local 또는 Remote DataSource와 이러한 데이터 소스를 추상화하는 Repository 클래스, 네트워크 통신(Retrofit 라이브러리)을 위한 Retrofit Service 인터페이스가 있습니다.

presentation = View, ViewModel :  화면을 표현하는 UI를 담당하는 View와 ViewModel 이기 때문에, "presentation"의패키지로 분리하였습니다. 

  • View : 화면을 담당하는, Activity, Fragment, CustomView 등등... 에 해당합니다.
  • ViewModel : View의 이벤트를 받아, Model에서 필요한 데이터를 가져와서 View에서 원하는 데이터로 가공하는 역할을 하는 클래스입니다.

이렇게까지가 MVVM에 해당하는 패키지이며,

이 외에 필요에 따라 Utils, Constants 패키지도 추가하여 사용하시면 됩니다!

Model (=data)

로또 정보 데이터(LottoEntity)  API를 통해 로또 정보를 가져오는 데이터 소스(LottoRemoteDataSource), 데이터 소스를 추상화하는 리파지토리(LottoRepository) 샘플 코드입니다.

 

/**
 * 로또 정보 데이터클래스
 */
data class LottoEntity(

	@field:SerializedName("totSellamnt")
	val totSellamnt: Long? = null,

	@field:SerializedName("returnValue")
	val returnValue: String? = null,

	@field:SerializedName("drwNoDate")
	val drwNoDate: String? = null,

	@field:SerializedName("firstWinamnt")
	val firstWinamnt: Long? = null,

	@field:SerializedName("drwtNo6")
	val drwtNo6: Int? = null,

	@field:SerializedName("drwtNo4")
	val drwtNo4: Int? = null,

	@field:SerializedName("firstPrzwnerCo")
	val firstPrzwnerCo: Int? = null,

	@field:SerializedName("drwtNo5")
	val drwtNo5: Int? = null,

	@field:SerializedName("bnusNo")
	val bnusNo: Int? = null,

	@field:SerializedName("firstAccumamnt")
	val firstAccumamnt: Long? = null,

	@field:SerializedName("drwNo")
	val drwNo: Int? = null,

	@field:SerializedName("drwtNo2")
	val drwtNo2: Int? = null,

	@field:SerializedName("drwtNo3")
	val drwtNo3: Int? = null,

	@field:SerializedName("drwtNo1")
	val drwtNo1: Int? = null
)

 

/**
 * Retrofit Interface
 */
interface LottoService {

    /**
     * 로또 정보 조회
     */
    @GET("/common.do?method=getLottoNumber")
    fun getLottoInfo(@Query("drwNo") drwNo: Int): Call<LottoEntity>
}

 

/**
 * 로또 조회 API Remote DataSource
 */
object LottoRemoteDataSource {
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://www.nlotto.co.kr")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    private val lottoService = retrofit.create(LottoService::class.java)

    /**
     * 로또 당첨 정보 조회
     */
    fun getLottoInfo(drwNo: Int, callback: LottoRepository.GetDataCallback<LottoEntity>) {
        lottoService.getLottoInfo(drwNo).enqueue(object : Callback<LottoEntity> {
            override fun onResponse(call: Call<LottoEntity>, response: Response<LottoEntity>) {
                if (response.isSuccessful) {
                    callback.onSuccess(response.body())
                }
            }

            override fun onFailure(call: Call<LottoEntity>, t: Throwable) {
                callback.onFailure(t)
            }
        })
    }
}

 

/**
 * 로또 데이터 리파지토리
 *
 * DataSource로 부터 Model을 가져오는 것을 추상화하는 역할
 */
object LottoRepository {

    private val remoteDataSource = LottoRemoteDataSource

    /**
     * 로또 당첨 정보 조회
     */
    fun getLottoInfo(drwNo: Int, callback: GetDataCallback<LottoEntity>) {
        remoteDataSource.getLottoInfo(drwNo, callback)
    }

    /**
     * 데이터 조회 콜백
     */
    interface GetDataCallback<T> {
        fun onSuccess(data: T?)
        fun onFailure(throwable: Throwable)

    }
}

 

View, ViewModel (=presentation)

화면에 해당하는 View(LottoActivity, activity_lotto.xml)와 View에서 버튼을 클릭 시, Model을 통해 로또 정보 조회를 하고, 조회된 데이터를 가공하는 ViewModel(LottoViewModel) 샘플 코드입니다.

 

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="viewmodel"
            type="com.jykim.lotto.presentation.lotto.LottoViewModel" />

        <variable
            name="drwNo"
            type="Integer" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/button_lotto_info"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginLeft="18dp"
            android:layout_marginTop="100dp"
            android:layout_marginRight="18dp"
            android:onClick="@{() -> viewmodel.getLottoInfo(drwNo)}"
            android:text="로또 정보 조회하기"
            android:textColor="@android:color/black"
            android:textSize="18dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="20dp"
            android:gravity="center"
            android:text="@{viewmodel.lottoInfo}"
            android:textColor="@android:color/black"
            android:textSize="18dp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/button_lotto_info" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

 

class LottoActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding: ActivityLottoBinding = DataBindingUtil.setContentView(this, R.layout.activity_lotto)
        binding.run {
            viewmodel = LottoViewModel() //뷰모델 할당
            drwNo = 896 //조회 할, 로또 회차
        }
    }
}

 

class LottoViewModel : BaseObservable() {

    //View에 표현 될, 로또 당첨 정보 문자열
    val lottoInfo = ObservableField<String>()

    /**
     * 로또 정보 조회
     * @param drwNo 회차
     */
    fun getLottoInfo(drwNo: Int) {
        LottoRepository.getLottoInfo(drwNo, object : LottoRepository.GetDataCallback<LottoEntity> {
            override fun onSuccess(data: LottoEntity?) {
                data?.let {
                    val results = "${it.drwNo}회차 당첨번호 : " +
                            "${it.drwtNo1}, ${it.drwtNo2}, ${it.drwtNo3}, ${it.drwtNo4}, ${it.drwtNo5}, ${it.drwtNo6} + ${it.bnusNo}"
                    lottoInfo.set(results)
                }
            }

            override fun onFailure(throwable: Throwable) {
                throwable.printStackTrace()
            }
        })
    }
}

 

ViewModel의 lottoInfo변수를 옵저빙 하고 있던 View는, ViewModel이 데이터 조회 완료 후, lottoInfo에 set(value)를 하여 업데이트를 하게 되면, ViewModel의 상태 변경을 감지하여 데이터 바인딩되어있는 View를 갱신하면서 UI가 업데이트 되게 됩니다.

 

이렇게, MVVM 샘플 프로젝트로, 간단히 로또 정보 API를 조회하여(Model), 조회된 데이터를 UI에 표현하고 싶은 대로 가공하고(ViewModel), 가공된 데이터가 자동으로 업데이트되는(View) 로또 조회 앱을 만들어 보았습니다.

 

이와 같이 MVVM 아키텍처는, 화면에 보여주는 UI와 실제 데이터가 처리되는 비즈니스 로직이 완전히 분리되기 때문에, 코드를 유지 보수하고 테스트하기가 매우 쉬운 장점을 가지고 있는 멋지고 강력한! 아키텍처입니다 ^^