본문 바로가기
Dev/고민과 삽질의 기록들🤔

Async/await와 Combine 함께 쓰기 (Network Layer)

by Mintta 2023. 12. 5.

 

async await를 통해서 구축되어있는 프로젝트 환경에서 저희는 Combine과 async await을 함께 쓰기로 결정하였습니다.

그 이유인 즉, async await과 Combine은 각각의 명확한 장점을 가진 방법들이었고, 그 장점들을 모두 얻고자 하였기 때문입니다.

async await의 경우 Swift Concurrency를 본격적으로 활용해서 비동기 로직을 보다 효율적으로 사용할 수 있는 장점이 있었고, 이를 통해서 얻은 데이터를 Observing하다가 UI에 반영해주기 위해선 Observing에 특화된 Combine을 사용하여 그 장점을 얻을 수 있었습니다. 이 이유에 대한 이야기는 아래 글에서 조금 더 자세히 다루고 있습니다.

https://codingmon.tistory.com/70

 

[Combine] Combine을 왜 써야할까 ? (async/await과 비교)

이전글 https://codingmon.tistory.com/62 [Reactive Programming] Intro: Functional Reactive Programming ? RxSwift, Combine를 시작하기 앞서 RxSwift라고 적었던 모든 단어들이 Combine으로 대체해서 보아도 되기 때문에 제목을 R

codingmon.tistory.com

 

이렇게 async await과 Combine을 활용하기 위해서는 async await으로 얻은 데이터를 stream으로 보내줄 필요가 있습니다.

그리고 오늘 글은 그 과정에 대한 글입니다.


1. Future를 통해 API 호출 결과값 Publish (async-await)

Combine에서는 Future를 사용하여 비동기 작업을 수행한 얻게 될 데이터를 비동기적으로 Publish할 수 있습니다. 

이 때 Future는 Promise(Result타입의 typealias)를 통해서 데이터를 전달합니다. Future를 활용해서 아래와 같이 코드를 작성할 수 있습니다.

 

// Viewmodel

Future<TodayArticle, NetworkError> { promise in
    Task {
        do {
            let responseArticle = try await self.manager.inquiryTodayArticle()
            promise(.success(responseArticle))
        } catch {
            promise(.failure(error as! NetworkError))
        }
    }
}

 

 

Future를 활용함으로써 이제 async await을 통해 받은 데이터를 stream의 형태로 값을 내보낼 수 있게 되었습니다.

 

하지만 한가지 더 고려할 점이 있습니다. 바로 에러 핸들링입니다. 보시다시피 네트워크 레이어에 속한 manager를 통해서 요청하는 API는 throws, 즉 에러를 던지는 메서드이기 때문에 try를 통해서 메서드를 호출하고 있습니다.

 

에러가 난다면 do-catch문에서 catch문으로 빠지게 되고 promise에서 failure의 연관값으로 error를 가진채 값이 Published됩니다.

 

만약 이 상태로 error가 stream을 통해서 ViewController까지 전파된다면 어떻게 될까요 ?

 

error는 completion의 한 종류로 stream을 종료시킵니다. 즉, 에러가 한번 난 stream은 그 에러를 마지막으로 끊어지게 됩니다.

 

이제 해당 stream에서 받고자 하는 이벤트는 더 이상 받을 수 없게 되는 것 입니다. 이건 우리가 원하는 결과가 아닙니다.

2. Error handling을 위한 catch operator

이런 경우를 막기 위해서 우리는 error handling operator중 catch를 사용할 수 있습니다.

catch가 추가된 형태는 아래 코드와 같습니다.

 

// ViewModel

Future<TodayArticle, NetworkError> { promise in
    Task {
        do {
            let responseArticle = try await self.manager.inquiryTodayArticle()
            promise(.success(responseArticle))
        } catch {
            promise(.failure(error as! NetworkError))
        }
    }
}
.catch { error in
    return Just(TodayArticle.emptyArticle)
}

 

Future에서 Promise를 통해 에러가 Publish된다면 이를 catch operator에서 잡아 placeholder 값을 담은 Publisher로 대체합니다.

이 때 Just Publisher를 활용해서 Error 타입을 Never로 바꾸게 된다면 에러가 발생할 수 없는 Publisher로 대체되어 이제 에러가 발생하더라도 completion을 맞아 stream이 끊어지게 될 걱정이 없어졌습니다 !!

...

 

정말 그럴까요....???? 🤔

 

사실 에러가 발생한다면 여전히 이 stream은 끊어지게 됩니다. 그 이유는 Catch에 있습니다.

 

처음에 헷갈렸던 포인트를 먼저 짚어보자면, Combine의 catch operator를 제대로 이해하지 못한채 catch operator를 기존에 사용하던 do-catch문의 catch문과 동일하게 생각했던 것이 해결책을 찾는데 더 오랜 시간을 걸리게 만든 것 같습니다.

catch operator는 에러가 발생했을 때만 실행이 되고, 에러가 발생이 안된다면 그냥 지나가는 operator라고 생각했던 것 입니다.

 

그럼 catch를 살짝 들여다보겠습니다.

 

이해를 돕기 위해 catch 코드부분만 똑 떼서 가져와보겠습니다.

 

Future<TodayArticle, NetworkError> { promise in
    ...
}
.catch { error in
    return Just(TodayArticle.emptyArticle)
}

 

 

 

코드와 함께 살펴봅시다. catch 뒤에 있는 후행 클로저는 handler에 해당됩니다. 여기서 handler의 Return타입은 위에 코드에서 Just<TodayArticle, Never>가 됩니다. 즉, P의 type placeholder가 Just<TodayArticle, Never>가 되는 것 입니다.

이것이 에러가 났을 때 우리가 대체해서 반환하고자 하는 "새로운 Publisher"입니다.

 

Self는 catch 메서드를 호출한 객체의 메타타입입니다. 즉, Upstream Publisher의 타입입니다. Self.Failure 코드를 통해서 upstream의 Failure 타입을 그대로 물려받는 사실을 알 수 있습니다.

Self는 위 코드를 기반으로 보면 Future<TodayArticle, NetworkError>에 해당합니다.

 

이 때 where절 제약 조건을 확인해보면 upstream의 Output과 대체될 New Publisher의 Output타입이 같아야한다고 되어 있습니다.

이건 우리가 원래 넘기고자 하는 데이터의 대체값이기 때문에 갑자기 완전 다른 타입이 가면 안되겠죠 ?

 

이제 Return타입을 확인해보면 Publisher.Catch<Self, P>입니다.

앞서서 Self는 upstream, P는 New Publisher에 해당된다고 이야기했던 것을 보아 자세한 타입을 적어보면..

Publisher<Future<TodayArticle, NetworkError>, Just<TodayArticle, Never>>

가 됩니다.

 

정말 그런지 확인해볼까요?

 

일단 여기까지만 봐도 처음 제가 생각했던 기존의 catch와는 많이 다른 것을 알 수 있습니다.

 

Publisher.Catch는 일단 두가지 선택지를 가진채 생성되고 있는 것입니다. 즉, 에러의 발생유무와는 완전히 별개로 Catch Operator를 거치게 됩니다. 두가지 선택지를 가진 채 생성되는 Catch.. 뭔가 상황에 맞게 적절한 Publisher를 골라서 내보내줄 것만 같은 느낌이 듭니다. 

 

만약 Future에서 에러가 발생한다면, Catch는 New Publisher의 값을 downstream인 Sink Subscriber에게 넘기게 됩니다.

우리 코드에서 New Publisher는 Just에 해당합니다. Just는 구독이 일어나게 되면 subscription.request(demand:)가 일어나는 시점에 바로 값을 Publish해서 넘겨줍니다. 그리고 Just이기 때문에 바로 completion도 이어서 보내게 됩니다. 아래 그림처럼 말이죠.

(자세한 Catch의 내부 구현 로직은 이곳에 적으면 글이 너무 길어지고 두서없어질까봐 따로 글을 정리해서 링크를 첨부하겠습니다 !)

 

 

분명 위에서 completion이 불리게 되면 stream이 끊어지게 때문에 completion을 불리지 않게 하기 위해서 catch를 사용해서 Failure가 Never인 Just로 바꿔서 내보내고자 하였는데, 한번 placeholder값이 나가게 되면 stream이 끊어지는 똑같은 결과를 맞게 됩니다..🥲

 

이 문제를 해결하기 위해 또 새로운 Operator가 등장하게 됩니다.

3. FlatMap Operator 

문제는 stream이 끊어진다라는 것이었습니다. 그 상황을 WWDC Combine in practice라는 WWDC 내용을 조금 가지고 와서 한번 짚고 넘어가겠습니다.

 



이렇게 에러가 발생하게 된다면 기존에 연결되어있던 upstream과 연결이 끊어지게 됩니다. 하지만 저희가 하고 싶은 것은 이 모든 기능을 유지한채로 stream을 유지시키는 것 입니다. 이 때 우리는 flatMap Operator를 사용할 수 있습니다.

 

flatMap

flatMap은 여러 개의 상위 Publisher들을 하나의 하위 Publisher로 펼치거나, 넘어온 value들을 하나의 Publisher로 flat시켜서 내보냅니다.

 

새로운 Publisher를 내보낸다에 초점을 맞춘다면 사실 새로운 Publisher를 return시켜주는 Operator는 많습니다. 하지만, flatMap과의 차이점은 flatMap은 새로운 Publisher를 만드는 transform 클로저를 내부에서 subscribe하면서, 세부적인 내부 구현 처리들을 모두 해줄 수 있다는 것이 차이입니다.

 

우리 코드로 치면 Future -> Catch 까지 이어지는 이 구현 디테일을 처리하고 얻게되는 value를 downstream인 Sink로 흘려보낼 수 있게 되는 것 입니다.

 

그렇다면 코드의 모습은 아래와 같게 됩니다.

let viewWillAppearSubject = input.viewWillAppearSubject
            .flatMap { _ -> AnyPublisher<TodayArticle, Never> in
                return Future<TodayArticle, NetworkError> { promise in
                    Task {
                        do {
                            let responseArticle = try await self.manager.inquiryTodayArticle()
                            promise(.success(responseArticle))
                        } catch {
                            promise(.failure(error as! NetworkError))
                        }
                    }
                }
                .catch { error in
                    self.errorSubject.send(error)
                    return Just(TodayArticle.emptyArticle)
                }
                .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()

 

그림으로도 어떤 상황인지 한번 확인해볼까요 ?

 

 

flatMap안으로 Nested Publisher, stream이 존재합니다. 만약 동일하게 Catch가 error를 받게된다면 completion은 어디로 가게 될까요 ?? 🤔

 

정답은 flatMap내부에서 새로운 stream을 구독한 Subscriber에게 전달됩니다. 즉, 상위 stream에 존재하는 Subscriber 코드 상으로 Sink에게는 error가 전달될 일이 없습니다. 즉, completion이 되지 않는, 끊기지 않는 stream이 됩니다.

 

 

유저 입장에서는 에러가 발생했을 때 그에 맞는 placeholder 데이터를 확인할 수 있게 되고, 앱의 기능이 죽어버리는 등의 경험을 겪지 않게 됩니다.

 

최종 코드

// ViewModel - transform(_ input:) -> Output

let viewWillAppearSubject = input.viewWillAppearSubject
            .flatMap { _ -> AnyPublisher<TodayArticle, Never> in
                return Future<TodayArticle, NetworkError> { promise in
                    Task {
                        do {
                            let responseArticle = try await self.manager.inquiryTodayArticle()
                            promise(.success(responseArticle))
                        } catch {
                            promise(.failure(error as! NetworkError))
                        }
                    }
                }
                .catch { error in
                    self.errorSubject.send(error)
                    return Just(TodayArticle.emptyArticle)
                }
                .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
            
return Output(viewWillAppearSubject: viewWillAppearSubject)

마무리

처음 async await와 Combine을 혼합해서 써보자! 고 결정했을 때 관련된 레퍼런스가 많이 없었고 (여기저기 구글링 해본결과) Combine 조차도 미숙했던 터라 구현했을 당시에 정말 오랜 삽질과 에러 끝에 위 방법을 찾아내고 기뻐했었던 것 같습니다. 

 

위 고민들과 과정은 모두 제가 작업하고 있는 아래 프로젝트에 적용되었으니 궁금하시거나 코드 전체가 궁금하시다! 하시면 방문해주셔서 스타까지 눌러주신다면 더할나위 없이 좋겠네요ㅎㅎ

오늘 글은 이것으로 마무리 짓고 Catch 동작과 flatMap 혹은 Unit test? 어느 것을 먼저 작성하게 될지는 모르겠지만 다음 주제로 돌아오겠습니다.

https://github.com/Team-LionHeart/LionHeart-iOS

 

GitHub - Team-LionHeart/LionHeart-iOS: 🦁라이온하트🦁

🦁라이온하트🦁. Contribute to Team-LionHeart/LionHeart-iOS development by creating an account on GitHub.

github.com

 

댓글