본문 바로가기

iOS/Combine Framework

[Swift] No7. Deep dive into AnyPublisher and eraseToAnyPublisher( )🤩 | Combine

 

안녕하세요.

 

이번 포스트는 Combine 프레임워크를 사용할 때 정말 많이 작성하고 호출했던 타입인 AnyPublisher와 eraseToAnyPublisher에 대해 탐구하려고 합니다. 왜 사용하는지, 어떻게 사용하면 좋을지에 대해 프로젝트를 하며 경험했고 알게 된 개념을 공유하려고 합니다😄.

 

컴바인 공부할 초기에는 기술 포스트에서 AnyPublisher를 왜 사용하는지, 왜 eraseToAnyPublisher를 사용하는지, 타입을 지워준다는 개념은 정말 잘 알겠는데 그렇게 확 와닿지가 않았었습니다. 그때 당시의 저와 같은 상황인 분들이 이 포스트를 보면서 Publishers의 구체적인 타입을 AnyPublisher로 wrapping하여 추상화하는 이유를 이해하시면 좋을것 같습니다😆

 

1. AnyPublisher는 무엇일까?

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

 

AnyPublisher는 컴바인에서 제공하는 Just, Future와 같은 Publisher protocol을 준수하는 구체타입 입니다. 주로 서로 다른 module이나 layer간 특정 기능의 publisher를 제공할때 AnyPublisher 타입을 사용하면 좋습니다.

 

Publisher의 Upstream에서 서로 다른 operator들을 체이닝하며 연이어 호출하면 각각의 Operator의 Output 내부가 길어질 수 있습니다. 특정한 operator는 self: publisher를 Output으로 반환하기도 하기 때문입니다. 그러나 AnyPublisher<Self.Output, Self.Failure> 타입을 사용한다면, AnyPublisher가 방출하는 값을 명확하게 Output으로 지정하고 이를 준수하도록 반환하거나, type을 erase to AnyPublisher로 변환한다면 Output역시 단순해지게 됩니다😎.

 

1-1. AnyPublisher를 활용하는 이유 . 단순한 Output. Type erase.

함수의 반환타입이 명확, 단순하게 할수록 이 함수를 호출하는 입장에서도 명확하게 파악할 수 있습니다. 또한 함수의 반환타입을 Publishers의 특정 구체타입으로 하지 않을 경우에도 장점이 존재합니다. 아래 코드를 직접 보며 이해해보면 좋을것 같습니닷: ]

 

extension Publisher {
  func subscribeAndReceive(
    on schedular: DispatchQueue
  ) -> Publishers.ReceiveOn<Publishers.SubscribeOn<Self, DispatchQueue>, DispatchQueue> {
    return self // 1.
      .subscribe(on: schedular)  // 2.
      .receive(on: schedular) // 3.
  }
}

_=Just("타입제거안했을때")
  .subscribeAndReceive(on: DispatchQueue.global())
  .sink { ... }

 

 

특정한 upstream에 대해서 매번 subscribe(on:), receive(on:) 체이닝으로 호출해서 subscriber가 publihser한테 구독할 때 발생되는 호출 함수들의 context를 특정 schedular의 thread에서 호출하도록, 및 downstream을 방출할 때 해당 down stream의 context를 특정 schedular에서 제어하기 위해 두 operator들을 저는 매번 각각 호출 했었습니다.

 

매번 upstream에서 두 함수를 호출해서 queue를 지정하는 것이 귀찮았기에,,,, subscribeAndReceive(on:)를 구현했습니다. 이를 호출하면 내부적으로 subscribe(on:), receive(on:)을 호출하는 것을 보장합니다. (cf. subscribe(on:), receive(on:)은 나중에 정리할 예정입니다.)

 

 

[주석 1] self는 subscribeAndReceive(on:)을 호출한 upstream's publihser 그 자체입니다. 아니면 combine에서 제공한 operator를 사용한 후에 반환된 특정 Publishers일 수도 있습니다.

 

[주석 2] subscribe(on:) 함수를 호출해  self가 subscribe 및 cancel등의 context를 특정 schedular로 지정합니다.

 

Combine의 operator를 사용할때 각각의 operator 함수를 사용할 때 반환타입이 명확하게 정해져 있습니다. 위와 같이 subscribe(on:)은 Publisher's extension에 선언되어 있습니다. 내부적으로 어떻게 구현됬는지 궁금한데.. 위의 코드에서는 이 함수를 호출하는 것임으로 반환타입에 집중하겠습니다. 반환타입은 Publishers.SubscribeOn<Self, S> 입니다. Self는 Publihser 타입입니다. 추후에 호출된다면 호출한 publihser 자신이 될 것입니다. 그리고 schedular를 반환합니다.

 

이 함수를 호출했을 때의 반환타입은 그래서 Publishers.SubscribeOn<Self, S>가 됩니다. ( cf. 이때 Self가 만약 Publishers. Zip, MergeMany Etc... operator를 사용했다면 실제로 컴파일러가 더 복잡하게 표시, 추론을 할 수 있습니다. 근데 AnyPublisher 타입을 사용한다면? 좀 더 단순해지니.. 추론을 쉽게하지않을까 하는 개인적인 생각입니다.)

 

[주석 3] receive(on:) 을 호출하며 subscribeAndReceive(on:)의 하위 스트림 (ex. subscriber, operator ...) 의 클로저들 context 실행 환경을 제어합니다.

 

 

마찬가지로 이 함수를 호출했을 때의 반환타입은 Publishers.ReceiveOn<Self, S>가 됩니다.

 

그래서 subscribe(on:)을 호출했을 때의 Self 타입 == Publishers.SubscribeOn<Self, S>을 receive의 output 타입 Self로 담기게 됩 니다. 그래서 최종적인 타입은 Publishers.ReceiveOn< Self, S> 인데 Self는 위에 업스트림에서 형성된 타입의 Self를 활용함으로 Publishers.ReceiveOn<Publishers.SubscribeOn<Self, S>, S> 가 됩니다.

 

 

다시, 더 정확하게 말하자면

 

ReceiveOn 퍼블리셔는 Output으로 Upstream.Output을 반환합니다.

 

그렇지만 Publishers.ReceiveOn 자료형을 생성할 때는 제너릭 타입인 Upstream을 지정해야 합니다. 이 타입이 Publisher 타입입니다.

 

Upstream Publihser's Output type과 Output이 일치해야지만 Publisher와 Subscriber가 Subscription을 통해 reqeust(_:)가 가능한것은 당연합니다. 이 관점이 아니라, ReceiveOn을 생성할 때는 Upstream의 제너릭 타입을 지정해야만 합니다. 위 코드에서 그 upstream이 반환하는 Publisher의 타입은 Publishers.SubscribeOn<Self,S> 를 반환합니다🤩 그래서 최종적으로 저렇게 감싸져서 반환됩니다.

 

참고로 업스트림에 여러 operator가 담기면 타입은 더 길어집니다.

 

다시 돌아와서 Combine's operator는 Publishers의 특정 퍼블리셔 구조체를 반환하는데 Upstream을 넘겨버려가지구 타입을 제거해야 합니다.

 

1-2. AnyPublisher vs any Publisher와 eraseToAnyPublisher()

 

이젠 위에서 작성한 코드를 리펙터링할 것입니다: ]

 

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

 

이때 사용되는 타입은 AnyPublisher입니다.

 

 

위에서 언급했지만, 이 타입은 Publisher protocol을 준수한 구조체입니다. Publisher를 준수했기에 AnyPublisher 타입을 사용하기 위해서 명확하게 Output, Error 타입을 선언해야 합니다. 역시 구조체에서 receive(subscriber:)를 구현했을 것으로 예상됩니다. 즉, subscriber가 subscribe(_:)를 통해 구독을 요청할때 subscriber한테 Output을 방출할 수 있습니다.

 

그러나 이 외에 public 함수로 선언된것이 없기에, Subject의 send() 함수 같은 Subject에 정의된 메서드 호출이 불가능합니다. 이 또한 장점이라고 볼 수 있습니다. "안정성?"

 

extension Publisher {
  func subscribeAndReceive(
    on schedular: DispatchQueue
  ) -> AnyPublisher<Self.Output, Self.Failure> {
    let upstream: Publishers.ReceiveOn<Publishers.SubscribeOn<Self, DispatchQueue>, DispatchQueue> = self
      .subscribe(on: schedular)
      .receive(on: schedular)
    return AnyPublisher(upstream)
  }
}

 

 

AnyPublisher 구조체를 생성합니다.

 

 

subscribeAndReceive(on:) 을 호출하는 측에서 컴파일러가 AnyPublisher와 Output타입을 추론했습니다. 컴파일러는 AnyPublisher와 이에 대한 Output의 타입을 추론합니다.

 

사실 Publishers.ReceiveOn도 Publisher protocol을 준수한 구조체이고, AnyPublisher 또한 Publisher protocol을 준수한 구조체인데, AnyPublisher를 사용하면 좋은 장점은 위에 보셨듯이 타입을 AnyPublisher로 단순화 할 수 있습니다. 정말 공식문서에 나왔든 기존 upstream publishers를 another publisehr(AnyPublisher)로 wrap합니다. 그러기에 AnyPublisher타입이 보여지게 되는 것입니다.

 

하위 스트림에선 Publisher의 operator들을 사용할 수 있습니다.

 

extension Publisher {
  func subscribeAndReceive(
    on schedular: DispatchQueue
  ) -> AnyPublisher<Self.Output, Self.Failure> {
    return self
      .subscribe(on: schedular)
      .receive(on: schedular)
      .eraseToAnyPublisher()
  }
}

 

위 코드에서 더 간단하게 하려면 eraseToAnyPublisher를 사용하면 됩니다. 내부적으로 AnyPublisher의 init을 생성해서 반환하기 때문입니다.

 

cf.

그러나 AnyOBject 타입이어서 참조가 가능하고, 값을 지속적으로 보낼수 있는 Subject protocol은 Publisher를 마찬가지로 준수하지만 만 AnyPublisher에서는 send()를 사용할 수 없습니다. 타입을 보면 당연한 것이고, 참조 성격을 띄기에 어떤 결과가 발생될지는 모르겠습니다.
다시말해서 AnyPublisher는 주로 값을 내보내거나 구독할때에도 잘 활용이 됩니다. 보낼수 없습니다.

 

 

extension Publisher {
  func subscribeAndReceive(
    on schedular: DispatchQueue
  ) -> any Publisher {
  	  return self
      .subscribe(on: schedular)
      .receive(on: schedular)
  }
}


타입을 추상화한다는 개념.. 그리고 AnyPublisher도 Publisher protocol을 준수했다는 점에서 반환타입을 any Publisher로 하게되면 정말 완전한 추상화가 됩니다. 그러나 이 경우 사용하는 측에서 다시 Publisher의 구체 타입으로 캐스팅하지 않으면 combine operator들을 활용할 수 없습니다. 

 

AnyPublisher를 사용해서 반환 Publisher의 타입을 추상화 해서 필요한 Output, Failure만 노출하면서도 Publisehr operator들을 사용할수 있다는게 첫번째 장점인 것 같습니다.

 

2. AnyPublisher로 타입을 추상화 하는 것의 장점.  의존성

또한 타입을 AnyPublisher로 추상화한다는 것은 의존성도 감소되기에 구현체가 달라져도 최소한의 변경으로 반응할 수 있습니다. 

 

struct Post {
  let postId: UUID
  let content: String
}

protocol PostRepository {
  func fetchPosts() -> AnyPublisher<Post, any Error>
}

 

포스트를 받아오는 레포지토리라는 프로토콜과 함수가 있습니다.

 

struct FirestorePostRepository: PostRepository {
  func fetchPosts() -> AnyPublisher<Post, any Error> {
    return Firestore.firestore
      .collection
      ...
      .eraseToAnyPublisher()
  }
}

struct SpringPostRepository: PostRepository {
  func fetchPosts() -> AnyPublisher<Post, any Error> {
    return sessionable
      .request()...
      .eraseToAnyPublisher()
  }
}

struct StubPostRepository: PostRepository {
  return Just(Post(postId: UUID(), content: "하하!!")).eraseToAnyPublisher()
}

 

만약 스프링 백엔드 서버가 구현한 api를 구현했는데 서버의 개인 사정으로 더 이상 개발을 같이 못해서  Firestore를 통해 개발해야 할 때,  또는 PostRepository를 의존성으로 갖는 객체의 로직 테스트 할때 등 PostRepository의 구현체는 이 프로토콜에 맞게 구현하면 됩니다. 

 

struct PostFetchUseCase {
  let postRepository: PostRepository
  
  func fetchPosts() -> AnyPublisher<Post, any Error> {
    return postRepository
      .fetchPosts()
      .map { post in
        // TODO: - Post ..
      }
  }
}

 

사용하는 측에서는 그저 추상화된 Protocol과 PostRepository 타입을 선언하고 type erased AnyPublisher를 가지고 이어서 동작을 구현할 수 있습니다. 이때 postRepository의 StubPostRepository, SpringPostRepository, FirestorePostRepository 어느 구현체가 오던, 추상화된 반환타입을 활용하고 그 반환 타입은 동일하기 때문에 변화에 대응하기도 쉽습니다. DIP(Dependency Inversion Principle) 준수하기에 좋습니다.

 

결론:

  • 타입 추상화 ( Upstream Publisher의 구체타입을 하위 모듈에서 알지 않아도 됩니다)
  • 코드 간결성 (Publisher 타입을 AnyPublisher로 줄입니다)
  • 다형성 
  • 편리합니다
  • etc...

 

궁금하거나 잘못된 내용이 있을 경우 댓글로 알려주시면 감사합니다.