티스토리 뷰

WWDC

[WWDC19] Combine in Practice 정리

Hani_Levenshtein 2021. 10. 21. 00:13

안녕하세요 hani입니다.

이번에는 Combine을 직접 코드로 보면서 이해하는 시간이에요. 🥰

 

 

Publisher에는 두 가지 associatedType이 있는데

Output은 Publisher가 발행하는 value의 종류이고, Failure는 value 발행 실패에 대한 여부입니다.

 

 

Publisher는 자기한테 Subscriber를 붙이는 메서드도 제공합니다.

 

Publisher.subscribe(Subscriber) 요로케 😎

 

 

 

Combine in Practice.

직접해보면서 컴바인을 알아봅시다 🧐

 

NotificationCenter는 Notification을 발행하는 Publisher를 생성할 수 있습니다.

 

이 Publisher의 아웃풋은 Notification이고, Failure는 Never.

 

map Operator를 통해 Notification를 Data로 바꿔줄 수 있습니다. ☺️

 

 

tryMap Operator를 덧붙여서 Data타입의 데이터를 디코딩하여 MagicTrick타입으로 만들어 줄 수 있습니다.

실패할 경우 Never가 아닌 Error를 보내주네요. 🧐

 

귀찮은 디코딩을 위해 아예 decode Operator도 제공합니다.

갬동의 물개 박수..

 

 

모든 Publisher는 Failure를 가지고 있어요.

이제 이걸 Operator로 잘 다뤄서 Failure에 대한 대비를 해야 합니다.

 

Operator를 한 번 살펴볼게요. 🦀

assertNoFailure Operator를 사용하면 Failure가 Never로 바뀝니다.

 

Never로 바뀌면 뭐가 다른뎅

 

 

그림으로 한 번 보겠습니다 😎

Publisher랑 Subscriber사이에 assertNoFailure Operator가 있는 상황이에요.

 

Publisher가 정상적으로 값을 발행하면 Subscriber가 잘 받아먹을 수 있습니다. 😊

 

 

근데 이제 Output이 아니라 Failure가 전달된다면..!

 

 

 

아예 Subscriber가 Failure도 받지 못하고 assertNoFailure선에서 정리가 됩니다.

(꺼진다는 소리ㅣ.. 🥺🥺🥺)

 

 

Combine은 Failure를 처리해주는 몇 가지 Operator들이 있는데

이번엔 catch에 대하여 알아보도록 하죵 😎

 

 

assertNoFailure말구 catch.

Output이 보내지면 Subscriber가 받을 수 있습니다.

 

 

이번엔 또다시 Failure를 보내는 우리의 능력 없는 Publisher

 

 

아앗.. Failure를 받은 catch가 Publisher와 Subscriber의 연결을 끊습니다.

 

 

그리고 새로운 Publisher가 연결됩니다.

이제 새로운 Publisher가 발행하는 값이 catch를 거쳐 Subscriber에게 전달됩니다.

 

 

코드로 보는 catch.

 

아까는 assertNoFailure() 하고 끝났는데 쬐끔 더 복잡하네요.

Just는 괄호 안에 있는 애를 발행해줍니다. 

 

a Just publisher can’t fail with an error. And unlike Optional.Publisher, a Just publisher always produces a value.

 

Just는 무적권 Failure를 주지 않는 Publisher같네용

 

정리해보면

Notifcation을 발행하는 Publisher를 만들었을 때는 Notifcation Output / Never Failure였죠?

 

 

map Operator를 통해 Notifcation를 Data로 만들 수 있었고

 

 

decode Operator를 통해 Data를 MagicTrick으로 디코딩할 수 있었어요.

이때 Failure도 Never에서 Error로 변환.

 

assertNoFailure는 강제로 앱이 꺼지니까 패쓰하구..

catch Operator는 Failure를 받으면 기존 Publisher와의 연결을 끊고 새 Publisher와 연결시켜줬죠?

 

Just Operator와 같이 써서 placeholder를 발행하는 것으로 문제를 해결했었습니다.

 

만약 새로운 Publisher를 연결해서 앞으로 새로운 Publisher가 발행하는 값만 받아오는 게 아니라

Failure가 발생할 때만 placeholder를 받고,

기존 Publisher가 발행하는 값을 받아오고 싶다면 flatMap Operator도 춫천..!

 

 

flatMap안에서 decode도 하고 catch도 하고 Just도 쓸 거예요.

 

flatMap 가즈아

 

Publisher가 전달한 것을 flatMap에서 받고,

 

외부 Publisher와는 독립적으로,

Publisher로부터 받은 값이랑 동일한 것을 Just Publisher로 발행합니다.

 

만약 디코딩에 실패해서 똥을 catch에게 전달하면

 

catch는 새로운 Just를 통해 placeholder 값을 발행하겠죠?

 

물론 기존 Just와의 연결은 끊어집니다.

 

중요 🌽🥕🥑

맨 처음 Publisher와 Subscriber 간의 연결이 끊어지는 게 아님!

 

암튼 이제 catch의 결과물을 flatMap이 Subscriber에게 전달해주면

 

상황 종료 🥰

 

까먹었을까 봐 catch를 다시 기억을 하궁

 

flatMap에서 map이 전달한 값을 Just를 이용해 새로 발행하고

그걸 decode하구 catch에서 문제가 발견되지 않으면 Subscriber에게 주고, 문제 있으면 placeholder 전달 ☺️

 

이 과정에서 기존 Publisher와 Subscriber의 연결을 끊지 않았음!

catch는 기존 Publisher가 아닌 Just Publisher와의 연결을 끊고 placeholder를 리턴,

그리고 그 결과를 flatMap이 리턴함 굳

 

Never를 Failure로 가지고 있지만 안전장치를 통해 막을 수 있당

 

마지막으로 publisher for Operator를 통해 디코딩한 모델의 값을 가져올 수 있음  ☺️

 

몇 가지 Operator들을 알아봤으니 다른 Operator도 알아보아요

 

언제, 어느 스레드에서 이벤트를 발생시킬지 정하는 Operator..!

 

런루프와 디스패치 큐에 의해 지원되는.... 

큰거온다

 

 

RxSwift에서 봤던 것들 ㅎㅎ

컴바인 이거 짭알엑스구만

 

 

receive on Operator로 publisher for Operator에서 얻은 name을 메인 큐로 전달!

 

 

요기까지가 flatMap으로 얻어온 MagicTrick 아웃풋

 

publisher for Operator로 MagicTrick모델의 name을 가져왔고

 

그 값을 메인 큐로 전달~!

 

자자 이제 Publisher에 대하여 마무리할 시간

 

 

 

 

이제 Subscriber를 알아볼 차례입니다. 

Publisher와 마찬가지로 두 개의 associatedType이 있는데, Output이 Input으로 변경되었습니다.

 

 

그리고 subscription, value, completion을 받을 수 있는 receive 메서드가 있네요 🧐

각각의 메서드를 알아봅시당.

 

Publisher와 Subscriber의 관계에서

 

Publisher.subscribe(Subscriber)에 의해 Subscriber가 Publisher에 attached되죠?

그다음 Publisher는 Subscriber에게 Subscription을 보내게 됩니다.

그걸 받는 메서드에용

 

딱 한 번만 실행 🦀

 

 

그다음에 Subscriber가 Publisher에게 N개의 값을 요청하면

Publisher는 N개 이하의 값을 보내주게 되는데

이때의 receive(_: Input) 메서드는 Publisher가 값을 보낸 만큼 호출됩니다.

 

그리고 마지막 메서드.

Publisher는 최대 하나의 completion을 보낼 수 있는데

이 completion는 작업이 끝났거나 Failure가 일어났다는 것을 나타낼 수 있습니다.

 

만약 똥을 보내게 되면

 

 

더 이상 어떠한 값도 방출되지 않습니다.

 

Subscribers.Completion

A signal that a publisher doesn’t produce additional elements, either due to normal completion or an error.

정상적인 completion도 이후에 값을 방출하진 않네요

 

정리하면

subscription은 딱 한 번

value는 N개의 요청 시 최대 N 번

completion은 최대 한 번 🦀

 

completion이 최대 한 번인 이유는 Publisher가 Notification처럼 infinite할 수 있기 때문이에요. (completion 없음)

 

하여튼! Subscriber의 종류들은 다음과 같습니다.

 

 

요런 Subscriber들을 어떻게 추가할 수 있는지 알아볼게요.

아까 봤었던 Publisher 코드예요.

Publisher의 아웃풋은 String, 실패는 Never입니다.

 

assign(to: on:) Operator를 이용하여 Publisher에 Subscriber를 추가한 모습입니다.

위에서 첫 번째로 언급한 Key Path Assignment 방식이에요.

 

assign은 Publisher로부터 받은 값을 someObject의 someProperty에 할당할 수 있도록 도와줍니다.

 

class MyClass {
    var anInt: Int = 0 {
        didSet {
            print("anInt was set to: \(anInt)", terminator: "; ")
        }
    }
}

var myObject = MyClass()
let myRange = (0...2)
cancellable = myRange.publisher
    .assign(to: \.anInt, on: myObject)
// Prints: "anInt was set to: 0; anInt was set to: 1; anInt was set to: 2"

여기선 Range Publisher가 0부터 2까지 값을 방출하는데

myObject의 anInt 프로퍼티에 값을 할당해줘서 didSet으로 출력이 되고 있어욥

 

Available when Failure is Never.

assign은 Failure가 Never일 때만 사용이 가능합니다. 🥺

 

 

assign의 반환형은 Cancellable입니다.

Cancellable에는 cancel 메서드가 있는데, 구독을 종료하기 위해 호출되는 메서드예요

 

구독을 종료하면 더 이상 Publisher로부터 발행되는 값을 받지 않습니다.

 

Cancellable 역시 Combine에 내장된 프로토콜이에요.

 

Cancellable 타입은 직접적으로 cancel을 호출할 수도 있지만

deinit이 될 때 자동적으로 cancel 메서드를 호출하기 때문에

deinit 시점에 명시적으로 cancel을 호출하지 않고도 cancel이 호출될 수 있도록 만들 수도 있습니다. 🥰

 

 

이제 다음 구독 종류를 보러 갑시다.

두 번째 구독 형태인 sink입니다.

 

let myRange = (0...3)
cancellable = myRange.publisher
    .sink(receiveCompletion: { print ("completion: \($0)") },
          receiveValue: { print ("value: \($0)") })

// Prints:
//  value: 0
//  value: 1
//  value: 2
//  value: 3
//  completion: finished

 

Range Publisher가 방출한 값을 먼저 receiveValue에서 처리하고

값을 처리하고 나면 receiveCompletion이 실행되네요.

 

Available when Failure is Never.

sink도 Failure가 Never일 때만 사용이 가능합니다. 🥺

 

Subject는 Publisher의 역할을 할 수도 있고 Subscriber의 역할을 할 수도 있습니다.

 

A publisher that exposes a method for outside callers to publish elements.

Subject는 Publisher 프로토콜만을 채택하고 있지만 send 메서드를 통해 스트림에 값을 주입해줄 수 있어요 ☺️

 

그럼 Subject의 동작을 살펴봅시다.

덩그러니 Subject가 놓여있네요.

검은색 Subject는 Publisher로 작동할 거예요.

 

보라색 Subject는 검은색 Subject에 대한 Subscriber로 작동합니다.

 

검은색 Subject이 보낸 값은 모든 보라색 Subject에게 브로드캐스트 됩니다. ☺️

 

더 상위에서 값을 보낸다면

검은색 Subject는 Subscriber의 역할을 해서 파란 Publisher가 보낸 값을 받고,

검은색 Subject는 다음에 Publisher의 역할을 해서 보라색 Subscriber들에게 값을 브로드캐스트 합니다.

 

 

Combine에서는 두 가지 Subject를 제공하는데

 

PassthroughSubject는 값을 저장하지 않아서 해당 Subject가 보낸 값을 받으려면 반드시 그전에 구독을 해놔야 합니다. 🥺

CurrentValueSubject는 마지막 값을 저장하기 때문에 마지막 값이 생긴 후에 구독을 해도 값을 얻을 수 있습니다. ☺️

 

 

값(초록색 박스)이 각 Subject를 관통하면

PassthroughSubject엔 값이 저장되지 않고

CurrentValueSubject에는 값이 저장된 것을 알 수 있습니다. 

 

 

두 번째 줄처럼 Subject는 Output과 Failure와 함께 어떤 Subject를 쓸지 간단히 정할 수 있습니다.

(Input이 아닌 Output인 이유는 Subject는 Publisher를 채택하기 때문!)

 

 

맨 끝에는 share 메서드가 보이네요.

 

Combine에서 Publisher는 구조체를 따르기 때문에 copy의 비용이 높습니다.

share는 Publisher를 reference로 얻어낼 수 있기 때문에 copy의 비용을 줄여줄 수 있는 Operator입니다. ☺️

 

 

let pub = (1...3).publisher
    .delay(for: 1, scheduler: DispatchQueue.main)
    .map( { _ in return Int.random(in: 0...100) } )
    .print("Random")
    .share()

cancellable1 = pub
    .sink { print ("Stream 1 received: \($0)")}
cancellable2 = pub
    .sink { print ("Stream 2 received: \($0)")}

// Prints:
// Random: receive value: (20)
// Stream 1 received: 20
// Stream 2 received: 20
// Random: receive value: (85)
// Stream 1 received: 85
// Stream 2 received: 85
// Random: receive value: (98)
// Stream 1 received: 98
// Stream 2 received: 98

 

assign, sink, Subject에 이어 대망의 SwiftUI.

 

SwiftUI는 자체적으로 Subscriber를 가지고 있기 때문에

우리는 Publisher만 제공하면 됩니다.

 

 

오..  Combine + SwiftUI 조합이 좋다 이거지 ☺️

 

씁 정보 찾다가 BindableObject는 ObservableObject로 이름이 바뀐걸 알게 됨..

 

하.. 이름 바뀐 거 공식문서에 알려주덩가 

 

후.. 이게 SwiftUI랑 Combine 둘 다 쓰는 최종 코드인데

ObjectBinding도 didSet도 요즘엔 안쓰이나봄..

 

 

 

나안해

 

 

정리하면 Combine에는 Publisher와 Subscriber, 그리고 다양한 Operator를 제공하고

Publisher와 Subscriber의 역할을 둘 다 할 수 있는 Subject까지 내장되어 있습니다.

 

 

 

그럼 이제 Combine을 실제로 적용해보는 시간을 가져봅시다.

계정을 생성하기 위해서는

유효한 이름인지 서버를 거쳐 확인해야 하고

패스워드가 8자 이상인지, 패스워드와 패스워드를 체크하는 부분이 같은지 로컬에서 확인해야 하고

조건이 맞으면 계정 생성 버튼을 활성화해야 합니다.

 

 

동기적 요구사항과 비동기적 요구사항이 혼재되어 있습니다. 🥺

Combine이 우리를 어떻게 구재해줄까요? 🥺🥺🥺

 

먼저 동기적 요구사항을 먼저 봅시당.

이를 위해 인터페이스 빌더를 이용하여 유저가 텍스트필드에 값을 입력할 때마다 신호를 받고

 

신호를 받으면 텍스트필드의 값을 변수에 저장합니다.

 

이제 이걸 Combine스럽게 처리하려면

 

@Published 라는 Property Wrapper를 변수 앞에 추가하면 됩니다.

간단하죠 ☺️

 

 

@Published 가 뭐냐!

프로퍼티를 Publisher가 될 수 있도록 만들어 주는 녀석이에요.  ( Swift 5.1 ~ )

 

The @Published attribute is class constrained. Use it with properties of classes, not with non-class types like structures.

@Published는 클래스의 프로퍼티에만 쓸 수 있어요 🥺🥺🥺

 

@Published가 붙은 변수는 $ 표시를 앞에 써서 Publisher로 사용 가능하기 때문에

Publisher에 쓸 수 있는 Operator들을 적용시킬 수 있고, subscribe이 가능합니다.

 

여기서는 sink가 사용이 되었네요. ☺️

다음 줄에서 password 프로퍼티가 "1234"에서 "password"로 바뀌면

Subscriber는 변경된 값을 받게 됩니다. 

 

다시 8자 이상의 패스워드 텍스트필드를 일치시켜야 하는 조건으로 다시 돌아가서

 

우리는 두 개의 Publisher를 만들었고,

두 Publisher가 조건을 만족시키는지 동시에 평가해야 했습니다.

 

두 패스워드의 값을 결합시켜서 하나로 만들고

그걸로 유효한 패스워드인지 확인하면 참 좋을 텐데

 

이게 되네.

 

CombineLatest Operator를 소개합니당. ☺️

 

CombineLatest에서 $ 표시로 Publisher를 받으면, 

둘 중 한 Publisher라도 값을 방출할 때 CombineLatest는 신호를 받습니다.

유효성을 판단하고 그 결과를 아래로 내려줄 거예요.

 

근데 반환형이 끔찍하네요 🥺

CombineLatest<Published<String>, Published<String>, String?> ....

 

map을 끼얹으면?

Map<CombineLatest<published<String>, published<String>, String?>> ....

 

내눈

 

 

eraseToAnyPublisher Operator를 적용시키면?

다운스트림의 Subscriber에게 AnyPublisher 형태로 감싸서 전달해줄 수 있습니다.

 

 

 

여기까지 순서를 살펴보면

첨에 프로퍼티에 @Published를 붙여서 Publisher로 작동할 수 있도록 만들었고

 

CombineLatest를 이용하여 두 Publisher가 방출하는 값을 묶어서 처리했습니다.

 

eraseToAnyPublisher Operator를 이용하여

Subscriber에게 값을 예쁘게 감싸서 전달해줄 수 있었어요.

 

깔끔한 결과물.

 

 

그럼 동기적 요구사항을 하나 풀었고

 

 

다음은 비동기적 요구사항을 살펴볼게요.

유저이름이 유효한지 알아내기 위해 서버랑 컨택해야 합니다.

 

 

앞으로 뭘 할 건지 일단 그림으로 ㄱㄱ

마찬가지로 @Published를 프로퍼티에 붙여줍니다.

이제 값이 변할 때마다 발행을 할거에요.

 

짜잔 Debounce Operator입니다.

 

debounce Operator는 userName이 변하더라도 즉각 반응해주지 않습니다. 

값이 변하고 일정 시간 이상 값이 유지되어야 행동을 취합니다.

 

무슨 말이냐!

 

빈 텍스트필드에 Hani_Levenshtein을 빠르게 입력하면

H

Ha

Han

Hani

...

...

를 전부 통과시켜주지 않고

정해진 시간 동안 텍스트필드의 값이 변하지 않아야 보내준다는 말씀

 

그다음 적용할 removeDuplicates Operator.

 

만약 userName과 텍스트필드에 적혀있는 값이 Hani_Levenshtein인데

n을 샤샥 지웠다가 빠르게 n을 다시 입력해주면 동일한 값이니까

그걸 판단해서 같은 값이 발행되지 않게 도와줘요. ☺️

 

우리는 지금 최대한 서버에 요청을 덜 하게끔 만들고 있는 중이에요. 🦀

 

0.5초의 쉴 틈도 없이 입력하면 그 값은 아래로 내려가지 않겠군여

 

아직 비동기 처리는 하지 않았습니다.

최대한 서버에 덜 요청하게 끔 만들었고,

이제 비동기 처리를 할 거예요. ☺️

외부에 userName이 valid 한 지 여부를 확인해주는 메서드가 있다고 합시다.

이걸 이제 안으로 들여보내고 싶어요.

 

위에서 Publisher에 대하여 공부할 때,

flatMap Operator를 이용하면 업스트림으로 부터 값을 받은 다음

새로운 Publisher를 반환할 수 있었죠?

 

Future라는 Publisher를 소개합니다.. 😎

promise는 미래에 호출될 클로저의 성공 / 실패 여부를 결과로 갖는 녀석이에요.

 

userNameAvailable 메서드를 호출했을 때

비동기적으로 클로저가 실행되고 값이 있으면 promise가 success로 채워집니다.

 

그럼 이제 Future가 그걸 받아먹고 flatMap이 받아먹고

다운스트림으로 내려주게 됩니다.

 

 

정리해보면

텍스트필드의 모든 입력 값이 서버로 향하지 않도록 debounce와 removeDuplicated로 방어해줬고

 

flatMap에서 업스트림으로부터 받은 값을 Future에서 userNameAvailable의 인자로 넘겨준 다음

클로저의 결과를 promise로 받아서 Future를 채워 넣고

flatMap이 Future를 다운스트림으로 내려줬습니다.

 

eraseToAnyPublisher로 예쁘게 감싸서 Subscriber에게 제공하면 끝!

 

두둥

 

이제 두 개의 결과를 결합한다면..?!

 

결합! 위에서 사용된 Operator를 떠올려볼 수 있는 기회 🥰

 

비동기적 요구사항인 userName을 해결했습니다.

 

마지막 관문만 남은 것..

 

아까 사용했던 CombineLatest를 적용해서 유효한지 판단할 수 있다 ㅎ

 

요것도 eraseToAnyPublisher를 이용하여 AnyPublisher로 반환

 

이제 값이 발행되면 유효한지 map으로 걸러내고

UI에 반영하기 위해 메인으로 옮긴 다음

버튼의 활성화를 유효성 판단 결과와 일치시키면 해결 🥰 

 

좌좐

 

캬.. 많이도 했네

 

 

좋구알 부탁 👍


References

https://developer.apple.com/videos/play/wwdc2019/721

https://developer.apple.com/documentation/combine/publishers/output/assign(to:on:)/

https://developer.apple.com/documentation/combine/observableobject/

 

 

댓글