오늘의 빌런 : materialize

저번 주의 후일담과 배경

저번 주에는 로그인 세션과의 사투를 벌였던 기억이 납니다. 분리하기 힘들겠구나~ 하고 하하 웃었더라고 전해드렸죠. 놀랍게도 그 이후로, 로그인 관련 코드들을 LoginSession이라는 별도의 객체로 만들어 빼는 데 성공했습니다. ASAuthorizationControllerDelegate를 채택한 인스턴스가 가지고 있는 메서드의 파라미터로 ASAuthorizationControllerPresentationContextProviding의 역할, 즉 윈도우를 제공할 역할을 수행할 뷰 컨트롤러를 (뷰 모델을 통해서) 받고, 그 위에서 로직을 수행해 주면 되었던 것이죠. 파라미터로 넘겨 줄 뷰 컨트롤러 역시 클래스기 때문에, 넘겨 준 뷰 컨트롤러가 곧 로그인 뷰 컨트롤러와 같은 메모리를 참조하게 되겠고, 결국 같은 곳에서 실행이 되겠네요. 역시 세상은 넓고 저희가 알지 못하는 방법은 있었습니다. 이제는 알게 됐지만요. 오늘 이야기해 볼 주제도 그런 이야기였으면 좋겠네요.

자, 어쨌든 이제 뷰 컨트롤러에서 OAuth 로그인 로직을 뜯어내는 데에는 성공했습니다. 그럼 이제 로그인이 됐냐 안 됐냐 여부를 따져 보고, 로그인이 됐다면 화면을 전환해 주어야겠죠? 오늘 해 볼 이야기는 뷰 컨트롤러와 뷰 모델, 그리고 코디네이터까지 엮여들어간 이야기입니다.

Rx 어렵네요

RxSwift와 ReactiveX, 반응형 프로그래밍, 그리고 함수형 프로그래밍까지. RxSwift를 제대로 알고 쓰려면 알아야 할 것이 참 많은 것 같습니다. 글을 쓰고 있는 저는 일단 이 모든 것에 대해서 엄청 제대로 알고 있지는 못합니다. 아는 대로 말해 보자면..

<aside> 🎶 잠깐 옆으로 새 볼까요? 모름지기 기술을 사용함에 있어서는 명확한 이유가 필요합니다. Rx도 그렇겠죠. 또한 잘 알지 못하는 기술을 사용하는 것은 위험한 면이 있는 것도 사실입니다. 그렇다면 우리는 왜 Rx를 사용할까요? 저희가 주목했던 것은 크게 두 가지였습니다. 데이터가 갱신되었을 때의 처리가 깔끔하고, 입출력의 처리를 통해 코드의 재사용성을 높일 수 있었기 때문이죠. 실제로 분업을 통해 작업하면서, 어떤 것이 완성되지 않았더라도 껍데기를 갖춰 두면, 그 껍데기 안을 누군가 채워넣는 동안 다른 사람은 거기에 연결되는 값에 대해서 동시에 작업할 수 있었습니다. 이 과정에서 모두가 감탄했었죠. 또 저 개인적으로는, 더 알고 싶었습니다. 값이 바뀌면 자동으로 착착 연동돼서 관련된 값이 바뀐다니, 얼마나 신기하던지요. Rx에 대한 저의 첫인상은 “세련된 코드” 였습니다. 이 세련된 프레임워크에 대해서 더 알고 싶다. 이것이 저에게는 꽤나 직접적이고도 강렬한 동기였습니다. 저번주에도 말했듯, 시도해보지 않으면 알 수 없는 부분도 있습니다. 쓰고자 하는 목적이 분명하므로, 학습과 적용을 병행함으로써 더 빠르게 깊이 알 수 있지 않을까? 하는 기대가 있었습니다.

</aside>

그래서 하고 싶은 게 뭐였냐구요?

오늘 화제의 출발점은, 그래서 어떤 값을 변화시켜서 어떤 결과를 이끌어내고 싶었는가? 라는 겁니다. 앞서 말씀드렸듯 로그인 세션을 분리했죠. 이 로그인 세션 객체는 로그인 유즈케이스에 위치합니다. 뷰 컨트롤러에서 버튼을 누르면, 뷰 모델에 로그인을 하고 싶다고 보내고, 다시 뷰 모델에서는 유즈케이스에, 유즈케이스는 로그인 세션에 로그인 요청을 보내는 겁니다. 그러면 로그인 세션에서는 필요한 정보를 받아서 로그인 작업을 진행하고 결과값을 보내 주죠. delegate을 통해 로그인 작업을 위임받은 로그인 세션이, presentation context를 보여 줄 뷰 컨트롤러(와 그것이 가지고 있는 윈도우) 위에서 필요한 정보를 받는 화면을 표시하고, 그 정보를 토대로 로그인을 진행합니다. 저희 설계상, 뷰 모델과 뷰 컨트롤러는 유저의 정보에 대해 알 필요는 없었습니다. 뷰 모델은 단지 로그인이 성공했냐, 실패했냐를 알면 될 뿐이죠. 그리고 뷰 컨트롤러는 이 성공과 실패 값의 변화에 따라 코디네이터에 화면 전환을 알릴지, 실패 얼럿을 띄울지 정도만 수행하면 되는 것이었습니다.

onError의 함정과 materialize의 벽

로그인이 안 되면 당연히 에러지! 라고 생각하기 쉽습니다. 논리적으로 보면, Observable에서 오류가 발생하면 onError를 당연히 써야 할 것 같죠. 저희는 그 자체가 Observer이며 또한 Observable도 될 수 있는 PublishSubject를 사용해 값을 변화시키고, 또한 변화한 그 값을 뷰 컨트롤러가 구독함으로써 여러 동작을 수행하고자 했습니다. 그리고 onNextonError 두 가지를 사용해 불리언 프로퍼티를 변화시킴으로써 이를 실현하고자 했죠.

문제는 onError에 있었습니다. onError는 에러를 전달하는 것까지는 좋은데, 데이터 스트림 또한 끝내 버리는 겁니다. 만약 누군가 처음에 로그인을 시도했을 때 실패했다면, 이미 에러를 받은 스트림은 끝나 버리기 때문에, 다음에 로그인이 성공한다 할지라도 스트림이 끝난 옵저버블 프로퍼티는 더 이상 변화를 감지하지 않고, 그렇기 때문에 결과적으로 화면이 넘어가지 않는 참사가 발생해 버린 겁니다… 뭔가 변화가 필요했습니다.

에러를 받고도 스트림이 끊기지 않게 할 방법은 없을까요? 이 문답은 이전에 부캠 캠퍼분들과 이야기 나누면서 나왔던 화제이기도 했습니다. materialize라는 근사한 해결법이 있는 것 말이죠.

materialize란?

옵저버블이 값을 관찰하는 대신, 값에 대한 이벤트를 관찰하게 하는 것.. 이라고 간단하게 정리할 수 있겠습니다. 코드로 볼까요?

//
//  ViewController.swift
//  Materialize
//
//  Created by Gordon Choi on 2022/11/25.
//

import UIKit

import RxSwift

enum ExampleError: Error {
    case anyError
}

struct Flag {
    var isRaised = PublishSubject<Bool>()
}

class ViewController: UIViewController {
    private var disposeBag = DisposeBag()
    
    static var raiserOne = Flag()
    static var raiserTwo = Flag()
    
    var raiser: BehaviorSubject<Flag> = BehaviorSubject(value: raiserOne)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
//        notMaterialized()
        materialized()
    }
    
    private func notMaterialized() {
        let watcher = raiser
            .flatMap { $0.isRaised }
        
        watcher.subscribe(onNext: {
            print($0)
        })
        .disposed(by: disposeBag)
        
        ViewController.raiserOne.isRaised.onNext(true)
        ViewController.raiserOne.isRaised.onNext(true)
        ViewController.raiserOne.isRaised.onError(ExampleError.anyError)
        ViewController.raiserOne.isRaised.onNext(false)
        
        raiser.onNext(ViewController.raiserTwo)
        ViewController.raiserTwo.isRaised.onNext(true)
        
//        실행 결과
//        true
//        true
//        Unhandled error happened: anyError
    }
    
    private func materialized() {
        let watcher = raiser
            .flatMap { $0.isRaised.materialize() }
            
        watcher
            .filter {
                guard $0.error == nil else {
                    print($0.error!)
                    return false
                }
                
                return true
            }
            .subscribe(onNext: {
            print($0)
        })
        .disposed(by: disposeBag)
        
        ViewController.raiserOne.isRaised.onNext(true)
        ViewController.raiserOne.isRaised.onNext(true)
        ViewController.raiserOne.isRaised.onError(ExampleError.anyError)
        ViewController.raiserOne.isRaised.onNext(true)
        
        raiser.onNext(ViewController.raiserTwo)
        ViewController.raiserTwo.isRaised.onNext(true)
        
//        실행 결과
//        next(true)
//        next(true)
//        anyError
//        next(true)
    }
}