[Combine] Combine Operator 완전 정복하기 - Combining Operators

2024. 12. 26. 14:22Framework, Library/Combine 완전 정복하기

예전 아래 제 글에서 Operator의 개념과 종류들을 소개한 적이 있습니다.

그런데 단순히 글과 표로만 정리해서 읽고 넘어가기에는,
Combine을 사용하면서 충분히 Operator를 적재적소에 사용하기가 어렵다고 생각이 들었어요.
그래서 이 참에 Combine 스터디에서 다뤘던 네 가지 분류 (Combining, Transforming, Filtering, Timing/Controlling Operator)로 나누어 Operator를 차근차근 자세하게 정복해보고자 합니다!

오늘은 먼저 Combining Operator를 준비했습니다 ^__^

 

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

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

mini-min-dev.tistory.com

 

1. merge, combineLatest, zip

merge, combineLatest, zip는 여러 Publisher의 Event를 하나의 데이터 스트림으로 결합(Combine)하는 Operator입니다.

그림으로 비교해보는 Merge, CombineLatest, Zip

✔️ Merge : 여러 Publisher의 Event를 하나의 데이터 스트림으로 결합해서 publish함. -> 여러 이벤트 소스(버튼 클릭, API 요청 등)를 하나의 데이터 스트림으로 합치고 싶을 때 사용!
✔️ CombineLatest : 여러 Publisher의 Event 중 하나만이라도 방출되면 튜플로 결합해서 publish함. -> 한 뷰에서 갖는 여러 상태(버튼 활성화 여부, 텍스트 필드 등)를 받아 UI를 갱신하고자 할 때 사용!
✔️ Zip : 여러 Publisher의 Event가 방출되면 순서대로 쌍을 맞춘 튜플로 결합해서 publish함. -> API 요청 시에 순서대로 사용자로부터 이벤트를 받아 Publish된 값을 묶어주고 싶을 때 사용!


merge는 말 그대로 합쳐주는 거라 그리 어렵지 않으니 간단하게 코드를 살펴볼게요.

텍스트 필드로 값을 입력받는 상황에서, 데이터를 publish하는 상황이 유저의 input String 값을 내보냄 + 오른쪽 x 버튼 클릭 시에는 “” 값을 내보냄이 목적이라면?

merge 연산자를 사용해 아래와 같이 코드를 짤 수 있을 거에요!
말 그대로 텍스트필드로 입력받은 값 + deleteButton 클릭으로 텍스트 필드에서 지워지는 값을 합쳐서 하나의 Publisher로 내보내는 것이죠. 여기까지는 간단합니다!

struct Input {
    let embedLinkText: AnyPublisher<String, Never>
    let clearButtonTapped: AnyPublisher<Void, Never>
}

// Merge 사용
let inputText = input.embedLinkText
    .merge(with: input.clearButtonTapped.map { "" })
    .eraseToAnyPublisher()

반면, CombineLatest와 Zip은 Merge보다는 자세히 비교해 볼 필요가 있는데요!

이 두 연산자는 여러 Publisher에서 방출되는 데이터를 하나의 단일 데이터로 묶어 publish 한다는 점에서 같지만, 각 Publisher로부터 방출되는 데이터들이 하나의 단일 데이터로 묶이는 시점이 다릅니다!

CombineLatest"When/Or" 연산 -> 즉, 각 Publisher가 최소 1번 이상 방출되고 / 이후에는 새 값이 어느 한 곳에서라도 방출되면 단일 값으로 변환해 publish 한다.
Zip"When/And" 연산 -> 즉, 모든 Publisher에서 방출되는 새 값들의 쌍(pair)이 매번 모두 맞춰지면 단일 값으로 변환해 publish 한다.


이해가 안 될 수도 있어서 예시 상황을 가정해 볼게요.
아래 화면과 같이 비밀번호(password)와 비밀번호 확인(passwordAgain) 값을 텍스트 필드로 받는 상황입니다!

두 Publisher에서 방출되는 데이터를 하나의 Publisher로 묶어서 Publish 해주기 위해 zip 혹은 combinelatest로 묶고자 하는데 각 operator 상황에 따라 동작이 어떻게 달라질지 / 어떤 Operator가 적합할지 한 번 생각해 봅시다!

스크롤 하기 전에 생각해보자구요!

정답을 공개하자면, 아무래도 이 상황에서는 combineLatest가 적합한 연산자일 겁니다!

zip으로 묶어주게 된다면, 항상 pair가 맞춰져야 publish를 하는 형태의 Publisher가 구성이 될 테니 password와 passwordAgain을 각각 최소 한 번은 수정해야 값이 방출될 거예요.

하지만 이 상황에서는 최초 pair가 맞추어지면, 둘 중 하나라도 수정값이 생길 때마다 매번 유효성을 체크해 주는 것이 필요하기 때문에 combineLatest의 작동 상황이 더 적합하다고 볼 수 있을 겁니다!

@Published private var password: String = ""
@Published private var passwordAgain: String = ""

var validatedPassword: AnyPublisher<String?, Never> {
    return $password
        .combineLatest($passwordAgain)
        .eraseToAnyPublisher()
    ...

 

[+추가, Why not support in Combine?] withLatestFrom에 대해서

제가 이번에 프로젝트 코드 리팩을 하면서 있었으면 좋았을법한 연산자가 하나 있어 여담으로…작성해봅니다.

그것은 Combine에는 없고 / RxSwift에만 있는 Combining Operator인 withLatestFrom이라는 연산자입니다.

withLatestFrom : 하나의 Observable로부터 이벤트를 방출할 때, 다른 Observable에서 가장 최근에 방출된 값을 가져와 함께 결합해 방출하는 것 (Observable = RxSwift의 Publisher 개념)

*주의사항 : 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음. 컴바인에는 없음. 알엑스에만 있음.


이것도 역시 예시 상황으로 이해해 볼게요. 코드는 이러했습니다.

텍스트 필드가 있었고요 (textFieldValueChanged라는 Publisher로부터 방출). 버튼을 누르면 (addClipButtonTapped라는 Publisher로부터 방출) 현재 텍스트필드 Publisher가 갖고 있는 값을 가지고 담아서 방출하면 되는 상황이었슴돠!
그런데 Apple에서 제공해 주는 combineLatest와 zip은 이 상황에 모두 적합하지 않았답니다.

let textFieldValueChanged = addClipBottomSheetView.textFieldValueChanged
    .compactMap { ($0.object as? UITextField)?.text }
    .eraseToAnyPublisher()

let addClipButtonTapped = addClipBottomSheetView.addClipButtonTap
    // 클럽 버튼 탭 액션이 있을 때, 현재의 텍스트 필드 값을 가져와 묶을 수 있는 방법 필요!
    .compactMap { $1 }
    .eraseToAnyPublisher()

 

combineLatest를 볼까요?

let addClipButtonTapped = addClipBottomSheetView.addClipButtonTap
    .combineLatest(textFieldValueChanged)

첫 작업은 텍스트 필드 - 버튼 클릭 액션이 맞춰져야 값을 Publish 해야 하는데,
한번 Pair가 맞추어지면 이후에는 텍스트 필드 입력만 하더라도 결합이 진행돼서 버튼을 누르기 전에 계속 보내는 문제가 있었죠!

아래와 같이 동작하면서, 적합하지 않은 모습을 보입니다.


그럼 zip은 어떨까요?

let addClipButtonTapped = addClipBottomSheetView.addClipButtonTap
    .zip(textFieldValueChanged)

두 번째 입력 상황을 자세히 보면, 저는 분명 ㄱㄱ을 클립 이름으로 지정하고 싶었던 상황이었어요.

그런데, ㄱㄱ이 아니라 / ㄱ이 클립 이름으로 저장이 되었죠?
이미 두 번째 pair가 맞추어질 때는 텍스트 필드의 수정 값이 두 번 (ㄱ, ㄱㄱ) 발생하여 방출을 두 번 했기 때문에 아직 pair 되지 않았던 첫 번째 방출값과 버튼 클릭 방출 값이 pair가 되어 방출됐기 때문에 이런 문제가 발생한 것입니다.

zip 역시도 이 상황에 적합하지 않습니다.

왼쪽이 CombineLatest의 동작 흐름, 오른쪽이 zip의 동작 흐름입니다.


이런 상황에서 딱 적합한 연산자가 바로 RxSwift에 있는 withLatestFrom입니다.

아래 그림을 보면 딱 적합하겠죠? - 텍스트 필드의 Latest 값 + 버튼의 액션 값을 결합할 수 있으니까!

zip의 pair와 withLatestFrom의 pair가 어떻게 다른지 이해가 되시는지요!

하지만 아쉽게도… Combine에는 저런 연산자가 없습니다.

저는 Publisher 값에 접근하지 않고 / 직접 텍스트 필드 컴포넌트에 접근하는 방법으로 해결할 수는 있었지만, 
만약 저 연산자가 있다면 조금 더 fancy 하게 Publisher를 결합한 새로운 Publisher를 만들 수 있었을까 하는 아쉬움이 남네요. 

let addClipButtonTapped = addClipBottomSheetView.addClipButtonTap
    .compactMap { _ in self.addClipBottomSheetView.addClipTextField.text }

 

2. append, prepend

✔️ Append : Publisher에서 방출되는 Output을 보낸 이후에 추가 데이터를 붙여서 publish 함.
✔️ Prepend : Publisher에서 방출되는 Output을 보내기 이전에 추가 데이터를 붙여서 publish 함.

Append와 Prepend는 서로 비교하면, 그 차이가 너무 명확합니다!


Append와 Prepend Operator들의 공통점은 기존 데이터 스트림의 앞뒤로 추가적인 데이터 스트림을 붙이는 데 사용된다는 점입니다!
*앞에 붙이면 Prepend, 뒤에 붙이면 Append인 거죠!
**단, 앞에 붙이는 Prepend는 데이터 스트림을 Publish하기 전에 / 뒤에 붙이는 Append는 Publisher가 완료돼야 동작합니닷!

Append 코드부터 봅시다!

일반적으로는 단일 값, 또는 여러 값 (Sequence Data)을 붙이는데 많이 사용되는 편이지만,
다른 Publisher를 결합해서 붙이는 것 또한 가능하다고 하네요!

let publisher = (0...10).publisher
publisher
    **.append(100, 1000, 10000)**
    .sink { print("\\($0)", terminator: " ") }
// Prints: "0 1 2 3 4 5 6 7 8 9 10 100 1000 10000"

let publisher1 = [1, 2, 3].publisher
let publisher2 = [4, 5, 6].publisher
publisher1
    **.append(publisher2)**
    .sink { print("\\($0)", terminator: " ") }
// Prints: "1 2 3 4 5 6"


Prepend도 마찬가지겠죠?

단일 값, 여러 값, 그리고 다른 Publisher까지 결합하는 것도 모두 가능하네요! 순서만 바뀌는 셈입니다.

let publisher = (0...10).publisher
publisher
    **.prepend(100, 1000, 10000)**
    .sink { print("\\($0)", terminator: " ") }
// Prints: "100 1000 10000 0 1 2 3 4 5 6 7 8 9 10"

let publisher1 = [1, 2, 3].publisher
let publisher2 = [4, 5, 6].publisher
publisher1
    **.prepend(publisher2)**
    .sink { print("\\($0)", terminator: " ") }
// Prints: "4 5 6 1 2 3"

 

3. collect, collectByCount, collectByTime

✔️ Collect : Publisher에서 방출되는 Output을 모아서 배열로 묶어 publish 함.

이번에 만나게 될 collect 3총사는 Publisher에서 방출되는 여러 값을 Array로 묶어서 publish하는 Operator입니닷.

Publisher에서 방출되는 값들을 합친다는 점에서 Combining Operator로 분류할 수도 있지만,
기존 데이터 스트림을 Array로 변환/가공하는 역할로 본다면 Transforming Operator로 분류를 더 많이 하는 편입니다!

아무튼 기본적인 collect 연산자의 사용 코드는 매우 간단합니다!

let publisher = (1...10).publisher
publisher
    **.collect()**
    .sink { print("\\($0)") }
// Prints: "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]"


⚠️ 이렇게 간단해 보이는 녀석이 중요하게 사용 시에 주의할 점이 있습니다!

This publisher requests an unlimited number of elements from the upstream publisher and uses an unbounded amount of memory to store the received values.
The publisher may exert memory pressure on the system for very large sets of elements.

collect라는 Operator는 데이터를 제공하는 Publisher
= 즉, upstream Publisher에 대해 무제한(unlimited)적인 데이터를 요청한다고 합니다.

collect는 위에서 본 것처럼 방출되는 값을 메모리에 저장하고 있다가 - Array 형태로 한 번에 방출하는 녀석인데요..
만약 upstream Publisher의 값 방출에 대한 어떠한 제약도 없다면, 메모리 상에는 이 데이터가 무제한적으로 저장되고 있을거에요. Array로 한 번에 방출해야 하니까… 하지만 사실 무제한으로 데이터를 저장하는 상황은 불가능…결국은 언젠가 메모리 문제가 발생하고야 말 겁니다.

Apple은 이 상황을 Memory Pressure라고 부르는 듯해요!


그래서 collect에서는 이 Memory Pressure 문제를 사전에 방지할 수 있는 두 가지 방법을 제공하고 있답니다!

바로, collect로 묶이는 Array가 생성되는 조건을 주는 방식이죠.
하나는 개수로 / 또 다른 하나는 시간으로 제약을 줍니다. 정말 똑똑하네요.

✔️ CollectByCount : 지정된 개수만큼 Output을 모아서 배열로 묶어 publish 함.
✔️ CollectByTime : 지정된 시간 동안 Output을 모아서 배열로 묶어 publish 함.

CollectByCount는 Array의 개수를 Int 타입의 파라미터로 받습니다.

만약, collect에서 지정된 개수만큼 채우기 전에 upstream publisher가 종료되면, 그대로 남은 값을 Array로 방출하게 되는 거죠.

// #1. CollectByCount - 지정된 개수만큼 Array를 묶어 Publish
let publisher = (1...10).publisher
publisher
    .collect(3)
    .sink { print("\\($0)") }
    // Prints "[1, 2, 3] [4, 5, 6] [7, 8, 9] [10] "


CollectByTime은 시간 간격을 설정해 Array로 묶어서 방출하는 방식입니다.

// #2. CollectByTime - 지정된 시간동안 방출된 값의 Array를 묶어 Publish
let publisher = Timer.publish(every: 1, on: .main, in: .default)
    .collect(.byTime(RunLoop.main, .seconds(2)))
    .sink { print("\\($0)") }

// Prints: "[2020-01-24 00:54:46 +0000, 2020-01-24 00:54:47 +0000]
//          [2020-01-24 00:54:48 +0000, 2020-01-24 00:54:49 +0000],
//          [2020-01-24 00:54:50 +0000] ..."

 

4. reduce, tryReduce

✔️ reduce : Publisher에서 방출되는 Output을 수집한 이후에 최종 결과를 연산해 publish 함.
✔️ tryReduce : reduce 연산자에서 예외 처리 기능 추가

reduce는 위에서 배운 collect와 비슷하게, Publisher에서 방출되는 값들을 누적/축적 (accumulate)합니다.

하지만 collect에서는 이 누적 값들을 Array로 한 번에 묶어서 방출했다면,
reduce에서는 파라미터로 받는 initialResult와 nextPartialResult를 바탕으로 어떤 작업을 진행하고 - 작업의 결과를 방출한다는 점에서 차이가 있다고 볼 수 있는데요.

reduce의 형태롤 살펴봅시다.

  • initialResult : 어떤 작업을 시작할 때 갖는 초깃값
  • nextPartialResult : 데이터 스트림에서 방출되는 Output과 누적값을 사용해 새 값을 계산하는 클로저 [작업을 클로저로 표현한다고 생각하면 됨!]
    • 첫 번째 인자는 T → 지금까지의 누적값 혹은 초깃값
    • 두 번째 인자는 Self.Output → Publisher의 데이터 스트림으로부터 방출된 현재 Output 값
func reduce<T>(
    _ initialResult: T,
    _ nextPartialResult: @escaping (T, Self.Output) -> T
) -> Publishers.Reduce<Self, T>


그러니까 더하기 연산을 한다고 하면,
아래와 같이 초깃값 0, sum + value 연산을 클로저에 대입해 15라는 누적합을 구할 수 있게 되는 것이다.

let publisher = [1, 2, 3, 4, 5].publisher

let cancellable = numbers
    .reduce(0) { sum, value in sum + value }
    .sink { result in
        print("Sum: \\(result)")
    }
// "Sum: 15"가 Prints될 것이다!


tryReduce는 여기에 에러를 던질 수 있다는 점만 차이가 납니다.
에러는 nextPartialResult 클로저 부분에서 던질 수 있고 - 에러를 방출하면 해당 데이터 스트림은 종료된다는 점만 차이!

 

5. switchToLatest

✔️ SwitchToLatest : 가장 최근에 받은 Publisher에서 방출되는 Output을 받아 publish 함.
*This operator works an upstream publisher of publishers. = 해당 연산자는 Publisher가 Publisher를 방출하는 경우에 사용됩니다.

switchToLatest는 아래 그림으로 보면 조금 복잡해 보이는데요. 아주 간단하게 이해할 수 있답니다.
딱 이것만 기억하세요!

  • 새로운 Publisher가 publish 되면, 이전 Publisher의 구독을 취소하고 새로운 Publisher로 전환한다.
  • 데이터는 가장 최신의 Publisher로부터만 받는다.


예시를 생각해 볼게요!

회원가입 화면에, id를 입력받는 Publisher와 pw를 입력받는 Publisher가 있고 - 두 입력값을 검증하는 로직이 같아, 하나의 Publisher(validatedPublisher)에서 처리를 해주고 싶은 상황이라고 합시다.
텍스트 필드가 활성화되면, validatedPublisher로 send 해주고 - 이후로 계속 값을 받도록 하는 상황입니다.

아래와 같이 코드를 작성할 수 있겠네요!

let idPublisher = PassthroughSubject<String, Never>()
let pwPublisher = PassthroughSubject<String, Never>()

let validatedPublisher = PassthroughSubject<PassthroughSubject<String, Never>, Never>()

validatedPublisher
    .switchToLatest()
    .sink { text in
        print("Latest Text: \\(text)")
    }

// 첫 번째 스트림 선택
parentTextStream.send(textStream1)
textStream1.send("a") // 출력: Latest Text: a
textStream1.send("ab") // 출력: Latest Text: ab

// 두 번째 스트림 선택
parentTextStream.send(textStream2)
textStream2.send("a") // 출력: Latest Text: a
textStream2.send("ab")   // 출력: Latest Text: ab

textStream1.send("abc")  // 여기서 첫 번째 스트림은 더 이상 동작하지 않음