총평

MVVM 구성을 하면서 Binder 역할을 하는 Combine에 대해서 알아보았습니다. Rx 기반의 좋은 라이브러리가 있음에도 불구하고, Apple에선 왜 Combine을 만들어서 귀찮게 했는가 궁금했는데, 짧게 공부해보니 Apple에서 Combine을 도입하면서 기존의 많은 것(Core Data, Network)에 통합되어 손쉽게 사용할 수 있었습니다. 하지만 학습곡선이 매우 가파르기 때문에 팀에 적용할 때 몇가지 주의해야 할 사항이 있을 듯 합니다.

[+] MVVM을 접하고 가장 좋았던 점은 테스트 코드 작성시 View에 의존하지 않아도 된다는 점이었습니다. 부산에서 근무하면서 테스트 코드를 작성했던 경험이 거의 없어서(초기 제가 회사의 CTO 역할을 할 때 정도?) 요즘은 "역시 MVC!"라면서 되돌아갔던 기억이 있는데 여전히 지금도 테스트 코드를 꼼꼼하게 작성할 여력이 없어서 아직은 MVVM에 대해선 입맛을 다시는 정도라 할 수 있습니다. 반면, MVVM으로 구성하면 구조가 단순해집니다. 거의 대부분의 코드가 VM에 몰려있기 때문에 로직 파악도 쉽고, 수정도 편리합니다. 때문에 팀단위 개발에 도입해보면 좋을 듯 싶다가도 학습 난이도까지 같이 고려하면 어떤게 좋은 방법인지 아직 확신이 없습니다.

Combine?

Combine을 사용하지 않더라도 애플리케이션을 만드는데 별다른 어려움이 없을 것으로 생각됩니다(그리고 없어야 됩니다). 그런데 왜 Combine을 학습하냐고 물어본다면 아래 3가지 이유 때문에 Combine을 학습할 필요가 있다고 판단했습니다. 그리고, 저는 회사 코드에 적용되어 있어서 당장 써야 되기 때문입니다.

  1. Combine은 선언적 접근 방식으로 이벤트를 처리하는 방식을 제공합니다. 플랫폼에서 이벤트를 다양한 방식으로 제공하는데 하나의 처리 과정(single processing chain)을 사용할 수 있으며, 처리 과정의 각 단계에서 다양한 연산을 적용할 수 있어서 이벤트를 처리하는 방식을 단일화 시킬 수 있는 장점이 있습니다. [+] 선언형, 반응형 프로그래밍은 지난 10년 동안 급부상한 개념입니다. 2009년 Microsoft 팀이 .NET(Rx.NET) 용 Reactive Extensions 라는 라이브러리를 시작하였고, 이를 2012년에 오픈 소스로 만들었습니다. 그 이후로 많은 언어가 그 개념을 사용하기 시작했습니다. 현재 RxJS, RxKotlin 등과 같은 많은 Rx 표준을 사용하고 있습니다. Apple 플랫폼에는 Rx표준을 구현하는 RxSwift와 같은 프레임워크가 있습니다. Combine은 Rx와 다르지만 Reactive Stream과 유사한 표준을 구현합니다. Reactive Stream은 Rx와 몇 가지 주요한 차이점이 있지만 둘 다 대부분의 핵심 개념이 유사합니다.

  2. 앞선 설명에서 '하나의 처리 과정'을 통해서 이벤트를 처리할 수 있기 때문에, Combine을 사용하면 비동기 처리도 '하나의 처리 과정'으로 축약 할 수 있습니다. Combine을 사용하면 비동기 작업에 Publisher를 사용하게 됩니다. 이러한 과정을 통해서 코드의 구성과 재활용이 편리해지고, 더 나아가서 코드의 구성이 간편해집니다. 이벤트를 비동기로 처리하기 위해서 Apple은 몇가지 다채로운 방식을 제공합니다. 대표적인 것으로 1) NotificationCenter, 2) Delegate Pattern, 3) GCD, 4) Closures 등이 있습니다. 몇년간 다양한 기술을 제공하고 있다는 점에서 알 수 있듯이 이벤트를 비동기로 처리하는 것은 쉽지 않은 일이고 여전히 까다로운 일이지만 Combine을 적용하면 조금은 쉽게 처리 할 수 있는 장점이 있습니다(하지만 팀 전체가 이러한 방식에 적응하는데 소모되는 비용을 고려해야 합니다). [+] 일반적으로 모든 UI 이벤트는 본질적으로 비동기적이므로 실행될 순서를 가정하는 것은 어리석은 일입니다. 비동기 코드는 재현이 어렵기 때문에 버그 수정에 많은 시간을 소모합니다. 따라서 비동기 코드에 대한 관리는 애플리케이션 개발에 꼭 필요한 업무라 할 수 있습니다. 비동기 코드에 대한 격리없이 작성된 코드는 향후 유지보수에 많은 비용이 소모되기 때문에 주의해야 합니다.

  3. Combine은 시스템 레벨에서 통합되어 있습니다. Combine에서 사용하는 연산이 이미 테스트 끝나 있기 때문에 Combine을 사용한 로직 테스트만 진행하면 됩니다. 이러한 테스트 코드 조차 버겁다면 Subscription의 결과를 중심으로 테스트 코드를 작성하더라도 기존의 코드에 비해서 좀 더 안정적인 코드를 작성할 수 있습니다. [+] Apple은 Combine의 API를 Foundation framework와 통합시켰기 때문에 Timer, NotificationCenter, Core Data는 이미 Combine을 사용하고 있습니다. 따라서 Combine을 기존의 코드에 도입하는 것은 아주 쉽습니다.

Combine은 어디에 사용하나?

  1. Combine은 앱 구조에 영향을 주지 않습니다. MVC, MVVM, VIPER, RIB 등 어디서든 사용할 수 있습니다. 데이터 모델을 변환하거나 네트워킹 계층을 조정하거나 기존 기능을 그대로 유지하면서 앱에 추가한 새 코드만 Combine을 사용하여 시작할 수 있습니다. 비동기 이벤트를 처리하는 부부만 Combine을 도입해보세요.

  2. 만약 SwiftUI와 함께 사용한다면 View Controller를 작성하지 않아도 됩니다. 애플리케이션의 전반적인 코드에 Combine을 사용할 경우 View를 제어하기 위해 특별한 Controller를 필요로 하지 않습니다(하지만 SwiftUI에 대한 학습이 더 필요하다는 점도 고려해야 합니다).

Key Concepts

Combine을 구성하는 중요한 4가지 개념을 간단하게 소개하고, 간단한 예제를 작성해보도록 하겠습니다.

Publisher

Combine의 핵심은 Publisher 프로토콜(protocol) 입니다. 이 프로토콜은 시간의 흐름에 따라 하나 이상의 Subscriber에게 값을 방출 할 수 있는 타입(type) 입니다. Publisher는 특정 값(value)을 가지는 이벤트를 방출합니다. Publisher는 값타입(value type ,struct)으로 선언되어 있습니다. Publishers는 Subscribers와 같이 하나 이상의 객체에게 값을 전달 할 수 있습니다.

@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Publisher {

    /// The kind of values published by this publisher.
    associatedtype Output

    /// The kind of errors this publisher might publish.
    ///
    /// Use `Never` if this `Publisher` does not publish errors.
    associatedtype Failure : Error

    /// This function is called to attach the specified `Subscriber` to this `Publisher` by `subscribe(_:)`
    ///
    /// - SeeAlso: `subscribe(_:)`
    /// - Parameters:
    ///     - subscriber: The subscriber to attach to this `Publisher`.
    ///                   once attached it can begin to receive values.
    func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
 
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher {

    /// Attaches the specified subscriber to this publisher.
    ///
    /// Always call this function instead of `receive(subscriber:)`.
    /// Adopters of `Publisher` must implement `receive(subscriber:)`. The implementation of `subscribe(_:)` in this extension calls through to `receive(subscriber:)`.
    /// - SeeAlso: `receive(subscriber:)`
    /// - Parameters:
    ///     - subscriber: The subscriber to attach to this `Publisher`. After attaching, the subscriber can start to receive values.
    public func subscribe<S>(_ subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
  1. Output은 publisher가 생성할 수 있는 값 타입입니다.
  2. Failure는 publisher가 발생시킬 수 있는 error 타입으로 만약 error를 발생시키지 않는다고 보장할 수 있다면 Never 라는 타입을 사용할 수 있습니다.
  3. subscriber는 publisher의 subscribe(:) 을 호출할 수 있습니다. subscribe(:) 구현체는 receive(subscriber:)를 호출하여 subscriber를 publisher에 연결하는 것으로 subscription을 만듭니다.

위의 코드를 기초로 Publisher에 대해서 확신할 수 있는 사실은 Publisher를 구독할 때 어떤 값을 방출할지, 실패한다면 어떤 에러가 발생될지 예측 가능합니다. 그리고 Output은 Publisher의 반환값인데 만약 Publisher가 Int라면 당연하게도 String 이나 Date 타입을 반환하지 않습니다.

Subscriber

Subscriber는 Publisher의 Output을 구독(Subscription)합니다. Subscriber는 전달받은 값을 기반으로 상태를 변경하기 때문에 class로 되어있습니다.

public protocol Subscriber : CustomCombineIdentifierConvertible {

    /// The kind of values this subscriber receives.
    associatedtype Input

    /// The kind of errors this subscriber might receive.
    ///
    /// Use `Never` if this `Subscriber` cannot receive errors.
    associatedtype Failure : Error

    /// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
    ///
    /// Use the received `Subscription` to request items from the publisher.
    /// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
    func receive(subscription: Subscription)

    /// Tells the subscriber that the publisher has produced an element.
    ///
    /// - Parameter input: The published element.
    /// - Returns: A `Demand` instance indicating how many more elements the subcriber expects to receive.
    func receive(_ input: Self.Input) -> Subscribers.Demand

    /// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
    ///
    /// - Parameter completion: A `Completion` case indicating whether publishing completed normally or with an error.
    func receive(completion: Subscribers.Completion<Self.Failure>)
}

  1. Input은 Subscriber가 받을 수 있는 값 입니다.
  2. Failure는 Subscriber가 받을 수 있는 error 타입 또는 Subscriber가 error를 받지 않는다면 Never를 사용할 수 있습니다.
  3. Publisher가 subscription을 전달하기 위해 Subscriber의 receive(subscription:)을 호출합니다.
  4. Publisher가 방출하는 새로운 값들을 전달하기 위해 Subscriber의 receive(_:)를 호출합니다.
  5. Publisher가 값 생성이 종료되었거나 error가 발생하였을 때 종료를 알리기 위해 Subscriber의 receive(completion:)을 호출합니다.

모든 구독은 Subscriber와 함께 종료됩니다. Subscribers는 일반적으로 전달된 값 또는 완료 이벤트를 통해 역할을 수행합니다. Combine은 데이터 스트림 작업을 간단하게 해줄 수 있는 두 개의 내장된 Subscriber를 제공하고 있습니다.

  1. sink; subscriber는 output 값과 완료를 수신할 수 있는 closure를 제공합니다.
  2. assign; subscriber는 개발자가 별도 작성한 코드가 없어도 결과 output을 데이터 모델 또는 UI control의 특정 속성에 바인딩하여 키 경로를 통해 화면에 직접 데이터를 표시할 수 있게 합니다.

Subscription

publisher와 subscriber는 subscription을 통해 연결됩니다. 아래의 Subscription protocol을 확인해봅시다.

/// A protocol representing the connection of a subscriber to a publisher.
///
/// Subcriptions are class constrained because a `Subscription` has identity -
/// defined by the moment in time a particular subscriber attached to a publisher.
/// Canceling a `Subscription` must be thread-safe.
///
/// You can only cancel a `Subscription` once.
///
/// Canceling a subscription frees up any resources previously allocated by attaching the `Subscriber`.
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Subscription : Cancellable, CustomCombineIdentifierConvertible {

    /// Tells a publisher that it may send more values to the subscriber.
    func request(_ demand: Subscribers.Demand)
}

extension Subscribers {

    /// A requested number of items, sent to a publisher from a subscriber via the subscription.
    ///
    /// - unlimited: A request for an unlimited number of items.
    /// - max: A request for a maximum number of items.
    public struct Demand : Equatable, Comparable, Hashable, Codable, CustomStringConvertible {

        /// Requests as many values as the `Publisher` can produce.
        public static let unlimited: Subscribers.Demand

        /// A demand for no items.
        ///
        /// This is equivalent to `Demand.max(0)`.
        public static let none: Subscribers.Demand

        /// Limits the maximum number of values.
        /// The `Publisher` may send fewer than the requested number.
        /// Negative values will result in a `fatalError`.
        @inlinable public static func max(_ value: Int) -> Subscribers.Demand

  1. subscriber는 request(_:)을 호출하여 최대(max) 또는 무제한(unlimited)의 값을 받을 수 있음을 나타낼 수 있습니다.

Subscriber에서 receive(:)가 Demand를 반환합니다. receive(:)에서 subscription.request(:)을 처음 호출할 때 subscriber가 받으려는 최대 값 수를 지정하더라도 새 값을 받을 때마다 최대 값을 조절할 수 있다는 의미입니다. 하지만 Subscriber.receive(:)에서 해당 값을 늘릴 수 있지만(max) 줄일 수 없다는 점을 주의해야 합니다.

Operator

Operator는 Publisher(당연히 선언적)이며, 값을 변경할 수 있습니다. 일반적으로 upstream을 구독한다고 말하며, 연산의 결과를 downstream으로 전달한다고 합니다(용어의 관례를 잘 알아둡시다)

public func combineLatest<P, T>(_ other: P, _ transform: @escaping (Self.Output, P.Output) -> T) -> Publishers.Map<Publishers.CombineLatest<Self, P>, T> where P : Publisher, Self.Failure == P.Failure

Operators는 Publisher protocol로 선언된 method이며, 선언된 Publishers와 동일하거나 새로운 Publishers로 반환합니다. 여러 Operator를 차례로 호출해서 체인을 연결할 수 있기 때문에 매우 유용합니다. 이들은 매우 독립적으로 구성가능하기 때문에 하나의 구독 사이에서 아주 복잡한 로직을 구현하도록 서로 결합될 수 있습니다. Operator가 퍼즐 조각처럼 서로 잘 맞도록 하는 방법은 간단합니다. Operator는 항상 Input 및 Output을 가지고 있으며 일반적으로는 이를 upstream, downstream이라고 합니다. Operator는 이전 operator로부터 받은 데이터 작업에 중점을 두고 그 결과를 체인의 다음 Operator에게 제공합니다.

Hello Combine

Publisher을 사용한 아주 간단한 형태의 예제를 만들어보겠습니다.

예제

먼저 Publisher을 생성합니다. Publisher는 시간의 흐름에 따라 하나 이상의 값을 Subscriber 전달 할 수 있는 구성요로입니다. 당장 Publisher를 사용할만큼 능력이 없는 관계로 Apple에서 제공하는 Publisher를 사용하도록 하겠습니다. 거의 대부분의 예제에서 소개하는 Notification을 사용하겠습니다.

NotificationCenter의 publisher(for:object:) method의 return 값이 default notification center가 notification을 broadcast 할 때마다 이벤트를 방출하는 Publisher 인 것을 확인 할 수 있습니다(Option + Click으로 확인해보세요).

아래 코드를 Playground에서 실행하면 아주 간단한 Publisher를 하나 생성 할 수 있습니다. 그런데 아래 코드에서 notification을 지금 post한다면(center.post(name: nameNotification, object: nil)), Publisher는 해당 값을 방출하지 않습니다.

매우 중요한 부분인데, Publisher는 최소 한 개 이상의 Subscriber를 가질 때 값을 방출합니다.

let nameNotification = Notification.Name("combineNotification")

let publisher = NotificationCenter.default.publisher(for: nameNotification, object: nil)

default notification center를 조작 할 수 있도록 center 객체를 가져옵니다.

let center = NotificationCenter.default

Subscription을 Publisher에 생성합니다. Combine에서 제공하는 Subscritpion 중에서 sink를 사용하도록 하겠습니다. sinK 연산자는 두 개의 closure를 제공하지만, 이번 예제에선 모든 closure를 무시하고, notification을 받았을 때 print를 호출하도록 하겠습니다.

let subscription = publisher.sink { _ in
    print("Notification received from a publisher!")
}

이제 notification을 post 하도록 하겠습니다.

center.post(name: nameNotification, object: nil)

위 코드를 돌려보면 Notification received from a publisher! 라는 문구가 콘솔에 표시됩니다. sink operator는 publisher가 방출하는 값들을 계속해서 받을 것입니다. subscription을 취소하는 코드는 아래와 같습니다.

subscription.cancel()

전체 코드를 정리하면 아래와 같습니다.

let nameNotification = Notification.Name("combineNotification")

let publisher = NotificationCenter.default.publisher(for: nameNotification, object: nil)

let center = NotificationCenter.default

let subscription = publisher.sink { _ in
    print("Notification received from a publisher!")
}

center.post(name: nameNotification, object: nil)

subscription.cancel()

어떻게 동작하는가?

publisher와 subscriber간의 상호 작용을 순서대로 기술하면 아래와 같습니다.

  1. subscriber가 publisher로 subscribe됩니다(subscribe(subscriber:)).
  2. publisher가 subscription을 생성하고 subscriber에게 전달합니다(receive(subscription:)).
  3. subscriber는 값을 요청합니다(request(demand:)).
  4. publisher가 값을 전달합니다(receive(input:)).
  5. publisher가 완료를 전송합니다(receive(completion:).

연습

(A...Z)까지 출력하는 Combine 예제를 작성해보겠습니다.

상호 작용 순서를 바탕으로 구성해보겠습니다.

subscriber가 publisher로 subscribe가 되야 합니다. 그래서 publisher를 먼저 생성하도록 하겠습니다. "a"에서 "z"까지 생성할 수 있는 publisher를 만듭니다.

let publisher = (97...122).map({Character(UnicodeScalar($0))}).publisher

그리고 publisher가 subscription을 생성해야 하기 때문에 subscriber를 생성합니다. 그런데 publisher의 Output이 Character 입니다. 그래서 CharacterSubscriber를 만들도록 하겠습니다.

final class CharacterSubscriber: Subscriber {}

해당 클래스의 Input과 Failure와 중요 메서드를 만들어봅시다. receive(subscription: Subscription)를 사용해서 subscription.request()와 연결합니다.

final class CharacterSubscriber: Subscriber {
    typealias Input = Character
    typealias Failure = Never   

    func receive(subscription: Subscription) {
        subscription.request(.unlimited)
    }

}

이제, subscription까지 연결했으니 값을 입력하고, 처리를 완료하는 메서드를 작성합니다.

    ...

    func receive(_ input: Character) -> Subscribers.Demand {
        print("Received value", input)
        return .none
    }

    func receive(completion: Subscribers.Completion<Never>) {
        print("Received completion", completion)
    }

    ...

잘 동작하나요?.

let subscriber = CharacterSubscriber()
publisher.subscribe(subscriber)

Combine의 실용적인 예제는 다음 기회에 다뤄보도록 하겠습니다.