Android

안드로이드 [Kotlin] - 아키텍처 패턴 with MVC, MVP, MVVM (feat 코드 예제)

🤖 Play with Android 🤖 2022. 6. 26. 15:15
728x90


MVP/MVVM/Clean Architecture 등 아키텍처 설계 혹은 적용 경험이 있으신 분

안드로이드 채용 공고를 보다 보면 어렵지 않게 볼 수 있는 글들이다.

오늘은 안드로이드 아키텍처패턴으로 많이 언급되는 MVC, MVP, MVVM 패턴에 대해 정리해보고자 한다.

 

 

우선 디자인 패턴이 무엇인가에 대해 부터 알아보자

 

디자인 패턴(Design Pattern) 이란?

디자인 패턴이란 기존 환경 내에서 반복적으로 일어나는 문제들을 어떻게 풀어나갈 것인가에 대한 일종의 솔루션이다.

디자인 패턴은 개발자로 하여금 재사용 하기 용이한 설계를 선택하고, 재사용하기 어려운 설계는 배제하도록 도와준다. 또한 개발자끼리 협업을 잘할 수 있도록 코드들의 패턴을 짬과 동시에 코드의 질, 효율성을 높이는 것이다.

 

 

아키텍처(Architecture)란?

소프트웨어를 구성하는 구성요소(모듈 / 컴포넌트 / 서브 시스템) 간의 관계를 관리하는 시스템의 구조이자 소프트웨어의 설계와 업그레이드를 통제하는 지침과 원칙이다.

 

그래서 이 둘의 차이는?

소프트웨어 아키텍처는 프로그램 내에서 큰 구조로 구성되어 다른 구성 요소들을 관리하는 역할을 한다. 반면에 디자인 패턴은 특정 유형의 문제를 해결하는 방법으로 소프트웨어 아키텍처보다는 조금 더 좁은 개념에 포함된다. 이 둘은 유사성을 가지나 범위의 제한이 존재하는 것이다.

 

 


 

 

안드로이드 아키텍처 패턴

안드로이드 앱을 개발할 때, 이용할 수 있는 여러 아키텍처 패턴들이 존재한다.

  • MVC (Model - View - Controller)
  • MVP (Model - View - Presenter)
  • MVVM (Model - View - ViewModel)
  • 기타

대표적으로 MVVM이 MVC, MVP를 의 단점을 보완하기 위해 가장 많이 쓰이는 방법론이라고는 하지만. 어느 게 더 낫다는 정답은 없다.

 

위 아키텍처 패턴들을 살펴보면, 공통적으로 View와 Model이 존재한다. 이는 안드로이드 앱을 개발함에 있어서 데이터와 UI는 필수적이기 때문이다. 따라서 자연스럽게 View와 Model사이의 의존성 역시 생길 수밖에 없다.

Presentation Logic : 실제 눈에 보이는 GUI(Graphic User Interface) 화면을 구성하는 코드
Businees Logic : 데이터를 보여주기 위해서 DB를 검색하는 코드 및 GUI 화면에서 새롭게 발생된 데이터를 DB에 저장하는 코드 등 실제적인 작업 동작이 이루어지는 코드

 

 

 

MVC

 

Model 

  • 애플리케이션에서 사용되는 실제 데이터 및 데이터 조작 로직을 처리하는 부분
  • View에 의존적이지 않다.

View

  • 사용자에게 제공되어 보이는 UI 부분
  • 모바일이라면 앱의 화면이 View에 해당

Controller

  • 사용자의 입력을 받고 처리하는 부분
  • 안드로이드에서는 주로 Activity나 Fragment로 표현된다. Compose라면 Composable이 될 수도 있겠다.
  • Model의 데이터 변화에 따라 View를 선택한다.

 

MVC 동작 순서

  1. 사용자의 Action들은 Controller에 들어온다.
  2. Controller는 사용자의 Action를 확인하고, Model을 업데이트한다.
  3. Controller는 Model을 나타내 줄 View를 선택한다.
  4. View는 Model을 이용하여 화면을 나타내게 된다.

 

MVC 패턴의 특징

  • Controller는 여러 개의 View를 선택할 수 있는 1:n 구조이다.
  • Controller는 Model에 직접적인 영향을 끼칠 수 있다.

 

MVC 패턴의 장점

  • 단순한 패턴이기 때문에 러닝 커브가 낮고 널리 사용된다.

 

MVC 패턴의 단점

  • View와 Model 사이의 의존성이 존재한다.
  • Controller가 안드로이드에 종속되기 때문에 테스트가 어려워진다.
  • Controller에 많은 코드가 모이게 되어 Activity가 비대해진다.
  • 안드로이드 특성상 Activity가 View 표시와 Controller 역할을 같이 수행해야 하기 때문에 두 요소의 결합도가 높아진다.

 

 

MVP

Model과 View는 MVC와 동일하다. 다만 Controller 대신 Presenter가 존재한다.

 

Model

  • 애플리케이션에서 사용되는 실제 데이터 및 데이터 조작 로직을 처리하는 부분
  • View에 의존적이지 않다.

View

  • 사용자에게 제공되어 보이는 UI 부분
  • 웹이라면 웹페이지, 모바일이라면 앱의 화면이 View에 해당

Presenter

  • View에서 요청한 정보를 Model로부터 가공하여 View로 전달하는 부분
  • View와 Model 사이를 이어주는 역할을 한다.

 

MVP 동작 순서

  1. 사용자의 Action들은 View를 통해 들어온다.
  2. View는 데이터를 Presenter에게 요청한다.
  3. Presenter는 Model에게 데이터를 요청한다.
  4. Model은 Presenter에서 요청받은 데이터를 응답한다.
  5. Presenter는 View에게 데이터를 응답한다.
  6. View는 Presenter가 응답한 데이터를 이용하여 화면을 나타낸다.

 

MVP 패턴의 특징

  • Preseter와 View는 1:1 관계이다.
  • View와 Model은 서로를 알 필요가 전혀 없다.

 

MVP 패턴의 장점

  • View와 Model의 의존성이 없다.(Presentor를 통해서만 데이터를 전달받기 때문)
  • 따라서 MVC 패턴의 단점을 해결할 수 있다.

 

MVP 패턴의 단점

  • View와 Presenter가 1:1로 강한 의존성을 가지게 된다.
  • 각각의 View마다 Presenter가 존재하게 되어서 코드량이 많아져 유지 보수가 힘들어질 수 있다.

 

 

MVVM

 

 

마찬가지로 Model과 View는 MVC, MVP와 동일하다. 하지만 이번에는 Presenter 대신 ViewModel이 존재한다.

 

Model

  • 애플리케이션에서 사용되는 실제 데이터 및 데이터 조작 로직을 처리하는 부분
  • View에 의존적이지 않다.

View

  • 사용자에게 제공되어 보이는 UI 부분
  • 웹이라면 웹페이지, 모바일이라면 앱의 화면이 View에 해당

ViewModel

  • View에서 사용되는 데이터를 관리하는 View를 위한 Model
  • View에 종속되지 않는다.

 

MVVM 동작 순서

  1. View에 입력이 들어오면 Command 패턴으로 ViewModel에 명령 전달
  2. ViewModel은 필요한 데이터를 Model에 요청
  3. Model은 ViewModel에 필요한 데이터를 응답
  4. ViewModel은 응답받은 데이터를 가공해서 저장

 

MVVM 패턴의 장점

  • View와 Model 사이의 의존성이 없다.
  • View는 ViewModel을 알지만 ViewModel은 View를 알지 못하고 ViewModel은 Model을 알지만 Model은 ViewModel을 알지 못한다.
  • 즉, 한쪽 방향으로만 의존 관계가 있어서 각 모듈별로 분리하여 개발을 할 수 있다.
  • 모듈화 개발에 적합한 만큼 테스트가 수월하다.

 

MVVM 패턴의 단점

  • 장점이 많은 만큼 러닝 커브가 높다.

 

 


 

 

코드 예제

지금부터 각각의 패턴의 간단한 샘플 코드 예제를 통해 더 깊이 있게 이해해보자.

코드는 간단한 로그인을 구현하는 예제이다.

 

MVC 패턴 예제

User.kt (Model)

package com.example.architecturepattern.mvc.model

data class User(
    var userName: String? = null,
    var password: String? = null
) {
    // 비즈니스 로직
    fun login(userName: String?, password: String?): Boolean {
        if (userName == secretName && password == secretWord) {
            this.userName = userName
            this.password = password
            return true
        }
        return false
    }

    // 로그인 정보
    companion object {
        const val secretName = "user"
        const val secretWord = "1234"
    }
}

위 User.kt는 MVP패턴의 Model역할을 한다. 즉 UI와 상관없는 데이터, 비즈니스 로직을 책임지고 있다. 이 코드에서는 로그인 정보가 제대로 된 것인지 판단하는 login()이 비지니스 로직을 담당하게 되고 데이터 즉 로그인 정보를 companion object에 담고 있다.

 

 

activity_mvc_login.xml (View)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".mvc.controller.MvcLoginActivity">

    <TextView
        android:id="@+id/title_tv"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:gravity="center"
        android:text="MVC Pattern"
        android:textSize="20dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <include
        android:id="@+id/included"
        layout="@layout/include_login_view"/>

</androidx.constraintlayout.widget.ConstraintLayout>

View는 말 그대로 사용자에게 보여지는 UI 화면이다. 안드로이드 MVC 패턴에서는 이 xml layout이 View에 해당한다고 할 수 있다.

 

 

MVCLoginActivity (Controller)

package com.example.architecturepattern.mvc.controller

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import com.example.architecturepattern.databinding.ActivityMvcLoginBinding
import com.example.architecturepattern.mvc.model.User

class MvcLoginActivity : AppCompatActivity() {
    private val binding: ActivityMvcLoginBinding by lazy {
        ActivityMvcLoginBinding.inflate(layoutInflater)
    }
    private lateinit var user : User


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 레이아웃 처리
        setContentView(binding.root)

        user = User()

        // 프레젠테이션 로직
        binding.included.loginBtn.setOnClickListener {
            val isLoginSuccessful = user.login(
                binding.included.etUserName.text.toString(),
                binding.included.etPassword.text.toString(),
            )

            if (isLoginSuccessful) {
                Toast.makeText(this@MvcLoginActivity, "${user.userName} Login Successful", Toast.LENGTH_SHORT).show()
            } else {
                Toast.makeText(this@MvcLoginActivity, "Login Failed", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

Controller는 View와 Model 사이의 상호작용을 담당하는 컨트롤타워이다. 외부에서 전달받은 입력을 처리해서 View의 내용을 갱신하고 표시할 View를 선택해서 그리기를 요청하게 된다. 위의 이론 설명처럼 Activity가 View의 표시와 Controller의 역할을 같이 수행하는 것을 확인할 수 있다. 따라서 조금만 로직이 복잡해진다면 Activity가 비대해질 것을 예측할 수 있다.

 

 

 

 

MVP 패턴 예제

User.kt (Model)

package com.example.architecturepattern.mvp.model

data class User(
    var userName: String? = null,
    var password: String? = null
) {
    fun login(userName: String?, password: String?): Boolean {
        if (userName == secretName && password == secretWord) {
            this.userName = userName
            this.password = password
            return true
        }
        return false
    }

    companion object {
        const val secretName = "user"
        const val secretWord = "1234"
    }
}

MVP의 Model 코드는 MVC와 동일하다. 대신 View와 직접적 의존성이 사라지고 Presenter가 중간에서 관리하도록 변경된다.

 

 

LoginPresenter.kt

package com.example.architecturepattern.mvp.presenter

import com.example.architecturepattern.mvp.model.User

interface LoginPresenter {
    val user: User

    fun login()
}

LoginPresenterImpl.kt

package com.example.architecturepattern.mvp.presenter

import com.example.architecturepattern.mvp.model.User
import com.example.architecturepattern.mvp.view.MvpLoginView

class LoginPresenterImpl(
    private val mvpLoginView: MvpLoginView
) : LoginPresenter {

    override val user: User
        get() = User()

    override fun login() {
        val userName = mvpLoginView.userName.toString()
        val password = mvpLoginView.password.toString()
        val isLoginSuccessful: Boolean = user.login(userName, password)

        mvpLoginView.onLoginResult(isLoginSuccessful)
    }
}

Presenter은 View를 직접 참조하지 않고 interface를 통해 참조하게 되기 때문에 결합을 낮추게 된다. 또한 Controller와 다르게 안드로이드 의존성을 가지지 않기 때문에 테스트도 용이해진다.

 

 

MVPLoginView (View)

package com.example.architecturepattern.mvp.view

interface MvpLoginView {
    val userName: String?
    val password: String?

    fun onLoginResult(isLoginSuccess: Boolean?)
}

MVPLoginActivity (View)

package com.example.architecturepattern.mvp.view

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import com.example.architecturepattern.databinding.ActivityMvpLoginBinding
import com.example.architecturepattern.mvp.presenter.LoginPresenterImpl

class MvpLoginActivity : AppCompatActivity(), MvpLoginView {
    private val binding: ActivityMvpLoginBinding by lazy {
        ActivityMvpLoginBinding.inflate(layoutInflater)
    }
    private lateinit var loginPresenterImpl: LoginPresenterImpl


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

        loginPresenterImpl = LoginPresenterImpl(this)
        binding.included.loginBtn.setOnClickListener { loginPresenterImpl.login() }
    }

    override val userName: String
        get() = binding.included.etUserName.text.toString()
    override val password: String
        get() = binding.included.etPassword.text.toString()

    override fun onLoginResult(isLoginSuccess: Boolean?) {
        if (isLoginSuccess == true) {
            Toast.makeText(this@MvpLoginActivity, "$userName Login Successful", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this@MvpLoginActivity, "Login Failed", Toast.LENGTH_SHORT).show()
        }
    }
}

MVC에서는 View와 Controller를 겸하고 있었던 Activity가 온전히 View의 역할만을 하게 된다. Model 참조가 없어진 대신 Presenter에 대한 참조가 생기게 된다.

 

 

 

 

MVVM 패턴 예제

MVVM은 실제 필자가 작업한 프로젝트를 예제 코드로 가져와 보았다. 간단한 TODO 앱에서 MVVM 패턴이 실제로 어떻게 활용될 수 있는지 살펴보자. 우리가 원하는 로직은 DialogFragment에 할 일을 적어서 확인 버튼을 누르면 이를 실시간으로 서버로 보내고 추가된 할 일 목록 리스트 데이터를 가져와 RecyclerView에 보여주는 것이다.

 

 

ViewModel

우선 ViewModel부터 살펴보자. ViewModel은 위에서 말한 것처럼 View를 표현하기 위해 만들어진 View를 위한 Model이다. 즉 View에 보일 데이터들을 보관하고 있다. ViewModel은 View를 몰라야 한다.

class TaskRemoteViewModel(private val taskRemoteRepository: TaskRemoteRepository) : ViewModel() {

    private var todoItem: MutableList<TaskDetailResponse> = mutableListOf()
    private val _todoTask = MutableLiveData<MutableList<TaskDetailResponse>>()
    val todoTask: LiveData<MutableList<TaskDetailResponse>>
        get() = _todoTask
        
    private val _error = MutableLiveData<String>()
    val error: LiveData<String>
        get() = _error

        
    private fun loadTasks() {
        viewModelScope.launch {
            when (val tasks = taskRemoteRepository.loadTask()) {
                is Result.Success -> {
                    todoItem = tasks.data.todo
                    _todoTask.value = tasks.data.todo

                    inProgressItem = tasks.data.inProgress
                    _inProgressTask.value = tasks.data.inProgress

                    doneItem = tasks.data.done
                    _doneTask.value = tasks.data.done
                }
                is Result.Error -> _error.value = tasks.error
            }
        }
    }
        
    fun addTask(task: Task) {
        viewModelScope.launch {
            when (val tasks = taskRemoteRepository.addTask(task)) {
                is Result.Success -> {
                    when (tasks.data.taskDetailResponse.status) {
                        Status.TODO -> {
                            todoItem.add(0, tasks.data.taskDetailResponse)
                            _todoTask.value = todoItem
                        }
                    }
                }
                is Result.Error -> _error.value = tasks.error
            }
        }
    }

MutableLiveData와 LiveData를 구분해서 사용하는 이유를 알아보자.

  • MutableLiveData : 값의 get/set 모두를 할 수 있다.
  • LiveData : 값의 get()만을 할 수 있다.

Mutable은 코틀린 언어에서 흔하게 볼 수 있는 내용인데, 값의 get/set 모두를 허용하기에, 값의 변경과 읽기 모두 가능하다.

반대로 LiveData는 읽기 전용의 데이터를 만들기에 값의 변경은 불가능하다.

 

 

이런 기법은 ViewModel과 View의 역할을 분리하기 위함이라 이렇게 사용한다. ViewModel은 언제나 새로운 값의 변경이 일어나고, 다시 읽을 수 있는 형태로 사용하는 것이고, View는 값의 입력이 아닌 읽기만을 허용하는 것이다.

즉, UI 컨트롤러(액티비티, 프래그먼트)가 값을 직접 수정하지 못한다.

 

 

또한 MutableLiveData 앞에는 _ 가 붙은 네이밍 컨벤션을 확인할 수 있고 LiveData는 커스텀 게터를 통해 MutableLiveData에 접근하고 있는데, 이것은 

https://kotlinlang.org/docs/coding-conventions.html#names-for-backing-properties

 

Coding conventions | Kotlin

 

kotlinlang.org

 

코틀린 공식 홈페이지의 backing properties 관련 코틀린 컨벤션에 따른 표현이다.

 

 

또한 private var로 선언된 todoItem은 MutableLiveData와 동기화된 MutableList이다. 이를 사용하는 이유는 MutableLiveData에 add를 통해 데이터를 추가해주어야 하는데 LiveData는 setValue()를 해주지 않으면 변화를 감지하지 못하기 때문이다.

이에 대한 자세한 내용은 https://jminie.tistory.com/158?category=1040997"LiveData에 직접 데이터를 add 시키는 경우 Observer가 인식하지 못하는 이슈"를 살펴보기 바란다.

 

위의 코드를 보면 ViewModel은 View를 알지 못한다.

또한 fun addTask(task: Task)를 통해 Repository(Model)로 Task 데이터를 보내고 Model에서 처리된 값을 fun loadTask를 통해 가져와 MutableLiveData에 데이터를 저장하고 있다.

 

그러면 데이터를 처리하는 Repository로 가보자

 

Repository(Model)

class TaskRemoteDataSource {

    suspend fun loadTasks(): TasksResponse? {
        val response = RetrofitAPI.service.loadTasks()
        return if (response.isSuccessful) response.body() else null
    }

    suspend fun addTask(cardData: Task): CommonResponse? {
        val response = RetrofitAPI.service.saveTask(cardData)
        return if (response.isSuccessful) response.body() else null
    }
    
 
interface Service {

    @Headers("Content-Type: application/json")
    @GET("cards")
    suspend fun loadTasks(): Response<TasksResponse>

    @Headers("Content-Type: application/json")
    @POST("cards")
    suspend fun saveTask(@Body cardInfo: Task): Response<CommonResponse>

위의 코드는 ViewModel에서 받아온 새로운 할 일 목록을 REST API를 통해 서버로 보내고 추가된 할 일 목록을 포함한 총 할 일 목록 리스트를 가져오고 있다.. 즉 데이터를 처리하는 일을 하는 Repository(Model)이 제 역할을 하고 있다고 보면 된다.

여기서 불러온 새롭게 불러온 총 할 일 목록 데이터를 다시 ViewModel에 보내준다.

 

(사실 더욱 OOP 스럽게 코드를 짜기 위해서는 TaskRemoteDataSource는 인터페이스로 만들고 이를 구현하는 TaskRemoteDataSourceImpl를 따로 만든 다음 ViewModel에서는 구현체를 생성자로 가지고 있는 것이 아니라 인터페이스를 생성자로 가지고 있는 것이 맞지만 이 포스팅에서 OOP를 다루고 있는 것이 아니기 때문에 직관적으로 이해하기 쉽게 위처럼 예시를 들어보았다.)

 

 

Activity(View)

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
  		
        ...
        ...
	viewModel.loadTask()		
                
        viewModel.todoTask.observe(this) { todoTask ->
            toDoAdapter.submitList(todoTask.toList())
        }
        
        viewModel.error.observe(this) {
            Toast.makeText(this, getString(R.string.error_message), Toast.LENGTH_SHORT).show()
        }

Activity 즉 View이다. MVVM패턴에서 View는 Model과의 의존성이 없다. 즉 서로를 몰라야 한다. 그저 Model에서 ViewModel에 보내준 데이터를 ViewModel을 통해 가져와 보여주기만 하면 된다.