본문 바로가기

iOS

Swift Async - Await, Combine 원만한 합의 부탁드리겠습니다.(3 - 完)

이전의 글 발행 이후 세번째이자 마지막 글인 이 글을 쓰는 데에까지 시간이 오래 걸린 이유가 있다.

 

간단하고 쉬운 aysnc-await 쓰면되지 왜 굳이 어렵게 Combine을 쓰느냐에 대한 대답 자체는 간단했으나, 그에 대한 부가설명을 위해서 공부해야할 것이 정말 많았고, 공부한 것을 적용하며 이번에 출시한 SeoulSalam을 개발하고, 내 몸으로 느낀 것들에 대해서 쓰고 싶었기 때문이다.

그러면 지금부터 왜 Combine을 쓰는지, 그리고 그 이유들에 대한 부가설명을 시작하도록 하겠다.

 

1. Why Combine?

 

전에 알아봤던 대로 swift의 modern concurrency(async-await)는 정말 쉽고 간단하다. 그렇지만 Combine은 단순히 비동기 처리만을 위한 프레임 워크가 아니였다.

 

Combine에 대한 설명을 해주는 영상과 글들에서 데이터 스트림, 데이터 흐름이라는 말들이 자주 나온다. 쉽게 말해서 Combine은 데이터 관리를 관장하는 기능들을 제공하는 프레임워크였다. 나 또한 문서들만 읽었을 때에는 이게 무슨 소린가 싶었기 때문에, 하나의 예로서 간단한 코드를 작성해보겠다.

let loginPublisher = loginAPI.request(username: username, password: password)
    .map { response in
        return response.token
    }

let dataPublisher = loginPublisher
    .flatMap { token in
        return dataAPI.requestData(token: token)
    }

loginPublisher
    .filter { token in
        return token != nil
    }
    .flatMap { _ in
        return dataPublisher
    }
    .sink(receiveCompletion: { completion in
        // 처리 완료
    }, receiveValue: { data in
        // 요청한 데이터 처리
    })


가상의 loginAPI라는 통신을 담당하는 struct가 있다고 가장하고, 로그인 API 요청을 loginAPI.request 함수로 만들고,
마찬가지로 가상의 데이터 API 통신을 담당하는 struct가 있다고 가정하고, 데이터 API 요청을 dataAPI.requestData 함수로 만들었다.

map과 flatmap은 고차함수로 데이터를 원하는 형태로 변환할 때에 쓰이는 함수형 프로그래밍의 연산자이다.

 

여기서 Publisher라는 개념이 필요하다. (https://developer.apple.com/documentation/combine/using-combine-for-your-app-s-asynchronous-code)

Publisher는 비동기 작업(위의 코드에서는 loginAPI.request라는 가상의 작업)이 생성하는 데이터(이벤트)를 이벤트 스트림을 통해 다룰 수 있도록 해준다.
따라서 위 코드의 각각의 요청은 Publisher 형태로 만들어져 이벤트 스트림으로 다룰 수 있다

loginPublisher는 로그인 요청 결과를 이벤트 스트림으로 만들고,dataPublisher는 로그인 결과에 따라 데이터 요청을 전달할 수 있도록 만들어진다.

이후, loginPublisher를 필터링하여 token이 nil이 아닌 경우(로그인이 성공한 경우)에만 데이터 요청을 전달하도록 처리한다.

마지막으로 sink 함수를 사용하여 이벤트 스트림의 처리 결과를 받아 처리한다.
receiveCompletion과 receiveValue는 Subscriber 프로토콜에 정의된 메서드이다.(https://developer.apple.com/documentation/combine/subscriber)

쉽게 말하면 Publisher가 발생하는 이벤트 스트림을 구독(Subscribe)하고 receiveCompletion에서는 완료 이벤트를, receiveValue에서는 값 이벤트를 처리할 것이다.


이렇게 Combine을 사용하면, 여러 개의 비동기 작업이 연결되어 데이터의 흐름이 발생하는 상황에서도 각각의 작업을 쉽게 관리하고 조합할 수 있다.

 

예시코드이기 때문에 정확한 처리 과정과 결과가 보이지 않아 이해가 어려울 수 있기 때문에 이번엔 SeoulSalam에 사용된 코드를 같이 보며 이해해보자.

 

public extension FirebaseApiClient {
    static let live = Self {
        .run { subscriber in
            let listenerRegistration = Firestore.firestore().collection(Self.restaurants).addSnapshotListener { snapshot, error in
                if let error = error {
                    subscriber.send(
                        completion: .failure(
                            .init(message: error.localizedDescription)
                        )
                    )
                }
                guard let documents = snapshot?.documents else {
                    subscriber.send(
                        completion: .failure(
                            .init(message: "Snapshot is nil.")
                        )
                    )
                    return
                }
                var restaurants: [Restaurant] = []
                documents.forEach { content in
                    do {
                        var restaurant = try Firestore.Decoder().decode(Restaurant.self, from: content.data())
                        restaurants.append(restaurant)
                    } catch {
                        subscriber.send(
                            completion: .failure(
                                .init(message: error.localizedDescription)
                            )
                        )
                    }
                }
                subscriber.send(restaurants)
            }
            return AnyCancellable {
                listenerRegistration.remove()
            }
        }
    }
}

 

우선 부가적인 설명을 하자면 live는 TCA의 네이밍 컨벤션에 해당하는데, 해당 모델이나 서비스를 실행하는데 필요한 모든 환경이 구성되어 있어서 라이브로 사용할 수 있다는 뜻으로 쓰인다.

 따라서 위 코드는 Firestore과 통신하는 코드이다.

통신이 성공적일 때의 위 코드의 시나리오는

'Firestore에서 제공하는 addSnapshotListener를 통과하면 snapshot과 error를 받고, subscriber.send 메서드를 사용하여, Firestore에서 가져온 데이터를 스트림에 게시하는 것이다.'

하지만 이때, 가져온 데이터를 Subscriber에게 전달하기 전에, Firestore에서 에러가 발생한 경우엔 completion 이벤트를 전송한다. 즉, 데이터 흐름에서 에러가 발생하면, completion 이벤트를 사용하여 에러를 처리하고 있다.

즉, 최종적으로 아무 문제가 없을땐, subscriber.send(restaurants)으로 AnySubscriber 객체에 스트림을 게시하게 될것이다.

마지막의 AnyCancellable은 View와 같은 객체가 메모리에서 해제될 때 해당 스트림의 구독을 취소(cancel)하는 데 사용되는 Combine의 클래스이다. '구독'이라는 개념에서 느껴지다시피 View에서는 그 스트림을 '계속 받아내고 처리하기 위해' 말그대로 구독을 해놓은 상태인데, 그러면 메모리 누수가 발생할 수 있다. 따라서 View에서 메모리가 해제될때 해당 스트림의 구독을 취소하기 위해 return AnyCancellable을 사용한다.

이전 글에서 소개한 Future을 사용하진 않았지만, Publisher 또한 subscriber가 구독을 할 때에 비동기적으로 이벤트를 생성하고 내보내기 때문에, 비동기적으로 수행이 되고 있다. 하지만 위 코드도 단일 비동기 처리이기 때문에 Future을 써도 무방했을 것이다.

 

지금까지 Combine에 대해서 지난번 보다 심층적으로 알아보았다. Async-await에 비해서 Combine 프레임워크는 데이터 스트림 처리와 데이터 편집을 하는 데에 용이한 프레임 워크인 것을 이번에 알게되었을 것이다. 하지만 애플의 공식문서를 보면 async-await 쪽에 애플이 더욱 집중하고 있는 것이 느껴지고, Combine의 문서에도 async-await의 프로토콜과 메서드들을 파란색 박스 안에서 소개한 것을 보면, 애플도 async-await를 앞으로 사용하려는 것이 아닐까? 하는 생각도 든다.