안드로이드 앱 아키텍처 가이드

Mgmix

·

2019. 10. 7. 16:40

Google Developer 사이트의 앱 아키텍처 가이드의 내용을 정리 한 것입니다.

일반적인 데스크톱의 어플리케이션은 보통 단일 진입점으로 하나의 모놀리식 프로세스로 실행이 됩니다.

하지만 Android 의 경우에는 훨씬 복잡하며, Activity, Fragment, Service, Content Provider, Broadcast Receiver 등과 같은 앱 구성요소를 포함합니다.

모바일 환경에서는 앱을 사용하는 도중 언제든지 전화나 알림에 의하여 사용 환경이 중단 될 수있으며, 중단에 대응 하고 난 뒤 본래 프로세스로 돌아가서 작업을 계속 진행 할 수 있어야 합니다.

휴대기기는 리소스가 제한되어 있기에, 운영체제에서 새로운 앱을 위한 공간을 확보하도록 일부 앱프로세스를 언제든지 종료 할 수 있어야 합니다.

-> 이런 특성 으로 인해 앱 구성요소는 개별적이고 비 순차적으로 실행 될 수 있으며, 제거 될 수 있기 때문에 앱 구성요소에 앱 데이터나 상태를 저장해서는 안되며, 앱 구성요소가 서로 종속되면 안됩니다.

그렇다면 앱을 어떻게 디자인 해야할까?

일반아키텍처 원칙

관심사 분리

관심사의 분리(SoC)란, 컴퓨터 과학에서 각 부문이 각자의 관심사를 갖도록 프로그램을 여러 부문으로 나누는 설계 원칙 입니다. 여기서 관심은 프로그램의 기능, 행동, 목적에 해당합니다.

Activity 나 Fragment 에 모든 로직에 관한 코드를 작성하는 실수는 흔히 일어난다. (나 또한 ..)

이런 UI 기반의 클래스는 UI 및 운영체제 상호작용을 처리하는 로직만을 포함해야 합니다.
왜냐하면 이러한 클래스를 최대한 가볍게 유지하여야 많은 생명주기 관련된 문제를 피할 수 있게 됩니다.

Activity 와 Fragment 구현은 Android OS 와 앱 사이를 연결하도록 이어주는 클래스에 불과하기 때문에 사용자의 터치나 메모리 부족과 같은 시스템 조건으로인해 언제든지 OS에서 제거 할 수 있습니다.

수월한 앱 관리를 위해 이러한 UI 기반 클래스에 대한 의존성을 최소화 할 필요가 있습니다.

모델에서 UI 만들기

모델은 앱의 데이터 처리 를 담당하는 구성요소로 앱의 View 객체 및 앱 구성요소와 독립되어 있기 때문에 앱의 생명주기와 관련된 문제의 영향을 받지 않습니다.

안드로이드 가이드에서는 가급적 지속적인 모델(Persistent Model)을 권장 합니다.
지속 모델이 이상적인 이유는 다음과 같습니다.

  • Android OS에서 리소스를 확보하기 위해 앱을 제거해도 사용자 데이터가 삭제되지 않음
  • 네트워크 연결이 취약하거나 연결되어 있지 않아도 앱이 계속 작동

데이터 관리 책임이 잘 정의된 모델 클래스를 기반으로 앱을 만들게 되면, 쉽게 테스트하고 일관성을 유지할 수 있다.

권장 앱 아키텍처

아키텍처 구성요소를 사용하여 앱을 구성하는 방법 표기

각 구성요소가 한 수준 아래의 구성요소에만 종속 됩니다.
Activity/Fragment 는 ViewModel 에만 종속되는 것을 볼 수있습니다. Repository 는 여러개의 다른 클래스에 종속되는 유일한 클래스이며 여기서 Repository 는 Model 과 Remote Data Source 에 종속됩니다.

사용자 인터페이스 정의

UI 는 Layout(XML) 과 Fragment 로 구성 됩니다. UI 에 표시되는 데이터 요소에 담길 정보를 유지하기 위해 AAC ViewModel 을 사용합니다.
ViewModel 객체는 Fragment 나 Activity 같은 특정 UI 구성 요소에 대한 데이터를 제공하고 모델과 커뮤니케이션 하기위한 데이터 처리 비즈니스 로직을 포함합니다. (일반적으로 MVVM 의 ViewModel 과는 다름)
LiveData 는 식별 가능한 데이터 홀더 입니다. 이 홀더를 사용하여 객체의 변경 사항을 모니터링 할 수 있습니다. (본래라면 상호간에 종속적인 처리를 통한 변경사항 모니터링이 필요)
데이터 모델 객체에 LiveData 를 사용하여 ViewModel 에서 LiveData 필드를 적용한 데이터에 대해 변경이 발생되면 Fragment 의 UI 가 새로 고침됩니다.

데이터 가져오기

백엔드에서 REST API 를 제공, Retrofit 을 사용하여 가져오도록 한다.
Retrofit 을 이용한 Webservice 를 정의

interface Webservice {
       /**
        * @GET declares an HTTP GET request
        * @Path("user") annotation on the userId parameter marks it as a
        * replacement for the {user} placeholder in the @GET path
        */
       @GET("/users/{user}")
       fun getUser(@Path("user") userId: String): Call<User>
    }

첫번째 방법으로는 ViewModel 구현을 위해서 Webservice 를 직접 호출 하여 데이터를 가져오고 이 데이터를 LiveData 객체에 할당합니다.
이 방법을 사용하게 되면 추후 앱이 커지게 되면 유지보수가 어려워 질 수 있으며, ViewModel 에 너무 많은 책임이 부여되어 관심사 분리 원칙을 위반하게 됩니다. 그리고 ViewModel 의 Scope 는 Acitivty 와 Fragment 의 생명주기와 연결되어있기에 관련 UI 객체의 생명주기가 끝나면 Webservice 의 데이터가 삭제됩니다.

이런 문제로 인해 ViewModel 에서 WebService 를 직접 호출 하지 않고, 이러한 작업을 새로운 Repository 에 위임합니다.

Reopsitory 모듈은 데이터 작업을 처리, 깔끔한 API 를 제공하므로 데이터를 간편하게 가져올 수 있으며, 데이터가 업데이트 될 때 데이터를 가져올 위치와 호출할 API 를 알고 있게 됩니다.

결국 Repository 는 Model, Remote Data Source, Cache 등 다양한 데이터 소스간의 중재자 역할로 볼 수 있습니다.

언뜻 보면 Repository 모듈은 불필요해 보이지만, 데이터 소스를 추출하는 중요한 용도로 사용된다. 이로써 ViewModel 은 데이터가 어떻게 갱신되는지 알지 못하기 때문에 서로 다른 데이터 소스로부터 가져온 데이터를 ViewModel 에 제공할 수있고, 관심사의 분리가 이루어진 것으로 볼 수 있습니다.

구성요소 간 종속성(Dependencies) 관리

Repository 에서 Webservice 를 통해 데이터를 가져오기 위해서는, 아래의 코드와 같이
Webservice 의 인스턴스가 필요하고, Webservice 클래스의 종속성이 필요합니다.

class UserRepository {
       private val webservice: Webservice = TODO()
       // ...
       fun getUser(userId: String): LiveData<User> {
           // This isn't an optimal implementation. We'll fix it later.
           val data = MutableLiveData<User>()
           webservice.getUser(userId).enqueue(object : Callback<User> {
               override fun onResponse(call: Call<User>, response: Response<User>) {
                   data.value = response.body()
               }
               // Error case is left out for brevity.
               override fun onFailure(call: Call<User>, t: Throwable) {
                   TODO()
               }
           })
           return data
       }
    }

단일 이라면 모르겠지만, 데이터 소스가 많아지고 새로운 Webservice 를 만들어야 한다면 앱의 리소스 소모량이 너무 커질 수 있는 문제가 발생합니다.

이러한 문제에 대한 해결방안으로 다음과 같은 디자인 패턴으로 해결이 가능합니다.

  • Dependency Injection (DI) : 종속성 주입(DI) 를 사용하면 클래스가 자신의 종속성을 구성할 필요 없이 종속성을 정의 할 수 있다. Runtime 시에 다른 클래스가 이 종속성을 제공 해야함. Android 에서 DI를 사용하려면 Dagger2 또는 Koin 라이브러리 사용
  • 서비스 로케이터 : 클래스가 클래스를 구성하는 대신, 종속성을 얻을 수 있는 레지스트리를 제공

이러한 패턴을 통해, 코드를 복제하거나 복잡성을 추가하지 않고 종속성을 관리하기 위한 명확한 패턴을 제공하므로 코드 확장이 가능해집니다.

데이터 캐시

Repository 구현은 Webservice 객체 호출로 부터 데이터를 가져오지만 하나의 데이터 소스에만 의존하기에 유연성이 떨어지게 됩니다.

Repository 구현에서 발생하는 문제는 백엔드로부터 데이터를 가져 온 후 어디에도 보관하지 않았다는 점으로 사용자가 UI 를 떠났다가 다시 돌아오면 데이터가 변경되지 않았어도 앱에서 데이터를 다시 가져와야 하는 문제가 발생합니다. 이러한 문제는 네트워크 대역폭의 낭비와, 새 쿼리가 완료 될 때까지 사용자가 기다려야하는 불편함이 야기됩니다.

이러한 단점을 해결하기 위해서 메모리에 객체를 캐시하는 Repository 에 새로운 데이터 소스를 추가하는 방법이 있습니다.

데이터 지속

현재 구현 방식을 사용하게되면, 사용자가 기기를 회전 하거나 앱에서 나갔다가 즉시 돌아오는 경우에 저장소가 메모리 내 캐시에서 데이터를 가져오기 때문에 기존 UI 가 즉시 표시 됩니다.

사용자가 앱에서 나간 후 Android OS 에서 프로세스 종료를 한 후에 돌아 왔을 시 현재구현에 의존하면 네트워크에서 데이터를 다시 가져와야 하며, 이는 모바일 데이터 낭비와 불필요한 프로세스가 됩니다.

이러한 문제는 지속 모델을 사용하여 해결을 할 수 있습니다.
Room 은 객체 맵핑 라이브러리로, 최소한의 상용구 코드로 로컬 데이터 지속성을 제공합니다. (SQLite 의 Wrapper 버전 정도..)
컴파일 시에 데이터 스키마에 대해 각 쿼리의 유효성을 검사하기 때문에 SQL 쿼리가 잘못되면 런타임 실패가 아닌 컴파일 시에 오류가 발생하기에 실제 앱 동작시 장애 포인트를 줄일 수 있습니다. 또한 데이터 베이스의 변경 사항을 LiveData 객체를 사용하여 관찰이 가능하므로, 변경사항을 즉시 반영할 수있습니다.

각 구성요소 태스트사용자 인터페이스 및 상호작용

  • : Android UI 계측 테스트 사용, Espresso 라이브러리를 사용하는 편이 좋다. Fragment 는 ViewModel 하고만 통신하므로, 모의 ViewModel 을 제공하여 테스트를 하면 UI 를 테스트 가능.
  • ViewModel
    : JUnit 테스트를 사용하여 ViewModel 클래스를 테스트 할 수 있다. Repository 클래스 하나만 모의 테스트 하면 된다.
  • Repository
    : JUnit 을 통해 테스트, 데이터 소스에 관해 모의 테스트 진행 하며 Repository 가 올바른 webservice 를 호출하는지, 데이터베이스에 결과를 저장하는지, 캐시되고 최신인 경우 불필요한 요청을 만들지 않는지 확인 해야한다.
  • Webservice
    : 백엔드로 네트워크를 호출하지 않게 한다. 웹 기반의 테스트를 포함한 모든 테스트가 외부로부터 독립적이어야함. MockWebServer 를 포함한 라이브러리로 가짜 로컬 서버를 만들 수 있다.
  • Dao
    : 계측 테스트를 사용하여 DAO 클래스를 테스트. UI 구성요소가 필요 없으므로 빠르게 실행

앱 개발시 권장사항

  • Activity, Service, Broadcast Receiver 과 같은 앱의 진입점을 데이터 소스로 지정하지 말것.
  • 앱의 다양한 모듈 간 책임이 잘 정의된 경계를 만든다.
  • 각 모듈에서 가능하면 적게 노출
  • 각 모듈을 독립적으로 테스트 하는 방법을 고려
  • 다른 앱과 차별되도록 앱의 고유한 핵심에 초점
  • 가능한 한 관련성이 높은 최신 데이터를 보존
  • 하나의 데이터 소스를 단일 소스 저장소로 지정 (REST API 는 같은 정보를 반환하는 다른 API 가 존재 할 수있고, 이는 UI 에서 잘못된 표시를 할 수있다. 그렇기에 데이터베이스에 API 응답을 저장하고 데이터베이스 변경시 LiveData 객체를 통하여 변경이 감지가 가능. 데이터 베이스가 단일 소스 저장소 역할을 하여 동작이 가능)

 

원본내용