[번역]안드로이드에서 Elm 아키텍쳐와 코틀린으로 상태 길들이기

18 Oct 2017

org : https://proandroiddev.com/taming-state-in-android-with-elm-architecture-and-kotlin-part-1-566caae0f706

안드로이드 앱 개발에 MVP/MVVM/MVC 패턴를 적용는 것은 뷰 레이어와 안드로이드 프레임워크 의존 클래스와의 상호 작용에서 비즈니스 로직의 분리를 돕는다. 비즈니스 로직을 위한 유닛 테스트를 작성하는 것과 리팩토링 하는 것이 훨씬 수월해진다.

하지만 presenter는 앱의 코드베이스가 성장하면서 다량의 비동기 작업 콜백들과 각각 다른 위치의 변화하는 상태에 대한 지역 변수들로 인해 점점 더 비대해진다. 데이터 흐름과 로직은 복잡해지며 특히 테스트하기가 어려워진다.

복잡한 UI 로직을 Elm 아키텍쳐를 사용하여 다룰 수 있는 방법을 3개로 이루어진 이 블로그 포스트의 시리즈에서 보여 줄 것이다.

파트 1에서는 기본적인 용어들과 Elm 아키텍처의 컨셉을 소개할 것이다.

파트 2에서는 안드로이드에서 Elm 아키텍처의 구현과 MVP 패턴과 함께 사용하는 방법을 보여줄 것이다.

파트 3에서는 클린 아키텍처와 Elm 아키텍처를 합치는 방법을 보여주고 네비게이션을 처리하는 방법과 time-travel을 구현하는 방법을 논의할 것이다.

실제 필드에서 비슷한 아이디어들이 많이 있다는 것이 매우 주목할 만 한다. 예를 들면, Jake Wharton의 강연 Managing State with RxJava에서 설명한 것 또는 Hannes Dorfmann의 Model-View-Intent architecture, 또한 Christina Lee과 Brandon Kase의 presentation 역시 대단하다.

The Elm Architecture

그럼 Elm은 무엇일까? 그리고 The Elm Architecture에서 더 흥미로운 점은 무엇일까?

Elm은 Javascript로 컴파일되고 웹 브라우저에서 실행할 수 있는 정적 타입, 순수 함수 프로그래밍 언어이다.

The Elm Architecture(TEA)는 여러 핵심적인 관점들을 가진, 웹 애플리케이션을 만들기 위한 접근법이다.

만약 당신이 Redux나 Cycle.js같은 자바스크립트 라이브러리에 친숙하다면 개념과 특히 용어에서 비슷한 점을 많이 찾을 것이다. 그리고 이는 우연의 일치가 아니다. 사실 Redux 상태 관리 패턴은 Elm 아키텍처에서 영감을 받았기 때문이다.

TEA의 핵심 개념들은 핵심 타입(또는 OOP의 클래스) 3개와 함수 3개만으로 요약된다:

Model (aka State in Redux) — 앱 또는 화면의 상태를 묘사하기 위한 타입이다. 지금부터는 State라고 부르겠다. 내가 판단하기엔 State가 이것이 하는 것을 더 잘 표현해준다. 반면 Model이라는 용어는 너무 많은 정의들을 가지고 있으며 엄청나게 팽창하게 된다.

Msg (Message의 축약, aka Actions in Redux) — UI와 상호작용하는 동안 일어나는 모든 이벤트(버튼 클릭, 텍스트 입력 등)들의 기본 타입.

Cmd (Command의 축약) — 외부 효과(side-effects)를 위한 타입. 만약 당신이 Cmd를 만들었다면 이는 특정한 외부 효과(Http 요청 또는 다른 IO 작업)를 실행하려 한다는 것을 의미한다. command가 실행되면 결과 데이터와 새로운 Msg를 반환할 것이다.

함수 Update(aka reduce in Redux)— Update 함수는 MsgState를 입력으로 받고, 새로운 StateCmd의 Pair를 반환한다. 또는 간단히 말해, 주어진 Msg를 위해 실행하고 싶은 외부 효과를 반환한다. 이 함수의 가장 중요한 측면은 순수 함수이라는 것이다. 이는 Update 함수 내부에서는 어떠한 외부 효과도 존재하지 않음을 의미한다.

함수 View(aka render in Redux)— State를 입력으로 받고, 선언적 방식으로 View(Elm의 경우에는 HTML)를 표현한다. View라는 용어는 이미 Android 프레임워크에서 많이 사용되고 있기 때문에 나는 이 함수의 이름을 Redux의 방식으로 명명할 것이다.

함수 Init— 여기서 State의 초기 값을 정의한다. 그리고 필요하다면 첫번째 Cmd를 반환한다. 예를 들면 최초 HTTP 요청이 있다.

이것이 당신이 The Elm Architecture를 위해 기본적으로 알고 있어야 하는 것의 전부이다.

‘아키텍처’라는 단어에 의해 혼란스러워 하지 마라. Android 개발에서 TEA는 Presenter 계층, 또는 간단히 말하면 당신의 Presenter에서 구현할 수 있는 디자인 패턴으로 볼 수 있다.

예제를 검토해보는 것이 새로운 것을 배우기에 가장 좋은 방법이다. 로그인과 암호를 위한 입력 필드와 Http 요청을 보내기 위한 버튼을 가진 간단한 로그인 화면을 보자. 이 포스트는 본질적으로 안드로이드 개발자들을 위한 것이므로 다음의 코드 조각들은 코틀린 언어로 되어 있다.

왜 코틀린인가?

코틀린은 타입 시스템에 빌트인된 매우 강력한 구성체를 몇 개 가지고 있다.

Sealed classes (또는 더 강력해진 enum). Sealed 클래스는 함수형 언어에서 온 Union Types에 다소 가까운 구현이다. 이는 매우 명확하고 간결한 방식으로 클래스 계층을 만들 수 있게 해준다. 더욱이, when 표현식의 패턴 매칭 능력을 추가로 얻을 수 있다.

Data classes. Data 클래스는 Msg와 Cmd 타입을 나타낼 클래스를 한 줄로 만들 수 있게 해주며, 역시, 패턴 매칭 능력을 사용할 수 있다.

TEA Concepts in practice

TEA의 핵심 관점들을 보도록 하자:

1. 불변 상태

애플리케이션 상태는 단일 불변 클래스에 보관하며 변경 할 수 없다.

data class LoginState(val login : String, val password : String, val auth : Boolean = false, val isLoading : Boolean = false) : State()

상태에 있는 값을 변경하고 싶다면 새로운 상태를 만든다.

loginState.copy(login = "name")

변경은 Update 함수 내부에서만 일어난다. 이 개념은 종종 진실의 단일 근원(Single Source of Truth)라고 언급된다. 이는 만약 상태가 변경된다면 이 변경은 오직 한 곳에서만 일어난다는 것을 의미한다.

2. 단방향 데이터 흐름

예를 들어, 사용자가 사용자 이름과 패스워드를 입력했다. 우리는 이 상호 작용을 TEA의 용어로 표현할 필요가 있다.

data class LoginInput(val login : String) : Msg()
data class PasswordInput(val password : String) : Msg()

이 메시지들은 view에서 와서 Update 함수로 발송된다.

LoginInput(J) -> Update(state.copy(login=J)) -> Render(state)
LoginInput(Jo) -> Update(state.copy(login=Jo)) -> Render(state)

PasswordInput(q) -> Update(state.copy(password=q)) -> Render(state)
PasswordInput(qw) -> Update(state.copy(password=qw)) -> Render(state)

데이터 흐름이 사이클을 따르는 것을 볼 수 있다. view에서(또는 부수 효과의 경우에는 외부 세계에서) Update 함수로 간 뒤에 Render 함수로 간다.

3. 부수 효과 관리

Elm(지금부터는 TEA)의 가장 멋진 점 중 하나는 런타임의 부수 효과 처리이다. 비동기 작업을 할 필요가 있다면 Elm 런타임에게 무엇을 해야 할지와 어떤 Msg가 이 작업의 결과를 가지고 반환될 지를 알려주면 된다. Elm 런타임은 모든 작업을 수행하고 결과를 Update 함수에 반환할 것이다.

이 동작을 안드로이드의 멀티스레드 환경에서는 어떻게 달성할 수 있을까?

여기가 바로 RxJava가 도움이 되는 지점이다. 더 자세한 것은 다음 포스트에서 다룰 것이다. 지금은 부수 효과가 있는 사이클을 보여 줄 것이다.

data class AuthClick : Msg()
data class AuthResult(val token: String?, val error : Throwable?) : Msg()
data class Auth(val login: String, val password: String) : Cmd()
AuthClick() -> Update(state.copy(loading=true)) -> Render(state) -> (execute Auth) -> AuthResult(token) -> Update(state.copy(loading=false, token=authResult.token)

여기서는 RxJava의 멀티스레드를 통제하는 명시적 스타일이 도움이 된다!

To be continued…

다음 포스트에서는 간단한 안드로이드 앱에서 코틀린 프로그래밍 언어와 RxJava로 TEA을 구현하는 방법을 보여 줄 것이다.

Resources for further learning:

안드로이드에서 Elm 아키텍쳐와 코틀린으로 상태 길들이기, Part 2

origin : https://proandroiddev.com/taming-state-in-android-with-elm-architecture-and-kotlin-part-2-c709f75f7596

이전 포스트에서는 Elm 아키텍처의 개념들과 주요 구성 단위들에 대해 이야기했었다. 이 포스트에서는 안드로이드에서 TEA를 구현하는 방법과 Presenter에서 사용하는 방법, 테스트하는 방법을 배울 것이다.

All source code, tests and the sample app, demonstrated in this post are available at github

코트린의 TEA

Elm 아키텍처의 주요 사이클을 구현하기 위해서 RxJava을 사용하고, 프로그램의 흐름에서 어떠한 종료 이벤트도 필요하지 않기 때문에 위대한 Jake Wharton가 작성한 강렬한 라이브러리 RxRelay를 사용할 것이다. 하지만 일단 먼저, Presenter에 로직을 구현하기 위해 사용할 API 또는 메인 클래스를 먼저 정의하자.

sealed class AbstractState
open class State : AbstractState()

sealed class AbstractMsg
open class Msg : AbstractMsg()
class Idle : Msg()
class Init : Msg()
class ErrorMsg(val err: Throwable, val cmd: Cmd) : Msg()


sealed class AbstractCmd
open class Cmd : AbstractCmd()
class None : Cmd()

interface Component {

    fun update(msg: Msg, state: State): Pair<State, Cmd>

    fun render(state: State)

    fun call(cmd: Cmd): Single<Msg>

}

Presenter는 Component 인터페이스를 구현하고 State 클래스를 상속한 자기 자신의 상태를 선언해야 한다. MsgCmd를 사용하는 방법은 다음에 보게 될 것이다.

이제, 사이클 그 자체를 구현하는 방법을 보도록 하자.

class Program(val outputScheduler: Scheduler) {

    private val msgRelay: BehaviorRelay<Pair<Msg, State>> = BehaviorRelay.create()
    private var msgQueue = ArrayDeque<Msg>()
    lateinit private var state: State
    lateinit private var component: Component

    fun init(initialState: State, component: Component): Disposable {
        this.component = component
        this.state = initialState
        return msgRelay
                .map { (msg, state) ->  
                    //update program state and return the new state and command   
                    component.update(msg, state)
                }
                .observeOn(outputScheduler)
                .doOnNext { (state, cmd) ->
                    //draw UI       
                    component.render(state)
                }
                .doOnNext{ (state, cmd) ->
                    this.state = state
                    //remove current message from queue      
                    if (msgQueue.size > 0) {
                        msgQueue.removeFirst()
                    }
                    //and send a new msg to relay if any       
                    loop()
                }
                .filter { (_, cmd) -> cmd !is None }
                .observeOn(Schedulers.io())
                .flatMap { (state, cmd) ->
                    //execute side effect with command
                    return@flatMap component.call(cmd)
                                 //if there is an error in side effect, send Error msg with failed command, 
                                 //which we can handle in Update function
                                .onErrorResumeNext { err -> Single.just(ErrorMsg(err, cmd)) } 
                                .toObservable()                    
                }
                .observeOn(outputScheduler)
                .subscribe({ msg ->                    
                    when (msg) {
                        is Idle -> {} //if the message is idle, then do nothing
                        else -> msgQueue.addLast(msg)
                    }

                    loop()
                })
    }

    fun getState(): State {
        return state
    }

    private fun loop() {
        if (msgQueue.size > 0) {
            msgRelay.accept(Pair(msgQueue.first, this.state))
        }
    }

    fun accept(msg: Msg) {        
        msgQueue.addLast(msg)
        if (msgQueue.size == 1) {
            msgRelay.accept(Pair(msgQueue.first, state))
        }
    }

}

RxJava에 익숙하다면 여기서 어려운 점은 없을 것이다. Init 함수는 초기 상태와 콜백을 처리할 Component를 받는다. 그 뒤 사이클-Update, Render, Call-을 밟아간다. Call 함수는 **Single**를 반환하며 반환된 **Msg**가 Idle이 아니면 이 외부 효과의 **Msg**로 새로운 사이클을 시작하다.

큐는 메시지의 순서를 보존하기 위해 필요하다.

Call 함수를 살펴보자. 이 것은 Elm 런타임에 대한 일종의 에뮬레이션이다. 만약 외부 효과를 만들고 싶다면 Cmd 클래스를 상속받은 새로운 클래스를 정의하고 Call 함수에서는 이 command에 대한 결과 Msg와 페이로드를 담은 Single 타입을 반환해야 한다.

Counter

단순한 증가 감소 프리젠터를 위한 코드는 다음과 같을 것이다.

class IncrementDecrementPresenter(private val view: IncrementDecrementView,
  private val program: Program) : Component {

  data class IncrementDecrementState(val value: Int = 0) : State()

  class Inc : Msg()
  class Dec : Msg()
   
  var programDisposable: Disposable

  init {
    programDisposable = program.init(IncrementDecrementState(), this)
  }

  fun init() {
    program.accept(Init())
  }


  override fun update(msg: Msg, state: State): Pair<State, Cmd> {
    val state = state as IncrementDecrementState
      return when (msg) {
        is Init -> {
          Pair(state, None())
        }
        is Inc -> {
          Pair(state.copy(value = state.value+1), None())
        }    
        is Dec -> {
          Pair(state.copy(value = state.value-1), None())
        }  
        else -> Pair(state, None())
      }
  }

  override fun render(state: State) {
    (state as IncrementDecrementState).apply {
      view.showValue(value)
    }
  }

  override fun call(cmd: Cmd): Single<Msg> {
    return when (cmd) {           
      else -> Single.just(Idle())
    }
  }

  fun plusClick() {
    program.accept(Inc())
  }
  
  fun minusClick() {
    program.accept(Dec())
  }
  
  fun onDestroy(){
    if (!programDisposable.isDisposed()){
        programDisposable.dispose()
    }
  }
}

이 규약은 간단하다. 함수 Init를 초기 상태와 함께 생성자(또는 생성자와 가까운 곳)에서 호출한다. 그 뒤 모든 UI 동작은 적합한 Msg와 데이터로 program.accept() 함수에 전파한다.

하지만 사실 이 예제는 우리의 관심사가 아니다. 외부 효과가 전혀 없기 때문이다! 좀 더 실제적이고 현실에 가까운 예제인 인증 화면을 보도록 하자.

Sample App

먼저 로그인 화면이 어떻게 되어야 하는지를 보자. 이 화면은 인증(authenticate)을 수행해야 하며 ‘Save login’이 체크되어야 한다. 그 뒤 Shared Preferences에 로그인과 패스워드를 저장해야 한다. 이후 시작시 저장된 자격(credential)을 체크할 필요할 필요가 있으며, 존재한다면 이 것으로 인증(authentication)을 만드는 것을 시도해야 한다.

이제, 이 화면을 위한 상태를 모델링해보자. 로그인과 패스워드 그 자체를 위한 필드가 필요하다. 만약 확인에 어떤 실패가 있다면 각각의 에러를 보여 주어야 하며 로그인 버튼을 비활성화하고 체크박스를 해제해야 한다. 인증 요청이 시작되면 프로그레스를 보여줘야 하며 요청이 실패하거나 성공하면 이에 대한 표시를 할 필요가 있다.

data class LoginState(val login: String = "",
                      val loginError: String? = null,
                      val pass: String = "",
                      val passError: String? = null,
                      val saveUser: Boolean = false,
                      val isLoading: Boolean = true,
                      val error: String? = null,
                      val btnEnabled: Boolean = false,
                      val isLogged: Boolean = false) : State()

그리고 이것이 우리의 render 함수이다.

override fun render(state: State) {
  (state as LoginState).apply {
    if (isLogged) {               
      loginView.goToMainScreen()
      return
    }
          
    if (isLoading) {
                loginView.showProgress()
            } else {
                loginView.hideProgress()
            }

            if (btnEnabled) {
                loginView.enableLoginBtn()
            } else {
                loginView.disableLoginBtn()
            }

            error?.let {
                loginView.showError()
                loginView.error(it)
            } ?: loginView.hideError()

            loginError?.let {
                loginView.showLoginError(it)
            } ?: loginView.hideLoginError()

            passError?.let {
                loginView.showPasswordError(it)
            } ?: loginView.hidePasswordError()
        }
    }

포스트의 분량을 위해 로그인 화면의 초기화에 대한 시나리오 하나만 보도록 하자. 우리는 두 가지 외부 효과가 필요하다 - Shared Preferences에서 데이터 로드와 인증을 위한 HTTP 요청.

class GetSavedUserCmd : Cmd()
data class LoginCmd(val login: String, val pass: String) : Cmd()

그리고 이 command들을 결과로 전달하기 위한 두 메시지.

data class UserCredentialsLoadedMsg(val login: String, val pass: String) : Msg()
data class LoginResponseMsg(val logged: Boolean) : Msg()

Init, Update 그리고 Call 함수는 다음과 같으며,

fun init() {
  program.accept(Init())
}


override fun update(msg: Msg, state: State): Pair<State, Cmd> {
  val state = state as LoginState
  return when (msg) {
    is Init -> {
      Pair(state, GetSavedUserCmd())
    }
    is UserCredentialsLoadedMsg -> {
      Pair(state.copy(login = msg.login, pass = msg.pass), LoginCmd(msg.login, msg.pass))
    }
    is LoginResponseMsg -> {
      Pair(state.copy(isLogged = true), None())
    }
    is ErrorMsg -> {
      return when (msg.cmd) {
        is GetSavedUserCmd -> Pair(state.copy(isLoading = false), None())
        is LoginCmd -> {
          if (msg.err is RequestException) {
            return Pair(state.copy(isLoading = false, error = msg.err.error.message), None())
          }
          return Pair(state.copy(isLoading = false, error = "Error while login"), None())
        }
        else -> Pair(state, None())
      }
    }
    else -> Pair(state, None())
  }
}

override fun call(cmd: Cmd): Single<Msg> {
  return when (cmd) {
    is GetSavedUserCmd -> appPrefs.getUserSavedCredentials()
      .map { (login, pass) -> UserCredentialsLoadedMsg(login, pass) }           
    is LoginCmd -> apiService.login(cmd.login, cmd.pass)
      .map { logged -> LoginResponseMsg(logged) }
    else -> Single.just(Idle())
  }
}

이 코드는 매우 간결하며 따라가기 쉬움을 알 수 있다.

Testing

TEA의 주요 장점 중 하나는 믿기 어려울 정도로 테스트 가능성이 높다는 것이다. 일반적인 MVP 앱에서 당신은 비즈니스 로직, 유스케이스 등을 테스트한다. TEA가 주는 것은 연관된 UI 행위를 테스트할 수 있는 능력이다. 단순히 Update -> Render -> Call 함수를 하나씩 미리 정의된 필요한 값들로 호출함으로서 유저의 UI와의 상호작용을 모의 실험할 수 있다! 당신이 일반적으로 Instrumentation test(또는 Espresso tests)로 했던 것을 Unit Tests로 할 수 있다!

다음과 같은 시나리오들에 대한 테스트를 쉽게 만들 수 있다:

첫 번째 시나리오로 구현해보자.

@Test
fun initWithSavedLogin_HaveSavedCredentials_LoginOk() {
  //login screen init and look for saved credentials in preferences
  var initState = LoginPresenter.LoginState()
  //update
  val (searchForLoginState, searchForLoginCmd) = presenter.update(Init(), initState)

  assertEquals(initState.copy(isLoading = true), searchForLoginState)
  assertThat(searchForLoginCmd, instanceOf(LoginPresenter.GetSavedUserCmd::class.java))

  //render
  presenter.render(searchForLoginState)
  verify(view).showProgress()
  verify(view).disableLoginBtn()
  verify(view).hideLoginError()
  verify(view).hidePasswordError()
  verify(view).hideError()
  verifyNoMoreInteractions(view)
  
  Mockito.`when`(prefs.getUserSavedCredentials())
    .thenReturn(Single.just(Pair("login", "password")))
  
  //call
  val loadedCredentialsMsg = presenter.call(searchForLoginCmd)

  //credentials loaded and start auth http call
  //update
  val (startAuthState, startAuthCmd) = presenter.update(loadedCredentialsMsg.blockingGet(), searchForLoginState)
  assertEquals((searchForLoginState as LoginPresenter.LoginState).copy(login = "login", pass = "password"), startAuthState)
  assertThat(startAuthCmd, instanceOf(LoginPresenter.LoginCmd::class.java))
  assertEquals("login", (startAuthCmd as LoginPresenter.LoginCmd).login)
  assertEquals("password", startAuthCmd.pass)

  Mockito.reset(view)
  //render
  presenter.render(startAuthState)
  verify(view).showProgress()
  verify(view).disableLoginBtn()
  verify(view).hideLoginError()
  verify(view).hidePasswordError()
  verify(view).hideError()
  verifyNoMoreInteractions(view)

  Mockito.`when`(loginService.login("login", "password"))
    .thenReturn(Single.just(true))
  //call
  val authOkMsg = presenter.call(startAuthCmd)

  //auth OK, go to main screen
  //update
  val (loggedState, noneCmd) = presenter.update(authOkMsg.blockingGet(), startAuthState)
  assertThat(noneCmd, instanceOf(None::class.java))

  Mockito.reset(view)
  //render
  presenter.render(loggedState)
  verify(view).goToMainScreen()
  verify(view).hideKeyboard()
  verifyNoMoreInteractions(view)
}

결론

첫 두 개의 포스트에서 Elm 아키텍처 패턴을 presentation layer 또는 presenter에 직접 구현함으로서 UI 로직을 놀랍도록 예측 가능하고 테스트 가능하게 할 수 있음을 보여주었다. 더욱이 TEA는 당신이 UI를 위한 로직을 작성하는 방식을 바꾼다. 이벤트들과 외부 효과들을 분리함으로서 TEA는 당신이 UI를 전이들과 상태들을 가진 상태 머신으로 생각하도록 자극한다.

Thanks for reading, I hope you enjoyed it!

If you want to discuss, follow to Reddit post

and follow me on twitter to get updates about my new posts