Redux basic for Android developers

강성우, 16 June 2020

Redux 기반의 안드로이드 아키텍쳐를 이해하기 위해서 Redux 에 대한 기초를 안드로이드 개발자 입장에서 정리해 보았다.

1. Redux

Redux는 자바스크립트 환경에서 사용 하는 글로벌 상태 관리 도구이다.

웹, 모바일 모든 어플리케이션에서는 비즈니스 로직과 뷰의 관계를 갖는데, 이 때 뷰의 업데이트를 위해서는 뷰에 대한 상태들을 관리할 필요성이 있다. 하지만 기존에는 상태를 비즈니스 로직의 콜백에서 접근 하여 화면을 그리고 업데이트 하는 환경이 대부분 이었다. 결국, 콜백이 많아질 수록 뷰에 대한 업데이트 코드는 늘어날 것 이고 화면의 명확한 상태를 보장하기 어려워 진다.

Redux는 이런 문제를 해결하기 위해 화면의 상태를 마지막 화면의 상태를 정의한 State로서 단방향 데이터 흐름(Uni-Directional data Flow)을 기반으로 단순하게 처리 할 수 있도록 도와준다. 단방향 데이터 흐름의 다른 아키텍쳐로는 MVI(Model-View-Intent)가 있으며 이는 Flux, Redux의 영향을 깊게 받은것 으로 알려져 있다.

2. Redux basic

redux_basic

2.1 Action

// marker interface 
interface Action

// domain Action
sealed class MessageAction: Action
data class ToastMessageAction(val message: String): MessageAction()

// Action dispatch by store(AppStore)
class SomeViewModel: ViewModel() {
    val store: AppStore by injec()
    fun foo() {
        store.dispatch(ToastMessageAction("메시지 내용"))
    }
}

Action은 View(사용자로부터) 혹은 다른 Action 에 의해 발생한 Result, Success, Failed, Error 등의 Action 들을 정의 한 immutable data class이다.

ActionState를 변하게 할 수 있는 유일한 Trigger 라고 할 수 있다. Action 은 후술할 Store를 통해 dispatch() 되어 비즈니스 로직을 태우거나 새로운 Action을 생성 할 수도 있다.

  • Action 의 종류
    • Action : 기본적인 domain하 에서 정의된 Action 이다. 보통 use-case에서 사용자 등에 의해 발생한 View event 에서 발생한다.
    • Result Action : Action 을 후술할 Middleware에서 핸들링 하고 난 뒤 발생한 Action이다. Middleware 에서 원격 혹은 로컬 저장소를 거쳐 나온 result를 새로운 Action 데이터 클래스의 인스턴스로 만든다.
    • Error Action : 런타임 도 중 발생한 예외들에 대해 만들어진 Action 이다. 일반적으로 화면에 보여지게 될 오류 메시지와 예외에 대한 정보를 갖는다.

2.2 State

// marker interface
interface State

// domain State
sealed class MessageState: State
data class ToastMessageState(val message: String): MessageState()

State는 후술할 Reducer를 통해 만들어진 immutable data class로서 화면의 상태를 정의 한다.

StateStore에 마지막 인스턴스혹은 Initialized State만 저장 된다. 그리고 Store 의 State는 오직 dispatch된 Action으로만 변경 될 수 있다.

2.2.1 at Redux base Android architecture

Redux 기반 안드로이드 아키텍쳐 에서 StoreBehaviorSubject로 퍼블리싱 되는 Observable로 래핑된 State인스턴스는 ViewModelrender() 함수를 통해서 핸들링 된다.

// rendering of State
class SomeViewModel: ViewModel() {
    val messageHelper: MessageHelper by inject()
    fun render(state: State): Boolean {
        return when (state) {
            is ToastMessageState -> {
                messageHelper.showToast(state.message)
            }
            else -> false
        }
    }
}

render()함수 에서는 반환 타입으로 Boolean타입을 갖는데 이는 퍼블리싱 된 State의 컨슘 여부이다. render()에서 반환될 Boolean 값이 true 일 경우 외부에서는 이 State가 ViewModel 에서 핸들링 되었으며 다시 핸들링 되지 않아야 한다. false 일 경우 ViewModel에서의 핸들링 여부에 상관없이 외부에선느 이 State를 참조 하여 사용 할 수 있다.

참고로 Rx에서 BehaviorSubject는 스트림에 대해 subscribe최초 시점에 마지막으로 생성한 Observable을 다시 스트림으로 내보내 준다.

2.3 Store

interface Store<S : State> {
    fun dispatch(action: Action)
    fun getStateListener(): Observable<S>
    fun getCurrentState(): S
}

Store는 어플리케이션 생명주기에 단 하나의 인스턴스만 존재해야 하는 클래스이다. Store가 하는 일은 아래와 같다.

  • 마지막 혹은 Initialize State를 저장 한다.
  • State를 변경 하기 위해서 dispatch(Action)함수를 통해 Action을 전달한다.
  • State의 변화를 감지 하고 변화된 State를 구독 하기 위해서 getStateListener()를 통해 콜백을 등록 한다.
  • 마지막 혹은 Initialize 상태를 얻기 위해서 getCurrentState()함수를 이용 할 수 있다.
2.3.1 AppStore

아래 AppStore예제는 실제로 작성자가 사용했었던 구조이다. Store에서 여러개의 State를 쉽게 관리 하기 위해서 AppState라는 글로벌한 상태를 두고 내부에 Map으로 각 하위 도메인 State들을 저장하게 하는 방법을 사용 하였다. 몰론 이 방법은 정석이 아니며 더 좋은 방법이 있으면 해당 방법을 고민하는게 좋을것 같다. 이 예제는 예상대로 구동되는 한가지의 예제 이므로 참고만 하고 더 나은 구조를 고민 하는것을 추천 한다.

안드로이드 에서는 Application내 도메인이 여러개가 만들어 질 수 있다. Redux에서는 단 한개의 Store만 존재 해야 하므로 Store 인터페이스를 구현한 AppStore라는 클래스를 만들어 사용 하게 된다.

// application state 
class AppState(
    val states: Map<String, State>
): State { 
    // ...
}

// Action dispatcher function
typealias Dispatcher = (Action) -> Unit

// application store
class AppStore(
    val reducer: Reducer<AppState>,
    initliazedState: AppState
): Store<AppState> {
    private val stateEmitter: BehaviorSubject<AppState> = BehaviorSubject.create()
    private val middleWares: Array<MiddleWare<AppState>> // Koin을 이용해 주입받거나 다른 방법을 통해 초기화 
    private var state: AppState = initliazedState
    private var dispatcher: Dispatcher

    init {
        dispatcher = middleWares.foldRight(
            { dispatchedAction: Action ->
                appState = reducer.reduce(appState, dispatchedAction)
                stateEmitter.onNext(appState)
            }
        ) { middleWare, next ->
            middleWare.create(this, next)
        }
    }

    override fun getStateListener(): Observable<AppState> = 
        stateEmitter.hide().observeOn(AndroidSchedulers.mainThread())

    override fun getCurrentState(): AppState = state

    override fun dispatch(action: Action) {
        dispatcher(action)
    }
}

안드로이드 에서는 도메인 State들 을 Map콜렉션으로 갖는 AppState를 정의 하고 Map 에서는 State의 클래스 를 key, value로 State자체를 저장 한다. Store의 인터페이스에서 제공하는 API의 구현을 확인 하도록 하자.

Dispatcher는 dispatch된 Action을 어떻게 후술할 Middleware를 이터레이셔닝 하는지 보여준다. foldRight()함수는 이터레이셔닝 가능한 콜렉션을 대상으로 마지막 index 로부터 0번째 index까지 역으로 이터레이셔닝 하면서 적용한 람다를 통해 반환된 객체를 다음 람다에 전달 해 주는 함수 이다. 여기서 전달되는 객체는 Dispatcher함수의 인스턴스이다.

dispatch() 후 Action이 어떻게 이터레이셔닝 되는지 예를 보면 아래와 같다.

  1. dispatch() 함수를 통해 어떤 ActionDispatcher함수에 전달 된다.
  2. Dispatcher 함수에서는 (Action) -> Unit 의 람다 형태 인데, dispatch() 된 Action을 람다의 패러미터로 받는다. 이 패러미터를 이용하여 AppStoreReducer를 통해 핸들링 하고 새로운 혹은 이전의 AppState를 만든다.
  3. Dispatcher를 통해서 생성된 AppStateBehaviorSubject<AppState>를 통해 Observable<AppState>스트림 으로 발행 한다.
  4. DispatcherArray<MiddleWare<AppState>>을 이터레이셔닝 하면서 핸들링 한다. 핸들링 이란 위 (1)번부터 (3)번의 행동을 반복한다고 생각 하면 된다.

2.4 Middleware

Middleware는 dispatch된 Action을 핸들링 하여 새로운 Action을 만들거나 그대로 반환한다.

Middleware는 여러개가 존재 할 수 있으며 이를 컬렉션에 저장 하여 Store에서 이터레이션 한다. 미들웨어는 dispatch된 Action을 핸들링 하는데, 예를 들면 비즈니스로직이나 network API, local DAO 등 비동기 작업들이 주 대상이다.

비동기 작업을 핸들링 하는 경우 이를 ActionProcessor라고 하는데 이를 미들웨어로 만들면 ActionProcessorMiddleware가 된다. 이 미들웨어에서는 비즈니스로직만 수행하며 이는 테스트 코드 작성하는데도 다른 코드들을 신경 안써도 되는 장점을 갖고 있다.

미들웨어 에서 핸들링 되어 나오는 Action은 새로운 Action 혹은 이전 Action 그대로 반환하는 경우도 있다. 일반적으로 Action은 순차적으로 발행되진 않는다. 하드웨어의 자원 가용 여부 혹은 OS의 우선순위 등 여러가지 조건에 따라서 dispatch된 Action은 순차적으로 수행되지 않을 수도 있다는 것 이다.

interface MiddleWare<S: State> {
    fun create(store: Store<S>, next: Dispatcher): Dispatcher
}

아래는 Middleware의 간단한 예제로서 dispatch 된 Action과 이 dispatch시점 에서의 마지막 State를 로깅 하는 미들웨어 클래스 이다.

class LoggerMiddleware<S : State> : Middleware<S> {
    override fun create(store: Store<S>, next: Dispatcher): Dispatcher {
        return { action: Action ->
            if (BuildConfig.DEBUG) {
                Log.d(LOG_TAG, "action dispatch : [${action.getSuperClassNames()}] $action")
            }
            val prevState = store.getCurrentState()
            next(action)

            if (BuildConfig.DEBUG) {
                val currentState = store.getCurrentState()
                if (prevState != currentState) {
                    (currentState as AppState).printStateLogs()
                }
            }
        }
    }

}

2.5 Reducer

interface Reducer<S : State> {
    val initializeState: S

    fun reduce(oldState: S, resultAction: Action): S
}

ReducerMiddleware를 통해 전달 받은 Action을 이전 State와 함께 핸들링 하여 새로운 State혹은 이전 State를 그대로 반환한다.

Reducer는 도메인에 대해 1:1 관계를 가질수 있으며 필요하다면 서브 도메인 으로 쪼개서 여러개를 가질 수 있다.

3. 결말

View의 상태를 관리 하기 위한 도구로서 Redux의 단일 방향 구조는 훌륭하다고 생각 된다. 단일 데이터 흐름으로 인해 갖는 장점은 유지, 보수에도 큰 장점을 준다. 단점이라면 아무래도 안드로이드 개발자가 Redux나 Flux를 알지 못하니 그에 대한 러닝커브가 존재 하는 점 이다. 그리고 Action이나 State를 생성 할 때 deep copy를 피하고 이전 객체를 최대한 재사용 해야 하는 점이 있다.

안드로이드에서 적용 될 Redux구조는 Rx와 활용하여 MVVM 구조에서 최고의 효율을 보여준다고 생각 한다. 여기에 Koin과 같은 DI 도구를 사용하면 더 편리하게 쓸 수 있다. (Dagger와도 같이 써보려고 시도 하고 있지만 dagger.anddroid 특유의 높디 높은 러닝커브가 아직은 힘들다)