[Combine] AnyPublisher와 Type Erasure 개념 뿌시기

2024. 12. 2. 17:21Framework, Library/Combine 완전 정복하기

💬 정말 오랜만에 쓰는 블로그 글이네요... 이번 학기 프로젝트가 너무 바쁜 바람에..이제서야 돌아오게 되었답니다...

다름이 아니라, 오늘 작성하는 글부터는 편하게 설명하는 느낌이 들도록 말투를 바꿔보려고 합니다 ^__^
블로그에서조차 딱딱한 말투로 기술을 설명하면, 가뜩이나 지피티를 찾아보는 요즘...공식문서가 아닌, 기술 블로그를 찾아볼 매력이 떨어진다고 생각이 들어.. 변화를 선택했습니닷..! 적응 안돼도 양해 부탁🙏🏻🙏🏻🙏🏻


예전에 제가 쓴 Combine 기초 설명 글에서 AnyPublisher에 대한 언급을 간략하게 한 적이 있었습니다!

당시에는 Combine의 전체적인 흐름이 중요하다보니 간단하게만 설명하고 넘어갔었는데,
사실 컴바인 스터디를 하면서 AnyPublisher가 꽤 중요하고 - 이 부분에서 딥 다이브~할 필요가 있더라고요? 그래서 오늘 글을 준비했습니다.

이 부분은 무슨 말인지 더 알아봐야 할 필요가.. 부분 떡밥 회수 갑니닷!

 

1. AnyPublisher 어떻게 사용되는지 일단 상황파악부터 해보자구요

 

AnyPublisher | Apple Developer Documentation

A publisher that performs type erasure by wrapping another publisher.

developer.apple.com


Combine을 쓰다 보면 eraseToAnyPublisher()라는 Publisher의 인스턴스 메서드가 많이 보이는데요!

예시 코드를 한번 살펴보죠! 간단하게만!

아래 코드에 있는 메서드에는 Future Publisher가 사용되었습니다.
*Future : 비동기 작업의 단일 결과를 나타내는 경우 사용하는 Publisher. 성공적(Output)으로 값을 배출할 수도/실패(Failure)할 수도 있다.

NetworkSerivce에서 patchOpenLink라는 네트워크 메서드를 호출하고, 성공 값이나 실패 값을 result switch문으로 구분해서 promise의 성공/실패 상태를 전달하는 코드인데요.

다른 부분은 그냥 대충 넘기고, 제가 주목해 볼 부분은 가장 하단에 있는 eraseToAnyPublisher() 호출 부분입니다!

private extension LinkWebViewModel {
    func patchOpenLinkAPI(requestBody: LinkReadEditModel) -> AnyPublisher<Bool, Error> {
        return Future<Bool, Error> { promise in
            NetworkService.shared.toastService.patchOpenLink(
                requestBody: PatchOpenLinkRequestDTO(
                    toastId: requestBody.toastId,
                    isRead: requestBody.isRead
                )
            ) { result in
               switch result {
               case .success:
                   promise(.success(!requestBody.isRead))
               case .unAuthorized, .networkFail, .notFound:
                   promise(.failure(NetworkResult<Error>.unAuthorized))
               default:
                   break
               }
           }
        }.eraseToAnyPublisher()
    }
}

Apple은 AnyPublisher를 뭐라고 공식문서에서 정의하고 있는지 읽어보죠!

💡 A publisher that performs type erasure by wrapping another publisher. (다른 Publisher를 wrapping하여, type erasure를 수행하는 publisher입니다.)


아!
Publisher를 감싸면서 타입 지우개(Type Erasure) 역할을 수행한다는 것이 AnyPublisher라고 하는데요.

그럼 이 코드에서는 Future Publisher를 AnyPublisher가 한 단계 래핑되고, Furure라는 타입이 지워지면서 - 겉으로 봤을 때는 AnyPublisher라는 껍데기로 사용되도록 만드는 거군요!
💬 Future를 감싸주는…따스한 AnyPublisher…같은 느낌이랄까....흠..오케이. 일단 AnyPublisher가 뭔지는 이해했으.

근데 왜? 그냥 Future로 사용하면 안되는겨? performs type erasure는 대체 뭐가 좋은 거지? 여러 의문이 제 머릿속에 스쳐가는데요..
Type Erasure를 조금 더 자세히 이해해 보러 가봅시다!

 

2. Type Erasure는 또 뭔데?

2-1. AnyPublisher 안 쓰면 아주 복잡해지는 코드

무엇보다 코드 가독성 측면에서 AnyPublisher가 필요한 상황이 딱 와닿게 됩니다.

아래는 WWDC에서 나왔던 비밀번호 유효성 검증하는 예시에서, 두 Publisher들을 combineLatest로 결합하고 / map 연산자로 방출되는 값을 변환한 이후 내보내는 Publisher인데요.

아래 보이는 것처럼 이런 Operator의 체이닝이 Publisher에 타입에 모두 반영되기 때문에.. Publishers.Map<Publishers.CombineLatest<Published<String>.Publisher, Published<String>.Publisher>, String?>라는 어마무시(?)하고 극악무도(?)한 Publisher 타입이 생기게 된답니다.

var validatedPassword: Publishers.Map<
    Publishers.CombineLatest<
        Published<String>.Publisher, Published<String>.Publisher>,
    String?> {
    return $password
        .combineLatest($passwordAgain)
        .map { password, passwordAgain in
            guard password == passwordAgain, password.count > 1 else { return nil }
            return password
        }
}

AnyPublisher 타입을 사용한다면, 이 극악무도한 Publisher 타입을 간편하게 사용할 수 있다는 점! 

var validatedPassword: AnyPublisher<String?, Never> {
    return $password
        .combineLatest($passwordAgain)
        .map { password, passwordAgain in
            guard password == passwordAgain, password.count > 1 else { return nil }
            return password
        }
        .eraseToAnyPublisher()
}

 

2-2. OOP의 캡슐화, 데이터 은닉을 떠올려보자!

💡 캡슐화는 클래스 안에 서로 연관있는 속성과 기능들을 하나의 캡슐(capsule)로 만들어 데이터를 외부로부터 보호하는 것을 말합니다!
*추가로, 데이터 은닉(data hiding)은 내부의 동작을 감추고 외부에는 필요한 부분만 노출하는 것을 말하죠!

위랑 이어지는 내용입니다.

결국 우리가 위에서 만든 validatedPassword라는 publisher를 사용할 때,
이 Publisher가 CombineLatest로 결합을 했던 / map으로 변환을 했던 / filter로 조건을 처리했던… 관심을 가질 필요가 전혀 없죠!

우리에게 중요한 건, “이 녀석이 어찌 됐던 <String?, Never> 값을 방출하는 Publisher구나!”죠.

그런 점에서,
Publisher의 내부 구현/동작 부분을 감추고, 사용하는 부분만 노출시킨다는 점에서 AnyPublisher가 사용된다고 볼 수 있겠네요😊

 

2-3. OOP의 다형성을 떠올려보자!

💡 다형성은 한 객체가 상황에 따라 다른 여러 형태(객체)로 재구성될 수 있는 것을 의미합니다!

또한, 아래 코드처럼 한 Publisher 내부에서 조건에 따라 다양한 타입의 Publisher를 내보내는 경우가 있을 수도 있을 것 같아요!

이런 경우에도 각 Publisher 타입을 아우를 수 있는 Publisher 타입으로,
AnyPublisher가 각 타입을 소거하고 / 래핑해서 사용할 수 있는 좋은 대안이 될 수 있는 거죠 ^__^

func fetchData(requestBody: DataRequestDTO) -> AnyPublisher<Data, Error> {
    let networkDataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: ""))
        .map(\\.data)
        .eraseToAnyPublisher()

    let localDataPublisher = Just(Data("Local data".utf8))
        .setFailureType(to: Error.self)
        .eraseToAnyPublisher()
    
    switch requestType {
    case .network:
        return networkPublisher    // 이 경우에는 Future를
    case .local:
        return localPublisher      // 이 경우에는 Just를 사용해야겠네요
    }
}

 

3. OpenCombine - AnyPublisher 코드 뜯어보기

 

OpenCombine/Sources/OpenCombine/AnyPublisher.swift at master · OpenCombine/OpenCombine

Open source implementation of Apple's Combine framework for processing values over time. - OpenCombine/OpenCombine

github.com

들어가기에 앞서 저는 오픈소스 코드를 잘 안 뜯어봐서.. 낯선 어노테이션이 있더라고요…?

자주 사용하는 것부터 먼저 정리부터 하고 갑니다!

  • @inlinable : 외부에서 직접 호출되는 공개 함수의 성능 최적화에 사용됨!
  • @usableFromInline : 외부에 노출되지 않아야 하는 세부 구현을 @inlinable 함수 내에서 사용할 수 있도록!

더 깊게 들어가면 복잡하긴 한데..
아무튼 공개되는 라이브러리 코드의 성능은 높이고, 내부 구현의 캡슐화는 유지하도록 하기 위해 쓰는 어노테이션이다.. 정도로..! 나중에 시간이 되면 별도의 글로 다뤄보도록 할게요!!

 

3-1. Publisher Extension - eraseToAnyPublisher() 메서드 구현

Publisher를 AnyPublisher로 래핑해서 반환하는 코드쥬? 간단하네유!

extension Publisher {
    @inlinable
    public func eraseToAnyPublisher() -> AnyPublisher<Output, Failure> {
        return .init(self)
    }
}

 

3-2. AnyPublisher struct

그럼 AnyPublisher 타입을 정의해야 합니다!

일단 AnyPublisher가 콘솔이랑, 플레이그라운드에서 원하는대로 표시할 수 있도록 두 프로토콜(CustomStringConvertible, CustomPlaygroundDisplayConvertible)이 채택되어 있군요.

public struct AnyPublisher<Output, Failure: Error>
  : CustomStringConvertible,
    CustomPlaygroundDisplayConvertible
{
    public var description: String {
        return "AnyPublisher"
    }

    public var playgroundDescription: Any {
        return description
    }
}

AnyPublisher의 핵심 부분은 아래 부분입니다.

AnyPublisher가 위에서 설명했다시피, wrapping another publisher의 역할을 수행한다고 했잖아요? 이 wrapping된 Publisher가 담기는 곳이 바로 box입니다.

초기화 init 부분을 보죠.
AnyPublisher 선언 시, 전달받은 Publisher 인스턴스를 AnyPublisher로 래핑하는 코드입니다.
당연히, Output과 Failure 조건이 부합하는지를 확인한 이후,
만약 AnyPublisher로 래핑된 경우에는 중복해서 래핑하지 않고, 기존 box를 재사용하고 / 그렇지 않으면 PublisherBox 인스턴스로 감싸서 box에 저장하도록 합니다.

{
    @usableFromInline
    internal let box: PublisherBoxBase<Output, Failure>

    @inlinable
    public init<PublisherType: Publisher>(_ publisher: PublisherType)
        where Output == PublisherType.Output, Failure == PublisherType.Failure
    {
        if let erased = publisher as? AnyPublisher<Output, Failure> {
            box = erased.box
        } else {
            box = PublisherBox(base: publisher)
        }
    }
}

조금 복잡하죠?

PublisherBoxBase랑 PublisherBox가 어떻게 구현되어 있는지를 봅시다!

 

3-3. PublisherBoxBase, PublisherBox - AnyPublisher box Type

PublisherBoxBase는 추상 클래스,
PublisherBox는 PublisherBoxBase의 구현부에 해당하는 코드입니다.

실제 래핑될 Publisher가 담기는 곳은 PublisherBox의 base인 것 탕탕탕!

PublisherType을 받아서 base라는 속성에 저장하고, receive(subscriber:) 메서드를 재정의하여 base가 subscriber를 받을 수 있도록 연결하는 것이 해당 클래스의 역할입니다.

@usableFromInline
internal class PublisherBoxBase<Output, Failure: Error>: Publisher {

    @inlinable
    internal init() {}

    @usableFromInline
    internal func receive<Downstream: Subscriber>(subscriber: Downstream)
        where Failure == Downstream.Failure, Output == Downstream.Input
    {
        abstractMethod()
    }
}
@usableFromInline
internal final class PublisherBox<PublisherType: Publisher>
    : PublisherBoxBase<PublisherType.Output, PublisherType.Failure>
{
    @usableFromInline
    internal let base: PublisherType

    @inlinable
    internal init(base: PublisherType) {
        self.base = base
        super.init()
    }

    @inlinable
    override internal func receive<Downstream: Subscriber>(subscriber: Downstream)
        where Failure == Downstream.Failure, Output == Downstream.Input
    {
        base.receive(subscriber: subscriber)
    }
}

 

3-4. 아이고 복잡해라. 그럼 정리!

아까의 상황처럼 Future라는 Publisher를 AnyPublisher로 래핑해서 사용하고 싶은 상황이라고 합시다.

  1. AnyPublisher는 box라는 PublisherBoxBase 타입의 속성을 통해 실제 Publisher 인스턴스를 간접적으로 보유하는 형태입니다.
  2. PublisherBoxBase는 추상 클래스에 해당하기에 직접적으로 Publisher를 보유하지 않지만, Publisher 프로토콜을 따르기 때문에 Publisher처럼 사용할 수 있습니다. → 그니까 AnyPublisher의 PublisherBoxBase 타입을 따르는 박스 인스턴스를 Publisher처럼 쓸 수 있는 거고.
  3. PublisherBox는 PublisherBoxBase를 상속받은 구체 클래스이며, 실제 Publisher 타입을 base에 저장하고, 이를 통해 구독자에게 값을 전달합니다. (receive)


즉, Future가 AnyPublisher로 래핑 되면,
AnyPublisher는 그 Publisher를 감싸는 PublisherBox 인스턴스를 생성하여 box에 저장하는데,
AnyPublisher는 Publisher 프로토콜의 메서드를 호출할 때 box를 통해 (PublisherBoxBase → PublisherBox의 base에 접근 과정을 거쳐) 실제 Publisher 인스턴스에 접근하고, Subscriber에게 값을 전달하도록 하는 것!

하지만 외부에서는 AnyPublisher만 보이기 때문에 Publisher의 실제 타입이 감춰지고 추상화된 상태로 사용되는 것!

이다.

이해 안 되면 댓글~!~!~@@

 

3-5. AnyPublisher Extension - receive method

그니까 receive를 호출하는 것은 곧 box로 receive를 접근해서 호출하는 것과 같은 거죠.

이해되죠? 쏙쏙.

extension AnyPublisher: Publisher {
    @inlinable
    public func receive<Downstream: Subscriber>(subscriber: Downstream)
        where Output == Downstream.Input, Failure == Downstream.Failure
    {
        box.receive(subscriber: subscriber)
    }
}