본문 바로가기
개발 이야기/안드로이드 개발

MVVM에 대하여, 그리고 간단한 샘플코드를 만들어보기

by 정선한 2022. 8. 31.
728x90
반응형

안드로이드에서는 MVVM을 제외하고 MVC, MVP 등의 구조를 제공하고 있습니다 MVVM 만큼이나 많이 쓰이는 구조들이므로 AAC(**Android Architecture Component)**는 모두 알아두는 것이 좋습니다. 하지만 오늘은 제가 실무 프로젝트에서 사용하였던 MVVM패턴에 대하여 자세히 알아보고 관련 내용의 가장 기본 골격을 어떻게 앱 프로젝트에 적용할 수 있는지 하나씩 알아보도록 하겠습니다.

AAC 중 MVVM은 [Model / View / ViewModel]라고 생각하시면 됩니다. Android Developer에서는 아래의 이미지를 App Architecture Diagram으로 소개하고 있습니다. 즉, 앱의 구조를 아래와 같이 구성하고 주요 살들을 붙여나가면 Android에서 권장하는 앱의 구조를 가지고 갈 수 있습니다.

이때 개발자가 MVVM패턴을 적용한다고 하면, [UI Layer = View] | [Domain Layer = ViewModel] | [Data Layer = Model]의 구조가 될 수 있습니다.

App Architecture Diagram : (출처 Andriod Developer)View - ViewModel - Model의 구조도 역순의 방향은 Notification을 수신하는 방향을 나타낸다.
App Architecture Diagram : (출처 Andriod Developer) View - ViewModel - Model의 구조도 역순의 방향은 Notification을 수신하는 방향을 나타낸다.

이러한 패턴들을 강조하는 이유가 무엇일까요? 왜 이런 구조를 알아야 하고 왜 적용해야 할까요?

보통 실무 프로젝트 경험이 없는 ‘아가 개발자'의 경우, 이러한 구조적 패턴에 익숙하지 않기 때문에 이러한 것들을 간과한 채, Activity, Fragment 코드에 정확하게 짚고 넘어가면 UI Layer에 해당하는 부분에 굉장히 많은 코드들을 집어넣습니다.

Activity, Fragment 코드에 Retrofit코드도 넣고, 관련 데이터를 뿌려주기 위한 Setting 코드도 넣고, View의 Component들에 대한 정의 코드도 넣고, 이벤트 코드도 넣고~

정말 많은 코드들이 Activity나 Fragment 코드에 들어갈 수 있습니다. 이렇게 되면 엄청난 문제가 발생하는데, 프로젝트의 유지보수가 불가능해집니다. 개발자 본인도 내가 어디에 어떤 코드를 넣었는지 알 수가 없고 큰 프로젝트가 아님에도 불구하고 코드라인이 1000줄 이상이 넘어가는 상황이 발생해서 찾을 수도 없게 됩니다.

그래서 이러한 패턴들이 필요해집니다. UI를 표현하는 프레젠테이션 로직과 그 밑단의 비즈니스 로직을 분리하고, 밀접한 관계를 가지는 코드들끼리 잘 정리해서 관리하기 위해서 이런 패턴들을 적용할 필요가 있는 것입니다. 데이터 모델이 변경되어도 UI단에서는 큰 문제가 생기지 않도록, 혹은 그 반대로 이벤트가 변경되어도 데이터 모델에는 영향이 가지 않도록. 코드들의 종속성을 잘 분리하고 유지보수를 용이하게 만드는 것이 이러한 패턴의 필요성이라고 볼 수 있습니다.

이건 여담으로 지나가는 저의 경험담입니다만, 처음 프로젝트의 구조를 잡는 업무를 담당했을 때, 정말 멘붕에 빠진 적이 있습니다. 주니어 개발자인 제게 이런 구조를 잡는 업무가 처음이기도 했고, 관련된 지식이 부족한 상태에서 이미 사내에서 혹은 인터넷에 올라와 있는 코드들을 실제 프로젝트에 적용하는 일이 쉽지 않았기 때문입니다. 머릿속에 구조에 대한 이미지도 없는 주니어 개발자에게는 너무 커다란 숙제였습니다. 그래도 결과는 보여드려야만 했기에 어찌어찌 Android에서 제공하는 예제 코드와 기존의 구조를 잘 조합하여 앱의 구조를 잡았었고 그 프로젝트가 끝나기 직전까지 해당 구조 때문에 자잘한 코드들을 전부 수정해야 했던 슬픈 기억이 납니다.

그때 구조적인 이미지를 그리는 데에 많은 도움을 주었던 이미지가 아래의 이미지입니다. 해당 이미지는 MVVM구조의 이미지이기도 하지만 Repository Pattern의 이미지 이기도 합니다. 위의 Architecture Diagram보다 아래의 이미지가 구조적 이미지를 그리기에는 저에게 많은 도움이 되었습니다. 언젠가 기회가 되면 Repository Pattern에 대해서도 소개를 해드리겠지만, 오늘은 MVVM구조에 대한 내용을 더 적어야 하기 때문에 이미지만 첨부하고 넘어가도록 하겠습니다.

이제 MVVM이 무엇인지 개괄적으로만 알아보았습니다. 더 정확하게는 코드를 보면서 설명을 드리는 게 좋을 것 같습니다. 코드만큼 개발자를 쉽게 이해시킬 수 있는 건 없으니까요. 😊

Sample Code

가장 빈 코드에서 시작하는 것도 좋은 방법이지만, 아무것도 모를 땐, Android Studio의 도움을 받는 것이 좋습니다. 저는 기본적으로 Activity위에 여러 Fragment를 구성하는 방식으로 프로젝트를 진행하기 때문에 Andorid Studio에서 Fragment를 생성할 때 Fragment(with ViewModel)를 이용하여 화면을 추가합니다. 해당 옵션에서 기본으로 ViewModel이 함께 만들어 지기 때문에 관련 Model과 View만 잘 만들어 주면 MVVM을 간단하게 적용해 볼 수 있습니다.

- build.gradle(:app)

dataBinding {
    enabled = true
}
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
}

android {
...
	buildFeatures {
	    viewBinding true
	    dataBinding true
	}
...
}

- layout (mvvm_test_fragment.xml)
MVVM 적용 시 DataBinding은 ViewModel에 선언된 값들을 쉽게 View단에 연결하여 사용할 수 있도록 합니다.

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

    <data>
        <variable
            name="viewmodel"
            type="com.dev_seonhan.application.ui.test.MvvmTestViewModel" />
    </data>

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".ui.test.MvvmTestFragment">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="Hello" />

    </FrameLayout>

</layout>

빈 프로젝트를 생성하고 Fragment를 연결하는 과정에서 저는 FragmentContainerView Component를 사용하여 구현하였습니다.
또한 이번 샘플 예제는 데이터베이스를 완전히 배제하고 App의 프론트엔드 단에서 관련한 mvvm처리를 알아보기 위해 작성된 코드입니다. 따라서 ViewModel의 구조에서 일반적인 프로젝트 형식의 코드와 많이 다를 수 있으니 개념의 습득으로만 해당 코드를 참조하시기 바랍니다.

아래는 제가 구현한 코드를 정리하였고, 관련 코드의 원본은 GitHub에 있으니 링크를 참조하시기 바랍니다.

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

    <data>
        <variable
            name="viewmodel"
            type="com.dev_seonhan.mvvm.ui.main.MainViewModel" />
    </data>


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

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/top_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintGuide_percent="@dimen/main_top_guideline" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/bottom_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:layout_constraintGuide_percent="@dimen/main_bottom_guideline" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/start_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="@dimen/main_start_guideline" />

        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/end_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="@dimen/main_end_guideline" />

        <EditText
            android:id="@+id/input_title"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:autofillHints="no"
            android:inputType="text"
            app:layout_constraintTop_toTopOf="@id/top_guideline"
            app:layout_constraintStart_toStartOf="@id/start_guideline"
            app:layout_constraintEnd_toStartOf="@id/button_check"
            tools:ignore="LabelFor" />

        <ImageView
            android:id="@+id/button_check"
            android:layout_width="30dp"
            android:layout_height="30dp"
            android:src="@drawable/plus_small_36"
            app:layout_constraintTop_toTopOf="@id/top_guideline"
            app:layout_constraintEnd_toStartOf="@id/end_guideline"
            tools:ignore="ContentDescription" />

        <androidx.recyclerview.widget.RecyclerView
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintTop_toBottomOf="@id/input_title"
            app:layout_constraintBottom_toBottomOf="@id/bottom_guideline"
            app:layout_constraintStart_toStartOf="@id/start_guideline"
            app:layout_constraintEnd_toEndOf="@id/end_guideline"
            bind_titleList="@{viewmodel.titleList}"/>

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

fragment_main.xml코드는 위와 같이 작성하였습니다. ConstraintLayout와 Guidline을 이용하여 화면의 공백을 잡아주었고, 그 안에 EditText, ImageView, RecyclerView를 이용하여 MVVM구조를 보여줄 수 있도록 구성해보았습니다.


결과물은 위의 영상처럼 나오는 코드입니다. EditText에 작성한 text를 + 버튼을 통하여 RecyclerView에 item을 추가하는 예제입니다.

mvvm구조 중 위에서 설명한 것처럼 View - ViewModel - Model을 구성해주면 됩니다.
이 코드에서는 MainFragment(View) - MainViewModel(ViewModel) - TestTitle(Model)로 구성을 하였습니다.
그 밖에 Binding을 하는 부분이나, RecyclerView를 추가하는 과정에서 많은 내용들이 들어오긴 하지만, 가장 주요 내용은 저 부분입니다.

 

 

 

 

 

MainFragment 코드 내에서 EditText의 입력된 값과 ImageView의 Click Event를 통하여 MainViewModel로 해당 값을 넘깁니다.

with(binding) {
    viewmodel = mainViewModel

    buttonCheck.setOnClickListener {
        mainViewModel.editTitle.set(inputTitle.text.toString())
        mainViewModel.addTitleList()
        inputTitle.text = null
    }

}

 

MainViewModel에서는 ObservableField, ObservableArrayList를 통해서 데이터들을 관리하려고 하는데 이 부분도 Observable을 사용하느냐, LiveData를 사용하느냐에 따라서 당연히 다양한 예제들이 만들어질 수 있습니다. 실무 프로젝트에서는 LiveData를 많이 사용해왔었는데 지금은 단순 string만 처리되기 때문에 ObservableField를 통해서 ObservableArrayList에 추가하는 방식으로 구현하였습니다.

package com.dev_seonhan.mvvm.ui.main

import androidx.databinding.ObservableArrayList
import androidx.databinding.ObservableField
import androidx.lifecycle.ViewModel
import com.dev_seonhan.mvvm.model.TestTitle

class MainViewModel : ViewModel() {

    val editTitle = ObservableField<String>()
    val titleList = ObservableArrayList<TestTitle>()

    fun addTitleList() {
        titleList.add(TestTitle(editTitle.get().toString()))
        editTitle.set("")
    }
}
data class TestTitle (
    var title: String
)
package com.dev_seonhan.mvvm.ui.main

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.dev_seonhan.mvvm.databinding.TitleItemBinding
import com.dev_seonhan.mvvm.model.TestTitle

class MainAdapter : RecyclerView.Adapter<MainAdapter.ViewHolder>() {
    var items = ArrayList<TestTitle>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding = TitleItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)

        return ViewHolder(binding)
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(items[position])
    }

    class ViewHolder(private val binding: TitleItemBinding) : RecyclerView.ViewHolder(binding.root) {

        fun bind(title: TestTitle){
            binding.model = title
        }
    }
}

코드상에서 볼 수 있는 것처럼 “@+id/button_check”를 가진 ImageView를 선택하면 MainViewModel에 있는 addTitleList() 함수를 통해 ObservableArrayList에 아이템을 추가하는 과정입니다.

결국 리스트에 아이템을 계속 추가하는 과정이기 때문에 View단에서는 다시 리스트에 대한 내용만 사용자에게 보여주면 됩니다. 저는 이 부분을 RecyclerVew를 통해서 구현하였고, RecyclerView의 내용은 databinding을 통하여 아이템에 model인 TestTitle의 title값을 적용해주었습니다.

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

    <data>
        <variable
            name="model"
            type="com.dev_seonhan.mvvm.model.TestTitle" />
    </data>


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

        <TextView
            android:id="@+id/title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            bind_title="@{model.title}"
            tools:ignore="MissingConstraints" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
package com.dev_seonhan.mvvm.utils

import android.annotation.SuppressLint
import android.widget.TextView
import androidx.databinding.BindingAdapter
import androidx.databinding.ObservableArrayList
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.dev_seonhan.mvvm.model.TestTitle
import com.dev_seonhan.mvvm.ui.main.MainAdapter

object DataBinding {

    @SuppressLint("NotifyDataSetChanged")
    @BindingAdapter("bind_titleList")
    @JvmStatic
    fun bindTitleList(recyclerView: RecyclerView, item: ObservableArrayList<TestTitle>) {
        if (recyclerView.adapter == null) {
            val lm = LinearLayoutManager(recyclerView.context)
            val adapter = MainAdapter()
            recyclerView.layoutManager = lm
            recyclerView.adapter = adapter
        }
        (recyclerView.adapter as MainAdapter).items = item
        recyclerView.adapter?.notifyDataSetChanged()
    }

    @BindingAdapter("bind_title")
    @JvmStatic
    fun bindTitle(textView: TextView, text: String) {
        textView.text = text
    }
}

RecyclerView와 관련한 내용들이라 이 포스팅에서는 상세히 다루지는 않겠지만, @BindingAdapter를 이용하여 RecyclerView와 MainAdapter클래스를 연결하고, 아이템으로 사용할 TextView에 String값을 적용하는 내용의 코드입니다.

여기까지 마무리하면 위에 첨부된 영상에서처럼 작동하는 코드를 마무리할 수 있습니다. 이렇게 구조를 mvvm으로 잡고 기능들을 추가해 나가면 코드들이 어느 정도 분리가 되기 때문에 더 쉽게 유지보수가 가능한 코드를 만들어 낼 수 있습니다.

 

 

728x90
반응형