본 포스팅은 Android 앱 개발 시 적용할 수 있는 여러 아키텍쳐 패턴들 중 Google에서 공식 문서를 통해 Recommend하는 방식을 소개하고자 합니다. 본문은 여기에서 확인하실 수 있으며, 본 포스팅에서는 이를 바탕으로 간단한 샘플 앱을 만들어 보고자 합니다.

크게 다루는 기술 셋은 다음과 같습니다.

  • 기본적인 언어: Kotlin
  • Jetpack – Lifecycles, LiveData, ViewModel
  • 비동기 처리: Coroutine

각각에 대해 언급하기는 하지만, 최소한의 정보만을 공유하며 상세한 내용을 언급하지는 않습니다. (좀더 공부하여 세부적으로 다루도록 하겠습니다.) 각 기술 셋을 간단하게 사용하면서 최대한 아키텍쳐가 전반적으로 어떤 식으로 돌아가는지, 왜 필요하고 어떤 점에서 장점이 있는지 등을 소개하고자 합니다.

이외에도 Clean Architecture나 Repository Pattern을 베이스로 하고는 있지만 해당 내용에 대해서도 따로 언급하지는 않겠습니다.


샘플 앱 소개

영화 목록을 보여주는 화면과 특정 영화를 선택했을 때 상세 정보를 보여주는 화면, 이렇게 2개의 페이지로 구성된 간단한 앱입니다.

영화 목록을 보여주는 화면으로 하단에 2개의 탭으로 구성되었습니다.
특정 영화를 선택하면 상세 정보를 보여주는 화면입니다.

영화 정보는 TMDB에서 제공하는 API를 통해 받아옵니다. 해당 코드를 돌려보시려면 TMDB에 가입하고 Api key를 발급받아야 하니 참고하세요.
전체 코드는 여기서 확인 가능합니다.

Retrofit 설정

TMDB는 영화 데이터를 REST API로 제공하고 있으며, 샘플 앱에서는 2개의 탭과 영화 상세 정보를 얻기 위해 총 3개의 API가 필요합니다. 이는 Retrofit을 통해 간단히 구현할 수 있으며 아래와 같습니다.

interface MoviesApi {

    @GET("/3/movie/now_playing")
    suspend fun getMoviesNowPlaying(@Query("api_key") apiKey: String = BuildConfig.API_KEY): NowPlayingResp

    @GET("/3/movie/upcoming")
    suspend fun getMoviesUpcoming(@Query("api_key") apiKey: String = BuildConfig.API_KEY): NowPlayingResp

    @GET("/3/movie/{movieId}")
    suspend fun getMovie(@Path("movieId") movieId: String, @Query("api_key") apiKey: String = BuildConfig.API_KEY): TmdbMovieResp

    companion object {
        val service: MoviesApi by lazy {
            Retrofit.Builder()
                .baseUrl("https://api.themoviedb.org/")
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(MoviesApi::class.java)
        }
    }

}

먼저 하단의 companion object 부분에서 API 서비스를 lazy delegation 기법을 통해 생성하는 것을 확인할 수 있습니다.

그리고 각 GET Request들의 반환 자료형은 특정 클래스로 표현되어 있는 것을 확인할 수 있습니다. Rx를 쓰게 되면 보통 Single을 쓰거나 혹은 Retrofit의 Call 클래스를 사용할텐데 그렇지 않고 Response 자료형을 그대로 적어주었습니다. 이것이 가능한 이유는 해당 함수가 suspend 함수이기 때문입니다. 즉 Retrofit은 코루틴을 지원하고 있습니다. 다시 해당 함수들을 보시면 아시겠지만 앞에 suspend 키워드가 prefix로 지정되어 있는 것을 확인할 수 있습니다.

ViewModel 구현하기

Github repository 상에서는 위에서 구현한 Retrofit 함수 결과 값을 UI에 보여주기 위해서는 꽤 많은 단계를 거치는 것을 볼 수 있습니다. DataSource layer를 지나, Repository를 거쳐, Usecase를 거치고 나서야 ViewModel로 넘어오죠. 확실히 불필요한 Over engineering이며, 단순 학습을 위한 목적이니 그냥 무시하시면 됩니다.

복잡한 단계는 무시하고 바로 ViewModel을 보시면 다음과 같습니다. 아래의 코드가 본 포스팅에서 소개하고자 하는 80%를 담고 있는 부분이라고 보시면 됩니다.

class NowPlayingViewModel(
    private val getMovies: GetNowPlayingMovies = GetNowPlayingMoviesUsecase(
        MoviesRepositoryImpl(MoviesCacheDataSource(), MoviesRemoteDataSource(MoviesApi.service))
    )
) : ViewModel() {

    val isLoading = MutableLiveData<Boolean>(false)

    val movies = liveData {
        isLoading.value = true

        withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
            emit(getMovies.get())
        }

        isLoading.value = false
    }

}

ViewModel에서 인자로 받는 getMovies는 무시하셔도 좋습니다. 간단히 Retrofit 객체를 꽂아준다고 보시면 됩니다.

위 코드에서 주목해야할 부분이 몇가지 있습니다.

  1. ViewModel을 상속받아 NowPlayingViewModel 클래스 구현
  2. LiveData 자료형으로 멤버 변수 선언
  3. KTX를 통해 제공되는 확장 함수인 liveData
  4. Coroutine

ViewModel 클래스 구현

Jetpack AAC의 ViewModel 클래스를 상속받아 구현하게 됩니다. Jetpack ViewModel은 View에 보여질 데이터 정보들을 갖고 있는 클래스로 화면 회전 등과 같은 config change가 발생해도 해당 데이터들을 유지하는 특징이 있습니다. 일반적으로 Activity 화면이 회전하게 되면 생명 주기를 다시 재시작하며 그 과정에서 다시 데이터를 로드하게 되는데 이는 리소스 낭비로 볼 수 있습니다. 그리고 ViewModel을 통해 이런 리소스 낭비를 줄일 수 있게 되죠.

한가지 궁금점은 ViewModel을 안 쓰더라도 리소스 낭비를 줄일 수 있지 않냐는 것입니다. 물론 가능합니다. Activity 콜백 함수 중 onSavedInstance와 같은 것을 사용하거나 캐싱 기능을 직접 구현할 수도 있습니다. 하지만 이런 방법에는 제약이 있거나 구현이 다소 복잡해질 수 있다는 단점이 있습니다. 그리고 ViewModel을 쓰게 되면 이런 부분이 개발자 입장에서 깔끔하고 쉽게 대응할 수 있다는 거죠.

Activity 생명주기에 따른 ViewModel의 생명주기
(ref: https://developer.android.com/topic/libraries/architecture/viewmodel#lifecycle)

ViewModel을 구현하는 것은 그 자체만으로는 어려울게 없습니다. 아래와 같이 상속만 하면 끝입니다.

class NowPlayingViewModel: ViewModel()

LiveData 멤버 변수 선언

LiveData는 Observer 패턴을 구현한 데이터 홀더 클래스로 보입니다. 공식 문서에 따르면 Android 컴포넌트의 생명주기를 인식하여 동작한다고 되어 있습니다. 실제 구현 클래스를 보면 아래와 같이 되어 있는 것을 확인할 수 있습니다.

public abstract class LiveData<T> {

    ...

    @MainThread
    public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {

        if (owner.getLifecycle().getCurrentState() == DESTROYED) {
            // ignore
            return;
        }

        owner.getLifecycle().addObserver(wrapper);
    }

}

간단히만 봐도 생명주기가 destroy 되면 observe를 하지 않는 것을 통해 컴포넌트의 생명주기를 인식하고 있는 것을 확인할 수 있습니다.

이러한 LiveData를 사용했을 때의 장점은 생명주기와 관련하여 개발자가 제어할 필요가 없으며 LiveData 스스로가 다 알아서 챙긴다는 점입니다. 이 말에는 여러 장점이 내포되어 있다고 보는데, 예를 들어 생명주기가 종료될 때 LiveData 역시 삭제되므로 메모리 누수가 없으며, 생명주기가 현재 보여지지 않는 상태에서 백그라운드에서 데이터가 갱신되더라도 LiveData는 포그라운드로 바뀔 때까지 이벤트를 받지 않도로 동작합니다.
LiveData 사용시 이점은 여기를 보시면 좀더 자세히 확인할 수 있습니다.

LiveData는 아래와 같이 간단히 생성할 수 있습니다. 아래 코드는 현재 데이터를 가져오는 중이라면 ProgressBar를 보여주기 위한 boolean 자료형의 flag라고 보시면 됩니다.

    val isLoading = MutableLiveData<Boolean>(false)

LiveData-KTX: liveData

LiveData는 LiveData-KTX와 함께 사용하여 아래와 같이 비동기 처리를 내포할 수 있습니다.

    val movies = liveData {
        isLoading.value = true

        withContext(viewModelScope.coroutineContext + Dispatchers.IO) {
            emit(getMovies.get())
        }

        isLoading.value = false
    }

liveData 함수 안에서 코루틴을 호출하고 있어 코루틴을 처음 보면 다소 이해하기 어려울 수 있습니다.

간단히 설명하면,
movies 멤서 변수는 화면에 보여줄 Movie List를 들고 있는 LiveData<List<Movie>> 객체입니다.
LiveData-KTX에서 제공되는 liveData 함수를 통해 LiveData 객체를 생성하는데, getMovies.get() 메서드 호출 부분은 외부 Network 통신이 필요한 부분이기 때문에 Dispatchers.IO를 통해 별도 스레드에서 실행해줍니다. 그리고 그 수행 결과를 emit 함수를 통해 Movie list를 LiveData에 넣어주게 됩니다.

함수의 상단과 하단에 있는 isLoading.value = true/false 로직은 비동기로 Movie 리스트를 불러올 때 ProgressBar를 보여주었다가, Movie 리스트를 다 얻어오면 다시 ProgressBar를 가리기 위한 로직입니다.

ViewModel-KTX: viewModelScope

위 코드 관련하여 한 가지 추가로 언급하고 싶은 부분이 있습니다. 바로 이 코드인데요.

    withContext(viewModelScope.coroutineContext + Dispatchers.IO) { ... }

사실 위 코드는 아래 코드로도 작성해도 잘 동작합니다.

withContext(Dispatchers.IO) { ... }

그럼 viewModelScope라는 건 어디서 등장했고, 왜 써야 하는지 살펴 보겠습니다. 먼저 viewModelScope는 ViewModel-KTX 의존성을 추가했을 때 사용할 수 있는 ViewModel의 확장 프로퍼티로 소스 코드를 보면 아래와 같습니다.

/**
 * [CoroutineScope] tied to this [ViewModel].
 * This scope will be canceled when ViewModel will be cleared, i.e [ViewModel.onCleared] is called
 *
 * This scope is bound to
 * [Dispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
 */
val ViewModel.viewModelScope: CoroutineScope
        get() {
            val scope: CoroutineScope? = this.getTag(JOB_KEY)
            if (scope != null) {
                return scope
            }
            return setTagIfAbsent(JOB_KEY,
                CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
        }

주석을 보면 이해가 빠릅니다. 이 viewModelScope는 종속된 ViewModel이 종료될 때 같이 cancel 처리됩니다.

즉, 만약 제가 아래와 같이 코드를 작성했다면

withContext(Dispatchers.IO) { ... }

제가 직접 해당 코루틴을 관리하지 않는 이상 해당 코루틴은 컴포넌트 생명주기에 의해 종료되지 않습니다. 예를 들어 영화 목록을 비동기로 받아오고 있는 중에 해당 UI (Activity/Fragment)를 종료하여 onDestory() 콜백 함수가 불리더라도 이미 수행 중인 코루틴은 이를 알지 못하며 여전히 수행을 할 것입니다. 따라서 아래와 같이 viewModelScope를 통해 생명주기를 연결해놓으면 UI 생명주기 종료 시 해당 코루틴도 더이상 진행하지 않고 같이 종료하게 됩니다.

withContext(viewModelScope.coroutineContext + Dispatchers.IO) { ... }

LiveData에 따라 UI 갱신하기

ViewModel에 Coroutine을 사용하여 영화 목록을 비동기로 가져와 LiveData에 할당까지 하였습니다. 이 데이터를 View에 보여주기 위해 View에서는 아래와 같이 LiveData를 관찰(Observe)해야 합니다.

class NowPlayingFragment : Fragment() {

    private val viewModel: NowPlayingViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        ...

        viewModel.isLoading.observe(viewLifecycleOwner, Observer {
            moviesProgressBar.visibility = if (it) View.VISIBLE else View.GONE
        })
        viewModel.movies.observe(viewLifecycleOwner, Observer {
            moviesAdapter.movies.clear()
            moviesAdapter.movies.addAll(it)
            moviesAdapter.notifyDataSetChanged()
        })
    }

}

관찰(observe)하고 있는 LiveData의 값이 변경되면 observe() 함수의 Observer 를 통해 이벤트가 오고, 이에 따라 UI를 갱신해주면 됩니다. 여기서 observe() 함수에 viewLifecycleOwner를 첫 번째 인자로 넣어주게 되는데 이는 해당 Fragment의 생명주기를 나타내는 객체로 이를 통해 앞서 언급한데로 LiveData는 View의 생명주기에 따라 동작할 수 있게 됩니다.


개인적인 느낌으로 MVP는 정말 심플한 아키텍쳐 패턴이란 생각이 듭니다. 생명주기에 따른 동작을 직접 제어해줘야 하긴 하지만, 개발자가 이해할 수 있는 영역이기 때문에 어렵다라는 느낌은 안듭니다. 하지만 MVVM을 적용한다고 하면 Jetpack AAC (LiveData, ViewModel, Lifecycle) 등을 함께 공부하여 적용하다 보니 공부할 것도 많고 본 포스팅에서 보셔서 아시겠지만 다양한 함수나 확장 함수, 확장 프로퍼티에 대해 새로 알아야 한다는 점에서 공부할 게 많다라는 단점(?)이 있습니다. 확실히 상대적으로 러닝 커브가 높다고 봅니다. 하지만 그런 만큼 UI 갱신 및 생명주기에 따른 처리를 프레임워크(라이브러리)에 완전히 맡길 수 있기 때문에 익숙해지기만 하면 생산성이나 안정성 측면에서 더 나은 접근법이 될 수 있을 것 같다는 생각이 듭니다.

아직은 MVP 패턴과 RxJava가 익숙하고 MVVM, Coroutine 그리고 Jetpack AAC를 학습해나가는 과정이라 설명이 부족하거나 잘못된 부분이 있을 수 있습니다. 만약 발견하신다면 알려주시기 바랍니다.


0 Comments

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다