RxCleanSwift icon indicating copy to clipboard operation
RxCleanSwift copied to clipboard

Clean Swift 연구일지 #7

Open GeekTree0101 opened this issue 6 years ago • 2 comments

Code less, More test

Lay's CleanSwift의 슬로건이 Code less, More test 입니다. 이번엔 최대한 코드를 적게쓰면서 Lay's CleanSwift의 TDD양식을 따라가며 최대한 Rx를 사용할 수 있는 방향에 대해서 많이 생각해봤습니다. 오늘은 Interactor만 최대한 집중해봤습니다. 연구일지 #4 에서 Interactor에 대한 Input/Ouput 설계에 대해서 예기한적이 있습니다. image

Interactor에 대한 Input/Output protocol을 만들어서 Input event에 대한 Output result event를 RxTest를 이용하여 독립적으로 테스트 가능하게 리펙토링을 하였습니다.

// Input에 대한 protocol
protocol CardInteractorInputLogic: class {
    
    var didTapTitle: PublishRelay<Void> { get }
    var didTapSubTitle: PublishRelay<Void> { get }
}

// Output에 대한 protocol
protocol CardInteractorOutputLogic: class {
    
    var mainTitle: Observable<String> { get }
    var subTitle: Observable<String> { get }
}

class CardInteractor: CardInteractorInputLogic {
    
    var didTapTitle: PublishRelay<Void> = .init()
    var didTapSubTitle: PublishRelay<Void> = .init()
    
    // Workers
    private let worker: CardWorker

    init(worker: CardWorker) {
        self.worker = worker
    }
}

extension CardInteractor: CardInteractorOutputLogic {
    
    var mainTitle: Observable<String> {
        return didTapTitle.withLatestFrom(worker.getMainTitle())
    }
    
    var subTitle: Observable<String> {
        return didTapSubTitle.withLatestFrom(worker.getSubTitle())
    }
}

Lay's CleanSwift의 Interactor에 대한 TDD는 앞서 #6 에서 언급했듯이 Interactor의 request에 대한 presenter 의 response 전달 통신에 대해서만 Spy 테스트하면됩니다. 기대값을 테스트하는 영역이 아니라는거죠 :) 그리고 input하나를 만들어 줄 때 마다 매번 output을 추가해줘야하는 번거로움도 없잖아 있습니다. 또한, 굳이 presenter logic이 있는데 같은 Type을 가지는 Interactor Output를 만드는건 CleanSwift에서 말하는 Code Less하지 않습니다. 그렇다면 Rx를 계속 유지한체 OutputLogic을 제거하면 다음과 같이 리펙토링됩니다.

protocol CardInteractorInputLogic: class {
    
    var didTapTitle: PublishRelay<Void> { get }
    var didTapSubTitle: PublishRelay<Void> { get }
}

class CardInteractor: CardInteractorInputLogic {
    
    var didTapTitle: PublishRelay<Void> = .init()
    var didTapSubTitle: PublishRelay<Void> = .init()
    
    // VIP Cycle configure과정에서 presenter에 대한 Logic을 set합니다.
    public var presenter: CardPresenterLogic!
    
    // Workers
    private let worker: CardWorker
    
    let disposeBag = DisposeBag()
    
    init(worker: CardWorker) {
        self.worker = worker

        didTapTitle.withLatestFrom(worker.getMainTitle())
                          .bind(to: presenter.presentMainTitle)
                          .disposed(by: disposeBag)

        didTapSubTitle.withLatestFrom(worker.getSubTitle())
                          .bind(to: presenter.presentSubTitle)
                          .disposed(by: disposeBag)
    }
}

이전 RxSwift를 이용하여 MVVM 구조로 설계하신 분들은 직감 하셨을 테지만, 간단한 비즈니스로직과 화면에서는 큰 문제는 없지만 복잡한 어플리케이션의 경우 init이 로직의 갯수에 따라 비대해지는 건 어쩔 수 없습니다.

protocol CardInteractorInputLogic: class {
    
    var didTap1: PublishRelay<Void> { get }
    var didTap2: PublishRelay<Void> { get }
    var didTap3: PublishRelay<Void> { get }
    var didTap4: PublishRelay<Void> { get }
    var didTap5: PublishRelay<Void> { get }
    var didTap6: PublishRelay<Void> { get }
    var didTap7: PublishRelay<Void> { get }
    var didTap8: PublishRelay<Void> { get }
    var didTap9: PublishRelay<Void> { get }
}

class CardInteractor: CardInteractorInputLogic {
    
    var didTap1: PublishRelay<Void> = .init()
    var didTap2: PublishRelay<Void> = .init()
    var didTap3: PublishRelay<Void> = .init()
    var didTap4: PublishRelay<Void> = .init()
    var didTap5: PublishRelay<Void> = .init()
    var didTap6: PublishRelay<Void> = .init()
    var didTap7: PublishRelay<Void> = .init()
    var didTap8: PublishRelay<Void> = .init()
    var didTap9: PublishRelay<Void> = .init()
    
    // VIP Cycle configure과정에서 presenter에 대한 Logic을 set합니다.
    public var presenter: CardPresenterLogic!
    
    // Workers
    private let worker: CardWorker
    
    let disposeBag = DisposeBag()
    
    init(worker: CardWorker) {
        self.worker = worker

        didTap1.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output1)
                      .disposed(by: disposeBag)

        didTap2.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output2)
                      .disposed(by: disposeBag)

        didTap3.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output3)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output4)
                      .disposed(by: disposeBag)

        didTap5.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output5)
                      .disposed(by: disposeBag)

        didTap6.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output6)
                      .disposed(by: disposeBag)

        didTap7.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output7)
                      .disposed(by: disposeBag)

        didTap8.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output8)
                      .disposed(by: disposeBag)

        didTap9.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output9)
                      .disposed(by: disposeBag)
    }
}

위의 경우는 interactor와 presenter간의 연결이 1:1이기 때문에 큰 문제는 없어보이지만 만약 interactor 하나의 logic을 여러 presenter가 필요에 따라 공유한다 가정하면 다음과 같이 될 수가 있습니다.

protocol CardInteractorInputLogic: class {
    
    var didTap1: PublishRelay<Void> { get }
    var didTap2: PublishRelay<Void> { get }
    var didTap3: PublishRelay<Void> { get }
    var didTap4: PublishRelay<Void> { get }
    var didTap5: PublishRelay<Void> { get }
    var didTap6: PublishRelay<Void> { get }
    var didTap7: PublishRelay<Void> { get }
    var didTap8: PublishRelay<Void> { get }
    var didTap9: PublishRelay<Void> { get }
}

class CardInteractor: CardInteractorInputLogic {
    
    var didTap1: PublishRelay<Void> = .init()
    var didTap2: PublishRelay<Void> = .init()
    var didTap3: PublishRelay<Void> = .init()
    var didTap4: PublishRelay<Void> = .init()
    var didTap5: PublishRelay<Void> = .init()
    var didTap6: PublishRelay<Void> = .init()
    var didTap7: PublishRelay<Void> = .init()
    var didTap8: PublishRelay<Void> = .init()
    var didTap9: PublishRelay<Void> = .init()
    
    // VIP Cycle configure과정에서 presenter에 대한 Logic을 set합니다.
    public var presenter: CardPresenterLogic!
    
    // Workers
    private let worker: CardWorker
    
    let disposeBag = DisposeBag()
    
    init(worker: CardWorker) {
        self.worker = worker

        didTap1.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output1)
                      .disposed(by: disposeBag)

        didTap1.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output7)
                      .disposed(by: disposeBag)

        didTap1.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output8)
                      .disposed(by: disposeBag)

        didTap2.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output2)
                      .disposed(by: disposeBag)

        didTap3.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output3)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output4)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output1)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output3)
                      .disposed(by: disposeBag)

        didTap5.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output5)
                      .disposed(by: disposeBag)

        didTap6.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output6)
                      .disposed(by: disposeBag)

        didTap7.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output7)
                      .disposed(by: disposeBag)

        didTap8.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output8)
                      .disposed(by: disposeBag)

        didTap9.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output9)
                      .disposed(by: disposeBag)
    }
}

눈치채셨는지 모르겠지만 didTap 1번과 4번 로직이 각각 Presenter output의 1,7,8번과 1,3,4번을 bind하고 있습니다. 여기서 가장문제가 되는 부분 하나만 꼽자면 **응집성(Cohesion)**이 떨어진다는 점입니다. 물론 이러한 문제점을 피하는 방법으로는 method를 작성해서 각각 input에 대해서 아래와 같이 나눠주는 방법도 있을 껍니다.

class CardInteractor: CardInteractorInputLogic {
    // ... 생략
    
    init(worker: CardWorker) {
        self.worker = worker
        self.input1()
        // ... 생략
        self.input4()
        // ... 생략
    }
    
   func input1() {

        didTap1.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output1)
                      .disposed(by: disposeBag)

        didTap1.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output7)
                      .disposed(by: disposeBag)

        didTap1.withLatestFrom(worker.getOutputResponse3())
                     .bind(to: presenter.output8)
                      .disposed(by: disposeBag)
   }
   
   func input4() {

        didTap4.withLatestFrom(worker.getOutputResponse1())
                     .bind(to: presenter.output4)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse2())
                     .bind(to: presenter.output1)
                      .disposed(by: disposeBag)

        didTap4.withLatestFrom(worker.getOutputResponse3())
                     .bind(to: presenter.output3)
                      .disposed(by: disposeBag)
   }
}

흠 응집도는 좀 좋아져 보이긴 하지만 Code Less하지 않군요. image

결국 위의 엉킨실타래와 같은 Interactor를 만들어 input과 output에 대한 Unit Test에 최대한 의존하며 리펙토링이나 버그를 잡는데 있어서 피로도는 누적될 수 밖에 없는 운명인거 같습니다.

따라서 Lay's CleanSwift처럼 method로 정의하지 않고 relay나 subject로 Interactor의 input logic을 정의하면 다음과 같은 장점과 단점이 있겠습니다.

장점

  • Rx쓸수있다.
  • 작은 규모의 어플리케이션일 경우 무난하게 쓸 수 있다.

단점

  • Relay나 Subject사용에 대한 제약이 선언적이지 못하고 모호성이 큼
  • method에 비해 1:N bind하는 데 있어서 코드 응집도가 떨어짐
  • 오히려 Lay's CleanSwift보다 코드를 제법 많이 쓰게됌

극복방법

저는 참 Rx좋아합니다. CleanSwift도 좋아하고 VIP Cycle구조도 너무너무 좋아합니다. 이런 이유때문에 매일은 아니지만 자주 연구일지를 써나가는거 같습니다. 단점을 극복하고 기대하는 방향으로 돌파해야하는게 개발자의 길 아니겠습니까? ㅎ

저는 Relay나 Subject를 떠나서 Rx를 쓰면서 인터렉터의 로직에 대한 프리젠터의 1: N바인딩도 적절히 소화해내며 코드를 덜 쓸 수 있는 방법을 생각한 결과 RxCocoa에서 제공하는 Binder를 활용하는 방법을 생각해봤습니다.

InteractorLogic을 Binder로 정의하면 다음과 같은 코드가 형성됩니다.

protocol CardInteractorLogic: class {
    
    var didTapMain: Binder<Void> { get }
    var didTapSub: Binder<Void> { get }
}

class CardInteractor {
    
   // Lay's CleanSwift와 같이 presenter의 logic만 set함
    public var presenter: CardPresenterLogic!
    
    // Workers
    private let worker: CardWorker
    
    let disposeBag = DisposeBag()
    
    init(worker: CardWorker) {
        self.worker = worker
    }
}

extension CardInteractor: CardInteractorLogic {
    
    var didTapMain: Binder<Void> {
        return Binder(self) { interactor, _ in
            interactor.worker.getMainTitle()
                .asSignal(onErrorJustReturn: "error")
                .emit(to: interactor.presenter.presentMainTitle)
                .dispose()
        }
    }
    
    var didTapSub: Binder<Void> {
        return Binder(self) { interactor, _ in
            interactor.worker.getSubTitle()
                .asSignal(onErrorJustReturn: "error")
                .emit(to: interactor.presenter.presentSubTitle)
                .dispose()
        }
    }
}

Relay나 Subject initialization하는거 보다 공수는 약간 들지만 Interactor logic각각에 대한 비즈니스로직 및 프레젠터로의 Response값 전달에 대한 모든 요소의 코드 응집도가 좋아진 느낌이 없잖아 있었다. 필요에 따라 Interactor와 Presenter간의 1: N 처리에도 이득을 보였었다.

extension TestInteractor: TestInteractorLogic {
    
    // 온전히 하나의 인터렉터 로직에 집중할 수 있음, 코드 응집도가 높아짐
    var input: Binder<Void> {
        return Binder(self) { interactor, _ in
            // Interactor: Presenter = 1: N 구현가능

            interactor.worker.getLogicResult()
                .asSignal(onErrorJustReturn: "error")
                .emit(to: interactor.presenter.output1)
                .dispose()

            interactor.worker.getLogicResult2()
                .asSignal(onErrorJustReturn: "error")
                .emit(to: interactor.presenter.output2)
                .dispose()

            interactor.worker.getLogicResult3()
                .asSignal(onErrorJustReturn: "error")
                .emit(to: interactor.presenter.output3)
                .dispose()
        }
    }
}

Binder로 Interactor Logic 선언시 장점

  • Rx를 온전히 쓸 수 있음
  • 1: N 처리가능
  • 코드 응집도
  • 새로운 로직 추가 및 기존 로직 제거 용이
  • 기존 CleanSwift의 Interacter TDD에 비해 짧은 코드라인으로 RxTest가능

Binder로 Interactor Logic 선언시 단점

  • Relay나 Subject에 비해 공수가 있음
  • RxCocoa디펜던시
  • interactor logic access마다 escaping closure 메모리 할당됌

GeekTree0101 avatar May 25 '19 14:05 GeekTree0101

참고 사이트 및 아티클

  • Signal and Relay in RxCocoa 4 Relay는 ObservableType 프로토콜을 기반으로하지만 중요한 차이점은 Subject 클래스와 달리 ObserverType을 기반으로하지 않는다는 것입니다 . Observable로부터 Complete/Error 를 받기를 원치 않기 때문bind(to :) 메소드를 사용하지 않는 것이 좋습니다. https://github.com/ReactiveX/RxSwift/pull/1470
  • Data Flow != Dependency Rule
  • 코드 응집도(High Cohesion) 응집도는 한 모듈 내에 존재하는 함수, 데이터 등의 구성 요소들 사이의 밀접한 정도를 나타낸다

GeekTree0101 avatar May 25 '19 14:05 GeekTree0101

Example GIST

https://gist.github.com/GeekTree0101/975c30412a2e79ec425264645222948d

GeekTree0101 avatar May 25 '19 14:05 GeekTree0101