이번 영상에서는 구글의 Android App Architecture(AAA)에 대해 알아보도록 하겠습니다.
구글에서는 개발자들이 안드로이드 앱을 더 안정적으로 만들수 있도록 Android App Architecture(AAA)라는 가이드를 제공하고 있습니다. 이 가이드에 따라 만드는 앱은 더 견고하고 고품질이 된다고 하는데 왜 그럴까요? 그 이유를 알기 위해서는 우선 소프트웨어 아키텍처 패턴의 역사에 대해 알아볼 필요가 있습니다.
App architecture design is an important consideration for ensuring that your apps are robust, testable, and maintainable. Android provides a set of libraries and components to help you put together your app according to best practices.
수많은 컴퓨터 프로그램을 만들면서 사람들은 자주 발생하는 문제들에 대해 반복적으로 사용가능한 효율적인 구조가 있다는 것을 알게되었고, 그것을 아키텍처 패턴 이라는 이름으로 정리했습니다. 대상으로 하는 시스템이 어떤 것이냐에 따라 적용할 수 있는 아키텍처 패턴은 수없이 많습니다만, 안드로이드 앱 개발에서는 주로 MVC, MVP, MVVM 패턴이 사용되어지고 있습니다.
Trygve Reenskaug가 1970년대에 MVC 패턴을 처음으로 프로그래밍에 도입한 이후로 패턴에 대한 많은 배리에이션이 이루어지게 됩니다. 그러다가 1990년 초반엔 Taligent가 MVC의 개선모델이라 할수 있는 MVP 패턴을 업계에 도입합니다. 그리고 2005년에는 John Gossman이 Microsoft's WPF 와 Silverlight에 MVVM 패턴을 도입하게 됩니다.
프로그램 작성시 이 패턴들을 도입하는 핵심적인 목적은, 관심사를 분리(Separation of concerns)함으로써 프로그램을 더 안전하면서도 확장가능하기 쉽게 만드는 것입니다. 각 패턴은 다음 그림과 같은 구조를 가지고 있는데요, 이 구조가 어떻게 관심사를 분리한다는걸까요? 그리고 관심사를 분리하면 왜 프로그램이 안전하고 확장가능해지는 걸까요?

여기서는 간단한 로그인 기능을 수행하는 앱을 MVC, MVP, MVVM 패턴으로 차례대로 구현해 보면서 그 이유를 설명해 보도록 하겠습니다. 이 외의 아키텍처 패턴에 대한 더 구체적인 내용을 알고 싶으시면 Martin Fowler 선생의 Patterns of Enterprise Application Architecture 책을 참조하시기 바랍니다.
로그인 기능을 수행하는 앱을 하나 보여드리겠습니다. 코드 그 자체의 내용은 중요하지 않으니, 구조를 중심으로 보아 주시기 바랍니다.
이 구조는 혼돈의 카오스라고 합니다. 혼돈의 카오스 구조에서는 레이아웃, 이벤트 처리, 데이터 취득, 로직 판정 등등... 액티비티 안에서 모든것을 처리합니다. 아키텍처 패턴도 뭣도 아니고 그냥 제가 맘대로 붙인 이름이지요. 앱이 어떻게든 동작하기는 하겠지만 코드는 뒤죽박죽이 되서 흐름도 파악하기 어렵고, 기능추가나 테스트는 당연히 기대할 수도 없을 것입니다.
class BareLoginActivity : AppCompatActivity() {
private val binding by lazy {
ActivityBareLoginBinding.inflate(layoutInflater)
}
var userName: String? = null
var password: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 레이아웃 처리
setContentView(binding.root)
binding.included.loginBtn.setOnClickListener {
// 데이터 취득
val isLoginSuccessful = login(
binding.included.etUserName.text.toString(),
binding.included.etPassword.text.toString(),
)
// 이벤트 처리
if (isLoginSuccessful) {
Toast.makeText(this@BareLoginActivity, "$userName Login Successful", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@BareLoginActivity, "Login Failed", Toast.LENGTH_SHORT).show()
}
}
}
// 로직 판정
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"
}
}
MVC에서는 코드를 Model-View-Controller 세 개의 파트로 나눕니다. 예제 코드로 각 파트의 역할을 알아보도록 하겠습니다.

Model은 데이터와 비즈니스 로직이라고 불리는, 앱의 UI와 관계없는 부분을 담당합니다. 이를테면 앱의 두뇌와 같은 것이죠. 예제 코드에서는 로그인 정보가 정확한지 판정하는 로직과 로그인 정보가 위치하게 됩니다.
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"
}
}
View는 사용자에게 보여지는 UI 화면입니다. Model의 데이터를 표시하거나 Controller로부터 갱신처리를 받아들이는 UI로직을 가지게 됩니다. 안드로이드 앱에서는 xml 레이아웃이 MVC의 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>
Controller는 View와 Model사이의 상호작용을 관리하는 컨트롤타워입니다. 외부에서 전달받은 입력을 처리하여 Model의 내용을 갱신하고, 표시할 View를 선택한 뒤 화면 그리기를 요청하는 프리젠테이션 로직을 가지고 있습니다.
class MvcLoginActivity : AppCompatActivity() {
private val binding 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()
}
}
}
}
MVC 패턴에서는 Model과 View가 완전히 분리되므로 Model은 쉽게 테스트 할 수 있지만 Controller가 안드로이드에 종속되기 때문에 테스트가 어려워집니다. 그리고 안드로이드 특성상 액티비티가 View 표시와 Controller 역할을 같이 수행해야 하기 때문에 두 요소의 결합도가 높아집니다.
결국 많은 코드가 Controller로 모이게 되니 액티비티가 비대해져서 유닛 테스트나 기능 추가가 용이하지 않게 된다는 문제가 있습니다.
이 문제를 극복하기 위해 MVP 패턴이 등장합니다. MVP 패턴은 코드를 Model-View-Presenter 세 개의 파트로 나눕니다.
내용은 MVC와 동일하지만 View와의 직접적인 의존성이 사라지고 Presenter가 중간에서 관리를 하도록 변경되었습니다.
본질적으로는 MVC의 Controller와 같은 역할을 하지만, View의 참조를 직접 가지지 않고 다음과 같은 인터페이스를 통해 교신하게 되었기 때문에 결합이 상대적으로 느슨해졌습니다. 또한 Controller와는 달리 안드로이드 의존성을 가지지 않으므로 테스트도 용이해졌습니다.
interface LoginPresenter {
val user: User
fun login()
}
interface MvpLoginView {
val userName: String?
val password: String?
fun onLoginResult(isLoginSuccess: Boolean?)
}
Presenter는 View와 Model의 참조를 가져서 View로부터 Action을 전달받고, 필요한 경우엔 Model로부터 데이터를 취득해서 그 결과를 View에 전달합니다.
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)
}
}
MVC에서는 View와 Controller를 겸하고 있었던 액티비티 혹은 프래그먼트가, MVP에서는 온전한 View로 간주됩니다. Model의 참조가 없어진 대신 Presenter의 참조가 생겼습니다.
class MvpLoginActivity : AppCompatActivity(), MvpLoginView {
private val binding 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()
}
}
}
MVP에서는 View와 Model 사이의 데이터 흐름이 사라지고 Controller를 대체한 Presenter가 중간에서 데이터 흐름을 제어하게 되면서 데이터 흐름이 단일해지는 효과를 얻었습니다. 단점이라면 인터페이스를 추가로 구현해야 하기 때문에 구현비용이 올라가게 된다는 점이 있습니다. 또 View와 Presenter가 1:1로 대응해야 하기 때문에, 앱이 커질수록 두 요소의 의존성이 강해지게 되는 한계가 있습니다.

그리고 마지막으로 MVVM 패턴입니다. MVVM은 프로그램을 Model-View-ViewModel 세 개의 파트로 나눕니다.
MVC의 Model과 동일한 역할입니다.
View를 만드는데 필요한 로직을 가지고 있는 모델이기 때문에 ViewModel이라는 이름이 붙었습니다. ViewModel은 View를 참조하지 않기 때문에 ViewModel:View = 1:n 관계를 가지게 되었고, 따라서 중복되는 코드를 ViewModel에 묶어서 줄일 수도 있게 되었습니다.
class LoginViewModel : ViewModel() {
private val user: User = User()
private val _isLoginSuccessfulFlag: MutableLiveData<Boolean> = MutableLiveData()
val isLoginSuccessfulFlag: LiveData<Boolean>
get() = _isLoginSuccessfulFlag
val userName: String
get() = user.userName.toString()
private fun setIsLoginSuccessfulFlag(isLoginSuccessful: Boolean) {
_isLoginSuccessfulFlag.postValue(isLoginSuccessful)
}
fun login(userName: String, password: String) {
val isLoginSuccessful: Boolean = user.login(userName, password)
if (isLoginSuccessful) {
setIsLoginSuccessfulFlag(true)
} else {
setIsLoginSuccessfulFlag(false)
}
}
}
사용자에게 보여지는 UI 파트입니다. 데이터 바인딩을 통해 ViewModel로부터 일방적으로 통지받은 데이터를 표시하는 역할만을 합니다. ViewModel은 View를 참조하지 않지만 View에 바인딩 할 옵저버블 데이터를 가지고 있기 때문에 View가 이를 옵저빙함으로써 UI를 갱신할 수 있습니다.
class MvvmLoginActivity : AppCompatActivity() {
private val binding by lazy {
ActivityMvvmLoginBinding.inflate(layoutInflater)
}
private lateinit var loginViewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
binding.included.loginBtn.setOnClickListener {
loginViewModel.login(
binding.included.etUserName.text.toString(),
binding.included.etPassword.text.toString()
)
}
loginViewModel = ViewModelProvider(this).get(LoginViewModel::class.java)
loginViewModel.isLoginSuccessfulFlag.observe(this, loginObserver)
}
// 데이터 바인딩
private val loginObserver = Observer<Boolean> { isLoginSuccessful ->
if (isLoginSuccessful) {
Toast.makeText(
this@MvvmLoginActivity, loginViewModel.userName + " Login Successful",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(this@MvvmLoginActivity, "Login Failed", Toast.LENGTH_SHORT).show()
}
}
}
MVVM에서는 View와 Model 사이에 의존성이 없으며, ViewModel도 View에 의존성을 가지지 않습니다. 또한 참조는 View > ViewModel > Model 순으로 단방향으로만 일어나게 되므로 유지보수가 용이해집니다.

구글은 지금까지 안드로이드 앱을 만드는 구체적인 접근방법을 제안한 적이 없었습니다. 하지만 2017년 Google I/O에서 개발자들이 안드로이드의 여러 컴포넌트와 복잡한 생명주기를 좀 더 잘 다룰수 있도록 Android Architecture Components(AAC)와 Guide to App Architecture라는 개념을 발표하게 됩니다. 이 두 개념은 2018년 Jetpack이라는 개념의 도입과 함께 확장되면서 다음과 같은 Android App Architecture(AAA)라는 개념이 만들어지게 되었습니다.

AAA란 AAC를 활용해 MVVM 패턴의 핵심 가치를 구현한 MVVM-like 패턴이라고 생각하면 이해가 쉬울 것 같습니다. 그림으로 보면 각 파트는 Model(Room)-View(Activity/Fragment)-ViewModel(ViewModel)로 대응됩니다. Model은 Room을 통해 구축되고 Activity 혹은 Fragment는 View로 작동하게 됩니다. View는 ViewModel의 LiveData를 옵저빙하게 하였는데 한가지 주의할 점은 그림의 ViewModel은 앞에서 설명한 MVVM의 ViewModel이 아니라, AAC의 ViewModel이라는 특정한 라이브러리를 의미한다는 것입니다.
개발자들이 AAA를 따름으로써 안정적인 MVVM-like 구조를 간편하게 앱에 도입할 수 있다면 안드로이드 앱의 전체적인 안정성이 향상되게 될 것이고 최종적으로는 플레이스토어 생태계가 더욱 건강해지는 효과를 얻을 수 있게 됩니다. 구글이 많은 리소스를 AAC 개발에 투자하는 이유는 결국 이것이죠.

2022년에는 이 다이어그램에서 특정 라이브러리의 언급을 삭제하고 그림과 같이 세 개의 레이어로 정리했습니다. UI Layer에는 View와 ViewModel이 해당되고 Data Layer는 Repository와 Data Source가 해당됩니다. 옵션으로 분류된 Domain Layer에는 UseCase가 해당됩니다.
Domain Layer가 없으면 ViewModel이 비즈니스 로직을 갖고 있어야 하는데 그러면 ViewModel이 너무 비대해지는 문제가 있습니다. Activity가 비대해지는 것을 막기 위해 ViewModel이 채용되었는데 이번에는 ViewModel이 비대해지게 된 것이죠. 이것을 막기 위해 ViewModel에서 다시 비즈니스 로직만을 분리한 것이 UseCase입니다.
Domain Layer가 추가되면서 AAA는 기존의 MVVM-like 구조보다는 엉클 밥이 제안한 Clean Architecture에 더 가까운 구조가 되었습니다. 이 강의에서 만드는 앱은 UseCase를 적용하기에는 규모가 작기 때문에 Domain Layer를 생략한 MVVM-like 구조로 구성하도록 하겠습니다.

아키텍처 패턴이란 교과서적으로 고정되어 있는것이 아니라 집단에 따라 생각하는 형태나 구조가 조금씩 다를 수 있고, 또 어떤 패턴이 다른 패턴보다 반드시 우월하다고 할 수 없다는 것을 미리 말씀드립니다. 구글조차도 AAA는 강제사항이 아니라 권장사항recommended 이라고 밝혀두고 있는 점을 봐도 AAA가 반드시 채용되어야 하는 정답이 아니며 필요에 따라서 적절한 아키텍처를 사용하면 된다는 의미로 받아들이면 될 것 같습니다.
다만 준비하고 있는 아키텍처 구조가 특별히 없다면 AAA를 그대로 도입하는 것만으로도 여러분의 앱은 견고해지고 더 고품질이 될 것입니다.