상세 컨텐츠

본문 제목

[RxSwift] Traits를 사용하는 이유와 Single, Driver, Signal 예제

Swift

by Mr.Garlic 2022. 9. 6. 16:50

본문

Traits 의 탄생 이유

어떤 이벤트들은 onCompleted나 onError에 걸려도 종료되지 않기를 바랄수도 있다. 예컨대 UI관련이라면 어쩌다 오류가 생긴다고 해도
구독 스트림이 해제되지 않는 것이 유저에게 어색하게 보이지 않을 것이다. 내부에서 오류가 생겼다고 해서 구독이 없어져버리면 그 다음에
오류가 아닌 제대로된 데이터가 들어오더라도 업데이트가 안될 것이 아닌가...!!

또, 어떤 경우는 값이 오는건 별로 중요하지 않지만 종료되는 순간만 중요할 수도 있다! 이런 여러가지 상황에 대응하기 위해서 특별한 Observable을 만들었는데 그게 바로 Traits다.

자주 쓰이는 Traits

RxSwift 의 Trait 'Single'

어떤 경우에는 딱 한번만 이벤트를 받고싶고, 그 이후에는 뭐가 들어오든 안받고 싶을 수 있다. 마치 API를 통한 HTTP 요청처럼! 그리고 Side effect를 공유하지 않아야 할 때 Single을 사용한다.

func getRepo(_ repo: String) -> Single<[String: Any]> {
    return Single<[String: Any]>.create { single in
        let task = URLSession.shared.dataTask(with: URL(string: "https://api.github.com/repos/\(repo)")!) { data, _, error in
            if let error = error {
                single(.failure(error))
                return
            }

            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves),
                  let result = json as? [String: Any] else {
                single(.failure(DataError.cantParseJSON))
                return
            }

            single(.success(result))
        }

        task.resume()

        return Disposables.create { task.cancel() }
    }
}

위는 api요청을 해서 error가 뜨면 single(.failure(error))를 통해 에러를 전달하고, 성공시에는 result를 전달하는 Single을 구현한 것이다.
그렇다면 구독하는 입장에서는 아래와 같이 사용하면 된다!

사용법 1 (Switch 구문 사용하기)

getRepo("ReactiveX/RxSwift")
    .subscribe { event in
        switch event {
            case .success(let json):
                print("JSON: ", json)
            case .failure(let error):
                print("Error: ", error)
        }
    }
    .disposed(by: disposeBag)

사용법 2 (제공되는 파라미터 사용하기)

getRepo("ReactiveX/RxSwift")
    .subscribe(onSuccess: { json in
                   print("JSON: ", json)
               },
               onError: { error in
                   print("Error: ", error)
               })
    .disposed(by: disposeBag)

RxCocoa의 Trait 'Driver'

부릉부릉~ 드라이버는 왜 이름이 드라이버일까? 공식문서에 친절한 설명이 있는데, 구동한다고 할 때 쓰는 그 Drive를 의미하는 것 같다.
드라이버는 RxCocoa에서 제공하는 Trait중 하나이다. Driver하면 바로 UI를 떠올릴 수 있는데 그 이유는 다음과 같다.

  • 아예 에러는 무시한다. 그러니까, 에러가 뜬다고해서 UI업데이트가 먹통이 되는 상황을 방지할 수 있다.
  • 메인 스레드에서만 동작하는걸 보장한다.
  • 사이드이펙트를 공유한다. ( 사이드 이펙트란? 외부에 데이터를 전달해 주는 것, 체이닝 중간에 다른 객체로 데이터 전달하는 것!, do로 subscribe와 똑같이 구현 가능)
    share(replay: 1, scope: .whileConnected)

Driver가 없다면...

let results = query.rx.text
    .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
    }

results
    .map { "\($0.count)" }
    .bind(to: resultCount.rx.text)
    .disposed(by: disposeBag)

results
    .bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

위의 코드가 하려는 일을 정리하면 다음과 같다.

  1. query 라는 텍스트필드에 유저가 넣는 글자에 throttle을 걸어서 매번 검색되게 하지는 않고 싶고, 검색한 결과는 results에 저장하도록 한다.
  2. 검색된 결과를 resultCount라는 라벨에 넣어주고 싶고,
  3. 마지막으로 셀의 textLabel에 넣어주고 싶다.

그런데 이 코드에는 몇가지 문제점이 있다!!

  1. fetchAutoCompleteItems 옵저버블이 error를 뱉기라도 하면!!! 모든 UI가 응답을 안하고 아무리 입력을 해도 아무것도 안나올 것이다...
  2. fetchAutoCompleteItems가 메인 스레드에서 동작하지 않으면 크래시가 날 수도 있다.
  3. 두 개의 UI 요소에 엮여있기 때문에, 쓸데 없이 두번 요청을 하게 될 것이다... 둘다 하나의 응답만 공유 받으면 되는건데 말이다!!!

이 코드를 조금 개선해 보면 아래와 같다.

let results = query.rx.text
    .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
            .observeOn(MainScheduler.instance)  // 메인스케줄러에서 동작하도록 강제
            .catchErrorJustReturn([])           // 에러가 뜨면 빈배열을 리턴하도록 추가
    }
    .share(replay: 1)                           // 요청한 결과값을 공유하도록 함, 두번 요청하지 않게됨


results
    .map { "\($0.count)" }
    .bind(to: resultCount.rx.text)
    .disposed(by: disposeBag)

results
    .bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

위 코드에서는 메인스케줄러에서 동작하도록 .observeOn을 사용해줬고, error시에 빈 배열을 리턴하도록해서 dispose되지 않도록 처리했다.
또한 share(replay: 1)을 통해서 새로 구독을 하더라도 이전 값을 1번은 사용하도록 하였다.

자 그럼 아래 코드를 보자

let results = query.rx.text.asDriver()        // asDriver()를 통해 시퀀스를 Driver로 바꾸어 주었다. 
    .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
            .asDriver(onErrorJustReturn: [])  // 에러때 무엇을 던져줄지 설정한다.
    }

results
    .map { "\($0.count)" }
    .drive(resultCount.rx.text)               // driver의 경우에는 bind(to:)대신 drive()를 사용한다. 
    .disposed(by: disposeBag)             

results
    .drive(resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

여기서 변화한 점은 asDriver() 부분이다. 단순히 Driver의 ControlProperty를 몇가지 더 쓸수있는 것 이외에 변한 것은 없다.
두번째로 변한 것은 .asDriver(onErrorJustReturn: [])부분이다.
그리고 Driver로 바꿔주니 share(replay:1) 부분을 생략해도 된다!

옵저버블은 언제나 Driver로 변환해서 사용할 수 있는데,

  • 에러를 던져주지 않고,
  • 메인에서 동작하며
  • 사이드이펙트를 공유할 때 사용할 수 있다.

만약에 그냥 옵저버블로 만들어서 Driver로 리턴하고 싶다면 아래와 같이 작성할 수 있다.

let safeSequence = xs
  .observeOn(MainScheduler.instance)        // observe events on main scheduler
  .catchErrorJustReturn(onErrorJustReturn)  // can't error out
  .share(replay: 1, scope: .whileConnected) // side effects sharing

return Driver(raw: safeSequence)            // wrap it up

Signal

시그널은 Driver랑 다른점이 딱 하나 있는데, 바로 마지막 element를 절대 replay하지 않는 다는 점이다. 자동으로 share되지 않는 Driver 인 셈이다.

공식 다큐멘테이션을 참고하였음 (링크)

관련글 더보기