[Combine] Advanced Combining Operator

결합 연산자 — switchToLatest / merge(with:) /combineLatest / zip

naljin
14 min readMar 17, 2020

저번 시간에는 결합 연산자로 prependappend 를 살펴봤어요!

이번에는 publisher의 결합과 관련된 족굼 더 복잡한 연산자를 알아보도록합시다!

switchToLatest

이 함수는 모든 publisher의 subscription을 최신의 것으로 변경합니다. 기존의 구독은 당연히 취소되겠죠?

오직 publisher를 방출하는 publisher에서만 사용할 수 있어요!

// 1. 3개의 PassthroughSubject를 생성합니다.
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let publisher3 = PassthroughSubject<Int, Never>()
// 2. PassthroughSubject를 방출하는 PassthroughSubject를 만듭니다.
let publishers = PassthroughSubject<PassthroughSubject<Int, Never>, Never>()
// 3. switchToLatest를 적용함에 따라 publishers가 다른 publisher를 방출할때 마다 이전의 subscription은 취소되고 새로운 것으로 전환됩니다.
publishers
.switchToLatest()
.sink(receiveCompletion: { _ in print("Completed!") },
receiveValue: { print($0) })
.store(in: &subscriptions)
// 4. publisher1을 publishers에 보낸 후 해당 publisher에 1과 2를 보냅니다.
publishers.send(publisher1)
publisher1.send(1)
publisher1.send(2)
// 5. publisher2를 보냄으로써 publisher1의 subscription은 취소됩니다. 따라서 publisher1에 3을 보내더라도 무시될 것입니다. 현재 subscription은 publisher2이기 때문에 4와 5를 보내면 push 됩니다.
publishers.send(publisher2)
publisher1.send(3)
publisher2.send(4)
publisher2.send(5)
// 6. 5의 상황과 유사합니다.
publishers.send(publisher3)
publisher2.send(6)
publisher3.send(7)
publisher3.send(8)
publisher3.send(9)
// 7. 현재 publisher인 publisher3에 완료 이벤트를 보내고, publishers에도 완료 이벤트를 보냄으로써 active한 subscriptions을 모두 완료시킵니다.
publisher3.send(completion: .finished)
publishers.send(completion: .finished)
}

다이어그램에서 추측할 수 있듯이 결과는 아래와 같습니다.

1
2
4
5
7
8
9
Completed! //publisher3, publishers 모두 .finished를 보내줘야 출력됩니다.

음.. 실생활에서 어떻게 쓸지 잘 감이 안오시나요? 아래와 같은 시나리오를 생각해봅시다.

유저가 네트워크 요청을 위해 버튼을 클릭했습니다. 성격이 급한 유저는 기다리지 못하고 또 다시 버튼을 클릭했습니다. 두번째 네트워크 요청이 가겠죠? 이때 이전의 요청은 버리고 최신의 요청만 사용하고 싶다면 어떻게 할까요? 이럴때 바로 switchToLatest 를 사용할 수 있습니다! 코드를 통해 확인해보죠

let url = URL(string: "https://source.unsplash.com/random")!

// 1. 네트워크 요청을 통해 랜덤 이미지를 가져오는 함수입니다.
func getImage() -> AnyPublisher<UIImage?, Never> {
return URLSession.shared
.dataTaskPublisher(for: url)
.map { data, _ in UIImage(data: data) }
.print("image")
.replaceError(with: nil)
.eraseToAnyPublisher()
}
// 2. 유저의 탭을 재현하기 위해 PassthroughSubject를 생성합니다
let taps = PassthroughSubject<Void, Never>()
taps
// 3. 버튼을 탭할때마다 이미지를 요청합니다. 이를 통해 Publisher<Void, Never>에서 Publisher<Publisher<UIImage?, Never>, Never> 형식으로 바뀝니다.
.map { _ in getImage() }
// 4. Publishers의 Publisher이기 때문에 switchToLatest를 사용할 수 있습니다. 이는 하나의 publisher만 값을 방출하고 이전의 구독은 취소될 것을 의미합니다.
.switchToLatest()
.sink(receiveValue: { _ in })
// 5. DispatchQueue를 이용해 세번의 지연된 버튼 탭을 재현합니다.
taps.send()
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
taps.send()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10.1) {
taps.send()
}

콘솔에는 다음과 같이 출력됩니다.

image: receive subscription: (DataTaskPublisher) 
image: request unlimited
image: receive value: (Optional(<UIImage:0x600000364120 anonymous {1080, 720}>))
image: receive finished //첫번째 요청 완료
image: receive subscription: (DataTaskPublisher)
image: request unlimited
image: receive cancel //두번째 요청 취소됨
image: receive subscription: (DataTaskPublisher)
image: request unlimited
image: receive value: (Optional(<UIImage:0x600000378d80 anonymous {1080, 1620}>))
image: receive finished //세번째 요청 완료

세번의 요청이 있었지만 두개의 이미지만 가져온것을 확인할 수 있습니다. 그 이유는 다음과 같습니다.

  1. 첫번째 요청이 이뤄진 10초 후에 두번째 요청이 이뤄졌기 때문에 첫번째 요청은 완료될 충분한 시간이 있었습니다.
  2. 두번째 요청의 경우 0.1초 후에 바로 세번째 요청이 이뤄졌습니다. 즉, 두번째는 완료될 충분한 시간이 없이 바로 다른 요청이 들어온 것입니다.
  3. 처리 도중에 요청이 들어왔기 때문에 이전의 구독(두번째)은 취소됩니다. 콘솔창에 찍힌 image: receive cancel. 을 확인할 수 있습니다.
  4. 새로운 요청(세번째)으로 변경 및 처리됩니다.

각 publisher의 값 결합하기

자 마지막으로 각 publisher에서 방출하는 값들을 결합하는 세 종류의 연산자를 살펴봅시다!

merge(with:)

해당 연산자는 아래 그림과 같이 각 publisher의 값을 교차 배치합니다. 물론 각 publisher는 같은 타입입니다.

// 1. 두개의 PassthroughSubject를 만듭니다.
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
// 2. publisher1과 publisher2를 merge함으로써 방출되는 값들을 교차 배치합니다. 최대 8개까지 결합할 수 있습니다.
publisher1
.merge(with: publisher2)
.sink(receiveCompletion: { _ in print("Completed") },
receiveValue: { print($0) })
// 3. publisher1과 publisher2에 값을 보냅니다.
publisher1.send(1)
publisher1.send(2)
publisher2.send(3) publisher1.send(4) publisher2.send(5)// 4. 완료 이벤트를 보냅니다.
publisher1.send(completion: .finished)
publisher2.send(completion: .finished)

결과는 아래와 같습니다.

1
2
3
4
5
Completed

combineLatest

combineLatest 는 각 publisher들을 결합시켜주는 또 하나의 연산자입니다. 이는 다른 타입의 값도 결합시킴으로써 매우 유용합니다. 하지만 값들을 교차 배치하는 대신, 어느 한 publisher가 값을 방출할때마다 각 publisher의 마지막 값들을 tuple로 방출합니다.

한가지 유의할 점은 기존 publishercombineLatest로 전달되는 모든 publisher적어도 한개의 값을 방출해야 combineLatest
가 동작
한다는 점입니다.

// 1. 두개의 PassthroughSubject를 만듭니다.
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<String, Never>()
// 2. combineLatest를 통해 마지막 방출 값들을 결합합니다. 최대 4개의 publisher까지 결합할 수 있습니다.
publisher1
.combineLatest(publisher2)
.sink(receiveCompletion: { _ in print("Completed") },
receiveValue: { print("P1: \($0), P2: \($1)") })
// 3. publisher1과 publisher2에 각각 값을 전송합니다.
publisher1.send(1)
publisher1.send(2)

publisher2.send("a")
publisher2.send("b")

publisher1.send(3)

publisher2.send("c")
// 4. 완료 이벤트를 보냅니다.
publisher1.send(completion: .finished)
publisher2.send(completion: .finished)

결과는 아래와 같습니다.

P1: 2, P2: a
P1: 2, P2: b
P1: 3, P2: b
P1: 3, P2: c
Completed

여기서 주목해야할 점은 publisher1 에서 방출된 1은 combineLatest 를 통해 나오지 않았다는 점입니다. 왜냐하면 combineLatest는 모publisher가 한번씩은 값을 방출해야 동작하니까요!!

zip

zip 연산자는 위의 combineLatest 와 비슷하게 동작합니다 .다만 차이점은 같은 index의 값들이 tuple 로 방출된다는 점이죠! 즉, 모든 publisher 가 특정 index에 해당하는 값을 내보냈을때 하나의 tuple을 방출합니다.

만약 두개의 publisherzip으로 묶는다면, 두 publisher 모두 값을 방출할때 하나의 tuple을 얻을 수 있다는 뜻인데..! 그림으로 보면 조금 더 이해가 쉬울거예요!

기존 publisher1, 2를 방출할때까지는 아무 일도 일어나지 않습니다. 이때 각각의 index0, 1 이라고 해볼게요.

시간이 조금 지난 후에 publisher2a를 방출하자마자 (1,a)tuple로 묶입니다. 즉!! 기존 publisherindex 0에 해당하는 1publisher2index 0에 해당하는 a가 묶인거죠!!

조금 후에 publisher2에서 b가 방출됩니다. 이는 publisher2index 1 값에 해당하겠죠? 기존 publisherindex 1에 해당하는 2 는 이때서야 비로소 다른 publisher에서도 자신의 index와 대응되는 값이 방출된것을 알고 (2,b)라는 tuple로 묶여 나옵니다.

// 1. 두개의 PassthroughSubject를 만듭니다.
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<String, Never>()
// 2. publisher1과 publisher2를 zip함으로써 대응되는 값들을 짝짓습니다.
publisher1
.zip(publisher2)
.sink(receiveCompletion: { _ in print("Completed") },
receiveValue: { print("P1: \($0), P2: \($1)") })
// 3. publisher1과 publisher2에 각각 값을 전송합니다.
publisher1.send(1)
publisher1.send(2)
publisher2.send("a")
publisher2.send("b")
publisher1.send(3)
publisher2.send("c")
publisher2.send("d")
// 4. 완료 이벤트를 보냅니다.
publisher1.send(completion: .finished)
publisher2.send(completion: .finished)
}

결과는 아래와 같습니다.

P1: 1, P2: a
P1: 2, P2: b
P1: 3, P2: c
Completed

두번째 publisher가 마지막으로 방출한 d 는 콘솔에 안찍혔는데요!! 이유는 첫번째 publisher에서 이에 대응하는 값을 방출하지 않았기 때문입니다.

마무리

자자 저번 포스팅에 이어 지금까지 결합 연산자에 대해 알아보았습니다! 도움이 되었으면 좋겠네욥 ㅎ 그럼 안뇽~~~

이전 포스팅 👈🏻

다음 포스팅 👉🏻

--

--

No responses yet