소프트웨어 개발자라면 누구나 들어 봤을 MVC, MVP, MVVM 등의 여러가지 디자인 패턴이 있습니다.
iOS는 기본적으로 MVC 차용 하고 있기 때문에 MVC 구조는 다들 익숙하게 사용 중일 겁니다.
(View와 ViewController를 사용하는 MVC라고 생각시는분도 계시지만 실제로는 MVP 패턴 입니다.)
해당 패턴을 MVC라고 부르시는 분도 계셔서 편의상 MVC라고 하겠습니다.
하지만 MVC 구조는 Controller가 과도하게 비대해 지는 문제가 있었고, Dooray앱도 MVC-> MVVM으로 전환 하게 되었습니다.
하지만 Dooray앱은 특성상 한 화면에서 많은 API호출과 사용자 조작이 일어 나고 이에 따라 UI가 자주 변경 되는데 비동기 동작들이 동시에 일어 나는경우 정확한 화면 상태를 표시 하는데 어려움을 겪었습니다.
이를 극복 하고자 약 3년 전부터 MVI 패턴을 적용 하였고 현재까지 활용 중입니다. 다소 늦은 감이 있지만 MVI 패턴 적용 방법과 장단점등을 공유하고자 글을 남기게 되었습니다.
MVI란?
iOS 개발자 분이시라면 다소 생소 할 수도 있는데요. Model-View-Intent의 약자로 단방향 아키텍처 패턴입니다.
구조를 간단하게 그려 보면 아래와 같습니다.

각 요소의 역할은 아래와 같습니다.
View: 사용자에게 제공되어 보여 지는 UI 부분으로 상태(state)를 입력 받아 화면에 출력
Intent: 앱 상태를 변경 하려는 의도를 의미하며 사용자의 상호작용으로 발생한 상태를 변경 하는 동작을 합니다.
Model(State): 앱의 상태를 의미하며 MVI에서는 하나의 상태만을 갖으며 imutable한 데이터 구조로 작성 되어야 합니다. intent를 트리거 하여 새로운 상태를 만드는 것만이 State를 바꾸는 유일한 방법입니다.
단방향 아키텍처?
MVI에서 가장 중요한 내용이 단방향 아키텍처라는것이 아닐까 합니다. 그럼 단방향 이키텍처란 무엇일까요?
단방향 아키텍처를 이해하기 위해서는 MVI에서의 이벤트 흐름을 먼저 이해 할 필요가 있습니다.
- 유저의 인터렉션이 발생(화면 터치 등의 동작)
- Intent로 해당 Event가 전달
- Intent가 상황에 맞는 Model(State)을 업데이트
- View에서는 Model(State)을 관찰하고 있다가 변화가 일어 날 때 마다 이를 감지하여 상태에 맞는 화면을 렌더링 합니다.
이때 가장중요 한것은 Model(State)가 View로 전달 될때 Imutable하다는 것입니다. 이는 View에서는 Model(State)를 직접적으로 수정 할 방법은 존재 하지 않음을 의미하며 Model(State)을 수정하기 위해서는 Intent를 거쳐야만 합니다. 또한 Intent에 서는 View에 대한 정보를 갖고 있지 않으며 Intent에서 View로 Event를 전달 할 수 없습니다.
개념적인 이야기 만으로는 MVI의 단방향 구조에 대해서 이해하기 어려울 수도 있습니다. Dooray에서는 MVI 패턴을 적용하기 위해 몇가지 오픈소스 프레임 워크들을 검토하였고 ReactorKit을 사용하기로 결정 하였습니다. ReactorKit을 적용 하면서 더 자세히 알아 보도록 하겠습니다.
ReactorKit?
Git 링크: https://github.com/ReactorKit/ReactorKit
ReactorKit은 Swift와 Objective-C기반으로 작성 되어 있으며 iOS, Mac OS 등에서 MVI 패턴을 사용 할 수 있도록 해주는 단방향, 반응형 Framework입니다. RxSwift에 의존성을 갖고 있어 Rx에 대한 선행 지식이 필수 적입니다.
ReactorKit에서는 MVI의 각 요소의 이름을 좀 다르게 지칭하고 있습니다. 먼저 MVI의 구조를 그림으로 그려 보면 아래 와같습니다.

위 그림을 ReactorKit에 대응하는 형태로 다시 그려 보면 아래와 같습니다.
View: MVI의 View와 ReactorKit View는 동일합니다. ReactorKit에서 View는 어떠한 비지니스 로직도 갖고 있지 않으며 아래 와 같이 View Protocol을 확장하여 사용 합니다.
//View protocol을 구현
class ProfileViewController: UIViewController, View {
var disposeBag = DisposeBag()
}
profileViewController.reactor = UserViewReactor() // Reactor 구현체를 주입
View는 UIEvent들을 Action으로 변환 하여 Reactor.action에 바인딩 해주고 State를 구독하고 UI콤포넌트에 바인딩 하여 화면에 렌더링하는 역할을 합니다.
func bind(reactor: ProfileViewReactor) {
// action (View -> Reactor)
refreshButton.rx.tap.map { Reactor.Action.refresh }
.bind(to: reactor.action)
.disposed(by: self.disposeBag)
// state (Reactor -> View)
reactor.state.map { $0.isFollowing }
.bind(to: followButton.rx.isSelected)
.disposed(by: self.disposeBag)
}
Action: View에서 Reactor로 전달 되는 Event를 ReactorKit에서는 Action으로 지칭 합니다.
Reactor: MVI의 Intent에 대응하는 개념으로 내부적으로 UI와는 독립적인 레이어에 속해 있으며 State를 업데이트하고 관리 합니다. 모든 뷰에는 해당 Reactor가 있으며 모든 로직을 해당 Reactor에 위임합니다. Reactor는 뷰에 종속되지 않으므로 쉽게 테스트할 수 있습니다.
Reactor를 구현 하기위해서는 ReactorKit에 정의 되어 있는 Reactor Protocol을 아래와 같이 확장 하면 됩니다.
Action, Mutation, State의 세가지 데이터 유형을 정의 해야 하며 최초 상태를 의미하는 initialState를 정의 하여야 합니다.
// <코드1.>
class ProfileViewReactor: Reactor {
// represent user actions
enum Action {
case refreshFollowingStatus(Int)
case follow(Int)
}
// represent state changes
enum Mutation {
case setFollowing(Bool)
}
// represents the current view state
struct State {
var isFollowing: Bool = false
}
let initialState: State = State()
}
앞에서 Action은 사용자 상호 작용을 나타내고 State는 화면을 나타내기 위한 상태라고 이야기 하였습니다. 그런데 Mutation이라는 새로운 개념이 등장하는데요. 간단하게 설명하면 Action과 State의 중간 다리역할을 하는 이벤트라고 보시면 됩니다. 아래의 그림을 보면서 더 자세하게 설명 하도록 하겠습니다.

View에서는 Action을 만들어서 Reactor에 전달하게되고 이때 Action을 수신 하는곳이 mutate() 함수 입니다.
mutate() 함수는 API요청 같은 비동기 작업들을 처리 하게 되고 해당 작업이 완료 되면 Mutation을 발생 시켜 reduce() 함수로 전달합니다.
SideEffect: 외부 세계와 영향을 주고 받는 일련의 과정으로 API통신, DB 사용 등이 이에 속한다고 볼 수 있으며 대부분의 SideEffect는 비동기 작업으로 수행 되게 됩니다. 이러한 SideEffect는 ReactorKit에서는 mutate()함수에서만 취급 하도록 되어 있습니다.
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case let .refreshFollowingStatus(userID): // receive an action
return UserAPI.isFollowing(userID) // create an API stream
.map { (isFollowing: Bool) -> Mutation in
return Mutation.setFollowing(isFollowing) // convert to Mutation stream
}
case let .follow(userID):
return UserAPI.follow()
.map { _ -> Mutation in
return Mutation.setFollowing(true)
}
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state // create a copy of the old state
switch mutation {
case let .setFollowing(isFollowing):
state.isFollowing = isFollowing // manipulate the state, creating a new state
return state // return the new state
}
}
이러한 흐름을 보면 몇가지 의문이 생길 수 있습니다.
1. 비동기 작업은 왜 mutate()함수에서 처리해야 하는가?
-> 비동기 작업은 실행된 스레드와 응답 스레드가 동일함을 보장 하지 않습니다.
이 말은 만약 reduce()에서 여러개의 비동기 작업이 수행 되는 경우 동시에 여러 스레드에서 state에 접근 하게 됩니다. 이 경우 Action -> Reactor -> State의 스트림에 치명적인 오류가 발생하게 됩니다.
예를 들면 A작업이 A스레드에서 동작하고, B작업이 B스레드에서 작동한다고 가정합시다. A작업이 완료되어 A스레드에서 State를 업데이트 하는 중 B스레드작업이 완료 되어 동시에 B스레드에서 State에 접근하는 상황이 발생 할 수 있고 이는 State의 원자성을 보장 할 수 없게 됨 을 의미 합니다.
이에 ReactorKit에서는 단일 스레드에서만 동작하는 reduce()함수를 만들어서 state에 동시에 여러 스레드가 접근 하는 상황을 막고 있습니다.
2. reduce() 함수에서왜 새로운 state를 생성하는가?
-> 초반에 State는 imutable하다고 하였습니다. 위의 <코드1>
을 보시면 Reactor의 구현체에서 State는 Struct로 사용 하는것을 보실 수 있습니다. 이는 Reactor-> View로 State가 전달 되는경우 복사가 일어 나게 되고 View에서 State를 바꾸더라도 이는 Reactor내부의 State는 바뀌지 않음을 의미합니다. 즉, View에서는 state에 대한 직접적인 참조를 갖기 않게 되어 View에서 state를 바꾸는 잠재적인 위험으로 부터 안전합니다.
State: MVI 의 Model 로서 View는 State를 참고 하여 화면을 렌더링 하게 됩니다. ReactorKit 에서는 보통 State는 구조체형태로 사용하고 이는 state를 imutable하게 만드는 효과를 갖고 있습니다.
여기 까지 대략적인 MVI패턴과 ReactorKit의 동작 방식에 대해 알아 보았습니다. 다음에는 Dooray에서 사용하는 실제 코드를 예시로 transform과 1회성 이벤트 들을 다루는 방법에 대해 알아보도록 하겠습니다.