https://developer.apple.com/videos/play/wwdc2019/721/
- 코드에서는 value 또는 이벤트 Publisher와 해당 퍼블리셔로부터 값을 수신하는 데 관심이 있는 Subscriber가 있는 곳이 많다.
- 그리고 어떤 이해 관계자(interestd party)가 나타나서 이 두 당사자 사이에 연결을 설정한다.
- 연결이 성립되면, 구독자가 해당 퍼블리셔로부터 value를 수신하는데 관심이 있다고 선언하면(request) 퍼블리셔는 자유롭게 다운 스트림으로 value를 전달한다.
- 퍼블리셔가 finish, failure 또는 cancel 되지 않는한 이러한 전송은 계속된다.
이러한 일반적인 형태의 통신은 Callbacks, Closures, Notifications 등 비동기 통신이 있는 소프트웨어 전반에 걸쳐 나타난다.
이 패턴이 Combine의 핵심이다.
A unified, declarative API for processing values over time
Publisher
- Combine의 퍼블리셔는 Publisher 프로토콜을 준수한다.
- 제세한 내용은 링크 참고!
예제
- Combine을 사용하면 NotificationCenter가 퍼블리셔를 통해 알림을 exposing 하는 것을 지원한다.
- Combine에서 중요한 것은 Output과 Failure 타입이며 이 예제에서는 각각 Notification과 Never이다..
- map을 통해 Output을 Data 타입으로 변환할 수 있다.
- Sequence에 존재하는 map과 매우 유사하다.
- JSON 페이로드를 tryMap을 사용하여 앱에 정의한 MagicTrick 타입으로 변환할 수 있다.
- tryMap 안에서 try decoder.decode 를 수행한다.
- 이 연산자는 map과 비슷하지만 스트림에서 발생하는 error를 failure로 변환하는 기능이 추가로 있다.
- 따라서 퍼블리셔의 Failure 타입이 Never에서 Error로 바뀌었다.
- 앞서 tryMap으로 하던 디코딩을 위한 별도의 Operator도 존재한다.
- 간편하게 decode를 호출하면 업스트림의 value를 디코딩한다.
Error Handling
- Combine에서는 잠재적인 failure에 적절히 대응하는 것이 매우 중요하다.
- 모든 Publisher와 Subscriber는 자신이 발생시키거나 허용하는 Failure의 정확한 타입을 설명할 수 있다.
- 많은 경우의 퍼블리셔는 Failure 타입으로 Never를 설정한다.
- 이를 위한 가장 쉬운 방법은 assertToFailure()를 호출하는 것이다. 퍼블리셔의 Failure 타입은 Never가 된다.
- 이 연산자는 업스트림이 Value를 넘겨주면 다운스트림에 그대로 전달하지만 Error를 받게 되면 프로그램이 멈추고 Fatal error를 발생시킨다.
- 이렇게 프로그램에 trap이 발생하면 그다지 좋은 경험은 아닐 것이다.
- 다행히도 failure를 다루기 위한 다양한 Operator들이 더 존재한다.
- 이중에서 특히 유용한 것은 catch이다.
catch
- catch를 사용하면 기존의 업스트림 퍼블리셔에서 failure가 발생한 경우 사용할 recovery Publisher를 정의하는 클로저를 제공한다.
- 앞선 예제에서 assertNoFailure 대신 catch를 사용한다면 기존 Upsrteam Pulisher에서 에러를 방출하면 catch와 업스트림의 연결이 terminated된다.
- 대신 제공된 복구 클로저를 호출하여 새 publisher를 생성한 다음 구독하고 이후부터 값을 자유롭게 받을 수 있게 된다.
- 이런식으로 catch를 사용하면 원래 퍼블리셔를 새 퍼블리셔로 대체하여 오류를 복구할 수 있다.
- 앞선 예제에서 assertNoFailure 대신 catch를 사용한 코드 예시이다.
- Just
- Combine에서 제공하는 publish 하려는 값이 이미 있을 때를 위한 특별한 퍼블리셔이다.
- catch를 사용했기 때문에 Failure 타입은 역시 Never가 된다.
예제 요약
- 앞선 예제 코드의 Operator들로 인한 퍼블리셔의 타입 변화를 표현하면 위와 같다.
- catch 를 사용하면 기존의 업스트림과의 구독은 terminated되고 Recovery 퍼블리셔에 대한 구독이 새로 생기게 된다.
- 그렇다면 업스트림과의 연결이 끊어졌기 때문에 더이상 Notification은 받지 못하게 된다.
- 이것은 우리가 원하는 결과가 아니다.
- 우리가 원했던 것은 디코딩에 실패할 경우 원래 업스트림에 대한 연결은 유지하면서 Placeholder를 사용하는 기능이다.
- 놀랍게도 Combine에는 이를 위한 Operator가 존재한다.
- 바로 flatMap이다.
flatMap
- flatMap은 map과 유사하게 작동한다.
- 업스트림으로부터 값을 받으면 그 값으로 새 퍼블리셔를 생성한다.
- flatMap은 중첩된 퍼블리셔를 구독하고 그 값을 다운스트림에 제공하는 세부 사항을 처리한다.
- flatMap은 클로저를 호출하여 해당 값을 새로운 퍼블리셔로 반환한다.
- 이 경우 새로운 퍼블리셔는 Just 뒤에 decode와 catch가 뒤따른다. (이전과 비슷)
- 그런 다음 flatMap은 이 새 퍼블리셔를 구독하여 결과 값을 다운스트림에 제공한다.
- 만약 decode에서 에러가 발생한다면 catch가 동작하고 Recovery Publisher로 대체된다.
- 그리고 이것이 flatMap으로 반환되는 퍼블리셔가 될 것이다.
- 따라서 해당 작업에 절대 실패하지 않도록 보장한다. (Failure가 Never가 된다.)
코드로 나타내면 다음과 같다.
- flatMap을 사용하고 클로저가 새로운 Publisher를 리턴하도록 한다.
- 퍼블리셔의 Value의 타입을 Publisher로 바꾸고 이것을 평탄화 하는 것이 flatMap이다.
- flatMap 연산자에 대해 nested scope를 사용하여 디코드하고 catch하고 이를 리턴한다.
- 그러면 flatMap이 이 퍼블리셔를 구독하게 되고 그 결과 퍼블리셔는 절대 실패하지 않는(Never 타입) 퍼블리셔가 된다.
- 이제 업스트림의 failure를 처리 했으니 원래 하려던 마술의 이름을 publish 해보자!
- publisher(for:) 연산자를 사용하여 type-safe key path를 통해 MagicTrick 내부에 접근하여 새로운 퍼블리셔를 생성한다.
- 이 경우에는 MagicTrickname이다.
Scheduled Operators
- Scheduled Operators를 사용하면 특정 이벤트가 언제 어디서 전달되는지 설명할 수 있다.
- 기본적으로 RunLoop와 DispatchQueue에서 지원된다.
- 다양한 연산자들이 있으며 이중에서 receive(on:)을 사용해 보자!
receive(on:)
- receive(on:)은 퍼블리셔의 Output과 Failure 타입을 변경하지 않는다.
- 다른 scheduled Operators들도 마찬가지이다.
- receive(on: RunLoop.main)으로 설정하여 작업이 메인 스레드에서 진행되도록 하였다.
- UI 업데이트는 메인 스레드에서 진행되어야 하기 때문!
Publisher 정리
Subscriber
- Combine의 Subscriber는 Subscriber 프로토콜을 준수한다.
- 제세한 내용은 링크 참고!
- subscription, values, completion을 수신하기 위한 3가지 이벤트 함수가 존재한다.
- 이러한 함수가 호출되는 순서는 잘 정의되어 있으며 다음의 3가지 규칙에 따라 결정된다.
첫 번째 규칙
구독 요청에 대한 응답으로 퍼블리셔가 receive(subscription:)을 정확히 한 번 호출한다.
두 번째 규칙
퍼블리셔는 구독자가 요청하면 0개 이상의 값을 다운스트림으로 제공할 수 있다.
세 번째 규칙
퍼블리셔는 최대 하나의 completion만 전송할 수 있으며 completion은 퍼블리셔가 finished 되었거나 failure가 발생했음을 나타낸다.
completion이 전송된 후에는 더 이상의 value를 전송할 수 없다.
무한한 퍼블리셔는 completion을 전송하지 않을 수 있다. (NotificationCenter 처럼!)
요약
Kind of Subscribers
- Combine에는 다양한 종류의 Subscriber들이 존재한다.
가장 간단한 구독 형태인 assign(to: on:)을 사용해 보자!
assign
- 업스트림이 보내는 몯느 값이 지정된 객체의 저정된 key path에 할당된다.
- assign은 나중에 구독을 종료하기 위해 호출 할 수 있는 cancellation token도 생성한다.
Cancellation
- 퍼블리셔가 이벤트 전송을 완료하기 전에 구독을 종료하고 싶은 경우가 많기 때문에 Cancellation 기능이 필요하다.
- 해당 구독에 대한 리소스를 확보하려는 경우 더욱 필요하다.
- Cancellable 프로토콜은 cancel 기능을 명시한다.
- deinit 시점에 자동으로 구독이 cancel 되도록 할 때 매우 유용한 AnyCancellable이라는 편의 기능도 존재한다.
sink
- assign 말고도 다양한 구독 형태가 있으며 대표적인 것이 sink이다.
- 클로저를 제공하면 모든 값이 수신될 때마다 클로저가 호출되고 원하는 side effect 작업을 수행할 수 있다.
- sink는 assign 처럼 AnyCancellable을 리턴한다.
또 다른 형태의 구독은 하이브리드이다.
Subject 라고 불리며 Publisher와 Subscriber 모두와 비슷하게 동작한다.
Subject
- send를 통해 이벤트를 Subject 에게 보낼 수 있다.
- 수신한 값의 멀티 캐스팅을 지원하며 중요한 것은 값을 명령형(imperatively)으로 전송할 수 있다는 점이다.
- 이는 기존 코드베이스와 동작할 때 중요한 기능이다.
- Subject를 사용하면 여러 다운스트림 구독자에게 broadcast 할 수 있으며 명령형으로 값을 send 할 수 있다.
- Combine에는 두 가지 종류의 Subject가 존재한다.
- PassthroughSubject
- value를 저장하지 않는다.
- Subject를 구독한 후에만 값을 볼 수 있다.
- CurrentValueSubject
- 마지막으로 수신한 value를 저장한다.
- 새로운 구독자가 앞서 저장한 value를 수신할 수 있다.
예제
- Subject는 업스트림 퍼블리셔를 구독할 수 있다는 점에서 Subscriber 처럼 동작한다.
- sink와 같은 연산자를 포함하여 스스로 구독자를 형성하는 방식으로 Publisher처럼 동작하기도 한다.
- send를 사용하여 명령형으로 값을 보낼 수도 있다.
- share는 스트림에 PassthroughSubject를 삽입하는 연산자이다. (링크 참고)
SwiftUI와 Combine
- SwiftUI의 놀라운 점 중 하나는 애펄르케이션의 dependencies만 설명하면 나머지는 프레임워크가 알아서 처리한다는 점이다.
- Combine의 관점에서는 데이터가 언제 어떻게 변경되는지 설명하는 퍼블리셔를 제공하기만 하면 된다는 의미이다.
- 이를 위해서는 ObservableObject 프로토콜을 채택하면 된다. (공식문서)
(이 WWDC 영상에서는 BindableObject라고 설명하지만 현재는 ObservableObject로 이름이 변경되었다.)
- 이 프로토콜에는 하나의 associatedtype이 있으며 이것은 Never로 실패 타입이 정해진 퍼블리셔이다.
- UI 프레임워크에서 동작해야 하기 때문에 Failure가 Never로 강제된 것이다!
- 이렇게 BindableObject(현재는 ObservableObject)를 채택하여 SwiftUI와 데이터를 바인딩 할 수 있다.
- @ObjectBinding 은 현재는 @ObservedObject로 변경되었다.
Designed For Composition
Combine은 Composition을 염두에 두고 설계되었다.
회원가입 Flow에 Combine을 적용해 보자!!
- 사용자 이름이 서버에 따라 유효한지 확인해야 한다.
- 비밀번호 입력란과 비밀번호 확인란이 일치해야 하며 8자 이상이어야 한다.
- 이러한 조건이 모두 충족되면 계정 생성 버튼을 활성화해야 한다.
이 예시에는 비동기 동작과 디바이스에 local인 동기 동작이 있다.
이 모든 것을 combine 할 수 있어야 한다.
우선 비밀번호 일치 여부를 파악하기 위해 두 개의 TextField에 타겟 액션을 생성한다.
사용자가 입력한 값을 @Published로 선언한 변수에 할당하면 된다.
@Published
- @Published는 Property wrapper이다.
- 지정된 프로퍼티에 Publisher를 추가한다.
- CombineLatest를 활용하여 비밀번호와 비밀번호 확인란에 입력된 두 개의 String을 동시에 평가하도록 구현한다.
- 입력된 두 비밀번호가 같은지 확인하고 8자리보다 긴 값인지 검사한다.
- eraseToAnyPublisher를 사용하여 AnyPublisher<String?, Never> 타입의 퍼블리셔를 리턴하도록 할 수 있다.
- 이렇게 하면 API 경계에 필요한 정보들만 노출하고 구현 디테일은 숨길 수 있다.
요약
- String 타입의 초기 프로퍼티를 가져와서 @Published를 사용해 퍼블리셔로 변환하였다.
- CombineLatest를 사용하여 두 퍼블리셔의 최신 값을 결합하고 비즈니스 로직을 추가했다.
- map을 사용해 잘못된 비밀번호를 필터링하고 eraseToAnyPublisher를 사용했다.
다음으로 넘어가서, 사용자가 이름을 입력하면 서버에 유효성 검사를 하는 로직을 구현하자!
이는 비동기 작업이다.
비밀번호 검사 로직처럼 username에 @Published를 추가하여 퍼블리셔를 생성한다.
하지만 사용자가 한 문자를 입력할 때마다 네트워크 작업을 수행하면 서버에 스팸이 발생한다.
이러한 문제를 해결하기 위해 debounce라는 연산자가 존재한다.
Debounce
- debounce를 사용하면 값을 수신할 기간을 설정하고 그보다 빨리 수신하지는 않는다.
사용자가 username으로 항상 동일한 값을 입력한다면 서버에 다시 유효성 검사를 요청할 필요가 없다.
이럴 때 removeDuplicate를 사용하면 된다.
이제 비동기 네트워크 작업을 추가해 보자!
- flatMap 에 네트워크 요청을 하는 usernameAvailable 함수를 호출하도록 한다.
- 이것을 Future로 감싸서 네트워크 요청 결과를 퍼블리셔로 리턴한다.
요약
간단한 username 퍼블리셔에서 시작해서 여러 Operator들을 거쳐 비동기 네트워크 호출을 하는 기존 API를 래핑했다.
그리고 flatMap을 사용하여 스트림을 fork했다.
이제 비밀번호 검사와 username 유효성 검사를 결합해서 계정 생성 버튼을 활성화 하는 로직을 구현하면 된다.
- CombineLatest를 사용하여 validatedUsername과 validatedPassword 퍼블리셔를 묶어 하나의 퍼블리셔로 변환한다.
- username과 password를 하나의 tuple로 반환한다.
- 앞서 생성한 validatedCrendentials 퍼블리셔를 VC에서 구독하여 회원가입 버튼과 바인딩한다.
- map을 통해 값이 nil이 아니라면 true를 방출하게 한다. (회원가입이 가능한 상태를 의미)
- receive(on:)을 통해 메인 스레드에서 UI 작업이 발생하도록 한다.
- assign(to:on:)을 통해 UIButton의 isEnabled와 회원가입 가능 상태를 바인딩한다.
이제 모든 요구사항을 전부 구현했다!!👍
실습 요약
Composition을 활용하여 작은 단계부터 차근차근 쌓아 올려 최종 체인을 형성했고 이를 버튼에 assign 했다.
이것이 Combine의 핵심이다.
결론
- 작은 부분부터 퍼블리셔로 구성하자!
- 점진적으로 Combine을 도입하면 된다!
- Future를 사용하여 Callback을 퍼블리셔로 구현하자!
'iOS > Combine' 카테고리의 다른 글
[Combine] Cancellable 탐구 (with OpenCombine) (2) | 2023.11.22 |
---|---|
[Combine] Publisher와 Subscriber 그리고 Subscription (with OpenCombine) (1) | 2023.11.15 |
Introducing Combine (1) | 2023.10.23 |
[iOS] SOPT - Swift 에러 핸들링 with Combine + Clean Architecture (0) | 2023.07.30 |
Combine 중복 바인딩 문제 (tableViewCell) (0) | 2023.02.15 |
댓글