[Combine] Introducing Combine

rbw·2023년 11월 22일
0

Combine

목록 보기
1/11

https://www.apeth.com/UnderstandingCombine/start.html
위 링크가 Combine 설명을 너무 잘해주어서 공부할 겸 정리글을 작성해보고자함니다. 첫 파트는 Combine의 개념과 간단한 사용예시들임니당

Combine

Introducing Combine

우리는 종종 백그라운드에 진입을 하면 데이터를 저장하는 기능이라던지, 서버에서 이미지를 들고와서 UI에 적용하는 dataTask 기능을 개발합니다.

Combine 프레임웤을 사용하지 않고도 충분히 작성이 가능하고 잘 동작하지만, 여기서 작성자는 한 가지 문제점을 제시합니다.

기능의 트리거가 어느 시점에 나타날지 모르고, 그 트리거는 미래의 어느 시점에서 발생할 수 있습니다. 그리고 미래의 어느 시점에는 문제가 있을수도 있구요. 그 부분을 Combine을 사용해서 해결해보고자 합니다.

Publish and Subscrbie

Combine 프레임워크의 아키텍처는 publish, subscribe 라는 개념을 중심으로 구성됩니다.

처음에는 항상 퍼블리셔라는 객체가 존재합니다.(퍼블리셔 프로토콜 채택) 이 객체는 미래의 어느 시점에 신호를 방출할 수 있다는 특징이있습니다.

그리고 마지막에는 항상 퍼블리셔를 구독하는 구독자(구독자 프로토콜 채택)가 있습니다. 얘는 구독하는 퍼블리셔로부터 신호를 수신할 수 있는 기능이 특징입니다.

앱이 백그라운드로 진입할때 작성한 함수를 살펴보겠습니다.

NotificationCenter.default.publisher(
    for: UIWindowScene.didEnterBackgroundNotification)
    .sink { _ in
        print("we're going into the background!")
    }
    .store(in:&self.storage)

맨 위 두 줄은 알림센터의 퍼블리셔 메소드를 호출하고, 실제 퍼블리셔 객체를 리턴합니다.

여기서 얻는 퍼블리셔 객체는 NotificationCenter.Publisher 구조체 인스턴스입니다만 정확한 유형은 크게 중요하지 않습니다. 중요한 부분은 퍼블리셔 프로토콜을 준수한다는 것입니다.

다음 sink 메소드를 살펴보겠습니다.

  • 구독자 객체를 생성합니다. 이 경우에 Subscribers.Sink Class Instance입니다만 위에 설명처럼 중요한건 구독자 프로토콜을 준수한다는 것입니다.

이제 퍼블리셔는 구독자가 있다는 것을 알고 해당 구독자에게 신호를 보낼 준비를 합니다. 하지만 추가 단계를 수행하지 않으면 신호를 받을 수 없습니다. 해당 단계는 아래와 같습니다.

.store(in: &self.storage)

storage안에 sink 객체를 저장합니다. 얘는 다음과 같은 Set 변수임니다.

var storage = Set<AnyCancellable>()

sink 객체를 저장소에 넣으면 아래와 같은 두 가지 작업이 수행됩니다.

  1. 해당 객체를 유지합니다 구독자 객체를 유지하는것은 중요합니다. 얘가 사라지면 나중에 신호가 도착할 곳이 없기 때문에 신호가 도착하지 않으므로 퍼블리셔로부터 알림을 받지 못합니다. 위에 작성한 코드인 백그라운드 감지 코드에서 실제로 백그라운드로 들어갔는지 저희는 이제 알 수 없게됩니다.
  2. 나중에 뷰 컨트롤러가 더 이상 존재하지 않게 된다면, Sink 객체가 해제 됩니다 Sink와 같은 내장형 구독자는 퍼블리셔를 구독하는 도중에 해제되면 구독을 취소하는 메시지를 퍼블리셔에게 보내는 것이 특징입니다. 따라서 퍼블리셔는 작업이 종료되었음을 통보받게 됩니다. 구독자에게 취소를 명시적으로 호출하여 언제든지 취소 가능하지만, 중요한 것은 구독자 자체가 사라지려고 할 경우에 런타임 때 구독자에게 취소를 대신 전송한다는 것입니다.

Introducing Operators

위에 게시, 구독 기능으로 다양한 형태의 비동기 신호를 하나의 통일된 API로 통합한다는 것을 알게 되었습니다. 하지만 요 기능 뿐이라면 Combine 프레임워크에 대해 배우고자 하는 동기가 약할 수 있슴니다.

이제 매우 중요한 Operator에 대해 알아보겠씀니다.

요 친구는 게시자 역할과 구독자 역할 모두 수행하는 객체입니다. 즉 원래 게시자와 원래 구독자 사이에서 실행되는 체인 내에 위치할 수 있습니다. 여기서 이제 입맛에 맞게 다루는거죵

퍼블리셔 끝을 업스트림 방향. 구독자 끝을 다운스트림 방향으로 보통 부릅니다. 퍼블리셔가 생성한 신호는 업스트림 오브젝트에서 각 오퍼레이터를 차례로 거쳐 다운스트림 오브젝트로 전달되며, 구독자에게 전달되는 도중에 다시 다운스트림 오브젝트로 전달됩니다.

요 작동 방식은 체인 내부의 오퍼레이터가 자신의 바로 위 업스트림에 있는 오퍼레이터를 효과적으로 구독하고, 다시 그 바로 다음 다운스트림에 있는 오퍼레이터가 구독하는 식으로 이루어짐니다.

오퍼레이터는 값을 변경할 수도 있고, 타입도 변경가능하며, 값을 차단하여 더 이상 체인 아래로 흐르지 않도록 할 수도 있습니다. 결과적으로 체인의 구독자 끝에서 최종적으로 나오는 것은 퍼블리셔 끝에서 들어간 것과는 완전히 다른것이 될 수 있슴니다 !

Pipeline

최종적인 다운스트림 구독자와 퍼블리셔를 연결하는 오퍼레이터 전체 체인을 파이프라인이라고 합니다.

아까 살펴본 코드를 다시 보겠슴니다.

let url = URL(string:"https://www.apeth.com/pep/manny.jpg")!
URLSession.shared.dataTaskPublisher(
    for: url)
    .sink(receiveCompletion: {_ in}) {
        if let im = UIImage(data: $0.data) {
            DispatchQueue.main.async {
                self.iv.image = im
            }
        }
    }.store(in:&self.storage)

이 코드의 파이프라인은 매우 짧고, 퍼블리셔와 구독자 사이에 연산자가 없습니다. 그리고 그다지 좋은 스타일은 아닙니다. 조금 수정해보겠슴니다

URLSession.shared.dataTaskPublisher(for: url)
    .compactMap { UIImage(data:$0.data) }
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: {_ in}) {
        self.iv.image = $0
    }
    .store(in:&self.storage)

요거가 이제 좋은 파이프라인의 예시임니다. 코드에서 볼 수 있는 부분은 메서드 호출 체인을 이루는 부분입니다. 보이지 않는 부분은 각 오퍼레이터 메서드가 파이프라인이 완료될 때 이전 퍼블리셔를 효과적으로 구독하는 오퍼레이터 객체를 생성한다는 것입니다. 실제 dataTaskPublsiher에서 sink 까지 호출까지 4개의 객체 체인이 있습니다.

위에 각 객체가 무엇을 하는지 살펴보겠습니다.

  • dataTaskPublisher: 두 가지 정보, Data와 URLResponse를 튜플로 출력합니다.
  • compactMap: response를 버리고, data를 UIImage로 변환하도록 시도하고, 실패한다면 신호를 중지합니다( 밑에 구독자들에게는 아무 것도 도착 x)
  • receive(on:): 얘는 수신한 값을 전달하고, 지정된 큐에 전달하도록 합니다. 위에서는 메인 큐.
  • sink: 여기에 도달했다면 메인 큐에 이미지가 있다는 것이 보장되므로 이미지 뷰에 넣기만 하믄 댑니다.

여기서 원본 퍼블리셔가 내보내는 신호는 파이프라인을 따라 변환되는걸 볼 수 있습니다.

  • (Data, URLResponse) -> UIImage

위에서 스레드도 적절히 할당해주었으므로 Sink 오퍼레이터의 작업이 크게 간소화되었습니다.

또한 파이프라인이 네트워크에서 이미지 뷰로 직접 데이터를 효과적으로 흘려보내는 것을 관찰할 수 있습니다. 파이프라인이 형성되고 저장되면 모든 것이 저절로 이루어집니다..! 서로 다른 엔드포인트 간에 데이터가 흐른다는 느낌은 앱의 동작을 구성할 때 확실성을 제공합니다..!!

Stopping a Pipeline

일반적으로 최종 구독자를 포함한 전체 파이프라인을 구성하는 것만으로도 파이프라인이 시작됩니다. 그렇다면 파이프라인 또는 일부 파이프라인은 어떻게 중단될 수 있을지 살펴보겠습니다. 주요 방법 세 가지로, 취소, 완료, 오류가 있습니다.

취소

퍼블리셔와 구독자가 정상적으로 관계를 끊는 방법은 구독자가 퍼블리셔에 대한 구독을 취소하는 취소를 통해 이루어집니다.(실제 커뮤니케이션 매체는 구독자가 공유하는 구독 객체입니다) 구독자가 구독을 취소하면 퍼블리셔가 구독을 취소합니다. 이렇게 하면 퍼블리셔는 게시를 중단하게 됩니다

퍼블리셔가 오퍼레이터인 경우, 즉 업스트림 퍼블리셔가 있는 경우 동일 매커니즘을 사용하여 해당 퍼블리셔에게 취소 메시지를 보냅니다. 따라서 취소 메시지는 파이프라인을 타고 올라가 최종 퍼블리셔에 도달할 때까지 퍼블리싱을 중지합니다.

이 매커니즘에 직접 참여할 수도 있습니다. 파이프라인 끝에 있는 구독자에게 취소를 지시하여 파이프라인을 수동으로 끄면 취소 메시지가 최종 퍼블리셔까지 퍼져나가고, 전체 파이프라인을 영구적으로 종료할 수 있습니다.

하지만 우리는 싱크 객체에 직접 접근할 수 없습니다. 우리는 그저 싱크 객체에서 store(in:)를 호출하여 저장소라는 집합에 요 객체를 저장하고 있을 뿐입니다. 이는 어떻게 작동하는걸까요 ?

.sink 이 메서드는 단순히 Sink 객체를 생성하는 것이 아니라 해당 객체를 AnyCancellable이라는 type-erasing 래퍼로 감싸는 것으로 알려졌습니다. store(in:) 메서드는 AnyCancellable 메서드이며, 지정한 컬렉션에 해당 타입 객체를 저장합니다. 이 타입에는 취소 메서드도 있습니다. 따라서 취소를 지시하면 해당 메시지가 래핑된 취소 가능 구독자에게 전달되고 파이프라인이 종료됩니다.

또 AnyCancellable 객체가 존재하지 않게 되면 자동으로 취소됩니다. 이는 store(in:)의 장점 중 하나인데, Self가 존재하지 않게 된다면 self.storage같은 프로퍼티에 유지되고 있는 모든 파이프라인이 종료된다는 것을 의미합니다.

완료

일부 퍼블리셔는 중단(취소) 지시가 있을 때까지 계속해서 값을 생성할 수 있습니다. 노티피케이션 센터 퍼블리셔가 대표 예시입니다.

NotificationCenter.default.publisher(
    for: UIWindowScene.didEnterBackgroundNotification)
    .sink { _ in
        print("we're going into the background!")
    }
    .store(in:&self.storage)

위 퍼블리셔는 백그라운드로 이동할 때마다 값을 생성합니다. 그러나 일부 퍼블리셔는 한정된 수의 값만 제공한 후 중단하고 작업을 종료합니다. 이전에 살펴본 dataTaskPublisher가 대표 예시입니다.

이전 예시에 살펴본 dataTaskPublisher는 서버에 한 번 접속하여 제공된 URL에서 데이터를 다운로드하려고 시도합니다. 이미지 제공에 성공하든 실패하든 시도를 했으므로 작업은 완료된 것입니다.

퍼블리셔가 작업을 완료하면 더 이상 값을 기대할 수 없음을 다운스트림에게 알려야합니다. 이를 위해 다운스트림 객체에 완료 메시지를 전달합니다. 이 메시지는 열거형이며, Subscribers.Completion 열거형의 .finished 케이스 입니다. 일반적으로 이러한 방식으로 완료 메시지가 최종 구독자에게 도달하면 파이프라인이 정상적으로 종료됩니다.

실패

취소, 완료 외에도 게시를 중단하는 방법으로 실패가 있습니다. 이는 퍼블리셔가 복구할 수 없는 장애물에 부딪혔다는 것을 의미합니다.

퍼블리셔가 실패하면 다운스트림한테 이 사실을 알려야 할 방법이 필요합니다. 이를 위해 메시지를 전달합니다. 이는 완료 메시지와 유사합니다. 일종의 완료메시지인거죠.

실패메시지의 타입도 위와 비슷하게 Subscribers.Completion.failure 임니다. 이 케이스에는 Error라는 연관값이 존재하므로 실패한 이유도 전달 가능함니다.

퍼블리셔가 실패하면 두 가지 일이 일어납니다.

  1. 퍼블리셔가 스스로 취소합니다 게시를 중지하고 취소 메시지를 파이프라인을 타고 모든 구독자의 구독을 취소하고 모든 퍼블리셔를 취소합니다.
  2. 퍼블리셔는 실패 메시지를 보냅니다 일반적으로 다운스트림 오퍼레이터는 실패 메시지를 수신하면 해당 메시지를 더 아래로 전달함니다. 따라서 기본적으로 최종 구독자에게까지 전파함니다.

따라서 기본적으로 체인의 어느 부분에서든지 실패가 일어난다면 전체 파이프라인이 종료되는것임니다. 오류 지점의 업스트림에 있는 모든 퍼블리셔(및 오퍼레이터)가 취소되고 그 이후에는 값을 생성하지 x. 다운스트림에서는 실패 메시지가 최종 구독자에게 전달되며, 이것이 수신되는 마지막 신호입니다.

위에서 살펴본 이미지를 가져오는 dataTaskPublisher 예시에서 데이터를 가져오는게 실패한 경우를 살펴보겠습니다.

데이터 작업이 오류와 함께 실패하면 실패 메시지와 오류를 감싸는 .failure 완료가 발생하며, 이 메시지는 파이프라인을 따라 싱크 구독자에게 전파됩니다. 현재 싱크 구독자는 실패,완료 신호를 무시하고 있습니다. receiveCompletion을 작성한다면 오류가 발생 했을 때 무언가를 수행하도록 지시할 수 있습니다.

실패의 변형

실패 메시지를 변경하는 것도 가능합니다. 이는 일종의 오류를 감싸고 있는데 오퍼레이터는 이를 다른 유형의 오류로 변경하여 실패로 감싸서 밑으로 보낼 수 있습니다. 또한 오퍼레이터는 오류가 체인 아래로 진행되지 않도록 효과적으로 차단할 수도 있습니다.

예시를 살펴보기 위해 데이터를 변환하기 전에 생기는 오류를 신경쓰지 않는다고 가정하겠습니다. 데이터 작업이 실패하면 이에 대해 듣고싶지도 않은거죠. 이렇게 하면 구독자는 어떤 경우에도 실패 메시지를 수신하지 않고 이미지만 받거나 아무것도 받지 않습니다. 이를 위해 replaceError(with:) 오퍼레이터를 사용하겠습니다.

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .sink() {
        self.iv.image = $0
    }
    .store(in:&self.storage)

위 코드를 살펴보면 map에서 데이터를 받습니다. 하지만 실패할 수도 있습니다. 이 경우에 replaceError(with:)을 사용하여 빈 데이터를 아래로 흘려보냅니다. 이제 이 시점부터는 어떤 실패도 파이프라인을 따라 진행할 수 없슴니다 !

이 시점부터 다운스트림의 실패 오류 유형은 전체가 실패할 수 없음을 의미하는 Never 임니다.

이제 위처럼 작성을 한다면, 데이터가 있든 없든 싱크에 도달하고 이는 실패가 발생하지 않는다는 것이 보장됩니다. 따라서 싱크 호출에서 수신 완료 매개변수를 지울 수 있습니다 실패가 불가능하므로 싱크 구독자를 assign 구독자로 변경이 가능합니다. 이를 사용하여 들어오는 이미지를 이미지 뷰의 이미지 속성에 직접 할당이 가능합니다

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv) // *
    .store(in:&self.storage)

이미지 할당까지 매우 아름다운 파이프라인이 하나 만들어졌습니다 ! 모든 작업을 수행하며 파이프라인은 네트워크에서 인터페이스의 이미지 뷰의 이미지 속성으로 직접 연결되는 역할을 합니다. 어떤 의미에서 우리 인터페이스는 파이프라인을 통해 스스로 구성하고 있습니다. 이것은 Combine 프레임워크의 본질을 보여줍니다.

타입들

앞서 말했듯이 파이프라인의 각 오퍼레이터는 게시자, 구독자 역할을 모두 수행합니다. 업스트림을 보고 앞의 업스트림 퍼블리셔(오퍼레이터 일수도 있음)를 효과적으로 구독하는 한에서는 구독자이며, 구독할 수 있고 바로 다음 다운스트림 오브젝트에 전달할 신호를 생성하는 한 퍼블리셔임니다. 파이프라인의 각 단계에서는 퍼블리셔-구독자 쌍이 포함됩니다.

data task publisher ↓publish
map                 ↑subscribe      ↓ publish
replaceError                        ↑ subscribe   ↓ publish
...

어느 단계에서든 퍼블리셔에서 구독자에게 전달할 수 있는 타입에 대해 생각해 보세요. 퍼블리셔가 게시하는 값은 특정 유형일 수 있고, 실패 메시지 또한 특정 오류 유형임니다. 그러므로, 모든 퍼블리셔와 게시자 쌍에는 항상 특정 두 개의 타입을 지정할 수 있습니다. 이 사실을 표현하면 퍼블리셔와 구독자 타입 자체가 제네릭이라는 것입니다

  • 퍼블리셔는 출력 유형과 실패 유형으로 매개변수화된 제네릭입니다.(실패 유형은 오류 프로토콜을 준수해야 하며, 실패 메시지를 전송할 수 없음을 나타내기 위해 Never일 수도 있습니다)
  • 구독자는 입력 유형과 실패 유형에 대해 매개변수화된 제네릭입니다

일반적으로 퍼블리셔와 구독자가 서로 직접 연결되려면 유형이 일치해야 합니다

  • 구독자의 입력 유형은 퍼블리셔의 출력 유형과 일치해야 함니다
  • 구독자의 실패 유형은 게시자의 실패 유형과 일치해야 함니다

즉, 구독자가 업스트림 퍼블리셔와 만나는 파이프라인의 모든 지점에 퍼블리셔의 출력 유형(구독자의 인풋유형이기도 함)과 퍼블리셔의 실패유형을 명시할 수 있다는 의미입니다. 예를 들어 파이프라인의 모든 단계에서 값(출력) 및 오류(실패) 유형으로만 파이프라인을 분석해 보겠슴니다

URLSession.shared.dataTaskPublisher(for: url)
    .map {$0.data}
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .receive(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)
  • dataTaskPublsiher 의 출력 유형은 튜플입니다 (data: Data, response: URLResponse), 실패 유형은 URLError 입니다
  • map의 입력은 업스트림의 출력에 일치시킴니다. 여기서 업스트림은 dataTaskPublsiher이므로 입력은 (data: Data, response: URLResponse)임니다. 출력 유형은 함수에서 반환하는 유형에 따라 결정되며 여기서는 Data 입니다 실패 유형은 업스트림과 동일하며 URLError 임니당
  • replaceError의 입력은 위의 출력과 동일하며 실패 유형은 Never 임니다. 실패를 어떤 형태의 성공으로 바꿨습니다
  • compactMap의 입력은 위의 출력과 동일하며 여기서는 Data가 됩니다. 출력은 반환하는 유형에 따라 결정되며, 함수는 반드시 Optional을 반화하고, 출력은 래핑된 유형입니다. 여기서는 UIImage를 감싸는 Optional을 반환하므로 출력 유형은 UIImage입니다 Failure 유형은 위의 유형과 일치하며 Never임니다
  • receive(on:)의 입력은 위의 출력과 일치시키며, 출력 유형은 입력과 동일하고 실패 유형은 위와 동일합니다. 이는 위에서 받은것을 변경하지 않고 그대로 전달하기 때문임니다
  • assign(to:on:)은 입력을 위의 출력과 일치시키며 여기서는 UIImage임니다. FailutreNever임니다. 최종 구독자이므로 파이프라인의 끝임니다.
profile
hi there 👋

0개의 댓글