Combine 프레임워크와 RxSwift

1. Combine이란?
Combine은 Apple이 2019년에 발표한 Swift 언어를 위한 리액티브 프로그래밍 프레임워크입니다. 리액티브 프로그래밍 말이 어려운데, 간단히 이야기하면 시간의 흐름에 따라 발생하는 이벤트를 처리하기 위한 선언적(declarative) Swift API를 제공합니다.
Combine이 나오기 전에는 마이크로소프트에서 개발한 외부 라이브러리인 RxSwift로 비동기 이벤트를 핸들링했는데, Apple에서는 외부 라이브러리가 불편했는지 iOS13 버전 이상부터 사용가능한 Combine 프레임워크를 만들었습니다.
1-1. 그럼 Combine을 왜 써야하는가?
Combine은 발생한 이벤트에 대해 어떻게 가공하고 소비해줄지 초점을 맞춘다! 컴바인을 채택하면 이벤트 처리코드들을 중앙 집중화하고, 중첩 클로저, 다른 타입들을 가진 콜백들과 같은 문제들을 제거하고 코드를 읽기쉽게 유지관리할 수 있다.
앞서 Combine은 비동기 이벤트를 구현할 수 있게 하는 Apple 순정 프레임워크라고 했습니다.
근데 Delegate, 콜백함수, completion, 클로저 등 비동기 프로그래밍을 잘 구현할 수 있음에도 굳이 왜 Combine을 사용할까요?
이는 비동기와 관련된 코드를 작성할 때 불필요한 보일러 플레이트를 줄여주고 런타임 오류를 효율적으로 처리할 수 있고 데이터의 제공자가 아닌 데이터 요청자 측에서 처리할 수 있는 등 이러한 귀찮았던 과정들이 다수 깔끔해지기 때문입니다.
1-2. Combine을 이루는 3가지
Combine은 크게 Publisher, Operator, Subscriber로 이루어져있습니다.
혼동개념주의 Subscription
Combine 중 Subscription은 Publisher, Operator, Subscriber로 이뤄진 하나의 작업입니다.
이는 정보 제공자와 소비자 사이를 매끄럽게 연결하는 생성자-소비자 패턴과 유사하며, RxSwift에서는 Observable-Subject와 비교할 수 있습니다.
Publisher와 Subscriber가 서로 데이터를 주고받을 때는 항상 두 가지의 타입이 존재합니다.
먼저 Publisher 입장에서는 Output 타입과 Failure타입이 존재합니다. 에러가 발생했을 경우 Failure 타입, 그렇지 않다면 Output 타입을 전달합니다.
이 데이터를 받는 Subscriber는 Publisher의 Output 타입과 동일한 Input타입과, 그리고 동일한 Failure 타입을 가져야 합니다.
2. Publisher와 Subscriber
Combine 프레임워크를 이해하기 위해 Publisher와 SubScriber 이 두 가지 프로토콜을 먼저 살펴봅시다.
2-1. Publisher protocol
public protocol Publisher<Output, Failure> {
associatedtype Output
associatedtype Failure : Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}- Publisher의 프로토콜을 살펴보면,
Output,Failure타입이 존재합니다.Output타입은 Publisher가 Subscriber에게 전달하는 값의 타입을 의미합니다.Failure타입은 Publisher가 Subscriber에게 전달할 오류 타입입니다.- Publisher가 Subscriber에게 전달할때 오류가 발생하지 않으면
Never로 지정합니다.
- 동일한 타입의 Subscriber를
receive(subscriber:)함수로 연결할 수 있습니다.- Subscriber protocol을 준수하는 인스턴스를 전달인자로 전달받는다는 말과 동일합니다.
여기서 잠깐,
associatedType이란?
protocol에는 일반적으로 함수에서 Generic을 선언 하듯이 Generic을 선언할 수 없다. 대신 protocol에서 Generic처럼 동 적으로 타입을 지정할 수 있는associatedtypeplaceholder가 있다.
2-2. Subscriber protocol
public protocol Subscriber<Input, Failure> : CustomCombineIdentifierConvertible {
associatedtype Input
associatedtype Failure : Error
func receive(subscription: Subscription)
func receive(_ input: Self.Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Self.Failure>)
}-
Publisher에서 Subscriber로 데이터를 전달하는 과정에서, 꼭 성공 타입과 실패 타입을 함께 명시해줘야합니다. 생성자와 소비자의 타입이 다르다면 에러가 발생하게 됩니다. 타입이 다를 때는 뒤에나오는 Operator를 사용해야합니다.
-
Input은 Publisher로 부터 전달 받은 값의 타입을 의미합니다. -
Failure는 마찬가지로 오류 타입을 의미, 마찬가지로 오류를 전달 받지 않으면Never타입을 사용합니다. -
receive(subscription:)메서드는 Publisher로부터 Subscription을 전달 받습니다. -
receive(_ input:)메서드는 Publisher로 부터 값을 전달 받는다. 반환 값은 앞으로
Publisher로 부터 얼마나 요소를 전달 받을 수 있는지를 나타냅니다. -
receive(completion:_)Publisher로 부터 completion 혹은 오류를 전달 받습니다.
WWDC 2019 내용을 살펴보고 일단 다음으로 넘어가봅시다~
2-3. 실제 코드 적용해보기
위의 두 protocol 종류에 대해 살짝 살펴봤으면, 이제 코드를 작성해봅시다. 실제 코드에 적용하는 것은 또 다른 문제니까요 🧐
플레이그라운드를 열고 한번 실습해볼까요? 먼저 Publisher 클래스를 작성해봅시다.
class CustomPublisher: Publisher {
typealias Output = String
typealias Failure = Never
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
DispatchQueue.global(qos: .utility).async {
let dummy: [String] = ["hi", "hello"]
dummy.forEach {
_ = subscriber.receive($0)
}
subscriber.receive(completion: .finished)
}
}
}Publisher 프로토콜을 따르는 CustomPublisher 클래스는 비동기 이벤트를 만들어 subscriber에게 전달합니다. subscriber가 구독을 시작하면, sink 이벤트 두개를 만들고 종료됩니다.
let handsupPublisher = CustomPublisher()
_ = handsupPublisher.sink(receiveCompletion: { _ in
print("완료됨")
}) {
print($0)
}
/* 결과
hello
hi
완료됨
*/2-3-1. struct Just
가장 단순한 형태의 Publisher로 에러타입은 항상 Never를 갖습니다.
단일 이벤트 발생 후 종료되는 Publisher로 하나의 값을 즉시 발생시킬때 사용합니다. (동기)
let just = Just<String>("hello")
_ = just.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
hello
finished
*/2-3-2. Sequence
주어진 Sequence를 방출하는 Publisher로 데이터를 순차적으로 발행합니다.
var letters = ["A","B","C","D","E","F","G"]
Publishers.Sequence<[String], Never>(sequence: letters)
.sink(receiveCompletion: {
print("receiveCompletion: \($0)")
}, receiveValue: {
print("receiveValue: \($0)")
})
/* 결과
receiveValue: A
receiveValue: B
receiveValue: C
receiveValue: D
receiveValue: E
receiveValue: F
receiveValue: G
receiveCompletion: finished
*/
앞서 예시들처럼 protocol을 채택한 class나 구조체로 Publisher와 Subscriber를 직접 정의할 수 있지만, Sequence의 extension에 .publisher 프로퍼티가 있기 때문에 매우 간편하게 사용가능합니다.
//MARK: - Publisher
letters.publisher //publisher 생성
.sink(receiveCompletion: {
print("receiveCompletion: \($0)")
}, receiveValue: {
print("receiveValue: \($0)")
})
/* 결과
receiveValue: A
receiveValue: B
receiveValue: C
receiveValue: D
receiveValue: E
receiveValue: F
receiveValue: G
receiveCompletion: finished
*/2-3-3. struct Future
Result 타입과 같이 성공 혹은 실패 두 가지 타입을 갖는 Publisher입니다. 하나의 값을 비동기적으로 발생할 때 사용합니다.
let future = Future<String, Error> { promise in
promise(.success("hello"))
}
_ = future.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
hello
finished
*/
let futureWithError = Future<String, Error> { promise in
promise(.failure(NSError(domain: "error", code: -1, userInfo: nil)))
}
_ = futureWithError.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
failure(Error Domain=error Code=-1 "(null)"
*/Error없이 사용하려면 Never와 함께 사용하며 되며, 이 경우 sink를 completion 블럭없이 사용가능합니다.
let futureWithNever = Future<String, Never> { promise in
promise(.success("jack"))
}
_ = futureWithNever.sink {
print($0)
}
/* 결과
hello
*/2-3-4. struct Deferred
구독이 일어날 때 실제 사용될 Publisher가 결정되는 Publisher인데,
구독이 일어나기 전까지 대기하고있다가 구독이 일어났을 때 Deferred 클로저 부분이 실행됩니다.
Deferred { Just(1) }
.sink(
receiveCompletion: { print("receiveCompletion: \($0)") },
receiveValue: { print("receiveValue: \($0)") }
)앞서 작성한 CustomPublisher에 init()를 추가해보겠습니다.
class CustomPublisher: Publisher {
typealias Output = String
typealias Failure = Never
init() {
NSLog("CusomPublisher init!")
}
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
DispatchQueue.global(qos: .utility).async {
let dummy: [String] = ["hello", "hi"]
dummy.forEach {
_ = subscriber.receive($0)
}
subscriber.receive(completion: .finished)
}
}
}
let publisher = CustomPublisher()
print("publisher init")
_ = publisher.sink {
print($0)
}
/* 결과
CusomPublisher init!
publisher init
hello
hi
finished
*/deferred를 만들었을 때, 아직 CustomPublisher가 생성되지 않을 걸 볼 수 있습니다. 참고로 플레이그라운드에서는 NSLog 안되는듯;;
let deferred = Deferred<CustomPublisher>{ () -> CustomPublisher in
return CustomPublisher()
}
print("deferrd init")
_ = deferred.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
deferrd init
CusomPublisher init!
hello
hi
finished
*/2-3-5. struct Empty
이벤트 없이 종료되는 Publisher로 어떤 데이터도 발생시키지 않습니다. 주로 에러처리나 옵셔널값을 처리할때 사용되며, 즉시 종료 혹은 영원히 종료되지 안되도록 설정 가능합니다.
let empty = Empty<String, Never>()
_ = empty.sink {
print($0)
} receiveValue: {
print($0)
}
// 결과 :
// finished2-3-6. struct Fail
오류와 함께 종료되는 Publsiher로 정의된 실패타입을 내보냅니다.
let failed = Fail<String, Error>(error: NSError(domain: "error", code: -1, userInfo: nil))
_ = failed.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
failure(Error Domain=error Code=-1 "(null)")
*/2-3-7. struct Record
구독할 때마다 이전에 발생했던 값들을 다시 내보내는 Publisher로 여러 동기/비동기 인터페이스에 Publisher 프로퍼티를 제공합니다
let record = Record<String, Error> { recoding in
print("make recording")
recoding.receive("hello")
recoding.receive("hi")
recoding.receive(completion: .finished)
}
_ = record.sink {
print($0)
} receiveValue: {
print($0)
}
_ = record.sink {
print($0)
} receiveValue: {
print($0)
}
/* 결과
make recording
hello
hi
finished
hello
hi
finished
*/2-3-8. @Published
Combine을 사용하는 가장 쉬운 방법 중 하나로, ObservableObject 프로토콜에 부합하는 클래스에서 사용될 때 자동으로 데이터 게시를 처리합니다.
// Publisher (ViewModel)
class ViewModel: ObservableObject {
@Published var data = "some data..."
}
// Subscriber (View)
struct PublishedView: View {
@StateObject var vm = ViewModel()
}
ObservableObject를 준수하는 클래스 내에서 @Published 속성 래퍼를 사용하고, @Published 속성은 변경되는 사항을 등록한 모든 View에 알립니다. View는 @StateObject 프로퍼티 래퍼를 사용해 이 ObservableObject와 연결될 수 있습니다.
3. Operator
앞서 Publisher와 Subscriber에서 중간에 타입이 같아야 오류가 발생하지 않는다고 했던 내용 기억하시죠? 타입이 같지않을 경우에는 Opeator가 생성자와 소비자 중간에 위치하여 데이터 스트림을 가공해주는 역할을 해줍니다. 중간에 위치하여 데이터를 가공하여 보내줄 수 있습니다.
Combine에서는 데이터 스트림을 처리하기 위해 다양한 Operator를 제공하는데, 이들은 크게 변환(Transforming), 필터링(Filtering), 결합(Combining), 타이밍(Timing) 등의 범주로 나눌 수 있습니다.
- 변환 Mapping Operators:
- 주로 데이터를 다른 데이터 타입으로 변형하는 역할을 합니다
map,flatMap,scan,setFailureType- 이들은 각각의 입력 값을 변환하거나 새로운 형태로 결합하여 출력합니다.
- 필터링 Filtering Operators:
- 조건에 맞는 데이터만 통과시킵니다.
filter,removeDuplicates,first,last,compactMap,replaceEmpty,replaceError
- 결합 Operators:
- 여러 Publisher로부터의 이벤트를 하나로 결합합니다.
combineLatest,merge,zip
- 타이밍 Operators:
- 이벤트의 타이밍을 조정합니다.
debounce,delay,timeout
- Reduce Operators:
- 데이터 스트림을 모아 출력합니다.
collect,reduce,tryReduce,ignoreOutput
- Mathematic Operators:
- 숫자시퀀스값과 관련된 스트림을 제어합니다.
max,count,min
- Sequence Elements:
- 데이터 시퀀스를 변형할때 사용합니다.
prepend,firstWhere,tryFirstWhere,first,lastWhere,tryLastWhere,last,dropWhile
4. Combine과 RxSwift의 차이점
Combine의 등장 이전에는 외부 라이브러리인 RxSwift가 널리 사용되었는데,
- 소유권: Combine은 Apple에 의해 개발되고 유지되며, iOS 13 이상에서만 사용할 수 있습니다. 반면, RxSwift는 오픈 소스 커뮤니티에 의해 유지되며 iOS 버전에 제한 없이 사용할 수 있다.
- API와 개념: 두 프레임워크 모두 리액티브 프로그래밍 패러다임을 따르지만, Combine은 Swift와 깊게 통합되어 있으며 Swift의 특성을 더 잘 활용합니다. RxSwift는 Rx (Reactive Extensions)의 다양한 플랫폼에 걸쳐 일관된 API를 제공한다.
- 성능: Apple이 제공하는 Combine은 시스템 통합이 잘 되어 있어 특정 상황에서는 RxSwift보다 더 나은 성능을 보일 수 있다.
Swift를 공부하면서 Combine에 대해 더 깊이 알아보는 것은 앱 개발에 있어서 매우 유용할 수 있습니다. 데이터를 효율적으로 처리하고, UI와의 상호 작용을 간결하게 만드는 데 큰 도움이 되니 Combine에 대해 더 알아봐야겠습니다.
다음에도 또 Combine에 대한 정보로 찾아오겠습니다.