주희하세요!

Why use Rx?

2019-07-14
Juhee Kim

Rx는 Observable<Element> 인터페이스를 통해 표현하는 방식으로 Generic 연산을 추상화한 것입니다. (알듯 말듯)

여러 언어로 Rx가 구현되어 있으며, RxSwift는 Swift 버전입니다.

원본 버전의 가능한 많은 컨셉을 적용하려고 하고 있지만, iOS/ macOS 환경에 더 적합하고 효율적으로 통합할 수 있도록 몇몇 다른 컨셉들이 채택되었습니다.

여러 플랫폼에 대한 공통적인 문서는 ReactiveX.io에서 찾아볼 수 있습니다.

원본 Rx처럼, 비동기 작업과 event/data stream을 쉽게 구성할 수 있습니다.

KVO observing과 비동기 작업들, 그리고 stream은 모두 시퀀스의 추상화 아래에 통합됩니다. 이는 왜 Rx가 단순하고 우아하고 강력한지를 말해줍니다.

왜 Rx를 사용하나요?

Rx는 선언적 방식으로 앱을 구성할 수 있도록 합니다. (이게 무슨 말이야)

Bindings

Observable.combineLatest(firstName.rx.text, lastname.rx.text) { $0 + " " + $1 }
    .map { "Greetings, \($0)" }
    .bind(to: greetingLabel.rx.text)

결과

// [(firstName: Heeya, lastName: Kim), (firstName: Juhee, lastName: Kim)]
// Greetings, Heeya Kim
// Greetings, Juhee Kim
// 각 결과가 greetingLabel의 text로 들어갑니다. (rx는 마법의 단어인가??? 싱기)

이는 UITableViewUICollectionView와도 함께 동작합니다.

viewModel
    .rows
    .bind(to: resultsTableView.rx.items(cellIdentifier: "WikipediaSearchCell", cellType: WikipediaSearchCell.self)) { (_, viewModel, cell) in
        cell.title = viewModel.title
        cell.url = viewModel.url
    }
    .disposed(by: disposeBag)

단순한 바인딩이라서 불필요하게 느껴지더라도 항상 .disposed(by: disposeBag)을 사용하길 권장합니다.

Retries

만일 API가 실패하지 않는 것이 좋겠지만, 불행히도 실패하죠 하핳. 다음과 같은 API 메서드가 있다고 칩시다.

func doSomethingIncredible(forWho: String) throws -> IncredibleThing

만약 기존처럼 이 함수를 사용한다면, 실패의 경우에 이 동작을 다시 시도하는 것이 매우 어렵습니다. exponential backoffs 모델링의 어려움을 언급하지는 않겠습니다. 가능하긴 하지만, 코드에 신경쓰지 않는 일시적인 상태들이 많이 포함될 것이며, 재사용되지 않을 것입니다.

이상적으로 재시도의 본질을 포착하고 모든 작업에 적용할 수 있어야 합니다.

Rx를 사용하면 다음처럼 간편하게 재시도할 수 있습니다 XD

doSomethingIncredible("me")
    .retry(3)

또한 손쉽게 사용자 retry 작업을 만들 수 있습니다.

Delegates

Rx를 사용하면 지루하고 비표현적인 방식을 대체할 수 있습니다.

public func scrollViewDidScroll(scrollView: UIScrollView) { [weak self] // what scroll view is this bound to?
    self?.leftPositionConstraint.constant = scrollView.contentOffset.x
}

이렇게요!

self.resultsTableView
    .rx.contentOffset
    .map { $0.x }
    .bind(to: self.leftPositionConstraint.rx.constant)
// 아니 근데 기존에 내가 알고 있는 걸 너무 많이 대체하는 것 같다 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
// ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
// 뭔가 돌아올 수 없는 강을 건너는 기분이야
// 하지만 Rx는 짱이겠지
// resultsTableView의 contentOffset이 변경되면 이 Event에 대한 처리를 하는 것 같은데
// contentOffset의 x 값을 받아와서 leftPositionConstraint의 constant 를 업데이트해준당.

KVO

기존의 KVO 방식에서는 다음과 같은 오류문구를 접할 수 있습니다.

-(void)observeValueForKeyPath:(NSString *)keyPath
                     ofObject:(id)object
                       change:(NSDictionary *)change
                      context:(void *)context
`TickTock` was deallocated while key value observers were still registered with it. Observation info was leaked, and may even become mistakenly attached to some other object.

TickTock이 KVO가 등록되어 있는 상태에서 dealloc되었습니다. Observation 정보가 누수되고 있으며, 다른 객체에 잘못 연결될 수도 있습니다.

그러니까 rx.observerx.observeWeakly를 사용합시다. 이렇게요:

view.rx.observe(CGRect.self, "frame")     // view의 키가 "frame"이고 CGRect 타입인 객체에 대해 관찰시작 > Observable을 넘겨주는 stream이 생성됩니다.
    .subscribe(onNext: { frame in         // 다음 항목에 대한 구독을 시작합니다 > frame이 변경될 때마다 발생되는 Observable을 구독합니다.
        print("Got new frame \(frame)")
    })
    .disposed(by: disposeBag)             // 처리된 Observable을 DisposeBag에 넣읍시다!

혹은

someSuspiciousViewController
    .rx.observeWeakly(Bool.self, "behavingOk")
    .subscribe(onNext: { behavingOk in
        print("Cats can purr? \(behavingOk)")
    })
    .disposed(by: disposeBag)

Notifications

Notification을 등록하는 방식도 다음과 같이 대체됩니다.

기존

@available(iOS 4.0, *)
public func addObserverForName(name: String?, object obj: AnyObject?, queue: NSOperationQueue?, usingBlock block: (NSNotification) -> Void) -> NSObjectProtocol

Rx

NotificationCenter.default
    .rx.notification(NSNotification.Name.UITextViewTextDidBeginEditing, object: myTextView)
    .map {  /*do something with data*/ }
    ....

일시적인 상태

비동기 프로그램을 작성할 때 일시적인 상태와 관련된 많은 문제들이 있습니다. 통상적인 예로는 자동완성박스가 있겠네요.

만약 자동완성 코드를 Rx없이 작성하려면, abcc가 입력되었을 때 첫 번째 문제를 만날 수 있습니다 XD. ab에 대한 보류 중인 요청이 있을 때, 보류중인 요청이 취소된다는 것입니다. 좋아요. 이는 풀어내기 크게 어렵지 않습니다. 보류된 요청을 참조하는 변수를 추가로 만들면 되니까요.

다음 문제는 만약 요청이 실패했을 때, 엄청난 양의 재시도 로직을 짜야할겁니다. 뭐, 이것도 정리해야하는 재시도 횟수를 파악할 수 있는 몇 가지 변수로 해결할 수 있겠죠.

만약 프로그램이 서버로의 요청하기 전에 몇 초 정도 기다려주면 좋겠네요. 누군가가 뭔갈 계속 입력중이라면 우리 서버에 스팸처럼 계속 요청을 보내고 싶지 않으니까요. 뭐 아마 다른 타이버가 필요하지 않을까요?

검색이 수행중일 동안 어떤걸 보여줘야하는지, 또 요청이 실패해서 재시도 중일 땐 어떤 화면을 보여줘야 할지에 대한 질문도 있을겁니다.

이 모든 것을 작성하고 올바르게 테스트하려면 지루할 것입니다. 이것은 Rx로 쓰여진 동일한 로직입니다.

searchTextField.rx.text
    .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
    .distinctUntilChanged()
    .flatMapLatest { query in
        API.getSearchResults(query)
            .retry(3)
            .startWith([]) // clears results on new search term
            .catchErrorJustReturn([])
    }
    .subscribe(onNext: { results in
      // bind to ui
    })
    .disposed(by: disposeBag)

추가적인 플래그와 필드가 필요하지 않습니다. Rx는 모든 일시적인 mess를 처리합니다.

Compositional disposal

테이블 뷰에 블러처리된 이미지를 보여주고 싶다고 가정해봅시다. 첫번째로 이미지는 URL로부터 패치되어야 하며, 디코딩 된 다음 블러처리를 해야합니다.

블러처리를 위한 대역폭과 프로세서 시간이 비싸기 때문에 셀이 테이블 뷰 영역에서 보여지지 않으면 전체 프로세스가 취소 될 수 있다면 좋을 것입니다.

사용자가 정말로 빠르게 스와이프하면 많은 요청이 시작되고 취소 될 수 있기 때문에 셀이 가시 영역에 들어가면 즉시 이미지를 가져오기 시작하지 않는다면 좋을 것입니다.

이미지를 흐리게하는 것이 값비싼 작업이기 때문에 동시 이미지 작업의 수를 제한할 수 있다면 좋을 것입니다.

Rx를 사용하면 다음과 같이 처리할 수 있습니다.

// this is a conceptual solution
let imageSubscription = imageURLs
    .throttle(.milliseconds(200), scheduler: MainScheduler.instance)
    .flatMapLatest { imageURL in
        API.fetchImage(imageURL)
    }
    .observeOn(operationScheduler)
    .map { imageData in
        return decodeAndBlurImage(imageData)
    }
    .observeOn(MainScheduler.instance)
    .subscribe(onNext: { blurredImage in
        imageView.image = blurredImage
    })
    .disposed(by: reuseDisposeBag)

이 코드는 모든 작업을 하고 있으며, imageSubscription이 dispose될 때, 모든 독립적인 비동기 작업을 취소하고 불량 이미지가 UI에 바인딩되지 않도록 합니다.

Aggregating network request

만약 두 개의 요청이 발생했고 두 요청이 모두 완료되었을 때 결과를 통합해야 한다면 어떨까요?

zip 연산이 있습니다!

let userRequest: Observable<User> = API.getUser("me")
let friendsRequest: Observable<[Friend]> = API.getFriends("me")

Observable.zip(userRequest, friendsRequest) { user, friends in
    return (user, friends)
}
.subscribe(onNext: { user, friends in
    // bind them to the user interface
})
.disposed(by: disposeBag)

만약 API가 결과를 background 쓰레드에서 넘겨주더라도 바인딩이 main UI 쓰레드를 통해서 해야한다면 어떨까요? observeOn을 사용할 수 있습니다.

let userRequest: Observable<User> = API.getUser("me")
let friendsRequest: Observable<[Friend]> = API.getFriends("me")

Observable.zip(userRequest, friendsRequest) { user, friends in
    return (user, friends)
}
.observeOn(MainScheduler.instance)
.subscribe(onNext: { user, friends in
    // bind them to the user interface
})
.disposed(by: disposeBag)

Rx는 실제로 더 많은 사례에 적용할 수 있습니다 XD

State

mutation을 허용하는 언어를 사용하면 전역 상태에 쉽게 엑세스하여 변형시킬수 있습니다. 제어되지 않는 공유된 전역 mutation 상태값은 손쉽게 문제가 될 수 있습니다.

하지만 스마트하게 사용되는 명령형 언어는 하드웨어에 더 효율적으로 코드를 작성할 수 있게 해줍니다.

그래서 Rx가 빛나는 거죠(응?)

Rx는 함수형, 명령형 세상 사이에 존재합니다. 불변의 정의와 순수한 함수를 사용하여 변경 가능한 상태의 스냅 샷을 안정적이고 구성 가능한 방식으로 처리 할 수 있습니다.

예를 볼까용?

Easy integration

만약 고유한 observable을 생성해야한다면 어떻게 하나요? 매우 쉽습니다 히힛. 이 코드는 RxCocoa에서 가져왔고 URLSession을 통해 HTTP 요청을 사용하는데 필요한 모든 것입니다.

extension Reactive where Base: URLSession {
    public func response(request: URLRequest) -> Observable<(Data, HTTPURLResponse)> {
        return Observable.create { observer in
            let task = self.base.dataTask(with: request) { (data, response, error) in

                guard let response = response, let data = data else {
                    observer.on(.error(error ?? RxCocoaURLError.unknown))
                    return
                }

                guard let httpResponse = response as? HTTPURLResponse else {
                    observer.on(.error(RxCocoaURLError.nonHTTPResponse(response: response)))
                    return
                }

                observer.on(.next(data, httpResponse))
                observer.on(.completed)
            }

            task.resume()

            return Disposables.create(with: task.cancel)
        }
    }
}

Benefits

짧게, Rx를 사용하면 당신의 코드를 다음처럼 만들어줍니다:

  • Composable(합성가능) : Rx는 Composition의 애칭이래요.
  • Reusable(재사용 가능) : 왜냐면 composable 하니까
  • Declarative(정의형?) : 정의는 변경 불가하고 데이터만 변경된다.
  • Understandable & concise (이해할 수 있고 간결함) : 추상화 수준을 높이고 임시적인 상태값을 제거하니까
  • Stable(안정적임) : Rx 코드가 철저한 Unit test를 거쳤습니다.
  • Less stateful(상태적이지 않음? 상태에 의존적이지 않음????) : 단방향 데이터 흐름으로 앱을 모델링하기 때문에
  • Without leaks(누출없이) : 자원 관리가 쉽다!

이건 아무것도 아냐 ㅎ

앱에 가능한 많은 Rx를 사용하는 게 좋아요. 하지만 특정 케이스에 대한 작업이 존재하는지 다 알 수는 없잖아요.. ㅜ Rx 연산자는 수학에 기반하며, 매우 직관적입니다!

우리에게 이미 익숙한 map, filter, zip, observeOn과 같은 익숙한 것들을 포함하고 있어요.

이 연산자들에 대해서는 목록으로 제공되고 있으며, 이해하기 쉽도록 marble diagram을 제공합니다.

만약 추가적으로 연산자가 필요하다면, 당신이 직접 만들 수도 있어요!

찾아보기

  • observe vs observeWeakly : 알듯말듯~
  • throttle : 이벤트를 일정 주기마다 발생하도록 하는 것. 주어진 시간에 한번만 이벤트가 발생한다. 시간 주기 사이에 들어온 이벤트는 무시되며, 주기가 끝나면 마지막 이벤트만 발생시킨다. vs : Debounce : 이벤트를 그룹화해서 특정시간이 지난 후 하나의 이벤트만 발생하도록 하기. 위와 다른 점은, 이벤트가 발생되면 타이머가 reset된다. throttle는 3초의 주기를 설정하고 매 초마다 이벤트(0,1,2,3,4,5, …)를 발생시킨다면, 이벤트가 처음 시작했을때 0, 3초 뒤에 마지막으로 발생했던 3, 이후 3초 뒤에 6 이런식으로 주기적으로 이벤트가 나온다. 하지만 Debounce의 주기를 3초로 설정하면 (0,1,2,3)의 이벤트가 발생했을 때 마지막 이벤트가 발생된 다음 3초를 기다려서 이벤트가 발생된다. (이전 이벤트는 모두 무시된다.)
  • observeOn : 가능한 thread 알아두기

원본


Similar Posts

이전 Relay

Comments