iOS/Reactive Programming in iOS

[RxSwift] RxSwift 왜 써야 하나요?

TDCIAN 2022. 5. 8. 21:31

클릭하면 링크로 넘어갑니다!

 

 

* 본 내용은 RxSwift의 Documentation에 있는 Why.md 문서를 번역한 내용입니다. 다소간의 부정확한 의역이 포함될 수 있으니 위 이미지를 클릭하셔서 원문을 확인하시기를 권장합니다!

 

 

Rx(Reactive Extensions) 왜 쓰냐면요

Rx를 사용하면 앱을 선언형 프로그래밍의 방식으로 만들 수 있습니다.

 

Bindings

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

UITableView와 UICollectionView를 구현할 때도 활용할 수 있습니다.

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

여러분이 다음과 같은 API 호출 메소드를 사용하다가 실패하는 상황이 발생했을 때 재시도를 하도록 만드는 것은 굉장히 어렵습니다. 지수 백오프를 모델링하는 일의 복잡함은 말할 것도 없지요. 물론 직접 만들 수도 있겠지만, 여러분들이 직접 관리하기도, 재사용하기도 어려운 많은 코드를 작성해야만 할 것입니다.

 

여러분들은 재시도에 필요한 코드를 작성하고, 여러분들이 재시도 기능을 적용하고 싶은 곳마다 사용할 수 있기를 기대할 것입니다.

Rx를 활용하면 아래와 같이 간단하게 가능합니다.

doSomethingIncredible("me")
    .retry(3)

또한 여러분들은 커스터마이징 된 retry operator를 간단하게 만들 수 있습니다.

 

Delegates

구구절절 알아보기 힘든 코드를 쓰는 대신에

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)

 

KVO

이렇게 쓰거나(이 내용이 왜 들어가 있는 것인지 잘 모르겠네요... 댓글로 알려주시면 감사하겠습니다ㅜㅜ),

`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.

이렇게 쓰기 보다는

-(void)observeValueForKeyPath:(NSString *)keyPath
                     ofObject:(id)object
                       change:(NSDictionary *)change
                      context:(void *)context

rx.observerx.observeWeakly를 사용해 보세요.

이렇게 사용하실 수 있습니다.

view.rx.observe(CGRect.self, "frame")
    .subscribe(onNext: { frame in
        print("Got new frame \(frame)")
    })
    .disposed(by: disposeBag)

이렇게 사용하실 수도 있습니다.

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

 

Notifications

이렇게 쓰는 대신

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

이렇게 써보세요.

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

 

Transient state

비동기 프로그램을 만드는 과정에서 과도 상태와 관련해 많은 문제가 있습니다. 검색 자동완성을 예시로 들 수 있습니다.

만약 여러분이 Rx를 사용하지 않고 자동완성 코드를 작성한다면, 우선 다음과 같은 문제를 겪게 될 겁니다.

abc를 타이핑하려 할 때, ab가 작성되고 이에 따라 ab에 대한 자동완성 리퀘스트가 존재하는 상황에서 c를 타이핑한다면 ab에 대한 리퀘스트는 취소되어야 합니다. 물론 그렇게 해결하기 어려운 문제는 아닙니다. 다만 여러분들은 리퀘스트를 보류하기 위한 추가적인 변수를 만들어야 하겠죠.

다음으로 발생할 문제는 리퀘스트가 실패했을 때입니다. 여러분들은 복잡한 retry 로직을 작성해야 합니다. 하지만 괜찮습니다. 이미 이를 간단하게 해결해 줄 몇 가지 방법이 있습니다.

(* 원문은 The next problem is if the request fails, you need to do that messy retry logic. But OK, a couple more fields that capture the number of retries that need to be cleaned up.' 인데, 이 문장에서 field가 의미하는 바가 무엇인지 모르겠네요... 댓글로 알려주시면 정말 감사하겠습니다 ㅜㅜ)

만약 프로그램이 서버에 리퀘스트를 요청하기 전까지 대기하는 시간을 설정할 수 있다면 아주 좋을 것입니다.

긴 문장을 타이핑하는 상황에서 서버에 계속해서 불필요한 요청을 보내고 싶지 않으니까요.

또한 검색이 진행되는 동안 화면에 무엇을 보여줘야 할지, 그리고 retry 요청이 전부 실패했을 때 어떤 화면을 보여줘야 할지에 대해서도 생각해 봐야 합니다. 이 모든 내용을 새로 만들려면 굉장히 복잡할 것입니다. 같은 내용을 RxSwift를 활용하면 다음과 같이 작성할 수 있습니다.

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가 과도 상태의 복잡함을 전부 관리해 주기 때문에, 추가적인 플래그나 필드는 필요하지 않습니다.

 

Compositional disposal

여러분들이 테이블뷰에 블러 처리된 이미지를 보여주고 싶다고 가정해 봅시다.

우선적으로 URL을 통해 이미지를 보여주고, 그 후에 블러 처리를 하게 될 것입니다.

이미지를 블러 처리하는데 들어가는 자원이 많이 소모되는 상황일 때, 테이블뷰의 보이지 않는 영역에 대해서는 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이 해제되면, 모든 종속된 비동기 작업을 취소하고, 불량 이미지가 UI에 바인딩되지 않도록 할 수 있습니다.

 

Aggregating network requests

두 가지의 리퀘스트를 날려 각 리퀘스트의 결과물들을 하나로 합치고 싶다면 어떻게 해야 할까요?

그럴 때는 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 요청에 대한 응답을 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를 끝내주게 활용한 사례들이 많습니다.

 

State

mutation을 허용하는 언어를 사용하는 경우 쉽게 전역 state에 접근하여 이를 변경시킬 수 있습니다. 제어되지 않은 전역 state의 mutation은 쉽게 조합적 폭발을 발생시킬 수 있습니다.

(* 조합적 폭발: 알고리즘의 실행 시간이 조합 함수에 따라 폭발적으로 증가하는 현상)

하지만 선언형 프로그래밍 언어를 사용한다면 더 효율적으로 하드웨어와 근접한 코드를 작성할 수 있습니다.

조합적 폭발을 해결하는 일반적인 방법은 state를 최대한 간단한 상태로 유지하고, 단방향 데이터 흐름으로 파생 데이터를 모델링하는 것입니다.

Rx가 활약하는 지점이 바로 이곳입니다.

Rx는 함수형 프로그래밍과 선언형 프로그래밍의 특성을 모두 갖고 있습니다. 이를 통해 여러분들은 안정적이고 조합하기 용이한 방식으로 스냅숏을 처리하는 과정에서 불변 정의와 순수 함수를 활용할 수 있습니다.

예시를 한 번 볼까요?

 

Easy integration

여러분들이 원하는 방식으로 Observable을 만들고 싶으면 어떻게 하면 될까요? 아주 쉽습니다.

이 코드는 RxCocoa를 활용한 것이고, 여러분들이 할 일은 HTTP 요청을 URLSession으로 래핑 하는 것 말고는 없습니다.

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 사용의 이점을 간단하게 요약해 봅시다.

- 필요한 부분들만 조립해서 활용하기 좋습니다.

- 재사용성이 좋습니다. <- 조립해서 활용하기 좋기 때문입니다.

- 선언적입니다. <- 정의 자체는 immutable하고 오직 데이터만 변경되기 때문입니다.

- 이해하기 쉽고 간결합니다 <- 추상화의 수준은 높이고 과도 상태는 제거했습니다.

- 안정적입니다 <- Rx로 작성된 코드들은 전부 유닛 테스트를 거쳤습니다.

- 덜 stateful 합니다 <- 데이터의 흐름이 단방향으로 구성되기 때문입니다.

- 메모리 누수 없이 활용할 수 있습니다 <- 리소스 관리가 쉽기 때문입니다.

 

It's not all or nothing

대부분의 경우 여러분들이 앱을 만들 때마다 Rx를 활용한다면 아주 좋을 것입니다.

하지만 여러분들이 operator를 사용할 줄 모르거나 여러분들의 상황에 필요한 operator가 있는지조차 모르는 상황이라면 어떡할까요?

걱정하지 마세요. Rx의 모든 operator들은 수학에 기반해 있기 때문에 직관적입니다.

좋은 소식은 전체 operator 중 거의 10~15% 정도의 operator만이 활용된다는 것입니다.

map, filter, zip, observeOn 처럼 익숙한 것들 말입니다.

 

[이하 생략]