본문 바로가기
WWDC

WWDC19: Combine 소개

by songmoro 2024. 9. 11.
728x90

비동기 프로그래밍에 대해서


여기 마법사 학교에 등록할 수 있는 간단한 요구사항을 지닌 앱이 있다.

  • 서버에 네트워크 요청을 통해 유효한 사용자 이름인지 확인
  • 비밀번호가 일치하는지 로컬에서 확인
  • 모든 작업 동안, 메인 스레드를 제한하지 않고 반응형 사용자 인터페이스를 유지

 

 

우선, 사용자 이름을 입력한다.
사용자 입력에 대한 notification을 위해 Target/Action을 사용했다.
그리고, 네트워크 요청으로 과부하 되지 않게 사용자가 타이핑을 멈추기를 기다리기 위해 타이머를 사용한다.
마지막으로, KVO를 사용해 비동기 작업에 대한 진행 상황을 추적한다.

 

 

위 과정에 많은 비동기 작업이 있다.

  • URL 세선 요청에 대한 응답
  • 응답에 대한 결과를 동기식 검사 결과와 병합
  • 모든 과정이후 KVC로 UI를 업데이트

 

이러한 비동기 인터페이스를 Cocoa SDK에서 찾을 수 있다.
Cocoa SDK에는 Target/Action, NotificationCenter, 많은 ad-hoc 콜백을 포함해 다양한 것들이 있으며, 대부분 closure 혹은 completion 블록을 갖는 API이다.
이 모든 것들은 중요하고, 다양한 사용 사례를 가지고 있지만, 때때로 이것들을 조립하는 게 어려울 수 있다.

 

콤바인은 이것들을 모두 교체하는 것이 아닌, 공통점을 찾기 위해 출발했다.

 

 

콤바인이란

콤바인이란 시간이 지남에 따라 값을 처리하기 위한 통합 선언 API이다

 

 

 

콤바인은 Swift를 위해 작성되었으며, 이 말은 제네릭과 같은 스위프트 기능을 활용할 수 있다는 것을 의미한다.
제네릭은 작성해야 하는 보일러 플레이트 코드를 줄이고, 이것은 비동기 동작에 대한 제네릭 알고리즘을 한 번 작성하면 다른 비동기 인터페이스에 적용할 수 있다는 것을 의미한다.

 

콤바인은 type safe해서, 런타임 대신 컴파일 타임에 오류를 캐치할 수 있다.
콤바인에 대한 중요 설계 포인트는 구성(composition)이 우선이라는 것으로, 이는 핵심 개념이 간단하고, 이해하기 쉽다는 것이다.
어떤 것을 만들 때, 구성의 조립으로 원하는 것을 만들 수 있다.

 

마지막으로 콤바인은 요청 기반(Request Driven)이며, 앱의 메모리 사용량과 성능을 보다 신중하게 관리할 수 있는 기회를 제공한다.

 

 

콤바인 핵심 개념

콤바인의 핵심 개념은 세 가지다.

  • Publishers
  • Subscribers
  • Operaters

 

 

Publishers
퍼블리셔는 콤바인 API의 선언적 부분이다.

 

퍼블리셔들은 값과 에러가 어떻게 생성되는지를 설명하며, 그것들을 반드시 생산하는 것은 아니다.
이 말은 값과 에러가 설명이며, valute type으로서 struct를 사용한다는 것을 의미한다.

 

퍼블리셔는 서브스크라이버의 등록을 허용하며, 서브스크라이버는 시간이 지남에 따라 값을 받는다.

 

 

위는 퍼블리셔의 프로토콜이며, 두 가지 associatedtype을 갖는다.

  • 생성하는 값의 종류인 Output
  • 생성하는 오류의 종류인 Failure
    • 퍼블리셔가 오류를 생성하지 않는 경우 Failure 타입에 Never 타입을 사용할 수 있다.

 

퍼블리셔는 구독(Subscribe)이라는 하나의 핵심 기능을 가지고 있다.
이 기능은 제네릭 제약에서 알 수 있듯이 구독은 서브스크라이버의 입력이 퍼블리셔의 출력과 일치해야 하며, 서브스크라이버의 실패는 퍼블리셔의 실패와 일치해야 한다.

 

 

퍼블리셔의 예로 Notification Center의 새로운 퍼블리셔이다.
코드를 보면 이것은 구조체이고, Output은 Notification이며 Failure 타입은 Never이다.

 

이것은 세 가지, center, name, object로 초기화된다.
만약, 기존 Notification Center에 익숙하다면 매우 낯익을 것이며, 다시 말하면 콤바인은 Notification Center를 대체하지 않는다.
그저, Notification Center를 적응(adapting)할 뿐이다.

 

 

Subscriber
서브스크라이버는 퍼블리셔의 대응이다.
서브스크라이버는 퍼블리셔가 유한한 경우 완료를 포함해 값을 받는 것이다.

 

서브스크라이버는 일반적으로 값을 수신하면 상태를 변경하고, 행동하기 때문에 Swift에서 reference type을 사용한다.
이는 서브스크라이버가 클래스임을 의미한다.

 

 

서브스크라이버를 위한 프로토콜이다.
보시다시피, 퍼블리셔와 동일한 관련 타입을 가지고 있고, 마찬가지로 Never 타입을 사용할 수 있다.

  • Input
  • Failure

 

서브스크라이버의 세 가지 핵심 기능은 구독을 받을 수 있고, 구독은 퍼블리셔에서 서브스크라이버로 데이터흐름을 제어하는 방법이다.

 

물론 입력을 받을 수도 있고, 마지막으로 연결된 퍼블리셔가 유한한 경우, 완료 또는 실패일 수 있는 완료를 받는다.

 

 

서브스크라이버의 예인 Assign이다.
Assign은 클래스이며 클래스 혹은 객체의 인스턴스와 그에 대한 KeyPath를 입력으로 초기화된다.
Assign이 하는 일은 입력을 받을 때, 그 객체의 속성에 값을 쓰는 것이다.
Swift에서는 속성 값만 작성할 때, 오류를 처리할 방법이 없기 때문에 Assign의 실패 타입은 Never로 설정한다.

 

 

Pattern

  • subscribe()
    • 퍼블리셔를 보유하는 일종의 컨트롤러 개체 또는 다른 타입이 있을 수 있고, 서브스크라이버와 함께 구독을 퍼블리셔에게 호출하고 첨부할 책임이 있다.
  • receive(subscription:), request(_: Demand)
    • 그 시점에 퍼블리셔는 서브스크라이버에게 구독을 보낼 것이며, 서브스크라이버는 특정 수 또는 무제한의 값을 퍼블리셔에게 요청하는 데 사용할 것이다.
  • receive(_: Input)
    • 퍼블리셔는 요청한 수 이하의 값을 서브스크라이버에게 자유롭게 보낼 수 있다.
  • receive(completion:)
    • 또한, 퍼블리셔가 유한하다면, 결국 완료 또는 오류를 보낼 것이다.

 

정리하자면, 하나의 구독(subscription), 0개 이상의 값과 단일 완료(completion)를 갖는다.

 

 

예시

마법사라는 모델 객체를 가지고 있고, 그 마법사가 몇 학년인지에 관심이 있다고 가정해 보자.
현재 5학년인 Merlin부터 시작하면, 지금 하고 싶은 건 학생들의 수료에 대한 Notification을 받는 것이고, 그들이 수료하면, 모델 객체의 값을 업데이트하고 싶다.

 

그래서, Merlin의 수료에 대한 default center의 NotificationCenter Publisher로 시작한다.
이후 서브스크라이버 Assign을 만들고, Merlin의 학년 속성에 새로운 학년을 쓴다.
subscribe를 사용하여 gradeSubscriber를 연결하는데, 이것은 컴파일되지 않는다.

 

그 이유는 타입이 일치하지 않기 때문이다.

 

 

NotificationCenter는 notification을 만들지만, Integer 속성을 쓰도록 구성된 Assign은 Integer를 기대한다.
그래서, 지금 필요한 것은 notification과 Integer 사이를 변환하기 위한 무언가이다.

 

이것을 Operator라고 한다.

 

 

Operators
오퍼레이터는 퍼블리셔 프로토콜을 채택한다.
선언적이며 따라서 값 타입이다.

 

오퍼레이터가 하는 일은 값 변경, 값 추가, 값 제거 등 다양한 종류의 행동을 설명하는 것이다.

 

그리고 업스트림이라고 부르는 다른 퍼블리셔를 구독하고, 다운스트림이라고 부르는 퍼블리셔에게 결과를 보낸다.

 

 

위 코드는 Map이라는 오퍼레이터의 예로 매우 친숙할 것이다.
Map은 업스트림을 연결하는 것과 업스트림의 출력을 자체 출력으로 변환하는 방법을 초기화하는 구조체이다.

 

Map은 자체적으로 Failure를 생성하지 않기 때문에, 단순히 업스트림의 Failure 타입을 미러링 하고 넘긴다.

 

 

그래서 Map을 사용하면 notification과 Integer 사이를 변환할 수 있게 된다.

 

 

이전과 동일한 퍼블리셔와 서브스크라이버를 유지하지만, 보시다시피 graduationPublisher에 연결하도록 구성한 클로저가 있는 converter를 추가했다.

 

클로저는 notification을 받고, NewGrade라는 userInfo 키를 찾는다.
만약 값이 존재하고, 정수라면 클로저에선 그것을 반환하고, 아니라면 기본 값인 0을 반환한다.
이는 어떤 경우라도 결과가 정수로 서브스크라이버에 연결할 수 있다는 걸 의미한다.

 

이 구문은 장황할 수 있고, 퍼블리셔 프로토콜의 확장으로 이를 유연한 구문으로 만들 수 있다.

 

 

위 코드는 Map의 예제이다.
퍼블리셔의 extension으로 self를 사용할 수 있기에 Map을 초기화하기 위한 인수만 필요로 한다.

 

 

예제를 새로운 구문으로 사용하면, NotificationCenter 퍼블리셔에서 Merlin의 graduated 속성을 구독한다.
notification을 받으면, 앞선 구문과 같이 클로저를 사용하여 매핑한 다음 Merlin의 학년 속성에 할당된다.

 

이 구문이 단계별로 일어나는 일에 대해 선형적이고, 이해하기 쉬운 흐름을 제공한다는 것을 알 수 있다.

 

Assign은 cancellable라고 불리는 것을 반환하며, Cancelation이란 콤바인에 내장되어 있다.
Cancelation를 통해 필요에 따라 퍼블리셔와 서브스크라이버 간 연결을 조기에 해제할 수 있게 한다.

 

이 구문은 콤바인을 사용하는 방법의 핵심으로 각 단계는 체인의 다음 명령 세트를 설명한다.
첫 번째 퍼블리셔에서 일련의 오퍼레이터를 거쳐 서브스크라이버로 끝나는 값을 변환한다.

 

이러한 오퍼레이터를 선언적 오퍼레이터 API라고 부른다.

 

 

선언적 오퍼레이터 API

선언적 오퍼레이터 API는 Map과 같은 기능적 변환을 포함한다.
또한 퍼블리셔의 첫 번째, 두 번째, 다섯 번째 요소를 취하는 것과 같은 Filter, Reduce를 포함한다.

 

오류를 기본 값 혹은 다른 값으로 바꾸는 에러 핸들링, 스레드 또는 큐의 이동, 통합된 반복, 디스패치 큐, 타이머, 타임아웃 등의 스케쥴링과 시간에 관한 기능을 제공한다.

 

 

이러한 오퍼레이터는 굉장히 많다.
그리고, 이 오퍼레이터들을 탐색하는 것은 부담스러울 수 있다.

 

그래서 권장하는 방법은 콤바인에 대한 핵심 디자인 원칙으로 돌아가는 것이다.

 

 

Composition

많은 일을 하는 여러 개의 오퍼레이터를 제공하는 대신, 작은 일을 하는 많은 오퍼레이터를 제공하면 이해하기 쉽게 만들 수 있다.
따라서, 모든 오퍼레이터를 탐색할 수 있도록 기존 Swift Collection API에서 이름에 대한 영감을 얻었으며, 방법은 다음과 같다.

 

 

사분면 그래프를 상상해 보자.
한쪽에는 동기식 API가 있고, 다른 한 쪽에는 비동기식 API가 있다.
상단은 단일 값이며 하단은 다중 값이다.

 

Swift에서 정수를 동기적으로 표현해야 하는 경우 Int를 사용할 수 있다.
많은 수의 Int를 표현해야 하는 경우, Int 배열을 사용할 수 있을 것이다.

 

콤바인에는 이러한 개념을 취해 비동기 레벨로 매핑했다.
단일 값을 비동기적으로 표현해야 한다면 Future, 다중 값을 비동기적으로 표현해야 한다면 Publisher이다.

 

 

기존 구문에선 키가 없거나 정수가 아닌 경우 기본 값을 반환한다.
이러한 방법 대신 모델에 값이 기록되지 않도록 하는 것이 더 나은 생각일 수 있다.

 

 

이때 사용할 수 있는 방법은 클로저가 nil을 반환하도록 허용한 다음 nil 값을 걸러내는 곳으로, Swift에선 compactMap이라는 작업을 제공한다.

 

추가로, 마법사 학교에는 5학년 이상의 학생만 허용된다고 가정해 보자.
필터를 사용해 이 작업을 할 수 있다.

 

여기에 최대 세 번까지 수료할 수 있다고 가정하면, prefix(3)를 사용할 수 있을 것이다.
prefix를 사용해 처음 세 개의 값을 받고, 업스트림을 취소하며 다운스트림으로 완료를 보낸다.

 

최종적으로, Merlin의 수료를 듣는 NotificationCenter 퍼블리셔가 있다.
Merlin이 수료하면 notification에서 NewGrade를 가져오고, 값이 5학년보다 크고 최대 세 번 발생했는지 확인할 것이다.

 

맵과 필터는 훌륭한 API지만 주로 동기 작업을 위한 것으로, 비동기 레벨에서 작업할 때 Composing은 빛을 발휘한다.

 

 

퍼블리셔 결합

마법사 학교 앱에서 사용자가 계속 진행하기 위해 세 개의 오래 실행되는 비동기 작업인 완드 생성이 이루어져야 한다고 하자.

 

세 개의 작업이 모두 끝나면 continue 버튼이 활성화된다.

 

 

Zip

Zip은 여러 개의 업스트림 입력을 단일 튜플로 변환한다.
계속하기 위해서는 모든 업스트림의 입력이 필요하기 때문에 3개의 작업이 끝났을 때, when/and 연산을 만든다.

 

예를 들어, 첫 번째 퍼블리셔는 A를 생성하고, 두 번째 퍼블리셔는 1을 생성하면 튜플을 생성하고 다운스트림으로 보낼 수 있는 충분한 정보를 갖게 된다.

 

 

마법사 학교 앱에서 세 개의 업스트림이 필요한 Zip을 사용해 각각 Bool 결과를 제공하는 비동기 연산의 값을 기다린다.
그리고 버튼을 활성화하기 위해 버튼의 isEnabled 속성에 할당한다.

 

 

Combine Latest

마법사 학교 앱에서 완드가 만들어지는 걸 기다린 후, 완드를 가지고 노는 것을 허용하기 위한 약관에 동의해야 한다.

 

즉, Play 버튼이 활성화되기 전에 세 개의 스위치를 모두 활성화해야 한다.
만약, 스위치 중 하나가 다시 비활성화된다면, Play 버튼을 비활성화해야 할 것이다.

 

이때 사용할 수 있는 게 Combine Latest이다.

 

 

Zip처럼 여러 개의 업스트림 입력을 단일 값으로 변환한다.
하지만 Zip과 달리 업스트림의 입력이 필요하므로 when/or 연산이다.

 

이를 지원하기 위해 각 업스트림에서 받은 마지막 값을 저장한다.
또한, 그것을 단일 다운스트림 값으로 변환할 수 있는 클로저로 구성되어 있다.

 

예를 들어, 첫 번째 퍼블리셔가 A를 생성하고, 두 번째 퍼블리셔가 1을 생성하면 클로저를 통해 이것을 A1로 변환해 다운스트림으로 보낸다.
하지만, 이후 두 번째 퍼블리셔가 새로운 값인 2를 생성하면, 첫 번째 퍼블리셔의 이번 값 A와 결합해 A2가 보내지는 것이다.

 

이것은 업스트림이 변경됨에 따라 새로운 이벤트를 얻는다는 것을 의미한다.

 

 

마법사 학교 앱에선 세 개의 업스트림으로 스위치 모두의 Bool 상태를 변경하고, 다시 단일 Bool 값으로 변환해 Play 버튼의 isEnabled 속성을 쓰도록 한다.

 

 

마무리

콤바인은 앱에서 점진적으로 구성을 채택할 수 있도록 설계되어 있다.
물론 콤바인을 사용하기 위해 모든 것을 콤바인으로 변환할 필요는 없다.

 

앱에서 콤바인을 사용하기 위한 몇 가지 제안은 다음과 같다.

  • NotificationCenter를 사용하는 경우 notification을 받은 다음 내부를 살펴보고 조치를 취할지 여부를 결정해 filter를 사용
  • 여러 비동기 연산의 결과에 가중치를 부여하면 네트워크 연산을 포함해 Zip을 사용
  • URLSession을 사용하여 일부 데이터를 수신한 다음 JSON 디코더를 사용하여 해당 데이터를 사용자 정의 개체로 변환하는 경우 decoding 사용

 

 

 

참고

Introducing Combine

728x90