Rx

[ReactorKit] ReactorKit 입문

Phililip
728x90

안녕하세요.

 

ReactorKit에 대해 처음 접하고 공부한 것들을 정리해보려고 해요.

 


# 0. ReactorKit을 알게 된 배경

MVVM과 Rx를 어떻게 하면 구조적으로 아름답게 구현할까 고민하던 중, Input-Output 구조라는 게 있다는 것을 알게 되었어요.

[참고 목록]
https://linux-studying.tistory.com/28
https://mildwhale.github.io/2020-04-16-mvvm-with-input-output/
https://coding-idiot.tistory.com/7
https://ios-development.tistory.com/173

 

Input-Output 구조라는 용어가 공식적으로 개발자들 사이에서 널리 사용되는 것은 아닌 것 같지만.. 간략하게 설명을 드리자면,

 

View에서 특정 비즈니스 로직을 실행시켜야 할 때 이벤트를 발생시키고, ViewModel에서는 그 이벤트를 받아서 비즈니스 로직을 돌리고 그 결과값을 데이터를 포함한 이벤트로 뿌려주는 거예요. 그럼 View에서는 ViewModel이 뿌려준 이벤트 & 데이터를 받아서 UI에 관련된 작업을 하면 되겠죠?

(자세한 내용은 위 참고 목록에 더 자세하고 정확하게 적혀있으니 참고해주세요!)

 

 

근데, 제 동기가 그러더라구요..

 

"이거 ReactorKit이랑 구조 똑같은데???"

 

 

 

ReactorKit의 Basic Concept을 보면 제가 위에 말씀드린 Input-Output 구조랑 매우 유사한 것 보이시나요??

 

출처: https://github.com/ReactorKit/ReactorKit#basic-concept

 

 

설명드린 것을 ReactorKit 관점에서 다시 설명을 하자면

 

View에서 특정 비즈니스 로직을 실행시켜야 할 때 이벤트(=Action)를 발생시키고, ViewModel(=Reactor)에서는 그 이벤트(=Action)를 받아서 비즈니스 로직을 돌리고 그 결과값을 데이터를 포함한 이벤트(=State)로 뿌려주는 거예요. 그럼 View에서는 ViewModel(=Reactor)이 뿌려준 이벤트 & 데이터(=State)를 받아서 UI에 관련된 작업을 하면 되겠죠?

 

 

똑같지 않나요??ㅎㅎ

 

서론이 길었네요..ㅎㅎ... 이런 이유들 때문에 ReactorKit에 대해 공부한 것에 대해서 정리를 해볼까 해요.

 

 

 

# 1. ReactorKit의 컨셉

(이 부분부터는 README를 읽기에 불가할 수 있기에... 원문도 함께 보시는 것을 추천드립니다.)

 

출처: https://github.com/ReactorKit/ReactorKit#basic-concept

 

ReactorKit은 Flux랑 Reactive 프로그래밍을 조합한 것이라고 합니다.

 

Action과 State는 Observable stream을 통해서 전달되고, 단방향으로만 동작합니다.

(View는 Action 이벤트를 전달하기만 할 뿐 Action 이벤트를 받을 수 없다는 것이죠)

 

 

 

## 1.1 View

View는 비즈니스 로직을 가지고 있지 않습니다. View는 어떻게 action 이벤트를 주고, state 이벤트를 받아서 어떻게 UI 처리할 것인지 정의해주면 됩니다.

 

View라는 것을 만들기 위해서 클래스(ViewController, Cell 등)에 View 프로토콜을 채택시켜줍니다.

 

그러면 자동으로 reactor라는 프로퍼티가 생기는데요. 보통 View 바깥에서 set 시켜줍니다.

class ProfileViewController: UIViewController, View {
  var disposeBag = DisposeBag()
}

profileViewController.reactor = UserViewReactor() // inject reactor, bind(reactor:) method 호출됨.

 

 

reactor 프로퍼티가 변경되면, bind(reactor:) 라는 method가 호출됩니다. (프로토콜의 필수 구현 method 입니다.)

 

bind(reactor:) method 안에는 action stream과 state stream에 대한 binding을 정의해줍니다.

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)
}

 

 

뷰를 스토리보드로 정의하는 경우도 많죠? 그럴 때는 View 프로토콜 대신 StoryboardView 프로토콜을 사용해주세요.

 

View 프로토콜 하고 차이점이 딱 1가지 있습니다.

 

View 프로토콜을 reactor 프로퍼티가 변경될 때 bind(reactor:) method가 호출되었는데,

 

StoryboardView 프로토콜을 사용하면 viewDidLoad 호출 이후에 bind(reactor:) method가 호출됩니다.

let viewController = MyViewController()
viewController.reactor = MyViewReactor() // will not executes `bind(reactor:)` immediately

class MyViewController: UIViewController, StoryboardView {
  func bind(reactor: MyViewReactor) {
    // this is called after the view is loaded (viewDidLoad)
  }
}

 

 

## 1.2 Reactor

Reactor는 view의 상태(state)를 관리하는 곳이에요. (UI와 독립적입니다.)

 

 

 

Reactor를 만들기 위해 Reactor라는 프로토콜을 채택시켜주세요.

 

Reactor 프로토콜을 Action, Mutation, State 라는 타입을 필수로 정의해줘야 합니다.

(initialState 프로퍼티도 필수로 구현해줘야 합니다.)

Action : user interaction
State : view state
Mutation : Action과 State의 bridge 역할
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()
}

 

 

mutate()reduce() method를 통해 Action Stream -> State Stream으로 변환해줍니다.

 

mutate()는 Action을 받아서 Observable <Mutation>을 생성해주는 method 입니다.

(비동기 작업은 여기서 수행해주세요.)

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)
      }
  }
}

 

reduce()는 이전 State와 Mutation을 통해 새로운 State를 생성해주는 method 입니다.

(reduce()는 순수 함수이기 때문에 동기적(sync)으로 처리되어야 합니다.)

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
  }
}

 

 

 

여기까지가 ReactorKit의 큰 틀이에요.

 

글로만 읽을 때는 아아... 오오... 하면서 봤는데, 실제로 어떻게 쓸지 생각하니까 살짝 막막하더라구요???

 

그래서 가장 기본적인 예시인 Counter를 ReactorKit을 사용해서 만들어볼게요.

(공식 예제랑 99.9% 동일하니 전체 코드는 여기를 참고해주세요.)

 

 

 

# 2. ReactorKit을 사용해서 Counter 만들기

이제.. 의식의 흐름대로 아래처럼 생긴 Counter를 만들어볼게요!

 

 

 

 

우선 Cocoapods으로 ReactorKit, RxCocoa를 설치해줄게요.

(RxSwift는 ReactorKit에서 의존성을 가지고 있기 때문에 직접 명시해주지 않아도 자동으로 설치가 됩니다ㅎ)

pod 'ReactorKit'
pod 'RxCocoa'

 

 

스토리보드로 뷰를 구성해줍니다.

 

 

 

스토리보드로 뷰를 구성했기 때문에 StoryboardView 프로토콜을 채택시켜줄게요.

class ViewController: UIViewController, StoryboardView {
    @IBOutlet weak var increaseButton: UIButton!
    @IBOutlet weak var decreaseButton: UIButton!
    @IBOutlet weak var valueLabel: UILabel!
    @IBOutlet weak var indicatorView: UIActivityIndicatorView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
}

 

지금 상태에서 에러가 날 텐데 우선 넘기고... Reactor를 먼저 만들어줄게요.

 

CounterViewReactor라는 클래스를 만들고 Reactor 프로토콜을 채택시킬게요.

class CounterViewReactor: Reactor {
    
}

 

 

마찬가지로 CounterViewReactor에서 에러가 날 텐데... 무시하고... 다시 ViewController로 돌아올게요.

 

 

ViewController는 StoryboardView(= View) 프로토콜을 채택했기 때문에 reactor 프로퍼티와 bind(reactor:) method를 구현해줘야 합니다. (disposeBag 프로퍼티도 추가해줘야 합니다...ㅎ)

 

StorybardView 프로토콜을 채택한 경우 bind(reactor:) method는 viewDidLoad 이후에 호출되기 때문에 viewDidLoad에서 Reactor 인스턴스를 주입해줘도 됩니다.

 

이때, bind(reactor:) method의 파라미터인 reactor 타입을 CounterViewReactor 타입으로 바꿔줘야 합니다.

class ViewController: UIViewController, StoryboardView {
	...
    var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.reactor = CounterViewReactor()  ✅
    }
    
    func bind(reactor: CounterViewReactor) {  ✅
        
    }
}

 

 

ViewController의 bind(reactor:) method는 나중에 구현할게요. (의식의 흐름이기 때문에... 왔다갔다 하는 점 죄송합니다...;;)

 

이제 Reactor로 다시 돌아올게요. 

 

Action, Mutation, State 타입을 구현해야 합니다.

 

Action은 user interaction을 담당하므로 + 버튼을 누른 경우, - 버튼을 누른 경우 밖에는 없겠죠?? 아래처럼 구현해줄게요.

class CounterViewReactor: Reactor {
    enum Action {
        case increase
        case decrease
    }
}

 

 

State는 view의 상태입니다. 지금 같으면 라벨에 표시될 값, indicator 숨김 여부, indicator 활성화 여부 정도가 되겠네요.

class CounterViewReactor: Reactor {
    enum Action {
        case increase
        case decrease
    }
    
    struct State {
        var value: Int
        var isShowing: Bool
        var isLoading: Bool
    }
}

 

Mutation은 Action <-> State 사이에 있는 브릿지 역할을 합니다. 지금 같으면 값 증가시키기, 값 감소시키기, indicator 숨김 여부 설정하기, indicator 활성화 여부 설정하기 정도가 되겠네요.

class CounterViewReactor: Reactor {
    enum Action {
        case increase
        case decrease
    }
    
    enum Mutation {
        case increaseValue
        case decreaseValue
        case setShowing(Bool)
        case setLoading(Bool)
    }
    
    struct State {
        var value: Int
        var isShowing: Bool
        var isLoading: Bool
    }
}

 

 

initialState 프로퍼티를 추가해줍니다.

class CounterViewReactor: Reactor {
    ...
    let initialState = State(value: 0, isShowing: false, isLoading: false)
}

 

그다음, mutate method를 구현해줄게요.

 

mutate method는 Action을 Observable<Mutation>으로 변환시켜줍니다.

 

증가, 감소 action이 들어왔을 때, 어떻게 행동하라 라는 것을 Observable로 내려준다고 이해를 하면 될 것 같아요.

(지금은 이벤트를 하나씩 순차적으로 내려주기 위해서 Observable.concat을 사용했습니다.)

class CounterViewReactor: Reactor {
    ...
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .increase:
            return Observable.concat([
                Observable.just(Mutation.setShowing(true)),
                Observable.just(Mutation.setLoading(true)),
                Observable.just(Mutation.increaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),  // indicator가 잘 동작하는지 확인하기 위한 delay
                Observable.just(Mutation.setLoading(false)),
                Observable.just(Mutation.setShowing(false)),
            ])
        case .decrease:
            return Observable.concat([
                Observable.just(Mutation.setShowing(true)),
                Observable.just(Mutation.setLoading(true)),
                Observable.just(Mutation.decreaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),  // indicator가 잘 동작하는지 확인하기 위한 delay
                Observable.just(Mutation.setLoading(false)),
                Observable.just(Mutation.setShowing(false)),
            ])
        }
    }
}

 

 

이번엔 reduce method를 구현해줄게요.

 

mutate method를 통해서 Mutation들이 stream으로 내려올 거잖아요??

 

stream으로 내려오는 Mutation과 이전 상태를 조합해서 새로운 state를 리턴하는 것이 reduce method 입니다.

class CounterViewReactor: Reactor {
    ...
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .increaseValue:
            newState.value += 1
        case .decreaseValue:
            newState.value -= 1
        case let .setShowing(isShowing):
            newState.isShowing = isShowing
        case let .setLoading(isLoading):
            newState.isLoading = isLoading
        }
        
        return newState
    }
}

 

 

View로부터 Action을 받아서 mutate, reduce를 거쳐 State를 방출하는 Reactor 구현이 끝났습니다.

 

이제 다시 ViewController로 가서 bind(reactor:)를 마저 구현해줄게요.

 

Action을 Reactor로 전달해주고, Reactor로부터 State를 전달받아 UI로 뿌려주면 됩니다.

class ViewController: UIViewController, StoryboardView {
    ...
    
    func bind(reactor: CounterViewReactor) {
        // Action
        increaseButton.rx.tap                   // 버튼 이벤트를
            .map { Reactor.Action.increase }    // Action으로 변환해서
            .bind(to: reactor.action)           // reactor에 binding
            .disposed(by: disposeBag)
        
        decreaseButton.rx.tap
            .map { Reactor.Action.decrease }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        // State
        reactor.state                           // reactor한테 state를 받아서
            .map { "\($0.value)" }              // value 값만 뽑은 뒤에
            .distinctUntilChanged()             // 이전 값하고 달라졌으면
            .bind(to: valueLabel.rx.text)       // label 텍스트로 설정
            .disposed(by: disposeBag)
        
        reactor.state
            .map { !$0.isShowing }
            .distinctUntilChanged()
            .bind(to: indicatorView.rx.isHidden)
            .disposed(by: disposeBag)
        
        reactor.state
            .map { $0.isLoading }
            .distinctUntilChanged()
            .bind(to: indicatorView.rx.isAnimating)
            .disposed(by: disposeBag)
    }
}

 

 

끝입니다!!ㅎㅎ

 

 

 

혹시 몰라서 전체 코드도 첨부할게요.

class ViewController: UIViewController, StoryboardView {
    @IBOutlet weak var increaseButton: UIButton!
    @IBOutlet weak var decreaseButton: UIButton!
    @IBOutlet weak var valueLabel: UILabel!
    @IBOutlet weak var indicatorView: UIActivityIndicatorView!
    var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.reactor = CounterViewReactor()
    }
    
    func bind(reactor: CounterViewReactor) {
        // Action
        increaseButton.rx.tap                   // 버튼 이벤트를
            .map { Reactor.Action.increase }    // Action으로 변환해서
            .bind(to: reactor.action)           // reactor에 binding
            .disposed(by: disposeBag)
        
        decreaseButton.rx.tap
            .map { Reactor.Action.decrease }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)
        
        // State
        reactor.state                           // reactor한테 state를 받아서
            .map { "\($0.value)" }              // value 값만 뽑은 뒤에
            .distinctUntilChanged()             // 이전 값하고 달라졌으면
            .bind(to: valueLabel.rx.text)       // label 텍스트로 설정
            .disposed(by: disposeBag)
        
        reactor.state
            .map { !$0.isShowing }
            .distinctUntilChanged()
            .bind(to: indicatorView.rx.isHidden)
            .disposed(by: disposeBag)
        
        reactor.state
            .map { $0.isLoading }
            .distinctUntilChanged()
            .bind(to: indicatorView.rx.isAnimating)
            .disposed(by: disposeBag)
    }
}
class CounterViewReactor: Reactor {
    enum Action {
        case increase
        case decrease
    }
    
    enum Mutation {
        case increaseValue
        case decreaseValue
        case setShowing(Bool)
        case setLoading(Bool)
    }
    
    struct State {
        var value: Int
        var isShowing: Bool
        var isLoading: Bool
    }
    
    let initialState = State(value: 0, isShowing: false, isLoading: false)
    
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .increase:
            return Observable.concat([
                Observable.just(Mutation.setShowing(true)),
                Observable.just(Mutation.setLoading(true)),
                Observable.just(Mutation.increaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),
                Observable.just(Mutation.setLoading(false)),
                Observable.just(Mutation.setShowing(false)),
            ])
        case .decrease:
            return Observable.concat([
                Observable.just(Mutation.setShowing(true)),
                Observable.just(Mutation.setLoading(true)),
                Observable.just(Mutation.decreaseValue).delay(.milliseconds(500), scheduler: MainScheduler.instance),
                Observable.just(Mutation.setLoading(false)),
                Observable.just(Mutation.setShowing(false)),
            ])
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .increaseValue:
            newState.value += 1
        case .decreaseValue:
            newState.value -= 1
        case let .setShowing(isShowing):
            newState.isShowing = isShowing
        case let .setLoading(isLoading):
            newState.isLoading = isLoading
        }
        
        return newState
    }
}

 

 

 

지금은 개념 잡느라 시간이 좀 걸렸는데, 익숙해지고 나면 개발 시간도 단축되고 유지보수에도 되게 좋을 것 같다는 생각이 들었어요ㅎㅎ

 

😁 😁 😁

 

 

 

## 참고

- https://github.com/ReactorKit/ReactorKit

 

 


 

이번 글은 여기서 마무리.

 

즐거운 한가위 보내세요~^^

 

 

 

반응형