RxSwift는 왜 쓰는걸까?(부제: DispatchQueue의 늪) - 2

2022. 12. 12. 12:19IT/iOS

2022.11.29 - [IT/iOS] - RxSwift는 왜 쓰는걸까?(부제: DispatchQueue의 늪)

 

RxSwift는 왜 쓰는걸까?(부제: DispatchQueue의 늪)

그동안 RxSwift를 공부하지 않았었다. 당장 스스로 Reactive Programming에 대한 필요성을 못 느꼈기 때문이다. 현업에서 어쩌면 가장 활발히 쓰이고 있는 라이브러리인 것이 자명할 지라도 아직 초보 iO

eleste.tistory.com

 

앞선 글에서 이렇게 서술한 바 있다.

간단하게 말해 코드의 실행이 짜여진 코드에 맞추어 일련의 과정을 따라 수동적으로 이루어지는 것이 아니라, 유저의 인터렉션, API 호출 등에 따라 일정 변수 값(정확이는 이를 포괄하는 상태 값)이 변경되었을때 해당 상태 혹은 상태 변화가 전파되어 연관된 요소들의 코드가 능동적으로 수행되는 것이다.

간단하게 말한다 하고선 간단한 설명이 아닌 느낌이라 예를 들어보자면, 배달음식을 시켜먹는 느낌이다. 배달앱이 없었을 적에는 마치 택배처럼 계속 음식이 왔나 안왔나 오매불망 시시때때로 문을 열어보며 택배를 확인했다면, 상태 변경을 감지한다는 것은 배달앱에서 내 음식이 도착했다고 알려주는 순간에 딱 문을 열고 테이블을 세팅하고 하는 것과 같다.

 

그래서 우리에게 필요한 건, 음식이 조리를 시작했을때, 배달기사님이 출발하셨을 때, 음식이 도착했을 때 우리에게 알려줄 수 있는 배달앱. 그리고 그것이 RxSwift이다. 그러면 앞서 API를 비동기적으로 호출하면서 호출된 결과를 메인쓰레드에서 UI 업데이트를 하기 위해 DispatchQueue을 겹겹이 사용했던 상황을 떠올려보자. DispatchQueue는 non-return 함수이기 때문에 아예 해당함수를 호출할 때에. completion을 통해 처리할 로직(UI업데이트)를 인자로 함께 전달하여 내부적으로 처리를 했다.

func getPhoto(_ urlString: String, _ completion: @escaping (UIImage?) -> Void) {
         DispatchQueue.global().async {
             let url = URL(string: urlString)
             let data = try? Data(contentsOf: url!)
             if let imageData = data {
                 let image = UIImage(data: imageData)

                 DispatchQueue.main.sync {
                     completion(image)
                 }
             }
         }
}

이렇게 되면 함수부와 호출부의 커플링이 심해지고, 하나의 함수는 하나의 로직만을 온전히 처리하지 못하며, 코드의 가독성 또한 떨어진다. 게다가 같은 이벤트에 대해 호출되는 모든 함수에 이렇게 completion을 따로 작성해야하고 디버깅을 생각해보면 아찔하다.

 

만약에 함수가 하나의 최종적인 상태를 반환하고(배달의 진행상황을 알려주고), 그 값이 변할때마다(배달 프로세스가 진행될때마다) 그 값을 관찰하던 객체들이(우리들) 저마다의 로직을 실행하도록 한다(음식을 받고 테이블을 세팅한다)면 코드는 간결해지고 이해하기 쉬워질 것이다.

 

이 때 각각에 대응되는 것은 다음과 같다. 먼저,

 

주문받는 사람 입장에서는

배달앱: Observable 객체

 

배달상태가 바뀌었다: .onNext(상태) 통해 값 전달

배달완료: .onComplete()

짜장면 그릇수거(너무 옛날 예시인가?): Disposable 객체

 

주문한 사람 입장에서는

앱에서 알림을 받겠다: .subscribe()

바뀌는 상태에 따라 어떤 행동을 할건가: .next()

음식 다 먹었다: .onCompleted()

짜장면 그릇 밖에 내놓기: dispose()

 

예시가 뭔가 지저분한 느낌이지만 굳이 실생활로 치환을 하자면 이렇게 설명할 수 있다.

이제 이 예시를 바탕으로 선언부에서는 원했던 것처럼 completion을 걷어내고 Observable 객체를 반환하는 함수를 만든다. 반환하는 객체의 타입은 사용하고자 하는 것에 따라 다르다. 

func getPhoto(_ urlString: String) -> Observable<UIImage?> {
         return Observable.create { emitter in
             DispatchQueue.global().async {
                 let url = URL(string: urlString)
                 let data = try? Data(contentsOf: url!)
                 if let imageData = data {
                     let image = UIImage(data: imageData)

                     DispatchQueue.main.sync {
                         emitter.onNext(image)
                     }
                 }
             }
             
             return Disposable.create()
         }
}

이처럼 completion 자리에 대신 .onNext()가 들어간 것을 볼 수 있고 Observable 객체는 이를 통해 특정 item을 반환(전달)하게 된다.

getPhoto(url: urlString ?? "")
	.subscribe { event in
    	switch event {
        case .next(let cellImage):
        	cell.cellImageView.image = cellImage
        case .completed:
        	break
        case .error:
        	break
        }
    }
    .disposed(by: disposableBag)

이렇게 함수가 반환하는 Observable 객체를 사용하는 부분은 다음과 같이 .subscribe() 메소드를 통해 구독을 한다. 다시말해 이제부터 Observable이 반환하는 item을 관찰하겠다는 것이다. 관찰할 수 있는 이벤트는 위와 같이 크게 세개로 이루어져 있으며, 먼저 .next()부터 살펴보고자 한다.

 

구독하는 대상은 위와 같이 .next()를 통해 Observable객체가 반환하는 특정 item을 받는다. 특정 상태변화(예컨데 API 호출이 끝나고 결과가 반환된 상태, 설정된 타이머에 대한 타임아웃 상태)가 발생하여 Observable객체가 .onNext()를 통해 변화된 상태나 아이템을 전달하면 이곳에 있는 함수가 자동으로 실행되며, 변화된 상태에 대한 작업이 가능해진다.

 

.onError()와 .onComplete()의 경우는 말 그대로 에러가 발생하거나 Observable의 수명이 다 되어 특정 상황을 알리고 종료단계로 들어가는 부분이다. 여기서는 간단하게 언급만하고 넘어가고자 한다.

 

따라서 위와 같이 Observable이 성공적으로 API 통신을 마치고 cellImage를 전달하면, 특정 cell에 있는 이미지를 변경하는 코드이다. 위의 코드는 앞서 봤던 동작을 동일하게 수행한다.

 

이제 한 단계 남았다.

 

마지막은 DispatchQueue를 삭제하는 부분이다.

먼저 Data 메소드를 통해 가져오던 사진을 URLSession의 dataTask(with:)을 통해 가져온다. 이 함수는 말그대로 task를 작성해 주는 것이므로 .resume()을 통해 실행해주어야 한다. dataTask(with:)의 특징은 해당 함수의 completionHandler 내에서 작성한 task를 실행할때 쓰레드를 분리해준다는 점이다. 아래의 공식문서 설명을 보면 알 수 있듯 .resume()을 통해 실행된 task는 delegate queue에서 실행이 되는데,

이 때 delegate queueOperation Queue의 일종이며, Operation QueueDispatchQueue와 달리 자동적으로 Concurrent Operation, 즉 병렬적(비동기적)으로 프로세스를 수행하므로 DispatchQueue로 감싸지 않아도 된다.

또한 UI 조작에 관해 따로 DispatchQueue를 통해 main 쓰레드로 분리해 주었던 .onNext() 역시도 꺼내줄 수가 있게 되는데 그 이유는 다음과 같다.

Observable 객체는 위와같이 .observe(on:) 과 같은 syntactic sugar를 제공하는데, 그 중 이  .observe(on:)의 경우에는 이벤트에 대한 처리를 어떤 쓰레드를 통해 할지 정할 수 있도록 해주고, 이 때 이 곳에서 MainSchedular.instace를 설정해줌으로써 main 쓰레드에서 처리를 가능하게 해준다.

 

이와 별개로 (on: ) 에 사용가능한 모든 종류의 ImadiateSchedularType가 존재하고 공식문서에는 다음과 같이 명시되어있다. 이때 Main Thread가 아닌 경우에는 qos등을 지정해줄 수 있지만 Main Thread는 단일한 Serial thread 이므로 별도의 옵션 없이 instance 그 자체를 호출하게 된다.

 

결과적으로 DispatchQueue를 전혀 사용하지 않은, 이벤트 기반의 Reactive Programming으로 코드를 개선할 수 있다.

그러나. RxSwift는 이것이 끝이 아닌 시작이다. 지금 작성된 저 여러줄의 코드가 RxCocoa를 통해 혁신적으로 줄어들 수 있는데 다음 포스트에서는 그것을 다루어 보고자 한다.

 


참고

https://velog.io/@ddosang/RxSwift-1-개념잡기

https://reactivex.io/documentation/observable.html

https://nsios.tistory.com/31

https://caution-dev.github.io/ios/2019/03/15/iOS-GCD-vs-Operation-Queue.html