티스토리 뷰

iOS

RxSwift를 시작하기 전에

cskime 2020. 8. 11. 23:34

ReactiveX는 Observable Stream을 사용한 비동기 프로그래밍을 지원하는 API이다.

 

이번 글에서는 RxSwift를 시작하기 전에 알면 좋을 몇 가지를 정리해보려고 한다. ReactiveX 라이브러리가 어떤 방식으로 비동기 프로그래밍을 쉽게 만들어 준다는 것인지 그 원리를 가볍게 살펴보고, 비동기 프로그래밍을 도와주는 다른 라이브러리들과 RxSwift를 간단하게 비교하여 RxSwift를 사용해야 하는 이유에 대해 생각해 볼 것이다.

 


 

반응형 프로그래밍 패러다임

ReactiveX를 검색하면 반응형 프로그래밍(Reactive Programming)이라는 용어를 마주하게 된다. '반응'이라는 단어는 어떤 변화(change)가 발생했을 때 어떤 동작이나 현상이 일어나는 것을 의미한다. 반응형 프로그래밍은 변화의 대상을 데이터(data)에 맞춘다. 데이터가 변화할 때 반응하기 위해서는 데이터의 상태(state)를 지속적으로 감시하고 있어야 한다. 즉, 반응형 프로그래밍 패러다임이란 데이터의 변화를 지속적으로 관찰하면서 변화가 발생했을 때 특정 동작을 하도록 프로그래밍하는 패러다임이다.

 

위키피디아에서는 반응형 프로그래밍을 다음과 같이 정의하고 있다.

 

In computing, reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change.

 

Stream은 '흐름'을 뜻한다. Data stream 및 변화의 전파(propagation of change)라 하는 것은 데이터의 상태 변화를 일련의 흐름(stream)으로 보고, 데이터가 변화되면 stream을 관찰하고 있는 관찰자(Observer)에 변경된 데이터를 전파(propagation)하는 것을 의미한다.

 

선언형 프로그래밍 패러다임

그렇다면 선언형 프로그래밍 패러다임(declarative programming paradigm)이란 무엇일까? 위키피디아에서는 선언형 프로그래밍을 다음과 같이 정의하고 있다.

 

In computer science, declarative programming is a programming paradigm—a style of building the structure and elements of computer programs—that expresses the logic of a computation without describing its control flow

 

선언형 프로그래밍이란 연산 로직을 명령의 흐름(control flow)이 드러나지 않게 표현하는 프로그래밍 패러다임이다. 명령의 흐름이란 컴퓨터가 수행해야 하는 작업을 `for`, `if` 등의 예약어를 통해 컴퓨터에 직접 명령을 내리는 과정을 말한다. 즉, 선언형 프로그래밍 패러다임은 명령형 프로그래밍 패러다임과 상반되는 개념이다.

 

예시를 통해 명령형과 선언형 프로그래밍의 차이를 알아보자. 정수 배열 `numbers`의 모든 element를 더하는 프로그램을 작성한다고 하자.

 

명령형 프로그래밍에서는 다음과 같이 컴퓨터에 명령을 내리는 코드를 작성한다.

 

1. 배열의 element를 하나 가져온다

2. 가져온 element를 sum에 더한다

3. 다음 element를 가져와서 sum에 더하는 것을 반복한다

let numbers = [1, 2, 3, 4, 5]

var sum = 0
for number in numbers {
  sum += number
}

반면에, 선언형 프로그래밍에서는 값을 더하는 과정을 일일히 명령하지 않는다. 단지 '배열의 모든 element를 더한다' 라는 동작만을 선언할 뿐이다.

func sum(_ elements: [Int]) -> Int {
  var sum = 0
  for element in elements {
    sum += element
  }
  return sum
}

let numbers = [1, 2, 3, 4, 5]
let sum = numbers.sum(numbers)

/* 고차함수 reduce를 사용하는 경우 */
let sum = numbers.reduce(0, +)

 

함수형 프로그래밍 패러다임

위키피디아에서는 함수형 프로그래밍을 다음과 같이 정의하고 있다.

 

In computer science, functional programming is a programming paradigm where programs are constructed by applying and composing functions

 

함수형 프로그래밍은 함수를 적용하여 프로그래밍을 구성하는 패러다임이다. 여기서 함수는 '순수 함수'로서 side effect가 발생하지 않는 함수이다. 즉, 외부의 값에 의해 출력이 변하지 않고 같은 입력에 대해서 항상 같은 결과를 도출한다. 이와 관련한 함수형 프로그래밍의 특징 중 하나가 '불변성(immutability)'이다. 함수형 프로그래밍을 통해 예기치 않은 데이터의 변경을 사전에 차단하여 프로그램의 검증을 용이하게 하고 여러 thread가 하나의 상태를 공유하는 문제를 해결하여 동시성 프로그래밍이 쉬워지기도 한다.

 

RxSwift는 RP(Reactive Programming)과 FP(Functional Programming) 패러다임을 모두 채택하고 있다.

 

FRP(Functional Reactive Programming)

ReactiveX는 위에서 알아본 RP(Reactive Programming), DP(Declarative Programming), FP(Functional Programming) 패러다임을 채택하는 FRP(Functional Reactive Programming) API이다. RP와 FP의 특징을 모두 갖기 때문에, 이것들을 합쳐서 FRP(Functional Reactive Programming)이라고 한다. 위키피디아에서는 FRP를 다음과 같이 정의하고 있다.

 

Functional reactive programming (FRP) is a programming paradigm for reactive programming (asynchronous dataflow programming) using the building blocks of functional programming (e.g. map, reduce, filter).

 

FRP는 `map`, `filter`, `reduce` 같은 FP의 building block들을 사용하는 RP의 확장된 형태이다. FRP가 RP의 특성을 갖지만, RP가 FRP의 특성을 갖지는 않는다. 즉, FRP와 RP를 같다고 말할 수는 없다.

 

Swift는 기본적으로 OOP(Object Oriented Programming), FP, POP(Protocol Oriented Programming)을 채택하는 언어이다. RxSwift는 Swift로 FRP를 가능하게 한다.

 


왜 RxSwift인가?

비동기 프로그래밍을 간결하고 직관적으로 작성하기 위한 라이브러리는 RxSwift 외에도 PromiseKit과  Bolts 등이 존재한다. 하지만 RxSwift는 다른 것들에 비해 더 많이 알려지고 사용되고 있다. 사람들이 RxSwift를 선택하게 된 이유는 무엇일까? PromiseKit과 RxSwift를 간략하게 비교하며 라이브러리가 만들어진 이유와 RxSwift가 더 인기를 끄는 이유에 대해 생각해보자.

 

기존의 비동기 프로그래밍에서는 비동기적으로 발생하는 데이터를 받아서 처리할 때 함수의 completion closure 인자를 통해 전달하는 방법을 사용했다. 다음은 URLSession을 통해 JSON 포맷 데이터를 요청하는 `request()` 함수이다.

func request(with url: String, completion: @escaping (String?)->()) {
  guard let url = URL(string: url) else { return }
  
  let task = URLSession.shared.dataTask(with: url) { (data, response, err) in
    guard err == nil else {
      completion(nil)
      return
    }
    
    guard let data = data, let result = String(data: data, encoding: .utf8) else {
      completion(nil)
      return
    }
    
    completion(result)
  }
  task.resume()
}

 

이 함수를 통해 비동기 데이터를 closure의 인자로 받아서 사용한다.

request(with: "https://someurl.com") { (result) in
  print(result ?? "Fail")
}

 

이런 방식의 문제점은 completion closure로 들어오는 값을 다른 비동기 작업의 입력으로 사용하게 되는 경우에 발생한다. Completion closure 안에서 연쇄적으로 비동기 작업을 수행하는 경우 아래와 같은 콜백 지옥(Callback Hell)에 빠지게 되어 가독성을 떨어뜨리고 유지보수를 어렵게 만든다.

request1(with: "someURL") { result1 in 
    ...
    
    request2(for: result1) { result2 in {
    	...
        
        result3(for: result2) { result3 in {
            ...
            
            result4(for: result3) { result4 in {
                ...
                
            }
        }
    }
}

 

위에서 언급한 라이브러리들은 기본적으로 이러한 Callback Hell 문제를 해결하여 비동기 프로그래밍을 간결하고 직관적으로 작성할 수 있도록 하는 목적을 갖는다. 그리고 그 해결 방법으로 비동기 데이터를 completion 인자로 전달하는 것 대신 함수의 반환값으로 전달한다. 이 방식을 구현하기 위해 지금 당장 데이터가 발생하지 않았지만 나중에(비동기적으로) 데이터가 발생(또는 변경)하면 구현해 둔 로직을 실행시킬 수 있는 타입을 정의하여 사용한다.

class 비동기데이터<DataType> {
  let task: (@escaping (DataType?)->()) -> ()
  
  init(task: @escaping (@escaping (DataType?)->()) -> ()) {
    self.task = task
  }
  
  func 비동기데이터발생(_ execute: @escaping (DataType?)->()) {
    self.task(execute)
  }
}

 

이 반환 타입을 사용하여 `request()` 함수를 다음과 같이 수정할 수 있다.

func request(with url: String) -> 비동기데이터<String> {
  return 비동기데이터 { task in
    guard let url = URL(string: url) else {
      task(nil)
      return
    }
    
    let task = URLSession.shared.dataTask(with: url) { (data, response, err) in
      guard err == nil else {
        task(nil)
        return
      }
      
      guard let data = data, let result = String(data: data, encoding: .utf8) else {
        task(nil)
        return
      }
      
      task(result)
    }
    task.resume()
  }
}

 

수정된 `request()` 함수는 다음과 같이 사용할 수 있게 된다. 데이터가 들어왔을 때 동작할 callback closure를 정의해 두면 실제로 데이터가 발생했을 때 해당 closure에 전달되고 실행시킨다.

request(with: "https://www.google.com")
  .비동기데이터발생 { (result) in print(result ?? "Fail") }

 

이렇게 비동기 데이터를 반환값으로 처리함으로써 가독성을 높이고 데이터의 흐름을 직관적으로 볼 수 있게 된다. 위에서 본 Callback Hell의 예시 코드와 비교했을 때 직관적이고 가독성이 높아진 것을 느낄 수 있다.

request1(with: "someURL")
  .데이터발생1 { result1 in request2(for: result1) }
  .데이터발생2 { result2 in request3(for: result2) }
  .데이터발생3 { result3 in request4(for: result3) }

 

PromiseKit과 RxSwift는 Swift 언어를 통해 이 개념을 구현하고 있다. 위 예시에서 `비동기데이터<DataType>` 타입과 `비동기데이터발생` method는 PromiseKit에서는 `Promise<T>`와 `then`으로, RxSwift에서는 `Observable<Element>`과 `subscribe`로 각각 구현되어 있다.

 

그런데, RxSwift는 PromiseKit에는 없는 '연산자(Operator)'라는 강력한 기능을 제공한다. 연산자를 사용하여 비동기적인 여러 개의 data stream의 변환, 병합, 필터링 등의 작업을 간단하게 처리할 수 있게 된다. 다음 예시는 RxSwift에서 두 개의 비동기 요청에 대한 응답을 병합하여 하나의 stream으로 처리하는 예시이다.

func request1() -> Observable<String> {
  return Observable.create { ... }
}

func request2() -> Observable<String> {
  return Observable.create { ... }
}

Observable.combineLatest(request1(), request2())
  .subscribe {
    // some task
  }
  .disposed(by: disposeBag)

 

이렇게 연산자를 사용하여 다수의 비동기 작업을 하나로 묶어서 처리하는 등의 번거로운 작업도 직관적이고 간략하게 표현할 수 있게 된다. 그리고 이런 연산자 덕분에 하나의 data stream에 대한 작업의 가독성이 높아지는 등 부수적인 이점들도 얻을 수 있다.

 


 

다음 포스팅부터는 본격적으로 RxSwift를 어떻게 사용하고 프로젝트에 어떻게 적용할 수 있을지 다룰 것이다. 본 글의 내용은 앞으로의 내용을 공부하는데 필수적인 것은 아니지만 '왜 RxSwift를 공부해야 하는가'라는 질문에 대해 어느정도 답을 줄 수 있을 것이다.

 

'iOS' 카테고리의 다른 글

Asset Catalog에서 audio file 관리하기  (0) 2020.09.03
RxSwift를 시작하기 전에  (2) 2020.08.11
Frame과 Bounds의 차이  (0) 2020.08.07
GCD: Grand Central Dispatch  (0) 2020.07.27
Serial/Concurrent와 Sync/Async 이해하기(feat. DispatchQueue)  (0) 2020.07.26
Application Life Cycle  (0) 2020.07.15
댓글
댓글쓰기 폼