ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ReactorKit이란
    Rx/ReactorKit 2022. 10. 3. 16:22

     

    이번 글에서는 ReactorKit이란 무엇인지에 대해 공부한다.

    ReactorKit이란

    먼저 공식 깃허브 리드미에서 제공하는 정의를 살펴보자.

    반응형 단방향 Swift 앱 아키텍처를 위한 프레임워크

    일단 정의를 봤을때 Swift로 앱을 만들때 사용하는 프레임워크고 아키텍처 패턴인건 알겠다.

     

    하지만 '반응형', '단방향'은 이해하기가 어려웠다.

    두 개념 모두 이 글에서 설명하기엔 너무 복잡한 개념이라 다른 자료를 보면서 이해해보자.

     

    반응형 프로그래밍 패러다임

    반응형 프로그래밍이라고 검색하면 최상단에 노출되는 글이다.

     

    단방향 데이터 플로우

    개인적으로 이해하기 좋았던 글이다.

    다만 양방향 데이터 플로우는 뭔지, 단방향 데이터 플로우에 비해 어떤 장단점이 있는지는 아직 모르겠다...

    단방향 데이터 플로우에 대한 고민은 최하단에 더 자세히 적어놓았다.

     

    그래서 ReactorKit을 사용하는 이유는 무엇일까?

    ReactorKit을 쓰는 이유

    보통 iOS 앱 개발에서는 MVVM 구조를 많이 사용하는데 개발자마다 구조가 다를 수 있다.

    Input은 method 호출로 만들고, Output만 stream으로 만든다거나,

    Input과 Output을 구조체로 만들어서 사용하기도 하고,

    뷰모델 자체를 프로토콜로 추상화하는 경우도 있고...

    패턴이라는게 정답이 없다보니 MVVM으로 개발하자고 결정을 해도 개발자마다 적용 방법은 다를 수 있는 것이다.

    이건 꼭 MVVM만의 문제는 아니다. 아키텍처 패턴 자체가 추상화된 큰 그림을 그려주는 것에 불과하기 때문에

    어떤 패턴이든 사람마다 다르게 구현될 가능성이 존재한다.

     

    아무튼 어떤 기준을 잡아놓고 거기에 맞춰서 개발하는 것으로 구조를 통일시킬수 있는데,

    구조를 단일화할때의 한 가지 선택지가 바로 ReactorKit을 사용하는 것이다.

     

    정리하자면, MVVM 패턴을 사용할때 코드가 사람마다 다양하게 구현될 수 있는데,

    이때 구조를 단일화하기 위해 ReactorKit을 사용한다고 이해하면 되겠다.

     

    구조를 굳이 통일시켜야 하는 이유는 그래야 다른 사람이 짠 코드를 볼때도 이해하기가 쉽기 때문이다.

     

    ReactorKit을 어떻게 사용할까

    ReactorKit 깃허브에서는 아래와 같이 소개하고 있다.

    출처: https://github.com/ReactorKit/ReactorKit

    나는 기존 ViewModel을 Reactor로,

    ViewModel의 Input을 Action으로,

    ViewModel의 Output을 State로 치환해서 이해했다.

     

    좀 더 코드를 보면서 이해해보자.

    일반적으로 아키텍처 패턴에선 어떤 화면에 대응하는 View가 있고, 이 View를 관리하는 ViewController를 View라고 부른다.

    그 ViewController에게 View 프로토콜을 채택하게 만들어줘야 한다.

    final class ChatRoomScheduleViewController: UIViewController, View {}

    View 프로토콜은 아래와 같이 구현되어있다.

    /// A View displays data. A view controller and a cell are treated as a view. The view binds user
    /// inputs to the action stream and binds the view states to each UI component. There's no business
    /// logic in a view layer. A view just defines how to map the action stream and the state stream.
    public protocol View: AnyObject {
      associatedtype Reactor: ReactorKit.Reactor
    
      /// A dispose bag. It is disposed each time the `reactor` is assigned.
      var disposeBag: DisposeBag { get set }
    
      /// A view's reactor. `bind(reactor:)` gets called when the new value is assigned to this property.
      var reactor: Reactor? { get set }
    
      /// Creates RxSwift bindings. This method is called each time the `reactor` is assigned.
      ///
      /// Here is a typical implementation example:
      ///
      /// ```
      /// func bind(reactor: MyReactor) {
      ///   // Action
      ///   increaseButton.rx.tap
      ///     .bind(to: Reactor.Action.increase)
      ///     .disposed(by: disposeBag)
      ///
      ///   // State
      ///   reactor.state.map { $0.count }
      ///     .bind(to: countLabel.rx.text)
      ///     .disposed(by: disposeBag)
      /// }
      /// ```
      ///
      /// - warning: It's not recommended to call this method directly.
      func bind(reactor: Reactor)
    }

    그래서 우리가 View에서 할 일은

    disposeBag을 선언해주고,

    Reactor 타입을 구체화해서 bind 메서드를 구현해주면 끝이다.

    func bind(reactor: ChatRoomScheduleReactor) {
        // ...
    }

    기존 ViewModel Input은 action에 매핑해서 bind하고,
    기존 ViewModel Output은 state 구조체의 각 property로 매핑해서 bind하면 된다.

    func bind(reactor: ChatRoomScheduleReactor) {
        self.rx.sentMessage(#selector(UIViewController.viewWillAppear(_:)))
            .map { _ in Reactor.Action.viewWillAppear }
            .bind(to: reactor.action)
            .disposed(by: self.disposeBag)
        
        self.exitButton.rx.tap
            .map { Reactor.Action.exitTrigger }
            .bind(to: reactor.action)
            .disposed(by: self.disposeBag)
        
        self.addButton.rx.tap
            .map { Reactor.Action.addDiscussionTrigger }
            .bind(to: reactor.action)
            .disposed(by: self.disposeBag)
        
        reactor.state.asObservable().map { $0.schedules }
            .bind(to: self.scheduleTableView.rx.items) { tableView, index, model in
                let indexPath = IndexPath(item: index, section: 0)
                guard let cell = tableView.dequeueReusableCell(withIdentifier: ScheduleCell.identifier, for: indexPath) as? ScheduleCell
                else { return UITableViewCell() }
                cell.bind(model)
                return cell
            }
            .disposed(by: self.disposeBag)
        
        reactor.state.asObservable().map { $0.addEnabled }
            .bind(to: self.addButton.rx.isEnabled)
            .disposed(by: self.disposeBag)
    }

    다음으로 리액터를 선언해주면 된다.

    final class ChatRoomScheduleReactor: Reactor {}

    Reactor 프로토콜은 아래와 같이 구현되어있다.

    public protocol Reactor: AnyObject {
      /// An action represents user actions.
      associatedtype Action
    
      /// A mutation represents state changes.
      associatedtype Mutation = Action
    
      /// A State represents the current state of a view.
      associatedtype State
    
      typealias Scheduler = ImmediateSchedulerType
    
      /// The action from the view. Bind user inputs to this subject.
      var action: ActionSubject<Action> { get }
    
      /// The initial state.
      var initialState: State { get }
    
      /// The current state. This value is changed just after the state stream emits a new state.
      var currentState: State { get }
    
      /// The state stream. Use this observable to observe the state changes.
      var state: Observable<State> { get }
    
      /// A scheduler for reducing and observing the state stream. Defaults to `CurrentThreadScheduler`.
      var scheduler: Scheduler { get }
    
      /// Transforms the action. Use this function to combine with other observables. This method is
      /// called once before the state stream is created.
      func transform(action: Observable<Action>) -> Observable<Action>
    
      /// Commits mutation from the action. This is the best place to perform side-effects such as
      /// async tasks.
      func mutate(action: Action) -> Observable<Mutation>
    
      /// Transforms the mutation stream. Implement this method to transform or combine with other
      /// observables. This method is called once before the state stream is created.
      func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
    
      /// Generates a new state with the previous state and the action. It should be purely functional
      /// so it should not perform any side-effects here. This method is called every time when the
      /// mutation is committed.
      func reduce(state: State, mutation: Mutation) -> State
    
      /// Transforms the state stream. Use this function to perform side-effects such as logging. This
      /// method is called once after the state stream is created.
      func transform(state: Observable<State>) -> Observable<State>
    }

    뭔가 스펙이 많지만 Action과 Mutation, State를 먼저 구현한다.

    final class ChatRoomScheduleReactor: Reactor {
    
        enum Action {
            case viewWillAppear
            case exitTrigger
            case addDiscussionTrigger
        }
    
        enum Mutation {
            case setEnable(Bool)
            case initalizeSchedules
            case addSchedules(ScheduleItemViewModel)
            case events
        }
    
        struct State {
            var addEnabled: Bool = false
            var schedules: [ScheduleItemViewModel] = []
        }
        
    }

    그리고 initialState를 구현해야 한다. 위에서 State 구조체를 어떻게 정의했는지에 따라 기본 값을 넣어줘야 할 수도 있다.

    let initialState: State = State()

    마지막으로 mutate와 reduce 메서드를 구현하면 된다.

    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .viewWillAppear:
            return Observable.concat(
                Observable.just(Mutation.initalizeSchedules),
                Observable.just(Mutation.setEnable(self.userID == self.chatRoom.adminUID)),
                self.usecase.discussions(roomUID: self.chatRoom.uid)
                    .map { Mutation.addSchedules(ScheduleItemViewModel(with: $0)) }
            )
        case .exitTrigger:
            return Observable.just(())
                .do(onNext: self.navigator.toChatRoom)
                .map { Mutation.events }
        case .addDiscussionTrigger:
            return Observable.just(self.chatRoom)
                .do(onNext: self.navigator.toAddDiscussion)
                .map { _ in Mutation.events }
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var state = state
        switch mutation {
        case let .setEnable(isEnabled):
            state.addEnabled = isEnabled
        case .initalizeSchedules:
            state.schedules = []
        case let .addSchedules(schedule):
            state.schedules.append(schedule)
        case .events:
            break
        }
        return state
    }

    mutate에서 Action은 State를 변경시키는 Mutation으로 변환되고,

    Mutation은 reduce에서 이전 상태를 변경시켜 새로운 상태를 반환하는 구조이다.

     

    마지막으로 viewController의 reactor 프로퍼티에 새로 정의한 Reactor 객체를 넣어주면 끝이다.

    let viewController = ChatRoomScheduleViewController()
    viewController.reactor = ChatRoomScheduleReactor()

     

    SideEffect

    ViewModel의 역할을 Reactor가 가져갔기 때문에 기존 ViewModel이 수행하던 화면 전환 로직도 Reactor가 처리해야 한다.

    물론 화면 전환 로직을 ViewModel이 직접 수행하는건 아니고 Navigator라는 별도의 객체가 담당한다.

    따라서 Reactor에 적절한 순간에 Navigator에게 화면 전환을 요청하는 SideEffect를 추가해야 한다.

     

    위 코드에 이미 나와있는데, 이런 side effect는 mutate에서 수행한다.

    case .exitTrigger:
        return Observable.just(())
            .do(onNext: self.navigator.toChatRoom)
            .map { Mutation.events }

    위와 같은 코드가 이런 side effect를 수행하는 코드이다.

     

    여기서 든 의문점은 이런 side effect만 있는 action은 Mutation이 필요하지 않다.

    그런데 mutate 메서드가 무조건 Mutation을 반환하게 정의되어있다.

    그래서 나는 아래와 같이 Mutation에 events라는 케이스를 추가하고, reduce에서 아무것도 하지 않도록 구현했다.

    enum Mutation {
        // ...
        case events
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        // ...
        case .events:
            break
        }
        return state
    }

    문제는 없지만 Mutation이 상태를 바꾸지 않는다는게 뭔가 부자연스럽다는 느낌이 들었다.

     

    Pulse란

    개인적으로 처음에는 pulse라는 개념이 잘 이해가 가지 않았다.

    우선 Pulse 코드는 아래와 같다.

    @propertyWrapper
    public struct Pulse<Value> {
    
      public var value: Value {
        didSet {
          self.riseValueUpdatedCount()
        }
      }
      public internal(set) var valueUpdatedCount = UInt.min
    
      public init(wrappedValue: Value) {
        self.value = wrappedValue
      }
    
      public var wrappedValue: Value {
        get { return self.value }
        set { self.value = newValue }
      }
    
      public var projectedValue: Pulse<Value> {
        return self
      }
    
      private mutating func riseValueUpdatedCount() {
        if self.valueUpdatedCount == UInt.max {
          self.valueUpdatedCount = UInt.min
        } else {
          self.valueUpdatedCount += 1
        }
      }
    }

    property wrapper이므로 어떤 프로퍼티에 대한 동작을 구현한 것이라고 할 수 있는데,

    코드를 좀 읽어보면 value가 새로 세팅될때마다 valueUpdatedCount를 하나씩 늘려가는게 목적인 것을 알 수 있다.

    아래와 같은 코드로 state.asObservable()이 방출될때마다 print를 찍어보니 총 5번이 출력된 것을 확인할 수 있었다.

    reactor.state.asObservable()
        .do(onNext: { print($0.addEnabled) })
        .map { $0.addEnabled }
        .bind(to: self.addButton.rx.isEnabled)
        .disposed(by: self.disposeBag)
        
    // false
    // false
    // true
    // true
    // true

    addEnabled와 무관하게 State가 방출될때마다 함께 코드가 실행되기 때문에 그런 것으로 보인다.

    이 코드에서는 addEnabled가 여러번 방출되어도 큰 이슈가 없지만 이걸 막고싶다면,

    // ChatRoomScheduleReactor.swift
    struct State {
        @Pulse var addEnabled: Bool = false
        var schedules: [ScheduleItemViewModel] = []
    }
    
    // ChatRoomScheduleViewController.swift
    reactor.pulse(\.$addEnabled)
        .do(onNext: { print($0) })
        .bind(to: self.addButton.rx.isEnabled)
        .disposed(by: self.disposeBag)
        
    // false
    // true

    state의 프로퍼티에 Pulse wrapper를 추가하고,

    reactor.pulse로 받아서 bind를 해주면, addEnabled값이 assigned될때만 값이 방출되는 것을 확인할 수 있다.

     

    기능은 조금 다르지만, distinctUntilChanged 연산자를 사용해도 방출되는 값을 제어할 수 있다.

    reactor.state.asObservable().map { $0.addEnabled }
        .distinctUntilChanged()
        .bind(to: self.addButton.rx.isEnabled)
        .disposed(by: self.disposeBag)

    Pulse를 적용하는 것과 차이는 distinctUntilChanged는 값이 다를때만 방출하고,

    Pulse는 값이 assign될때만 방출한다는 차이가 있다.

    distinctUntilChanged는 해당 값이 Equatable을 채택하고 있거나 직접 같은지를 확인하는 코드를 제공해야한다는 단점이 있다.

    그리고 추가하는걸 깜빡할수도 있다.

     

    참고로 \.로 접근하는 문법은 KeyPath라고 부르는 문법이다.

    TypeName.propertyName으로 접근하는게 일반적이라 생소하게 느껴질 수도 있는데, 여기를 참고하자.

     

    transform이란

    처음에는 transform이라는 메서드도 잘 이해가 가지 않았다.

    Reactor 프로토콜을 채택할때 필수로 구현해야 하는 메서드도 아니어서 놓치고 넘어가기 쉬웠다.

     

    transform은 각 스트림을 변환하기 위해 사용한다.

    action 스트림, mutation 스트림, state 스트림 모두 변환해서 사용할 수 있도록 오버로딩되어있다.

    또 앱 내에 전역 상태를 관리하는 스트림이 있다면, 전역 스트림과 결합하는 코드를 transform 메서드 내부에 작성하도록 권장된다.

     

    그래서 전역 상태가 바뀔때마다 state를 바꾸고 싶다면,

    Mutation에 새로운 case를 추가하고 transform(mutation:) 함수에서

    기존의 mutation 스트림과 전역 상태 스트림을 merge해주면 된다.

     

    그래서 나는 Action → Mutation → State 흐름에 새로운 스트림을 끼워넣을때 transform을 사용하는 것으로 이해했다.

    ReactorKit을 좀 더 사용하다보면 다른 사용 방식도 이해할 수 있을 것이다.

     

    단방향 앱 아키텍쳐?

     

    ReactorKit 깃허브 리드미 첫 문장은 다음과 같다.

    ReactorKit is a framework for a reactive and unidirectional Swift application architecture.

     

    이 문장에서 개인적으로 unidirectional 이라는 내용이 이해가 가지 않았다.

    공식 리드미를 더 읽어보니 ReactorKit은 Flux와 반응형 프로그래밍의 조합이라고 설명이 되어있었다.

     

    그래서 Flux와 단방향 아키텍처가 어떤 관계가 있음을 짐작할 수 있었고, Flux를 좀 더 찾아보았다.

    Flux

    Flux는 앱의 데이터 흐름을 관리하는 아키텍처 패턴이다. 데이터를 단방향으로 흐르게 하는 것이 목적이다.

    페이스북에서 개발했고, 양방향으로 데이터를 바인딩하는 MVC 패턴으로 개발하다가 확장가능성에서 한계를 느끼고,

    단방향으로 데이터가 흐르는 패턴을 새로 디자인했는데, 그것이 바로 Flux이다.

     

    그럼 Flux 패턴은 어떻게 단방향 데이터 플로우를 구현하는가?

    굉장히 잘 설명된 영상을 보는 것을 추천한다.

     

    보면서 든 의문은 iOS의 MVC 패턴은 이미 단방향 데이터 플로우이지 않나? 싶었다.

    출처:&nbsp;https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MVC.html

    Flux에서 말하는 MVC는 아무래도 옛날 facebook이 구현한 MVC 패턴이기도 하고,

    또 Web application이라서 MVC 구현이 좀 다르게 되어있는것 같았다.

    그래서 다이어그램을 봤을때 양방향으로 데이터가 흐른다는걸 납득할 수 있었다.

    하지만 iOS에선 애플이 위 그림과 같이 구현하도록 권장 + 강제해놔서 이미 단방향으로 데이터가 흐르도록 설계된 것 같은데...

    내가 단방향 데이터 플로우를 잘못 이해하고 있는건가 싶다.

     

    추가적인 궁금증: Action A와 B가 State를 각각 바꾼다고 하자. reduce는 상태를 파라미터로 전달하잖아.

    a라는 상태에서 두 action이 발생했음. 액션 A가 상태를 a에서 a'으로 바꿨는데 액션 B가 a'을 b'으로 바꾸는게 아니라 액션이 시작할 시점의 상태인 a를 b로 바꿔버리는 문제가 발생하지는 않을까? 그러면 어떻게 될까?

Designed by Tistory.