[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (1) - Combine 기초 개념 이해하기

2024. 8. 8. 15:37Framework, Library

⚠️ 이번 글은 Combine Framework가 처음 소개되었던 WWDC19의 두 세션 Introducing CombineCombine in Practice의 예제 <Wizard School Signup>을 직접 구현하면서 사용된 Combine 개념들에 대해 집중적으로 소개합니다.
이 글에서 소개되지 못한 Combine의 다른 여러 개념들은 Apple Developer Documentation-Combine 글을 참조하길 바랍니다.

번역 또는 프로젝트의 목적, 그리고 저의 부족한 이해력 때문에 일부 잘못된 내용이 있을 수 있다는 점. 전제하고 읽어주시면 감사하겠습니다 (잘못된 개념 제보 및 질문 댓글로 얼마든지 환영입니다:) ^__^

 

1️⃣ Combine이 뭔데? 왜 공부하는 건데? 언제 쓰는 건데?

Combine에 대해 공부하기 전에 우리가 공부하려는 녀석이 뭔지는 알고 넘어가야겠지?

💡 Combine is a unified declarative API for processing values over time. (Combine은 시간이 경과함에 따른 값을 처리하기 위한 통합적이고 선언적 API를 제공합니다.) - in WWDC19

WWDC19에서는 위와 같이 소개했지만, 조금 와닿지 않아 애플 개발자 공식문서상의 표현도 살펴보겠다.

💡 Combine is customize handling of asynchronous events by combining event-processing operators. (Combine은 이벤트 처리 연산자를 결합하여 비동기 이벤트 처리를 커스터마이즈합니다.)


복잡하게 나와있지만, 쉽게 말해 Combine은 비동기 작업을 처리하는 데 있어 기존보다 더 나은 방식을 제공해 주는 도구라고 보면 된다.
엥? 비동기 처리는 기존에도 할 수 있던 거 아닌가?
맞다.
흔히 사용했던 Completion Handler, GCD, Notification Center, Delegate, Target/Action 등등.. 우리는 이미 Combine을 굳이 모르더라도 비동기 작업을 잘 처리하고 있었다.

그럼에도 불구하고, 왜 Combine이란 비동기 이벤트 처리 프레임워크가 새롭게 등장하게 된 것일까?
그 이유는 WWDC에서 소개한 Combine의 도입 배경과 Combine이 갖는 특징 몇 가지를 살펴보면 알 수 있다.

  1. Unified (= Using Generic) : 기존 비동기 작업을 처리하던 방식을 대체하는 것이 아니라, 기존 방식들의 공통점을 찾아 일관된 프레임워크로 통합한 것이 Combine이다. -> 즉, 여러 비동기 작업들을 일관적으로 처리할 수 있다는 뜻!
  2. Decalarative : 개발자는 코드가 어떻게(How) 동작하는지보다, 무엇을(What) 해야 하는지를 중심으로 작성할 수 있다. -> 더 간결한 코드 + SwiftUI의 선언형 프로그래밍 특성과 시너지를 낼 수 있음! (물론 UIKit에서도 선언형으로 비동기를 처리할 수도 있다)
  3. Operator : 다양한 연산자를 제공해 시간 흐름에 따라 넘어오는 데이터를 쉽게 변환(filtering)하거나 조합(mapping)하는 등의 작업을 수행할 수 있다. (자세한 내용은 아래에서 설명합니다!)
  4. Error processing & Type Safe : 비동기 작업의 에러 처리를 일관되게 할 수 있는 메커니즘을 제공한다. 또한 Type Safe 하여 Runtime이 아닌 Compile time에 에러를 잡을 수도 있다.
  5. Request Driven : Combine은 메모리를 관리하거나 앱의 성능을 관리하는 데 있어서도 용이하다.


지금은 무슨 말인지 모르겠어도 괜찮다.
어차피 이 글 아래로 가면서 Combine의 구체적인 문법이나 사용 상황에 대해 살펴보며, 자연스례 Combine이 갖는 특징을 알 수 있을 거다.
 

2️⃣ Combine 핵심 3가지 (Key Concepts) : Publisher, Subscriber, Operator

큰 틀 먼저 잡고 가보겠다.
Combine의 핵심은 Publisher, Subscriber, Operator 이렇게 세 가지다.
의심의 눈초리로 나를 바라볼 수도 있지만, 정말 이게 다다. 이 세 가지 개념만 빠싹하게 알고 있으면 Combine 공부는 끝난 거라고 봐도 된다.
(물론.. Publisher 종류가 또 뭐 뭐 있고... Operator는 이렇구 저렇구하고 하겠지만....)


일단 세 가지에 대해 쉽게 설명해 보겠다. 지금은 이 정도의 느낌만 알고 넘어가자.

  • Publisher : 말 그대로 뭔가를 Publish(출판하다, 방출하다, 내보내다)하는 애다.
  • Subscriber : 말 그대로 subscriber(구독자, 이용자)이다. 아마도 publish하는 애를 subscribe하게 될 것 같은 느낌이지 않나?
  • Operator : 주어지는 무언가에 대해 지지고 복고(= 입맛대로 변환시키거나 필터로 거르거나 등등..)하는 애다.

대충 이 정도 설명만 들어도 Combine이 어떠한 흐름으로 동작이 되는지 예측이 될지 모르겠다.

💡 Publisher라는 애가 어떠한 형태로 값을 publish 하게 될 거고, 이 값이 Operator를 통해 원하는 형태로 가공된 다음, 해당 publisher를 subscribe 하고 있던 Subscriber라는 애가 이 값을 받아와서 어찌어찌 처리하는 흐름!

Basis of Combine Key Concepts : 자세한건 아니지만 기본적인 Combine 흐름!

 

3️⃣ Publisher : 값과 에러의 생성을 정의하는 부분 (Defines how value and errors are produced)

위에서 언급했던 세 가지 요소 중에서 Publisher부터 살펴보자.
위에서는 대충 "Publisher가 무언가를 publish하는 애"라고 설명했는데, 공식문서상에서는 Publisher를 아래와 같이 설명한다.

💡 Declares that a type can transmit a sequence of values over time. (시간이 경과함에 따른 전송 가능한 일련의 값을 정의합니다.)

시간이 경과함에 따른 전송 가능한 일련의 값? 이게 무슨 말이지?

  • 사용자가 텍스트필드에 값을 입력할 때마다(.editingChanged) 입력된 text값이 Publisher를 통해 publish 되는 상황
  • 1초마다 현재 시간을 publish하는 Timer
  • 서버에 Request를 보낸 후, 성공적으로 or 실패한 Response를 받아와서 그 값을 publish하는 상황

위의 상황들은 모두 "어떤 특정 상황에 데이터가 생성"되고 이 생성된 값을 publish(여기서는 "방출"이라고 해석해보자)한다"는 공통점이 있다.

이때 방출되는 값은 네트워크 통신과 같이 요청 직후의 딱 한 번일 수도 / 텍스트 필드나 타이머와 같이 지속적으로 이루어질 수도 있는데, 
여기서는 이 값의 방출 횟수와 상관없이 "Publisher는 시간이 흐르고 -> 어떤 특정 이벤트가 발생하면, 그 흐름에 따른 값이 순서대로 방출 (타이머는 1초, 2초, 3초 순으로, 네트워킹은 요청에 맞는 데이터가 받아와지는 것)된다"는 것을 말하고 있는 느낌이다.

Publisher : transmit a sequence of values over time에 대한 설명 그림


조금 더 자세하게 Publisher의 특징에 대해서도 알아보자.

우선, Publisher에서 방출되는 값은 (당연히) 하나 또는 하나 이상의 Subscriber에게 전달된다.
여기서 방출되는 값의 형태는 <Output, Failure>의 제네릭으로, Publisher에서 방출되는 값의 타입(Output)과 에러의 타입(Failure: Error)을 함께 publish 한다는 특징이 있다.

protocol Publisher<Output, Failure>

둘째, Publisher는 Subscriber가 연결을 원할 때 호출할 수 있는 subsribe(_:)라는 메서드를 Subscriber에게 제공해 준다.

subscribe(_:)는 Subscriber가 원하는 Publisher를 구독(subscribe)하고자 할 때, 해당 Publisher의 메서드를 호출할 때 사용된다.
이때 아래 코드에서 보이는 것처럼, Publisher의 Output과 Subscriber의 Input이 일치하고, 상호 간 Failure 타입도 일치해야 subscribe(연결)를 할 수 있다는 것! (아마 Subscriber 부분에서 다시 한번 설명하게 될 거다.)

protocol Publisher<Output, Failure> {
    func subscribe<S: Subscriber>(_ subscriber: S)
        where S.Input == Output, S.Failure == Failure
}

셋째, Publisher가 Subscriber에게 subscribe(_:)라는 메서드를 제공해 주는 것처럼,
Subscriber도 Publisher가 호출할 수 있는 3개의 메서드(receive(subscription:), receive(_:), receive(completion:))를 제공해 주므로 Publisher에서 사용할 수 있다.
*각 메서드에 대한 설명은 역시 아래 Subscriber 부분에서 더 자세하게 설명할 거다. 그냥 아 그렇구나하고 넘어가자.

마지막으로, Publsiher의 프로토콜에서는 내보낼 데이터를 변환하고 처리할 수 있는 많은 Operator를 제공한다.
*map, filter, combineLatest와 같은 여러 종류의 Operator에 대해서는 아래 Operator 부분에서 자세히 알아보기로 하자.
이때 각 Operator는 모두 Publisher Protocol Type를 반환하므로 SwiftUI나 Builder Pattern에서 봤던 체이닝(Chaining)의 형태로 작성할 수 있다.

뭔가 Publisher 부분만 먼저 설명하려고 했는데,, 다른 개념(Subscriber, Operator)이 계속 등장해서 제대로 설명하기가 쉽지 않은 것 같다. 아래를 계속 이어가보자.
 

4️⃣ Subscriber : Publisher가 방출한 값을 받아 처리하는 부분 (Receive values and a completion)

역시 Publisher 때와 마찬가지로 공식문서상에서 Subscriber를 어떻게 정의하고 있는지 살펴보자.

💡 Declares a type that can receive input from a publisher. (Publisher로부터 받을 수 있는 입력 타입을 정의합니다.)

'무슨 말인가' 싶겠지만 쉽게 말해 Subscriber는 그냥 구독자다.
Publisher에서는 시간의 흐름에 따라 값을 순차적으로 방출(publish)한다고 했는데, 이 방출된 값을 받겠다고 선언하는 게 바로 구독(subscribe)이고 / 방출된 값을 받는 녀석이 구독자(Subscriber)인 거다.


Publisher가 <Output, Failure>의 형태로 값을 publish 하기 때문에 Subscriber는 <Input, Failure> 제네릭으로 값을 받게 된다.

"Publisher로부터 받을 수 있는 입력 타입을 정의합니다"의 의미는
Subscriber에서 값을 받기 위해 정의하는 <Input, Failure>가 Publisher에서 publish 되는 <Output, Failure> 타입과 일치하도록 정의해야 한다는 말이다.

즉, Publisher 부분에서도 설명했듯이
Publisher의 Output과 Subscriber의 Input 타입이 일치하고, 상호 간 Failure 타입도 일치하도록 Subscriber를 만들어야 하는 것이 Subscriber의 첫 번째 특징이다!

protocol Subscriber<Input, Failure>

Subscriber의 두 번째 특징은 Publisher가 호출할 수 있는 세 개의 receive 함수가 Subscriber에 구현되어 있다는 점이다.
각 메서드가 어떤 역할을 하는지 차근차근 살펴보겠다.

protocol Subscriber<Input, Failure> {
    func receive(subscription: Subscription)
    func receive(_ input: Input) -> Subscribers.Demand
    func receive(completion: Subscribers.Completion<Failure>)
}

첫 번째 receive(subscription:)는 Subscriber가 Publisher에게 구독을 등록한다는 메서드 subscribe(_:)를 호출한 후 Publisher가 이에 대한 회신의 개념으로 호출하는 메서드다.

이때 파라미터로 넘어오는 subscription은 "내가 너의 구독을 허한다(?)"와 같은 "구독권" 비슷한 개념이다.
이 구독권과 같은 subscription을 통해 Subscriber는 Publisher와의 연결 관계를 설정할 수 있게 된다.
subscription.request(.unlimited)와 같이 Publisher에게 무제한 데이터 스트림을 요구할 수도 있고 / subscription.cancel()과 같이 더 이상 값을 받지 않겠다고 Subscriber 입장에서 요청을 끊을 수도 있다.


두 번째 receive(_ input:)는 Publisher가 새로운 데이터를 publish할 때 호출하는 메서드다.

이 메서드의 return 값은 Subscribers.Demand 타입인데, 이를 통해 Subscriber가 얼마나 더 많은 데이터를 요청할지 지정할 수 있다.
예를 들어 .unlimited면 제한 없이 Publisher가 생산(produce)할 수 있는 모든 값을 계속 받아오고 / .none이면 더 이상 추가 데이터를 받아오지 않겠다는 의미!


세 번째 receive(completion:)는 Publisher에서 데이터 방출이 정상적으로 완료되었거나 에러로 인해 오류가 발생했을 때 호출하는 메서드다.

completion 파라미터 타입은 Subscribers.Completion 타입이다.
정상적으로 완료되었을 때는 finished case로 / 에러가 발생했을 때는 failure(Failure) case로 각각을 구분할 수 있게 된다.

자 그럼 이제 당신은 Publisher와 Subscriber가 어떤 메서드를 서로 호출하며 상호작용하는지 알 수 있게 되었다! (The Pattern)

1. Subsriber는 subsribe 메서드를 통해 Publisher가 방출하는 데이터를 받겠다고 구독(subscribe)을 신청한다.
2. Publisher는 Subsriber의 receive(supscription:)의 파라미터를 통해 구독증(subscription)을 보낸다.
3. 구독증을 받은 Subscriber는 해당 구독증(subscription)으로 Publisher에게 N개의 값을 요청(request)한다.
4. Publisher는 Subscriber가 요청한 만큼 N개의 값을 보낸다 (receive(_:)).
5. Publisher가 더 이상 값을 pulish 하지 않거나 에러가 발생해서 관계가 종료되면 Publisher가 receive(completion:)을 호출해 관계의 끝을 알린다.

Publisher와 Subscriber 간의 메서드 호출 흐름을 설명하고 있다!

 

5️⃣ Operator : Publisher가 방출한 값을 변환하는 부분 (Describes a behavior for changing values)

Publisher와 Subscriber까지 알아봤으니 마지막 Operator에 대해서도 알아보자!
Operator는 Publisher와 Subscriber 사이에서 뭔가 publish 된 value를 지지고 복고하는 정도까지 설명했는데, <왜 value를 지지고 복고하는 과정이 필요한지>부터 설명이 필요할 것 같아 여기서 짚고 넘어가 보려 한다.

Publisher와 Subscriber에서 공통으로 언급한 점이
"Publisher의 Output과 Subscriber의 Input 타입이 일치하고, 이 둘 사이에 Failure 타입도 일치해야 한다"였던 것 기억하는가?

예상했겠지만. 이 Publisher와 Subscriber 상호 간의 타입을 맞춰주기 위해 사용할 수 있는 것이 Operator라고 생각하면 된다.
+ 추가로 여러 개의 Publisher를 묶어서 하나의 Publisher로 만들거나(mapping), 특정 조건에 충족하는 경우에만 publish 하도록 필터를 걸거나(filtering), publish 요청에 제약을 두는 등(restrict)의 Publisher를 특정 범위로 규정지을 때도 사용할 수 있다.

Publisher와 Subscriber 사이의 타입 불일치로 발생하는 문제를 Operator로 해결할 수 있다는 것을 설명한다. (? = Operator)

기본적으로 Operator는 Publisher 프로토콜을 채택한다.
즉, 이로 인해 Operator는 Subscriber에게 값을 방출(publish)하는 자체적인 Publisher로써 동작할 수 있다는 의미가 된다.
아래에서 Operator를 사용하는 코드를 보면 알겠지만, 마치 SwiftUI처럼 체이닝의 형태로 사용하는 것도 자체적인 Publisher 타입을 반환하기 때문이다.

그렇지만, Operator는 Publisher와 Subscriber 중간에 위치한다.
= Operator는 "상위" Publisher를 구독하고 / "하위" Subscriber에게 결과 값을 보내는 형태. 
=> 이때 상위에 있는 Publisher를 "upsteram"이라 부르고 / 하위에 있는 Subscriber를 "downstream"이라 부른다.

그리고 Operator는 아주아주 많다. 생각 이상으로 매우 다양한 Operator가 준비되어 있다!
이번 글에서는 Operator 종류까지 살펴보지 않고 이 정도만 소개하고 넘어가고자 한다. Operator의 자세한 종류는 다음 글에서!


 
여기까지가 기본적인 Combine의 개념이다!

Publisher, Subscriber, Operator 이 세 가지를 가지고 시간의 흐름에 따라 값이 방출되고 - 그 값을 어찌저찌 처리하고 - 처리된 값을 받는 것! 이 큰 틀을 가지고 Combine Framework를 사용하면 된다.
Combine이 러닝 커브(Learning Curve)가 높다고 들어봤겠지만, 생각보다 별거 없다. 그동안 여러분은 괜히 겁먹은 거다.

이제 큰 개념을 알았으니
다음 글에서는 Publisher, Subscriber, Operator의 종류 / 그리고 실제 코드에서의 사용법은 어떻게 되는지 등에 대해 <2탄 글>로 돌아오도록 하겠다. 오늘은 여기까지!


2탄 글 작성 완료!

 

[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (2) - 실전 코드와 함께 Publisher, Operator 심화 개념

[Combine] Combine 진짜 알기 쉽게 정리해서 올려줄게 (1) - Combine 기초 개념 이해하기⚠️ 이번 글은 Combine Framework가 처음 소개되었던 WWDC19의 두 세션 Introducing Combine과 Combine in Practice의 예제 을 직접

mini-min-dev.tistory.com